我对秒杀防超卖的四层方案推演(单体 / 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
2
3
4
if stock > 0 {
stock--
createOrder()
}

在并发下 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 方案压缩成三件事:

  1. 库存扣减用原子条件更新:把“库存是否>0”与“扣减”放进一条 SQL。
  2. 扣库存与写订单放在一个事务里,同成同败。
  3. 幂等和一人一单交给唯一键兜底,避免“应用层判断被并发穿透”。

关键 SQL(语义不变,尽量短):

1
2
3
UPDATE seckill_stock
SET available = available - 1
WHERE activity_id = ? AND sku_id = ? AND available > 0;

订单表上的两个唯一约束,是我会刻意强调的“兜底装置”(业务维度可按规则调整):

  • 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
2
3
4
5
6
7
-- KEYS[1]=stockKey, KEYS[2]=userSetKey, ARGV[1]=userId
if redis.call("SISMEMBER", KEYS[2], ARGV[1]) == 1 then return -2 end
local stock = tonumber(redis.call("GET", KEYS[1]) or "-1")
if stock <= 0 then return -1 end
redis.call("DECR", KEYS[1])
redis.call("SADD", KEYS[2], ARGV[1])
return 1

注意:这里为了讲清“闸门”的核心逻辑,Lua 只演示了一人一单去重;工程里还需要把 request_id 幂等考虑进去,否则超时重试可能被误判为“重复购买”而拿不到第一次的结果。常见做法是把 request_id 的状态(如 PENDING/SUCCESS/FAILorder_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
2
3
4
5
6
CREATE TABLE mq_consume_log (
msg_id VARCHAR(64) NOT NULL,
consumer_group VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (msg_id, consumer_group)
) ENGINE=InnoDB;

为什么不会超卖: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)”的产品语义。

把兜底讲闭环:每个方案都点名三个异常:重试(幂等键)、局部失败(回滚/补偿)、系统崩溃来不及补偿(对账与重建)。我会强调“宁可少卖不超卖”的立场:对不确定状态,优先让请求失败或排队,库存正确性永远不靠猜。

延伸阅读


我对秒杀防超卖的四层方案推演(单体 / MySQL / Redis 预扣 / MQ 异步)
https://blog.phlin.cn/2026/01/20/seckill-oversell-prevention/
作者
phlin
发布于
2026年1月20日
许可协议