仿 12306 售票系统:Spring Boot + Redisson 分布式锁解决“超卖”与“余票缓存一致性”难题
😱 前言:一张票引发的血案
在单机应用中,防止超卖很简单,加个 synchronized 关键字就行。
但在微服务集群下,synchronized 只能锁住当前机器的线程,锁不住由于负载均衡分发到其他机器的请求。
超卖场景还原:
库存剩 1 张。
- 服务器 A 的线程读库存:
stock = 1。 - 服务器 B 的线程同时读库存:
stock = 1。 - A 卖出:
stock = 0。 - B 卖出:
stock = 0。
结果:卖出了 2 张票,实际上只有 1 张。 这在铁路系统里意味着有人要站票或者上不了车,属于 P0 级事故。
我们需要一把**“分布式锁”**。
🔒 一、 为什么选 Redisson?
Redis 自带的 setnx 虽然能实现锁,但有巨大缺陷:
- 死锁风险:如果服务宕机,锁没释放怎么办?(需要加过期时间)
- 过期时间难定:业务执行了 10s,锁 5s 就过期了,导致锁失效(锁误删)。
- 不可重入:复杂的业务逻辑调用链无法多次拿锁。
Redisson 是 Redis 的 Java 驻内存数据网格,它完美解决了上述问题,特别是它的**“看门狗(Watch Dog)”**机制。
Redisson 锁流程图 (Mermaid):
💻 二、 实战:Redisson 锁住高铁票
1. 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.0</version>
</dependency>
2. 抢票核心逻辑 (OrderService)
我们不仅要加锁,还要锁得细粒度。如果锁整个 G1024 车次,那吞吐量太低。我们应该锁具体座次或车厢。
@Service
public class TicketService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StringRedisTemplate redisTemplate;
public boolean buyTicket(String trainNumber, String seatType) {
// 关键点:锁的粒度。这里锁住特定车次的特定席别
String lockKey = "lock:ticket:" + trainNumber + ":" + seatType;
RLock lock = redissonClient.getLock(lockKey);
try {
// 1. 尝试获取锁
// waitTime: 等待获取锁的时间,leaseTime: -1 表示开启看门狗自动续期
boolean isLocked = lock.tryLock(5, -1, TimeUnit.SECONDS);
if (isLocked) {
// 2. 双重检查 (Double Check) - 防止拿到锁之前库存被扣光
// 这里不仅要查 Redis,最稳妥是查数据库或预加载的缓存
int stock = getStockFromCache(trainNumber, seatType);
if (stock > 0) {
// 3. 扣减库存 (操作数据库 + 更新缓存)
decreaseStock(trainNumber, seatType);
createOrder();
return true;
} else {
return false; // 没票了
}
} else {
return false; // 系统繁忙 (获取锁失败)
}
} catch (InterruptedException e) {
return false;
} finally {
// 4. 释放锁 (必须放在 finally 中)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
🔄 三、 难点攻克:余票缓存一致性
抢票时,用户疯狂刷新查看余票。如果每次都查数据库,数据库必死。我们必须查 Redis。
但这就引出了经典问题:数据库扣减了库存,Redis 里的缓存还没更新,怎么办?
在 12306 这种场景下,我们通常采用 Cache-Aside Pattern (旁路缓存模式) 的变种,并配合 Lua 脚本。
方案 A:先更库,再删缓存 (延时双删)
这是通用方案,但在极端高并发下依然有脏数据风险。
方案 B:Redis 预扣减 (12306 推荐)
真正的余票其实是以 Redis 为准 的。
- 初始化:将数据库库存预热到 Redis。
- 扣减:直接在 Redis 中扣减 (
decr)。 - 异步同步:通过 MQ 异步将扣减结果同步回 MySQL,做最终持久化。
Redis Lua 脚本实现 (保证原子性):
-- keys[1]: 库存 key
-- argv[1]: 扣减数量
local stock = tonumber(redis.call('get', KEYS[1]))
if (stock == nil) then
return -1
end
if (stock >= tonumber(ARGV[1])) then
redis.call('decrby', KEYS[1], tonumber(ARGV[1]))
return 1
else
return 0
end
Java 调用:
// 这样就不需要 Redisson 锁住“读”操作,只需要锁住“写”操作
// 或者完全依赖 Redis 单线程特性,连分布式锁都可以省去(针对纯扣减逻辑)
Long result = redisTemplate.execute(script, Collections.singletonList(key), "1");
if (result == 1) {
// Redis 扣减成功,发送 MQ 消息去异步更新 MySQL
sendToMQ(orderInfo);
}
🚀 四、 性能优化:分段锁 (Segment Lock)
如果 G1024 次列车只有一把锁,那么全中国想买这趟车的人都要排队。
我们可以借鉴 ConcurrentHashMap 的思想,将库存分段。
假设二等座有 1000 张票:
- Key1:
stock:G1024:second:part1(0-100) - Key2:
stock:G1024:second:part2(101-200) - …
用户请求进来时,随机路由到一个分段库存 Key 上。
- 如果 Key1 有票,直接扣。
- 如果 Key1 没票,尝试去 Key2 扣。
这样,并发度瞬间提升了 10 倍!
🎯 总结
开发一个简易版的 12306,核心就在于对 “共享资源”(库存)的争抢控制。
- Redisson 看门狗:解决了锁过期导致的并发安全问题。
- Redis 预扣减 + MQ:解决了数据库的性能瓶颈和缓存一致性问题。
- 分段锁:解决了热点商品的单点瓶颈。
Next Step:
思考一下,12306 还有一个极其复杂的逻辑:区间票。
比如 北京 -> 上海 的车,中间经停 南京。如果我买 北京 -> 南京,那么 北京 -> 上海 的全程票库存也要减 1。
这涉及到 Bitmap (位图) 技术。下一篇,我们挑战用 Redis Bitmap 实现区间库存管理!
更多推荐


所有评论(0)