3.4 超卖兜底:数据库最后防线

超卖事故复盘时,架构师问了一句话:Redis 预减、分布式锁、乐观锁三层防护都上了,为什么还能超卖?查到的原因是 Redis 主从切换的瞬间,预减库存的值没同步过来。这次复盘让我们换了个思路:别指望任何一层是万能的,真正不能超卖的只有数据库,所有兜底设计都应该让最后防线简单到不可能错。

场景:四道防线与各自失效模式

我们盘点了一下四道防线可能失效的方式:

防线 失效模式 失效后果
前端限流 按钮可绕过 请求洪水
Redis 预减 主从切换丢值 预减失效
分布式锁 锁超时 并发穿透
DB 条件更新 无 最终不能超卖

前三层都会失效,只有数据库的条件更新是在 InnoDB 层面保证原子性。兜底设计的思路随之明确:前三层是「尽量挡住流量」,第四层是「就算挡不住也不能错」。它们的价值不是杜绝超卖,而是把到达数据库的并发控制在可承受范围。

方案:数据库层面的四重约束

超卖的最后防线由四个数据库约束叠加构成:

约束 实现 作用
条件扣减 UPDATE ... WHERE available >= qty 原子防超
唯一键约束 idempotentKey 唯一索引 防重复单
状态机条件更新 WHERE status = from 防状态冲突
恒等式约束 触发器或对账 记账守恒

其中最硬核的是第一条。一切库存扣减 SQL 都必须写成「条件更新」,任何「先 select 再 update」的实现都是错的。代码评审我们把它列为自动打回项,与 库存冻结的记账设计 是同一套规则的最终防线。

代码:兜底约束的审计脚本

防线是否有效靠定期审计。我们写了个脚本扫描所有更新 t_stock 的 SQL,检查是否都带条件:

-- 恒等式检查:找出破坏 available + frozen <= total 的行
SELECT sku_id, available, frozen, total,
       available + frozen - total AS overflow
FROM t_stock
WHERE available + frozen > total;

这个查询每天跑一次,正常情况返回空集。某次它返回了三行,查到一个新来的同事写的需求绕过了公共 DAO 直接改了 available 字段,复盘后 DAO 层加了写权限的统一收口。

踩坑:三个防线失效案例

第一个是 Redis 主从切换丢失预减值。解决办法不是让 Redis 更可靠(做不到),而是默认它会丢:预减只是许可,不是账本。真正的账本只有 MySQL,超卖与否永远由 SQL 的 WHERE 条件决定。预减失败回退到数据库即可。

第二个是前端时间把按钮禁用,结果用户开了俩页面绕过。这说明前端任何校验都只是体验优化。在 高并发防护 的讨论里也强调,凡是能被用户构造的输入都不能作为安全边界。

第三个是库存为 0 的边界。available >= qty 在 qty 为 1 时把 available=0 的行排除在外,看起来安全。但某次活动允许下单 qty=0(作为赠品),条件变成 available >= 0 永真,超卖发生了。教训:数量字段必须有非零下限约束,qty <= 0 的请求在参数校验阶段直接拒绝,不进入库存逻辑。

还有一个运营视角的经验:超卖发生后别急着「释放订单」,先冻结现场。我们保留当时的 t_stock 行快照和 Redis 值,事后全部能复现,定位超卖发生在哪一层只用了半小时。快速定位依赖日志的完整性,这部分在 CLS 全链路日志 一节展开。

进阶实战:超卖应急预案

超卖真的发生时,最重要的不是技术,是处理流程。我们定过一份应急预案,第一次用上时全员按流程执行,没有二次事故。

阶段 动作 时限
发现 监控自动报警或用户投诉 立刻
冻结 涉事 SKU 下架,停止下单 5 分钟
定位 快照 + 流水还原超卖路径 1 小时
补救 按策略补货或退款 当天
复盘 全链路文档与改进项 一周内

应急预案里特别规定:技术同学第一动作不许是「修复数据」,而是先保留现场、冻结流量。数据修错了还能再修,现场破坏了就永远说不清。这个规定救过我们一次:运营急着恢复销售,催技术直接清掉异常库存,坚持按流程保留快照之后,定位速度反而更快。

超卖补救的业务策略也要提前定好。哪类商品超卖优先补货、哪类直接退款并补偿优惠券、高件值商品超卖要不要加人工审批,这些让业务先定原则,技术只负责执行。技术临时决定业务策略是大忌,所有人的标准不一致会更乱。

速查清单

超卖防线的落地检查:

检查项 方法
SQL 全部条件更新 代码扫描器
恒等式每日校验 审计脚本
参数校验非零下限 接口层拦截
快照保留机制 现场可还原

小结

超卖兜底的哲学是「让最可靠的层最简单」:数据库条件更新是唯一可信的防线,其他层都是性能优化。审计脚本守护防线不被绕过,参数校验挡住边界输入。记住:任何宣称「不会超卖」的中间件方案,最终都要回答数据库那行 SQL 长什么样。

常见误解:防超卖的三个陷阱

第一个陷阱是「防御都在应用层」。很多团队的防线是「缓存挡、锁挡、队列挡」,数据库那层只有普通 SQL。一旦前面任何一层失效,超卖立刻发生。真正不可妥协的只有数据库层的条件更新,其他层都是为它挡流量。这个认知决定了排查优先级:看库存扣减的 SQL 是不是条件更新,比看缓存策略重要。

陷阱 误区 真相
防线在应用层 锁和缓存足够 数据库才是契约
超卖必然致客诉 可补货兜底 看品类策略
库存为 0 最可怕 数量边界更隐蔽 qty=0 条件永真

第二个陷阱是「超卖一定需要赔付」。补货成本低、时效要求不高的品类,第一时间联系供应商补货往往比退款更优。赔付策略按品类分级:普通品补货、贵重品退款加补偿券、生鲜品直接销毁处理(无法补货)。业务规则先行,技术按规则执行。

第三个陷阱是「只盯库存为 0 的边界」。前面提过 qty=0 的条件永真事故,类似的边界还有负数、超大数(一次买十万单的测试单)。输入校验是防超卖的一部分,数量必须在合理区间内。所有从外部进来的数值,都要假定它可能超出预期。

团队协作:超卖应急的跨部门演练

超卖应急预案写过之后,团队做了一次桌面演练:模拟大促当天某爆款超卖 50 单,从报警到决策到赔付全流程跑一遍。演练暴露的最大问题不是技术,是决策权归属——技术想直接退款,运营想补货,客服等着话术,三方互相等了二十分钟。演练之后明确了决策树:先由运营按品类策略定方向,超时十分钟由值班经理兜底。决策路径清晰了,真事故才不会内耗。

演练场景 暴露问题 改进
超卖爆发 决策权不清 明确决策树
快照取证 取证脚本不熟 固化工具
用户通知 话术不统一 预置模板

演练的另一个产物是「超卖取证工具箱」:一键保存库存行快照、冻结流水、相关订单、Redis 值。工具箱的存在让现场保护从「口口相传的纪律」变成可执行的步骤。每年大促前演练一次,团队对超卖的肌肉记忆一直保持在线。防超卖是技术问题,处理超卖是组织问题,两个都重要。演练当天我们还邀请了财务与法务旁听,他们对「赔付口径」的现场讨论直接完善了应急预案的两个空白条款,这又一次说明演练的参与者越全越好。预案里没写清楚的决策空白,只有把所有相关方拉到一起演一遍才暴露得出来。预案不是写出来的,是演出来的,这句话后来贴在了作战室的墙上。演练之后我们还把超卖应急预案做成了十五分钟可讲完的版本,录成内训视频,让全组随时能复习。

延伸阅读