Skip to content
Go back

秒杀系统设计——从超卖到分段锁的完整演进

秒杀系统:从超卖到分段锁的完整演进

一句话结论(30s)

我在「吃什么」本地生活平台的秒杀模块,把下单接口从「超卖 + 数百 QPS」演进到「零超卖 + 50000 量级 QPS」。核心设计是用 Redis 分段锁把单点互斥拆成多段并行——因为秒杀的第一优先级是「绝不能超卖、其次是不能拖垮系统」,所以在「绝对公平」上做让步,换取并发和可用性。其中分段锁这块最有意思,可以展开讲。

背景诉求

目标边界

做到

不做(明确边界)

核心难点

  1. 写写冲突:MySQL 默认 RR 隔离级别防的是「同一事务内两次读不一致」,防不住「两个并发事务读到同一旧值后各自更新」——这是超卖的根本原因。
  2. 数据库成为瓶颈:行锁(SELECT ... FOR UPDATE)能保证正确性,但锁竞争把吞吐压到数百 QPS,秒杀场景不可接受。
  3. 单锁竞争:单把分布式锁虽然快,但同一券只有一个线程能进,锁本身就变成吞吐天花板。
  4. 锁等待尖刺:Redisson 默认用 Pub/Sub + 时间轮等待锁释放,高并发等同一把锁时任务积压,出现偶发毫秒级尖刺。

关键取舍

方案优点代价 / 为什么取舍
事务 + 普通读(@Transactional)实现最简单防不住写写冲突 → 超卖,弃用
数据库行锁(FOR UPDATE)正确性有保证锁竞争把 QPS 压到数百,扛不住秒杀
单 Redis 分布式锁(Redisson)跨进程互斥 + 快同一券单锁串行,QPS 天花板明显
分段锁(最终选择)并发提升一个量级允许「局部耗尽、全局有余」的弱公平

横向对比(面试常追问):

以上是设计层面的取舍说明,具体落地数据以压测为准。

个人行动

  1. 先定位超卖根因:用并发压测复现,确认是 RR 隔离级别防不住写写冲突,而不是「锁加得不够」。
  2. 逐步演进:行锁保证正确 → Redis 原子扣减 + Redisson 分布式锁提吞吐 → 分段锁把热点拆成多段并行。
  3. 处理锁等待尖刺:改用 tryLock(waitTime=0),拿不到锁直接返回「系统繁忙」,绕过 Pub/Sub 等待机制。
  4. 异步化下单:扣减成功即返回,订单创建走异步,避免拖慢主链路。
  5. 库存回补:扣减失败或后续下单失败时 increment 回补,保证 Redis 与最终 MySQL 记账不漂移。

结果与复盘

三版本回答

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=0MySQL 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 UPDATE500数据库行锁
单 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 倍。代价是牺牲绝对公平——这正是秒杀”绝不超卖、其次不拖垮系统”优先级下的合理让步。


Share this post on:

Previous Post
雪花算法——分布式全局唯一ID的64bit设计
Next Post
海量数据处理——从哈希分桶到位图的通用解题框架