我对秒杀防超卖的四层方案推演(单体 / MySQL / Redis 预扣 / MQ 异步)
前情提要
上周面试得物时被问到一句很典型的问题:“高并发场景如何避免超卖?”我当时讲得比较混乱。这篇文章就是把那次追问逼我补齐的思考写下来:按系统演进把方案拆成 4 层,从单体到 MySQL,再到 Redis 预扣,最后到 MQ 异步。
我把“不会超卖”拆成三条必须成立的不变量
“不会超卖”不是一句口号,它本质上是三条不变量能不能长期成立:
- 库存扣减的原子性:成功扣减次数不能超过初始库存,扣减必须是并发下的原子动作,避免“明明只有 1 份却卖出 2 单”。
- 幂等与去重:同一
request_id的重试不会变成多次下单;同一用户在同活动/同 SKU 下不会绕过限制重复成功(如果业务要求一人一单)。 - 一致性闭环:扣库存与“订单最终态”要么同成同败,要么在失败路径上能补偿回去(不要求每一步都强一致,但要能自洽);否则会出现“库存被扣但订单失败”(少卖)或“订单成功但库存没扣到”(超卖)。
后面每一层升级,我都用同一套问题来审视:它靠什么守住这三条不变量?守不住时怎么兜底?
四个方案的演进关系
| 层级 | 典型形态 | 一致性观感 | 承载能力 | 工程代价 | 主要风险 |
|---|---|---|---|---|---|
| 单体 | 内存库存 + 互斥锁 | 进程内强一致 | 单机可,但不可扩展 | 低 | 重启丢状态、多实例即失效 |
| MySQL | 事务 + 条件更新扣减 | 强一致(以 DB 为准) | 中等 | 中 | 热点行锁竞争、DB 成瓶颈 |
| Redis 预扣 | Lua 预扣 + DB 落库 + 补偿 | 最终一致(靠补偿/对账) | 高 | 中-高 | Redis/DB 漂移、冻结库存 |
| MQ 异步 | 预扣 + 异步下单 + 结果查询 | 最终一致(幂等+重试) | 很高 | 高 | 链路复杂、堆积与排障成本 |
我会把 MySQL 方案当作“最小正确解”,Redis/MQ 属于“在最小正确解之上,为了承载峰值做的工程投资”。
单体:内存库存 + Mutex(把并发问题锁进一个临界区)
升级触发点(为什么需要这一层)
最常见的起点是这种“先判断再扣减”的直觉代码:
1 | |
在并发下 stock > 0 和 stock– 分离,多个请求会同时判断“还有库存”,最终把库存扣成负数;这在原子性这条上直接崩掉。
核心机制(我会怎么守三条不变量)
单体版能做到“不超卖”,靠的是把关键路径串行化:
- 用
sync.Mutex包住库存检查、扣减、创建订单。 - 用
request_id -> order_id做请求幂等,处理超时重试。 - 用
activity_id+sku_id+user_id的去重结构实现一人一单(若业务要求)。 - 下单失败时立刻把内存库存加回去,避免“少卖”。
为什么不会超卖:同一时刻只有一个 goroutine 能进临界区,扣减前永远能看到最新库存,stock-- 的执行次数被 stock > 0 的判断限制住。
失败兜底
- 下单失败:进程内立刻
stock++回滚。 - 请求重试:同一
request_id返回历史结果,不重复扣减。 - 系统崩溃来不及补偿怎么办:如果进程在扣减后、回滚前崩溃,这层没有可靠手段自动修复(内存状态丢失),只能靠人工对账/重置活动来处理。
代价(用吞吐换正确性)
Mutex 把并发压成串行,延迟和吞吐的上限被单点临界区锁死。这层的价值是把竞态与原子性讲清楚,不是为了扛住峰值。
MySQL:事务强一致(最小正确解)
升级触发点(单体到多实例,结果要可恢复)
一旦系统要扩容、要重启可恢复、要能追溯结果,内存状态就不够用了。我会把库存与订单落在 MySQL,用 InnoDB 的事务与锁语义来守住不变量。
核心机制(条件更新扣减 + 事务 + 唯一约束)
我会把 MySQL 方案压缩成三件事:
- 库存扣减用原子条件更新:把“库存是否>0”与“扣减”放进一条 SQL。
- 扣库存与写订单放在一个事务里,同成同败。
- 幂等和一人一单交给唯一键兜底,避免“应用层判断被并发穿透”。
关键 SQL(语义不变,尽量短):
1 | |
订单表上的两个唯一约束,是我会刻意强调的“兜底装置”(业务维度可按规则调整):
UNIQUE KEY uk_req (request_id):请求幂等UNIQUE KEY uk_act_sku_user (activity_id, sku_id, user_id):一人一单(同活动同 SKU)
为什么不会超卖:available > 0 是扣减条件,更新发生在 DB 的原子执行里;成功更新的次数被 available 的下界限制住,不会扣穿。
失败兜底(把“成功但没返回”当作常态)
- 提交成功但响应丢失:客户端用同一
request_id重试,服务端要么查到订单,要么命中唯一键冲突后返回同一结果。 - 死锁/锁等待超时:有限重试 + 退避;业务上要允许“抢购失败/稍后重试”的体验。
- 订单取消/支付超时:反向加库存(
available = available + 1),仍然走事务,避免补过头。 - 系统崩溃来不及补偿怎么办:如果进程在事务提交前崩溃,InnoDB 会回滚;如果提交后崩溃导致客户端没收到响应,用
request_id查最终态/重试,不会重复扣一次。
代价(热点行锁竞争是硬伤)
当所有请求都打在同一行库存记录上,行锁排队会直接把 P99 拉高。MySQL 方案是“正确且可控”,但它的性能上限和你能承受的锁等待时间强相关。
Redis 预扣:Lua 原子闸门 + MySQL 落库(削峰前置)
升级触发点(DB 扣得动,但峰值会把它排队打爆)
当问题变成“正确性已解决,但峰值把 DB 行锁打爆”,我会考虑 Redis 做入口闸门,让大量请求更早、更便宜地失败,避免重试风暴把下游抖成雪崩。
核心机制(Redis 预扣成功才允许进 DB)
这层我会把 Redis 的多步操作做成一个原子动作(Lua),同时完成“一人一单去重 + 扣减 + 记录用户”:
1 | |
注意:这里为了讲清“闸门”的核心逻辑,Lua 只演示了一人一单去重;工程里还需要把 request_id 幂等考虑进去,否则超时重试可能被误判为“重复购买”而拿不到第一次的结果。常见做法是把 request_id 的状态(如 PENDING/SUCCESS/FAIL 或 order_id)也写入 Redis,并在 Lua 里先按 request_id 走幂等短路。
MySQL 端仍然保留“最小正确解”的事务与条件扣减。我把 Redis 预扣当作削峰装置,不把它当作最终事实源。
为什么不会超卖:入口由 Lua 保证原子预扣,控制进入量不超过库存;后端由 MySQL 条件更新再兜底,避免缓存漂移导致的超扣。
失败兜底
- Redis 预扣成功但 MySQL 落库失败:执行回滚脚本(
INCR + SREM)释放库存与用户标记。 - 系统崩溃来不及补偿怎么办:如果预扣成功后进程崩溃,来不及写 MySQL/回滚 Redis,库存会被冻结。我会把“预扣动作”写成可追踪的预扣流水(至少要能关联到
request_id,并且可过期),再用对账任务以 MySQL 订单事实为准做释放/补偿,避免冻结变成长期少卖。 - Redis 故障:降级为 MySQL 直连 + 严格限流,先保“不超卖”,再谈体验。
代价(一致性从“靠事务”变成“靠机制”)
从这一步开始,是在用工程复杂度换吞吐:双写、回滚失败、对账任务、Key 初始化/过期策略、占位超时窗口,这些都得写在设计里,否则系统会从“超卖”迁移成“库存冻结/状态不明”。
MQ 异步:受理即返回 + 结果查询(把写库从同步路径拿掉)
升级触发点(同步链路不允许再承压)
即使有 Redis 预扣,同步写库依然可能把应用线程池打满。接口超时会造成更多重试,抖动会进一步放大。这时我会把下单落库改成异步:接口只做“受理”,把吞吐能力外置到 MQ。
核心机制(预扣 + 入队 + 消费落库 + 查结果)
我会把这一层描述成三段闭环:
- 入口:Redis Lua 预扣,保证进入队列的请求不超过库存,同时做一人一单与
request_id幂等(重复请求返回同一状态)。 - 入队:发送 MQ(建议用事务消息/Outbox 把“入队”和“状态落库”做一致),发送失败就回滚 Redis 预扣;发送成功就把
request_id状态写成PENDING(带 TTL),前端轮询查询。 - 消费:消费者侧做幂等 + DB 事务落库;成功后把
request_id状态更新为SUCCESS(可附带order_id)。
消费幂等我会用“消费日志唯一约束”来解释(MQ 至少一次投递是默认前提):
1 | |
为什么不会超卖:Redis 原子预扣控制进入量;消费端用 mq_consume_log 去重,重复消息不产生重复扣减;MySQL 仍用条件更新扣库存与订单唯一键兜底,防止重复落库。
失败兜底(把异步的不确定性显式化)
- MQ 发送失败:提交端回滚 Redis 预扣并返回失败,避免把“消息没入队”伪装成“排队中”。
- 消息重复投递:消费端幂等表去重,保证重复消息不重复扣减与下单。
- 消费失败:按 MQ 的重试语义重投;超过阈值进入 DLQ,由补偿任务人工/自动处理,避免请求永远卡在
PENDING。 - 系统崩溃来不及补偿:我会重点盯两段“来不及”——
- 崩在预扣之后、发 MQ 之前:库存占位会冻结,靠对账任务按 DB 权威释放超时占位。
- 崩在 DB 提交之后、写结果缓存之前:用户看到的可能是 PENDING,但 DB 已经成功。结果缓存是投影层,可以由“按 request_id 查 DB 订单状态”或后台重建任务修复,不会反向影响库存正确性。
- 队列堆积:PENDING 时间变长属于体验问题,需要在产品层定义排队超时与取消策略,同时在系统层监控 lag 并扩容消费者。
代价(复杂度是一次性叠上去的)
这一步的吞吐能力来自异步化,代价是链路复杂与可观测性要求高:消息堆积、DLQ、幂等表膨胀、结果投影重建、补偿任务的正确性验证,都要写进方案里,否则“抗峰值”会变成“问题更隐蔽”。
回到面试现场:我会怎么把这题讲得像一条闭环
我会把回答组织成三段话:把问题说清楚、把方案分层、把兜底讲成闭环。
把问题说清楚:先确认边界——库存维度(SKU/活动)、一人一单规则、接口是否必须同步返回成功、客户端是否提供稳定 request_id、能接受的最终一致窗口。边界不清时,我宁愿用 MySQL 强一致作为默认基线,避免把“高并发”当成借口跳过正确性。
把方案分层:从能落地的最小闭环开始讲,再把扩展路径讲出来。
- MySQL:事务 + 条件扣减 + 唯一约束,先把不超卖讲硬。
- Redis:Lua 预扣做闸门挡掉峰值失败请求,DB 仍是最终账本,补上回滚与对账。
- MQ:受理与落库拆开,消费幂等 + DLQ + 投影重建,讲清楚“排队中(
PENDING)”的产品语义。
把兜底讲闭环:每个方案都点名三个异常:重试(幂等键)、局部失败(回滚/补偿)、系统崩溃来不及补偿(对账与重建)。我会强调“宁可少卖不超卖”的立场:对不确定状态,优先让请求失败或排队,库存正确性永远不靠猜。