秒杀系统:从超卖到分段锁的完整演进
一句话结论(30s)
我在「吃什么」本地生活平台的秒杀模块,把下单接口从「超卖 + 数百 QPS」演进到「零超卖 + 50000 量级 QPS」。核心设计是用 Redis 分段锁把单点互斥拆成多段并行——因为秒杀的第一优先级是「绝不能超卖、其次是不能拖垮系统」,所以在「绝对公平」上做让步,换取并发和可用性。其中分段锁这块最有意思,可以展开讲。
背景诉求
- 瞬时高并发:秒杀券在开抢瞬间流量陡增,单接口瞬时并发远超日常,直接打 MySQL 会把连接池和行锁拖垮。
- 库存超卖:
读库存 → 判断 → 更新是经典的 check-then-act,并发下多个请求读到同一旧库存,导致「卖出 > 库存」的资损。 - 缓存一致性:库存要预加载到 Redis 供高频扣减,同时最终要落 MySQL 记账,二者之间如何保持一致、失败如何回补,是必须处理的边界。
- 项目里这类高并发场景不止秒杀:点赞排行榜、下单都是读多写多的典型,秒杀是其中最「尖」、最考验正确性的一环。
目标边界
做到:
- 扣减原子(不超卖);
- 下单链路可用(高并发下不被打崩);
- 库存与订单最终一致。
不做(明确边界):
- 不做绝对公平的排队(分段锁允许「局部段耗尽、全局仍有库存」);
- 秒杀链路的订单生成走异步(削峰),不追求同步强一致下单;
- 不做跨券全局限流(那是网关/风控层职责,不在本模块)。
核心难点
- 写写冲突:MySQL 默认 RR 隔离级别防的是「同一事务内两次读不一致」,防不住「两个并发事务读到同一旧值后各自更新」——这是超卖的根本原因。
- 数据库成为瓶颈:行锁(
SELECT ... FOR UPDATE)能保证正确性,但锁竞争把吞吐压到数百 QPS,秒杀场景不可接受。 - 单锁竞争:单把分布式锁虽然快,但同一券只有一个线程能进,锁本身就变成吞吐天花板。
- 锁等待尖刺:Redisson 默认用 Pub/Sub + 时间轮等待锁释放,高并发等同一把锁时任务积压,出现偶发毫秒级尖刺。
关键取舍
| 方案 | 优点 | 代价 / 为什么取舍 |
|---|---|---|
| 事务 + 普通读(@Transactional) | 实现最简单 | 防不住写写冲突 → 超卖,弃用 |
| 数据库行锁(FOR UPDATE) | 正确性有保证 | 锁竞争把 QPS 压到数百,扛不住秒杀 |
| 单 Redis 分布式锁(Redisson) | 跨进程互斥 + 快 | 同一券单锁串行,QPS 天花板明显 |
| 分段锁(最终选择) | 并发提升一个量级 | 允许「局部耗尽、全局有余」的弱公平 |
横向对比(面试常追问):
- 为什么不用乐观锁(版本号/CAS):乐观锁靠「失败重试」收敛并发,热点冲突下大量重试被浪费;秒杀这种高冲突场景,悲观锁 + 快速失败更可控。
- 为什么扣库存用 Redis 而不是直接 MySQL:Redis 单线程 +
decrement原子指令,天然串行化热点扣减,避免数据库行锁竞争。 - MQ 削峰的角色:扣减成功后订单生成走异步(原文代码里
asyncCreateOrder/ 异步写 MySQL),把「下单落库」的尖峰摊平,让主链路只做「扣库存」这一件事。 - 读写分离:库存读(商品页展示剩余量)走缓存/从库,扣减写走 Redis + 主库,避免读流量挤占扣减链路。
以上是设计层面的取舍说明,具体落地数据以压测为准。
个人行动
- 先定位超卖根因:用并发压测复现,确认是 RR 隔离级别防不住写写冲突,而不是「锁加得不够」。
- 逐步演进:行锁保证正确 → Redis 原子扣减 + Redisson 分布式锁提吞吐 → 分段锁把热点拆成多段并行。
- 处理锁等待尖刺:改用
tryLock(waitTime=0),拿不到锁直接返回「系统繁忙」,绕过 Pub/Sub 等待机制。 - 异步化下单:扣减成功即返回,订单创建走异步,避免拖慢主链路。
- 库存回补:扣减失败或后续下单失败时
increment回补,保证 Redis 与最终 MySQL 记账不漂移。
结果与复盘
- 结果:从「超卖」到「零超卖 + 50000 量级 QPS」,完成四层演进(500 → 5000 → 50000 三档递进,见文末总结表)。具体压测工具、并发数、错误率等,需结合当时的压测数据补全。
- 怎么证明稳定:以「零超卖」这一正确性指标为核心(而非只看 QPS),再配合扣减失败率与回补量是否平衡来兜底。
- 复盘 / 学到的:
- 并发问题先判断「正确性问题 vs 性能问题」——先保证不超卖,再谈提吞吐。
- 锁的粒度决定吞吐上限:表/行 → 单对象 → 分段,本质是不断缩小互斥范围。
- 分布式锁的「等待」有成本,秒杀场景「快速失败」比「排队等待」更合适。
- 分段锁的「弱公平」是可接受的 trade-off,但要能向面试官讲清代价是什么。
三版本回答
30 秒版
我负责秒杀模块,把下单从「超卖 + 数百 QPS」做到「零超卖 + 50000 量级 QPS」。核心是把单把分布式锁拆成 Redis 分段锁——因为秒杀要的是「不超卖 + 不拖垮系统」,所以我在绝对公平上让步换并发。其中分段锁这块最有意思,可以展开讲。
2 分钟版
秒杀开抢瞬间并发陡增,最怕两件事——超卖和拖垮数据库。我先复现了超卖,确认是 RR 隔离级别防不住写写冲突。然后做了四层演进:
@Transactional超卖 →SELECT FOR UPDATE行锁正确但只有数百 QPS → Redis 原子扣减 + Redisson 单锁到 5000 量级 → 把库存切成 10 段、用userId.hashCode() % 10路由到不同锁,把单点互斥拆成 10 路并行,到 50000 量级。代价是允许「局部段耗尽但全局还有」。另外用tryLock(waitTime=0)绕过 Redisson 的 Pub/Sub 等待,消除高并发下的锁等待尖刺。
5-10 分钟版
从四层演进讲起,每一层都交代「为什么上一层不够」;再展开分段锁的 key 设计、路由规则、段耗尽问题;接着讲锁等待尖刺的根因与
tryLock解法;最后落到库存回补、异步下单、以及「正确性 vs 性能 vs 公平」的三方权衡,留钩子让面试官选一个方向深挖。
底层深入(技术细节)
第一层:为什么 @Transactional 仍然超卖了?
@Transactional
public void seckill(Long voucherId) {
Voucher v = voucherMapper.selectById(voucherId); // 读到 stock=1
if (v.getStock() > 0) {
v.setStock(v.getStock() - 1);
voucherMapper.updateById(v); // 更新 stock=0
}
}
1000 并发下,多个线程同时执行 selectById 读到相同的 stock=1,然后各自 update stock=0。MySQL RR 隔离级别防的是”同一事务内两次读的不一致”,防不住”两个并发事务同时读到相同旧值后各自更新”的写写冲突。
想一想:为什么 RR 隔离级别防不住超卖? 因为 RR 的”可重复读”解决的是”同一事务内两次读结果一致”,防止读到别的事务已提交的改动;而超卖是”两个事务各自读到同一个旧库存 stock=1,又各自基于这个旧值更新”的写写冲突。RR 不阻止两个事务同时读同一个旧值,也就无法阻止它们都判断”库存还够”,于是都去扣减——超卖就发生了。
第二层:数据库行锁 → QPS 500
@Transactional
public void seckill(Long voucherId) {
Voucher v = voucherMapper.selectForUpdate(voucherId); // SELECT ... FOR UPDATE
// 只允许一个线程进入,其他阻塞
}
SELECT ... FOR UPDATE 行锁保证同一券的并发安全,但 QPS 受限于数据库锁竞争——约 500 QPS。
第三层:单 Redisson 分布式锁 → QPS 5000
RLock lock = redisson.getLock("voucher:" + voucherId);
lock.lock(30, TimeUnit.SECONDS);
try {
// 扣 Redis 库存(原子操作)
Long stock = redisTemplate.opsForValue().decrement("voucher:stock:" + voucherId);
if (stock >= 0) {
// 异步写 MySQL
seckillService.asyncCreateOrder(userId, voucherId);
} else {
redisTemplate.opsForValue().increment(lockKey); // 恢复库存
}
} finally {
lock.unlock();
}
Redis atomic decrement + Redisson 分布式锁跨进程互斥。锁粒度在单个 voucher,不同券之间完全不互斥。
第四层:分段锁 → QPS 50000
同一把锁 voucher:1001 仍然只允许一个线程进。将库存 100 分成 10 个段,每段 10 个库存:
int segmentCount = 10;
RLock lock = redisson.getLock("voucher:" + voucherId + ":" + userId.hashCode() % segmentCount);
10 个分段 = 10 把不同的锁 = 10 个线程可以同时扣同一券的不同分段库存。QPS 理论上提升 10 倍。
代价:某分段库存耗尽时,用户看到还有全局库存但无法购买(因为库存卡在其他分段里)→ 高并发场景下这点不完美是值得的。
想一想:为什么分段锁能再提升 10 倍?代价是什么? 因为把”同一把锁串行”拆成”10 把锁并行”,同一时刻允许 10 个线程同时扣同一券的不同分段库存,互斥范围缩小了 10 倍。代价是牺牲了绝对公平——某一段耗尽时,用户看到全局还有库存却买不到(因为库存卡在别的段里)。这是”用弱公平换并发”的典型取舍。
锁等待的尖刺问题
Redisson 默认获取锁失败时,通过 Pub/Sub + Netty HashedWheelTimer 等待通知。1000 并发等同一把锁时,时间轮任务积压导致偶发 50ms 锁等待尖刺。
秒杀场景的解法:tryLock(waitTime=0) — 获取不到锁直接返回”系统繁忙”,前端提示稍后重试。完全绕过 Pub/Sub 等待机制,消除等待尖刺。
想一想:为什么
tryLock(waitTime=0)能消除锁等待尖刺? 因为 Redisson 默认拿不到锁时走 Pub/Sub + 时间轮等待通知,高并发等同一把锁时任务积压,出现偶发毫秒级尖刺。waitTime=0让线程拿不到锁就立刻返回”系统繁忙”,完全绕过等待机制——秒杀场景里”快速失败”比”排队等待”更合适,用户重试一次的成本远低于几十毫秒的等待。
总结
| 方案 | QPS | 瓶颈 |
|---|---|---|
| @Transactional | 超卖 | 写写冲突 |
| SELECT FOR UPDATE | 500 | 数据库行锁 |
| 单 Redisson 锁 | 5000 | 单锁竞争 |
| 分段锁 | 50000 | 库存均衡问题 |
章末提问
Q1:为什么 @Transactional 会超卖,RR 隔离级别为什么防不住?
结论先行:因为 RR 防的是”同一事务内两次读不一致”,防不住”两个事务同时读到同一旧值后各自更新”的写写冲突。
因为超卖是典型的 check-then-act:多个线程同时 selectById 读到相同的 stock=1,各自判断 >0 后各自 update。RR 不阻止两个事务并发读同一个旧值,所以都认为库存够、都去扣,卖出数就超过了真实库存。要防超卖必须加锁或原子操作,而不是靠隔离级别。
Q2:为什么扣库存用 Redis 而不是 MySQL 行锁?
结论先行:因为 Redis 单线程 + decrement 原子指令天然串行化热点扣减,避免数据库行锁竞争。
因为 SELECT ... FOR UPDATE 行锁虽然正确,但锁竞争把 QPS 压在数百,扛不住秒杀;Redis 的 DECR 在单线程里原子执行,同一时刻只有一个命令在改库存,天然无竞态,且内存操作极快,能到 5000 量级。MySQL 负责最终记账,Redis 负责抗热点扣减,各司其职。
Q3:分段锁为什么能提升并发?代价是什么?
结论先行:它把单点互斥拆成多段并行,同一时刻多个线程可同时扣不同分段;代价是允许”局部段耗尽、全局仍有库存”的弱公平。
因为单把分布式锁让同一券只有一个线程能进,锁本身是吞吐天花板;把库存切成 10 段、用 userId.hashCode() % 10 路由到 10 把锁,就变成 10 路并行,理论上 QPS 提升 10 倍。代价是牺牲绝对公平——这正是秒杀”绝不超卖、其次不拖垮系统”优先级下的合理让步。