秒杀系统怎么防超卖:从一把锁到 Redis 和 MQ
这篇文章的来由
上周面试得物,被问到“高并发场景怎么避免超卖”。这个问题并不陌生,但我当时一上来就讲 Redis、Lua 和 MQ,越说越散,反而没有先把最基本的正确性讲清楚。
回来以后我重新顺了一遍:先不考虑多大的流量,只看一个方案为什么不会超卖;再看流量上来后,瓶颈会出现在哪里。这样一路推下来,方案自然会从单体锁走到 MySQL、Redis 和 MQ。
先说清楚,什么才算“没有超卖”
库存没有扣成负数,只能说明最明显的问题没发生。一个能真正落地的方案,至少还要处理下面三件事:
- 扣库存必须是原子操作。库存只剩一件时,不能让两个请求都通过检查。
- 请求要能去重。客户端超时后通常会重试,同一个
request_id不能生成两笔订单;如果规则是一人一单,还要在活动、SKU 和用户这个维度去重。 - 库存和订单最终要能对上。扣了库存却没生成订单,会少卖;订单成功但库存没扣到,才是真正的超卖。做不到一个事务解决时,就要有补偿和对账。
我更愿意把 MySQL 方案当成基线:它先解决“做对”的问题。Redis 和 MQ 不是更高级的标准答案,只是在流量继续上涨后,用更多工程复杂度换吞吐。
最简单的版本:一把锁
最容易写错的是先判断、再扣减:
1 | |
stock > 0 和 stock-- 是两个动作。多个 goroutine 可能同时看到还有库存,然后各自扣减。单体服务里,最直接的处理方式是用 sync.Mutex 把检查库存、扣减和创建订单放进同一个临界区。
锁解决的是并发修改库存的问题,幂等仍要单独做。可以保存 request_id -> order_id 的映射;如果活动限制一人一单,还要按 activity_id + sku_id + user_id 去重。创建订单失败时,再把库存加回来。
这个方案适合理解问题,不适合真正的秒杀。所有请求都要排队拿锁,吞吐受单进程限制;服务一旦重启,内存里的库存和幂等记录也没了。部署多个实例后,各进程还有各自的一把锁,彼此根本约束不了。
先把 MySQL 方案做对
服务需要扩容、重启和追查订单后,库存就不能只放在内存里。用 MySQL 时,关键不是先查库存再更新,而是把判断和扣减写进同一条 SQL:
1 | |
执行后检查影响行数。影响一行说明抢到了,影响零行说明已经没库存。available > 0 和扣减由数据库一起执行,并发请求不会把库存扣穿。
扣库存和写订单要放在同一个事务里。订单表再加两类唯一约束,作为最后一道保险:
UNIQUE KEY uk_req (request_id):同一次请求只处理一次。UNIQUE KEY uk_act_sku_user (activity_id, sku_id, user_id):同一活动、同一 SKU 下限制一人一单。
唯一键不只是为了防恶意重复购买。更常见的情况是事务已经提交,但响应在路上丢了。客户端拿原来的 request_id 重试时,服务端应该查到原订单并返回,而不是再扣一次库存。
事务提交前进程崩溃,InnoDB 会回滚;提交后进程崩溃,可以按 request_id 查询最终结果。死锁或锁等待超时可以做有限次数的退避重试。订单取消、支付超时,则在事务里反向增加库存,同时保证这次释放不会重复执行。
MySQL 方案已经能把事情做对,但它有一个很现实的上限:热门商品的请求会集中争抢同一行。行锁排队越来越长,P99 延迟上升,数据库也会成为整条链路的瓶颈。
Redis 不是账本,它更像入口闸门
如果数据库的问题不是扣不准,而是峰值时排队太长,可以在入口用 Redis 拦掉大部分注定失败的请求。只有 Redis 预扣成功的请求,才继续访问 MySQL。
检查用户、读取库存、扣减库存这几步不能分开执行,否则 Redis 里一样会有竞态。可以把它们放进 Lua 脚本:
1 | |
这段脚本只展示了库存和一人一单。实际项目还要把 request_id 放进去。否则第一次请求已经预扣成功,客户端重试时只能得到“重复购买”,却拿不到第一次请求的处理结果。
一种做法是在 Redis 里保存 request_id 对应的 PENDING、SUCCESS、FAIL 或 order_id,Lua 开头先检查它。重复请求直接返回已有状态,不再扣库存。
即使有了 Redis,MySQL 里的条件更新、事务和唯一键也不该删。Redis 负责挡流量,MySQL 仍是最终账本。缓存初始化错误、数据漂移或者补偿延迟,都不应该突破数据库的库存下限。
这套方案真正麻烦的是失败窗口。Redis 预扣成功、MySQL 落库失败时,需要执行反向脚本,恢复库存并移除用户标记。如果服务恰好在两者之间崩溃,这次占位就可能一直挂着。
所以预扣记录必须能追踪到 request_id,还要有超时和对账机制。后台任务以 MySQL 订单为准,释放已经超时但没有订单的占位。否则系统虽然不超卖,却会因为冻结库存而少卖。
Redis 故障时也要提前决定怎么办。我的倾向是降级到 MySQL,并配合严格限流。性能可以先差一点,但不能为了维持吞吐绕开最终的库存约束。
什么时候才需要 MQ
加了 Redis 后,成功预扣的请求仍然要同步写数据库。如果瞬时流量足够大,应用线程会在写库阶段堆住,接口超时又会带来更多重试。这时才有理由把下单改成异步。
一次请求大致会经过这条链路:
- Redis Lua 完成预扣和去重。
- 请求进入 MQ,接口返回“排队中”。
- 消费者创建订单并扣减 MySQL 库存。
- 处理结果写回
request_id对应的状态,前端轮询查询。
这里的接口语义已经变了。返回成功不代表下单成功,只代表系统接受了请求。前端需要认识 PENDING,也要知道排队超时、最终失败和取消分别怎么展示。
MQ 通常是至少一次投递,同一条消息可能被消费者收到多次。消费端必须做幂等,可以用消息 ID 和消费组建立唯一约束:
1 | |
消费日志、扣库存和创建订单需要放在同一个数据库事务里。重复消息命中唯一键后直接返回,不能再次扣库存。
发送消息同样有失败窗口。预扣成功但消息没有入队,要释放 Redis 占位;消息到底有没有发送成功无法确认时,可以用事务消息或 Outbox 收窄不一致窗口,但无论选哪种方式,都离不开可重试、可查询的状态记录。
消费者持续失败时,消息会反复重试,超过阈值后进入死信队列。此时不能让请求永远停在 PENDING,需要补偿任务或人工处理。队列堆积则要监控 lag,并根据业务能接受的等待时间扩容消费者。
还有一种常见情况:数据库事务已经提交,进程却没来得及更新 Redis。用户暂时看到 PENDING,但订单其实已经创建。这时 Redis 只是结果投影,可以按 request_id 查数据库,或者由后台任务重建,不需要回滚已经成功的订单。
如果面试时再问一次
我不会再从 Redis 开始答,而是先确认几个边界:库存按什么维度扣,一人能买几件,接口是否必须同步返回结果,客户端能不能提供稳定的 request_id,以及业务能接受多长的最终一致窗口。
如果没有夸张的流量,我会先给出 MySQL 方案:条件更新保证库存不扣穿,库存和订单放在同一事务里,唯一键处理重试和一人一单。这已经是一个完整、能落地的答案。
接着再说扩展。数据库出现热点行竞争,就在前面加 Redis Lua 做预扣,把失败请求挡在入口;同步写库仍然扛不住,再用 MQ 把受理和下单拆开。每增加一层,都要同时说明新的失败窗口以及对应的补偿办法。
这次复盘后,我觉得这道题真正想问的不是记住多少中间件,而是能不能说清楚:库存由谁负责,重复请求怎么处理,服务在任意一步崩溃后,订单和库存最后怎样重新对上。