缓存、队列和分片都能替你扛流量,却不能替你回答「最后一张票属于谁」。这一章把并发竞态摊在账本上:多卖是法律与信誉,少卖是收入,二者从来不对称。
创业第 3 年 5 个月,「秒抢」累计用户约 500 万,公司 190 人,后端 35 人。顶流场次十点开售,三分钟售罄。你刚在群里发出「扛住了」,客服主管把一张表放到你面前:场馆 50,000 个座位,系统却收了 50,300 笔有效付款。
你把库存、订单和支付三本账并排。缓存没有报错,数据库也没有宕机;真正刺眼的是几组彼此都「成功」的数字:
订单日志很快给出一组最小复现:库存还剩 1 时,请求 A 和 B 都在 10:02:11.204 读到 1;A 在 10:02:11.218 写回 0,B 在 10:02:11.222 也写回 0。两次更新都成功,两笔订单都进入支付,数据库最终库存仍是 0。监控看见的是两个成功事务,业务得到的是一张票卖给两个人。
这不是流量把机器压坏,而是并发把一段非原子逻辑拆开了。现在 300 位用户已经付款,任何一次仓促退款都可能演变成投诉;与此同时,还有渠道订单尚未完成座位分配,差额可能继续扩大。
B 是教科书答案:先冻结事实。关入口阻止差额继续扩大,停自动出票避免把座位重复分给不同订单;锁定库存、订单、支付和渠道快照,才能确认影响集合。先保存证据与业务上限,再决定补票、换场或退款顺序。
A 会把事故写进权威账本。Redis 数字加 300 不能让场馆长出 300 个座位,只会把明确的超卖伪装成「库存充足」。技术指标也许变绿,法律义务却增加了。
C 的问题是你还不知道哪 300 笔该退。付款时间不等于座位权利排序,渠道保留票、无障碍席和已分配座位都可能改变处置集合。未经核对批量退款,可能退掉已有合法座位的用户,却留下真正无法履约的订单。
D 方向可能有用,但救不了现在。热修复只能保护后续请求,无法撤销已经形成的 300 笔付款;故障中直接引入新锁还可能触发第 11 章会讲的过期与误删问题。先停写、对账,再用可证明的原子扣减替换竞态。
「可售库存的真相,对同一份额度,任何时刻只能有一个权威裁决规则批准扣减。」这条裁决规则可以落在数据库的一行、Redis 中由 Lua 原子操作的计数器,也可以是按场次串行消费的单一分区;关键不在产品名字,而在同一份额度在同一时刻只有一处能够批准扣减。额度本身可以被不重叠地拆分给多个裁决规则并发批准——第 15 章的单元化配额就是把库存按单元预先切成互不重叠的份额,让多个单元各自的裁决规则同时工作,只要每份额度不被重复使用、总量仍能对账闭合。
读-改-写 的危险不在每一步,而在三步之间可以插入别的请求。把现场缩成最后一张票:
最终库存 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 的凭证是历史事实,它对应的槽位已经变回可售,再单独加一次就会双计数。所以要拆成两条不变量:
代入这 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 章会把预扣、排队、限流与最终落库放进完整秒杀漏斗里。
超卖不新增病历卡。它是几张既有卡同时落到高价值状态上的综合题:双写窗口让两本库存账分叉,缓存故障让批准权失控,锁失效则可能让两个持有者同时进入临界区。
这场会诊的共同处方只有一句:先找出批准库存变化的权威点,再把缓存、锁、队列和数据库放到它的前后。若架构图上有两处都能独立回答「还有票」,超卖只是等待一个足够密集的并发窗口。
第 5 章说过:库存这个 key 对旧值零容忍,缓存三种过期保护方案都不敢直接拿来当真相。现在钩子收回来——DB 与 Redis 都能持有裁决权,但只能选清楚谁批准、谁跟随。每一个都是对的,每一个都有账单。
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:07 之后的正确剧本——先停止新增义务,再锁定事实,最后才修代码与恢复销售:
冻结入口与出票。关闭该场次销售,暂停未分配订单的自动出票;有效付款数停在 50,300,差额不再扩大,原始日志和三本账快照完成保全。
按座位权利对账。合并场馆锁座、渠道保留票、订单与支付状态,确认 50,000 笔可履约、300 笔缺少座位;法务、客服和产品据此确定补偿优先级。
切换原子扣减。库存入口改为 DB 条件更新,以影响行数作为批准结果;关闭旧的读后写路径,并用并发回放证明库存 1 时只有一个请求成功。
逐笔履约与补偿。客服联系 300 位受影响用户,提供退款与补偿选择;确认账平后才恢复其他场次销售,并启动 Redis 预扣方案的专项设计而非现场冒险上线。
库存服务成功率和延迟曲线呈 ▁▁▁▁▁,没有技术告警;日终却发现 Redis 预扣数与 DB 订单数相差 27。首先应该联想到什么?
A。平坦曲线却由对账发现差额,正是 No.03 的无症状型。C 很诱人,因为基础设施全绿,但双写窗口可能只影响少数业务记录;D 在没有逐笔凭证时直接覆盖,会掩盖哪些订单应补扣或释放。
库存只剩 1,50 个请求先同时读到 1,再各自写回 0,并且都据此创建订单。最多产生多少笔超出真实库存的订单?
C。真实库存允许 1 笔,50 个请求都被非原子逻辑批准,因此超出 49 笔。A 是最危险的误判:覆盖写让最终值保持 0,无法从负库存发现超卖;D 把合法的第一笔也算进了超出量。
执行 UPDATE inventory SET stock=stock-1 WHERE show_id=? AND stock>0 后,应用应依据什么决定是否创建订单?
B。条件判断与扣减已经在一条语句中完成,影响 1 行才代表本请求拿到库存。A 又把判断搬回非原子读路径;D 读取的是另一份可能落后的账;C 只能说明快慢,不能证明获批。
Redis 预扣成功后,订单落库请求超时,调用方不知道 DB 是否已提交。此时最安全的第一步是什么?
B。超时只表示调用方没拿到结果,不证明事务失败。A 最诱人,却可能在原事务已提交时把同一库存再卖一次;C 换订单号会绕过幂等护栏。保留预扣会短时少卖,但先守住不可履约风险。