系统是被打出来的 · 系统设计实战手册
PART 3 · 分布式深水区 · 第 10 章

超卖!
库存一致性

缓存、队列和分片都能替你扛流量,却不能替你回答「最后一张票属于谁」。这一章把并发竞态摊在账本上:多卖是法律与信誉,少卖是收入,二者从来不对称。

10 · 1 值班现场10:07,法务和客服同时推开门

创业第 3 年 5 个月,「秒抢」累计用户约 500 万,公司 190 人,后端 35 人。顶流场次十点开售,三分钟售罄。你刚在群里发出「扛住了」,客服主管把一张表放到你面前:场馆 50,000 个座位,系统却收了 50,300 笔有效付款。

PagerDuty · P0 · 触发于 10:07:26
[CRITICAL] paid-order count 50,300 > sellable seats 50,000
[CRITICAL] allocation-unmatched 300 orders(无可分配座位)
[WARNING]  inventory reconcile delta −300
──────────────────────────────────────
客服群最新消息:
「已有用户拿着付款凭证要求出票,法务正在会议室等你。」

你把库存、订单和支付三本账并排。缓存没有报错,数据库也没有宕机;真正刺眼的是几组彼此都「成功」的数字:

场馆可售座位
50,000
合同确认上限
有效付款订单
50,300
▲ 多出 300 张
库存服务成功率
99.99%
● 技术成功不等于账平
读后写冲突窗口
18ms
▲ 峰值同库存并发 1,840

订单日志很快给出一组最小复现:库存还剩 1 时,请求 A 和 B 都在 10:02:11.204 读到 1;A 在 10:02:11.218 写回 0,B 在 10:02:11.222 也写回 0。两次更新都成功,两笔订单都进入支付,数据库最终库存仍是 0。监控看见的是两个成功事务,业务得到的是一张票卖给两个人。

这不是流量把机器压坏,而是并发把一段非原子逻辑拆开了。现在 300 位用户已经付款,任何一次仓促退款都可能演变成投诉;与此同时,还有渠道订单尚未完成座位分配,差额可能继续扩大。

决策点 D1

此刻是 10:09。你的第一个动作是什么?

B 是教科书答案:先冻结事实。关入口阻止差额继续扩大,停自动出票避免把座位重复分给不同订单;锁定库存、订单、支付和渠道快照,才能确认影响集合。先保存证据与业务上限,再决定补票、换场或退款顺序。

A 会把事故写进权威账本。Redis 数字加 300 不能让场馆长出 300 个座位,只会把明确的超卖伪装成「库存充足」。技术指标也许变绿,法律义务却增加了。

C 的问题是你还不知道哪 300 笔该退。付款时间不等于座位权利排序,渠道保留票、无障碍席和已分配座位都可能改变处置集合。未经核对批量退款,可能退掉已有合法座位的用户,却留下真正无法履约的订单。

D 方向可能有用,但救不了现在。热修复只能保护后续请求,无法撤销已经形成的 300 笔付款;故障中直接引入新锁还可能触发第 11 章会讲的过期与误删问题。先停写、对账,再用可证明的原子扣减替换竞态。

10 · 2 原理最后一张票不能有两个答案

「可售库存的真相,对同一份额度,任何时刻只能有一个权威裁决规则批准扣减。」这条裁决规则可以落在数据库的一行、Redis 中由 Lua 原子操作的计数器,也可以是按场次串行消费的单一分区;关键不在产品名字,而在同一份额度在同一时刻只有一处能够批准扣减。额度本身可以被不重叠地拆分给多个裁决规则并发批准——第 15 章的单元化配额就是把库存按单元预先切成互不重叠的份额,让多个单元各自的裁决规则同时工作,只要每份额度不被重复使用、总量仍能对账闭合。

错误做法:读库存 → 在应用里判断 > 0 → 写回库存 − 1
正确边界:判断与扣减必须是同一个原子动作

读-改-写 的危险不在每一步,而在三步之间可以插入别的请求。把现场缩成最后一张票:

初始库存
1
A、B 同时开始
A 读取 / B 读取
1 / 1
都通过大于 0 判断
A 写回 / B 写回
0 / 0
两次更新均成功
业务结果
卖出 2 张
实际只有 1 张

最终库存 0 看起来完全合法,但成功订单是 2。库存数值本身没有越过下界,所以只监控 stock < 0 抓不到这类覆盖写。必须把「成功扣减次数 + 剩余库存 = 初始可售量」作为对账不变量。

四种常见方案,购买的是不同位置的原子性:

方案 原子边界 优点 账单与适用条件
悲观锁 事务中锁住库存行,再读取与扣减 语义直观,强一致结果留在 DB 热点行串行等待,锁持有时间会放大 P99;适合并发有限、事务短的库存
乐观锁 按版本号条件更新,冲突者重读或失败 无长时间阻塞,读多冲突少时吞吐好 高冲突场景重试会放大流量;适合冲突概率低、允许显式失败的写入
DB 原子扣减 UPDATE ... SET stock=stock-1 WHERE stock>0 一条语句完成判断与扣减,以影响行数判成功 热点仍压在单行,数据库承担入口并发;适合容量能承受、正确性优先的核心账
Redis + Lua 预扣 脚本内检查、扣减、记录预扣凭证 内存吞吐高,可在数据库前挡住大流量 Redis 与 DB 之间产生新一致性窗口,需要持久化、补偿与对账;适合峰值高且治理成熟的场景

DB 原子扣减的数字最容易验算。库存 100 张,1,000 个请求同时执行条件更新:前 100 次各影响 1 行,后 900 次影响 0 行。应用只为影响 1 行的请求创建订单,因此成功数 100、剩余库存 0,不会因为请求并发而出现第 101 个批准。

Redis + Lua 预扣把批准权移到内存层。脚本要同时完成三件事:验证库存大于 0、扣 1、写下与幂等键绑定的预扣凭证。消费者再异步落库;落库失败时,补偿任务凭同一键归还库存。若只扣计数、不留凭证,系统会知道「少了 1」,却不知道该为谁补。

库存不是一个数字,是一组有方向的状态

只保存 stock=497,只能回答「还剩多少」,无法回答三笔扣减分别走到了哪一步。可靠的库存链路至少要让每个业务键经历可审计的状态迁移:

状态 它代表什么 允许的下一步 重复请求怎么处理
AVAILABLE 配额尚未被任何订单占用 原子转为 RESERVED 没有预扣凭证,不返回成功
RESERVED 库存已为一个幂等键预留,订单可能仍在落库 确认成交或按规则释放 同一键返回现有预扣,不再扣第二次
CONFIRMED 订单与支付满足成交条件,库存正式消耗 退款时进入独立补偿流程 返回第一次确认结果
RELEASED 预扣因超时、取消或落库失败而归还 配额回到可售池 旧键不可再次把同一凭证确认成交

这里的关键不是英文状态名,而是状态只能沿允许的边前进。若一条迟到的「订单成功」消息能把 RELEASED 直接改成 CONFIRMED,而这份库存已经卖给别人,补偿流程就亲手制造了超卖。解决办法是版本号或 fencing token:只有携带当前预扣版本的处理者才能推进状态,旧结果被记录但不能覆盖新裁决。

用 1,000 张配额做一次验算:Lua 产生 1,000 条 RESERVED 凭证,消费者确认 980 条,取消 15 条,另有 5 条超时未知。此时不能把「剩余库存」直接写成 20;能立即释放的是 15,另外 5 条仍占用,直到按幂等键确认 DB 中没有订单。这里最容易犯的错,是把「凭证曾经处于什么状态」和「槽位现在被谁占着」写进同一个等式——RELEASED 的凭证是历史事实,它对应的槽位已经变回可售,再单独加一次就会双计数。所以要拆成两条不变量:

槽位不变量(当下) 初始配额 = CONFIRMED + RESERVED + AVAILABLE
凭证不变量(历史) 已签发凭证数 = CONFIRMED + RESERVED + RELEASED
两者的桥:RELEASED 的槽位已经回到 AVAILABLE,不得重复计入

代入这 1,000 张:槽位为 CONFIRMED 980 + RESERVED 5(超时未知,仍占用)+ AVAILABLE 15 = 1,000;凭证为 CONFIRMED 980 + RESERVED 5 + RELEASED 15 = 1,000 张已签发。两条等式各自闭合,而「15」只在槽位侧算作可售、在凭证侧算作已释放,不会被加两次。对账脚本要同时跑这两条;只跑一条的对账,正是超卖最爱藏身的地方。

四个失败窗口,四种不同动作

失败窗口 调用方知道什么 安全动作 危险本能
Lua 执行前超时 不知道预扣是否发生 用幂等键查询预扣凭证 换新键重试,可能扣两次
预扣成功、消息发送失败 有预扣,无落库任务 事务外盒或扫描凭证补发 静默等待,形成少卖
DB 提交后确认丢失 消费者认为结果未知 同一键重试,读取既有订单 先归还库存,再重复落库
取消释放与成功确认并发 两条状态转移争同一凭证 版本条件更新,只接受当前版本 后到者无条件覆盖先到者

这张表解释了为什么「加一把分布式锁」不够:锁保护的是一段执行时间,库存正确性还跨越消息投递、数据库提交、超时重试和补偿任务。每个窗口都需要可查询的业务事实与幂等状态转移,而不是靠调用者记住自己刚才做过什么。

库存一致性不是让两个副本尽量同步,而是让每一次批准都有可追溯的单一裁决点。缓存可以快,队列可以削峰,最后仍要有一本账能回答:这张票为什么属于这个订单。

超卖与少卖,不是镜像问题

超卖意味着平台收了无法履行的款:退款、补偿、客服、合同责任与用户信任同时发生。它的单次成本可能远高于票价。少卖是还有真实座位却显示售罄,主要损失收入与转化;它也严重,但通常可以通过补库存、二次开售或补偿任务修复。

因此库存策略常有意偏向「宁可短时少卖,不让系统批准无法履约的订单」。例如 Redis 预扣成功、DB 落库超时但结果未知时,先把这张库存视为占用,待对账确认后再释放;若立即归还,原请求其实已落库,就可能让同一张票再次出售。这里购买的是安全边界,账单是暂时冻结一部分收入。

第 4 章讲过缓存与 DB 双写不一致:更新顺序只能缩短窗口,不能让两个独立写入自动成为原子事务。库存正是那个读错代价高到不能拿普通 cache-aside 冒险的 key。第 12 章会把预扣、排队、限流与最终落库放进完整秒杀漏斗里。

10 · 3 病历卡这不是新病,是旧病在同一张票上会诊

超卖不新增病历卡。它是几张既有卡同时落到高价值状态上的综合题:双写窗口让两本库存账分叉,缓存故障让批准权失控,锁失效则可能让两个持有者同时进入临界区。

病历卡 No.03缓存与 DB 双写不一致▁▁▁▁▁ Redis 预扣与 DB 实际库存分属两次写入,任何失败窗口都要靠预扣凭证、补偿和对账收敛。第 4 章定义 →
病历卡 No.04缓存穿透▁▂▂▃▃▄▄ 非法场次或伪造库存 key 若次次落到权威存储,会在开票时持续消耗宝贵的扣减容量。第 5 章回顾 →
病历卡 No.05缓存击穿▁▁▁█▁▁▁ 热门场次库存 key 失效时,并发回源会把本应串行裁决的热点重新砸到数据库。第 5 章回顾 →
病历卡 No.06缓存雪崩███▁▁▁▁ 大批库存 key 同时失效,会让预扣层退出战斗;此时入口限流必须保护权威库存账。第 5 章回顾 →
病历卡 No.13分布式锁失效▁▁█▁▁ 锁过期、误删或故障切换会让两个请求同时认为自己持有库存裁决权;不要把锁名当成原子性证明。第 11 章定义 →

这场会诊的共同处方只有一句:先找出批准库存变化的权威点,再把缓存、锁、队列和数据库放到它的前后。若架构图上有两处都能独立回答「还有票」,超卖只是等待一个足够密集的并发窗口。

10 · 5 权衡库存计数放 DB,还是放 Redis

第 5 章说过:库存这个 key 对旧值零容忍,缓存三种过期保护方案都不敢直接拿来当真相。现在钩子收回来——DB 与 Redis 都能持有裁决权,但只能选清楚谁批准、谁跟随。每一个都是对的,每一个都有账单。

决策点 D2

顶流开票峰值下,可售库存计数应该放在哪里?

A · DB 权威库存:事务与订单写入容易放在同一数据边界,审计和恢复路径最直观。账单:热点行成为串行瓶颈,峰值前必须用限流和排队把请求压到已验证容量。适合库存量和并发可控、正确性预算高于吞吐预算的场景。

B · Redis 预扣裁决:Lua 在内存中完成原子判断与扣减,能把大部分竞争挡在数据库外。账单:预扣与落库之间要处理丢消息、重复消费、超时未知和补偿;Redis 故障恢复也必须重建权威状态。适合瞬时流量极高、具备成熟对账与演练能力的场景。

C · 分层持有:只为少数顶流场次支付 Redis 预扣的复杂度,长尾库存继续走 DB 原子更新。账单:两条路径意味着两套容量、对账和故障手册,场次切换时还要确保裁决权不重叠。适合热点高度集中、平台治理能力足够的业务。

「秒抢」的选择:采用 C。普通场次由 DB 原子扣减;顶流场次在开票前把明确配额装入独立 Redis 单元,Lua 预扣同时写凭证,订单事件进入第 9 章的队列异步落库。DB 账与 Redis 预扣账持续对账,故障时默认停止新增批准而不是猜库存。第 12 章会把这条独立通道放进完整漏斗。

条件翻转

假设这次不是一场 5 万个座位的顶流开票,换成同一天 1,000 个小场次同时开票,每场 50 张、各自独立计数(总库存仍是 5 万)。库存从一个人人都在抢的热 key,变成一千个并发量很低的普通 key。你在 D2 的选择还成立吗?

翻转变量是「库存的集中度」——是不是存在一个所有人都在抢的单一热 key,而不是库存总量。C 方案在 D2 的账单里,唯一要花钱解决的问题是「热点行成为串行瓶颈」;1,000 个各自 50 张的场次里,任何一个 key 的并发都只是原来的千分之一量级,DB 条件更新(UPDATE ... WHERE stock >= n)单独扣一个 key 完全扛得住,热点行这个前提本身就不存在了。这时候 B 是在为一个已经不存在的问题继续买单:预扣与落库之间的丢消息、重复消费、故障重建这些复杂度一分没少,换来的收益却是零。C 更隐蔽——它想在 1,000 个结构相同的场次里挑几个「更热」的特殊对待,但 D2 里「顶流场次」的定义本来就是「并发压过 DB 单行能力」,这批场次里没有一个满足这个定义,C 的差异化标准无从谈起,只是把 D2 的路径依赖原样搬了过来。

这个翻转有边界:如果 1,000 个场次真的在同一秒集中开票,1,000 个 DB 连接同时抢连接池、日志刷盘,聚合写入吞吐仍可能压过整台 DB 的总容量——只是这次要保护的是总吞吐,不是某个热点行,第 2 章的连接池和第 6 章的入口限流才是对应的处方,而不是重新请回 Redis 预扣。

10 · 6 复盘那 300 张票后来怎么处理了

回放 10:07 之后的正确剧本——先停止新增义务,再锁定事实,最后才修代码与恢复销售:

10:09

冻结入口与出票。关闭该场次销售,暂停未分配订单的自动出票;有效付款数停在 50,300,差额不再扩大,原始日志和三本账快照完成保全。

10:18

按座位权利对账。合并场馆锁座、渠道保留票、订单与支付状态,确认 50,000 笔可履约、300 笔缺少座位;法务、客服和产品据此确定补偿优先级。

10:42

切换原子扣减。库存入口改为 DB 条件更新,以影响行数作为批准结果;关闭旧的读后写路径,并用并发回放证明库存 1 时只有一个请求成功。

12:10

逐笔履约与补偿。客服联系 300 位受影响用户,提供退款与补偿选择;确认账平后才恢复其他场次销售,并启动 Redis 预扣方案的专项设计而非现场冒险上线。

壹
库存的成功必须由业务不变量证明。接口成功率、库存非负和数据库健康都可能同时成立;还要核对成功扣减数、剩余库存与初始可售量。
貳
先冻结义务,再修技术。超卖时继续销售或自动出票都会增加法律与信誉账单。止血动作是停止新增批准、保存事实,而不是先让监控变绿。
叁
宁可可解释地少卖,不要不可履约地多卖。超时未知时先保留预扣,靠对账后释放;把安全偏向写进状态机,别让每个调用方临场猜测。

10 · 7 自测你能守住最后一张票吗

Q1 · 看形状诊断

库存服务成功率和延迟曲线呈 ▁▁▁▁▁,没有技术告警;日终却发现 Redis 预扣数与 DB 订单数相差 27。首先应该联想到什么?

A。平坦曲线却由对账发现差额,正是 No.03 的无症状型。C 很诱人,因为基础设施全绿,但双写窗口可能只影响少数业务记录;D 在没有逐笔凭证时直接覆盖,会掩盖哪些订单应补扣或释放。

Q2 · 并发算术

库存只剩 1,50 个请求先同时读到 1,再各自写回 0,并且都据此创建订单。最多产生多少笔超出真实库存的订单?

C。真实库存允许 1 笔,50 个请求都被非原子逻辑批准,因此超出 49 笔。A 是最危险的误判:覆盖写让最终值保持 0,无法从负库存发现超卖;D 把合法的第一笔也算进了超出量。

Q3 · 原子扣减

执行 UPDATE inventory SET stock=stock-1 WHERE show_id=? AND stock>0 后,应用应依据什么决定是否创建订单?

B。条件判断与扣减已经在一条语句中完成,影响 1 行才代表本请求拿到库存。A 又把判断搬回非原子读路径;D 读取的是另一份可能落后的账;C 只能说明快慢,不能证明获批。

Q4 · 超时未知

Redis 预扣成功后,订单落库请求超时,调用方不知道 DB 是否已提交。此时最安全的第一步是什么?

B。超时只表示调用方没拿到结果,不证明事务失败。A 最诱人,却可能在原事务已提交时把同一库存再卖一次;C 换订单号会绕过幂等护栏。保留预扣会短时少卖,但先守住不可履约风险。

🎯 面试映射 · 本章直接覆盖的高频追问

「秒杀系统怎么防超卖?」 答题骨架:定义单一库存裁决点 → 拒绝非原子读改写 → DB 条件更新或 Redis Lua → 成功凭证与幂等键 → 队列削峰 → 对账与补偿 → 压测最后一张票。
「预扣库存和实际库存怎么对账?」 答题骨架:初始配额 → 预扣凭证 → 订单落库 → 支付状态 → 取消释放 → 按业务键做差集 → 超时未知先查询再补偿。说明对账不变量与人工处置入口。
「Redis 挂了,库存怎么办?」 答题骨架:先停止新增批准 → 明确 RDB/AOF 与副本恢复边界 → 用已落库订单和预扣凭证重建 → 对账后慢放量。反直觉加分点:库存服务降级应偏向少卖,不能在状态未知时绕过权威账继续收款。