3.1 库存冻结:先占后扣

库存模块第一版是「下单直接扣库存」,上线第二周出了事:用户下单后不付款,库存被占着,其他用户买不了,运营只能手工释放。被投诉两次后我们改成「支付时再扣库存」,结果超卖 200 单——并发下单时库存看似够,支付时才发现不够。这两个极端之间,就是「先占后扣」的位置。

场景:三种扣减时机的对比

下单扣库存、支付扣库存、预扣冻结,三种时机的差别全在对「占而未付」的处理上:

方案 超卖风险 占而未付 适合场景
下单直接扣 低 无法释放 秒杀
支付时扣 高 无 付款快
预扣冻结 低 超时自动释放 通用

预扣冻结是三者之间的平衡:下单时把库存从可售数搬到冻结数,支付成功再从冻结数真正扣掉,超时未付把冻结数搬回可售数。库存总数恒等式(库存总数 = 可售数 + 冻结数)在任何时刻都成立。

方案:双字段记账

库存商品表设计为三个计数字段,而不是一个总数:

字段 含义 谁改
available 可售数 下单减、回滚加
frozen 冻结数 下单加、回滚减、支付减
total 物理库存 仓库入库

恒等式 available + frozen <= total,所有更新都要维护这个不变量。记账而不只是计数是关键:审计时可以还原任何时刻的流水。

代码:冻结与释放

冻结的 SQL 是一个原子性的带条件的更新:

public boolean freeze(long skuId, int qty) {
    String sql = "UPDATE t_stock SET available = available - ?, frozen = frozen + ? "
                 + "WHERE sku_id = ? AND available >= ?";
    return jdbcTemplate.update(sql, qty, qty, skuId, qty) == 1;
}

available >= ? 这个条件就是数据库层面的防超卖:并发情况下只有一个请求能更新成功。返回 false 意味着库存不足,调用方直接告诉用户库存不够,不存在「先查再更新」的中间窗口。

释放是对称操作,frozen 减、available 加。这里必须防一个坑:释放只能对冻结过的订单执行,判断条件依赖订单的 stock_frozen 标记,不能仅凭状态推断。

踩坑:记账法的三个细节

第一个是 DB 事务边界。冻结库存和创建订单必须在一个事务里,一旦订单写成功而库存没冻结成功,事务回滚,两者保持一致。但事务时长必须控制,曾经我们在事务里串行调用了风控接口,事务拉长到 800 毫秒,行锁把 t_stock 表卡死,整个下单链路雪崩。教训:事务内不许有外部网络调用。

第二个是释放不能对同一单执行两次。某次超时关单任务与支付回调并发执行,关单释放了库存,回调又把它扣掉,数据彻底乱掉。解决办法:释放前先做条件更新,只有 stock_frozen=1 才执行,同时更新标记位。这与 防重复下单的幂等设计 是同一个套路,把并发竞争压到数据库的条件更新里。

第三个是库存预热。商品详情页读库存每次都查 t_stock,大促时 DB 扛不住。可售数读 Redis 缓存,但缓存只能读不能参与记账决策——真实冻结永远以数据库为准。缓存不一致时宁可让用户看到旧库存,下单时以数据库条件更新结果为准,这个原则在 云数据库 Redis 的应用 里也有类似讨论。

还有一个会计视角的经验:每天对账时核对 available + frozen 的总和与 t_stock 的 total 是否一致,任何不一致都说明记账有漏洞。这个检查救过我们一次,某次发布把释放逻辑写成只改 frozen 不改 available,对账当天就发现了。

进阶实战:库存表结构与流水

记账法落地之后,还有一个配套设计:库存变动流水表。每次冻结、扣减、释放都写一条流水,字段包括订单号、动作类型、数量、操作时间。流水的价值在两处:一是审计可还原,二是异常可定位。某次客服反馈某 SKU 的可售数突然多了 10,翻流水发现是某订单释放了两次,顺藤摸到任务重复执行的 bug。

流水动作 数量方向 触发方
冻结 available 减 下单
扣减 frozen 减 支付成功
释放 available 加 超时关单
回滚返还 frozen 减 available 加 人工补单
入库 total 加 仓库系统

流水表按月分区,与订单表的生命周期对齐。流水只追加不修改,任何库存修正都新增一条记录而非更新历史。这个纪律保证流水本身就是完整的证据链。

再补一个压测观察:冻结操作的热点 SKU 行锁是单机并发上限的决定因素。模拟秒杀某爆款时,并发 1000 全部打同一行,行锁等待把单 SKU 的扣减吞吐限制在约 800 每秒,加机器没用。解决方式是「库存分桶」:把一个 SKU 的库存拆成 N 个桶,扣减按用户哈希分散到各桶,查询时聚合。分桶之后单 SKU 吞吐提到 6000 以上,代价是临界点的超卖要各桶之间再做一次平衡。分桶在秒杀大促前临时启用,日常不用。

速查清单

库存记账的四条铁律:

铁律 说明
三字段记账 available、frozen、total
条件更新防超卖 where available >= qty
流水只追加 修正走新记录
事务内无远程调用 控制事务时长

小结

先占后扣的核心是记账思维:三个字段、一个恒等式、所有更新都是条件更新。冻结与释放必须幂等,事务内不许外部调用,缓存只读不决策。下一篇讲并发更高时的第二道防线,乐观锁。

常见误解:库存设计的三个陷阱

第一个陷阱是「库存就是减一是加一」。真要这么简单,库存系统就不会是电商最难的部分。真实库存有多个口径:可售库存、实物库存、在途库存、预售库存,每种口径服务不同业务。订单中心只管「可售库存」这一本账,其它口径归别的系统,这个边界前面说过一次,这里强调的是:单一口径记账是简单的前提,混口径必乱。

陷阱 后果
混口径记账 账面与实物永远差一截
冻结记录只在内存 重启即丢失
事务包远程调用 事务拉长拖垮锁

第二个陷阱是「冻结记录只放内存」。Redis 里记一份「谁冻结了什么」而不落库,Redis 一重启全部丢失,那些订单变成「冻结 nobody knows」。我们的做法是冻结操作先落流水表(订单维度永久记录),再做 Redis 缓存加速,MySQL 才是账本。这个教训和幂等键的设计如出一辙:内存提速,磁盘保命。

第三个陷阱是「事务图省事包远程调用」。库存事务里调用风控、调用券系统,事务时长从毫秒拉到秒级,行锁被长时间持有。规则是事务内只做数据库操作,外部调用全部移到事务外(事务提交后发消息)。整改后大促期间数据库行锁等待下降了 80%,这条纪律后来被写进团队代码规范第一条。

团队协作:库存异常的作战室

库存出问题时,跨团队协作的效率决定影响面。我们组建了虚拟「作战室」:库存、订单、客服、运营各出一人,一旦 P1 库存告警自动拉群。作战室的 SOP 很简单:技术先给结论「旁观」,即涉事 SKU 已下架;运营评估影响范围并通知用户;客服统一话术;技术排查根因;每天两次进度同步。第一次启用作战室时,用户感知上只觉得「处理得很快」,并不知道背后跨了多少团队。

角色 职责
库存开发 冻结与释放代码层面定位
DBA 数据库快照与流水还原
运营 业务影响评估与通知
客服 统一话术与工单分流

库存系统的每一次改动还必须做「横向影响清单」:订单、履约、客服系统分别有哪些地方依赖库存接口。清单与回归测试绑定,改动涉及哪几个系统就跑哪几个系统的回归。这是单体解耦之后保持质量的必要功课,可参考 服务治理实践 的变更管理思路。

延伸阅读