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 | 数据库快照与流水还原 |
| 运营 | 业务影响评估与通知 |
| 客服 | 统一话术与工单分流 |
库存系统的每一次改动还必须做「横向影响清单」:订单、履约、客服系统分别有哪些地方依赖库存接口。清单与回归测试绑定,改动涉及哪几个系统就跑哪几个系统的回归。这是单体解耦之后保持质量的必要功课,可参考 服务治理实践 的变更管理思路。
