库存不是一个数字:从 On Hand 到 Available、Reserved 的销售订单库存预留设计
很多业务系统一开始都会把库存理解成一个字段。
商品有库存,库存够就能卖,库存不够就拦住。这个模型在零售 POS 的瞬时交易里通常能跑起来:顾客下单、收款、出货几乎发生在同一条链路上,系统在支付成功时扣库存,问题不大。
但一旦销售订单进入批发、赊账、订货、长周期履约场景,问题就出来了。
客户今天创建了一张待支付销售单,约定了 100 件货。库存表里还有 100 件。因为还没付款,系统没有扣库存。第二个业务员看到库存仍然是 100 件,又卖了一张 100 件的订单。等第一张订单支付、审核或发货时,系统才发现库存不够。
这不是支付校验写得不够严,也不是把 SQL 再加一层锁就能解决的问题。根因是系统缺少 ERP 库存模型里非常关键的一层:订单承诺库存。
换句话说,库存不是一个数字。
1. 先把三个库存口径分清楚
设计库存预留之前,要先把 On Hand、Reserved 和 Available 分开。

On Hand 是现存库存,也就是仓库当前真实持有的实物数量。它回答的是:仓库里现在有多少货。
Reserved 是已承诺库存,也可以叫已预留库存、已占用库存。它回答的是:这些货虽然还在仓库里,但已经承诺给某些业务单据,不能再随便卖给别人。
Available 是可售库存。它回答的是:现在还可以继续承诺给新订单的数量。
一期最核心的公式很简单:
Available for Sale = On Hand - Active Reserved
未来如果系统继续扩展损坏冻结、质检冻结、安全库存、草稿单保留等能力,公式会变成:
Available for Sale = On Hand - Active Reserved - Active Hold
在途采购、调拨在途、预计生产入库属于供给侧信息。它可以参与补货计划和预计可承诺日期,但默认不应该直接变成当前可售库存。货没入库,就不能当成仓库现有货来卖。
2. 库存预留不等于库存扣减
这是整套设计里最容易被混淆的地方。
库存扣减表达的是实物库存已经发生变化。比如支付后立即出货、仓库确认发货、生产领料出库,这些动作会减少 On Hand,会写库存流水,后续还可能影响成本和财务口径。
库存预留表达的是业务承诺已经发生,但实物还没离开仓库。它不应该减少 On Hand,也不应该写真实库存流水,更不应该提前触发财务成本。
它只影响一件事:可售库存。
举个例子:
仓库现存:100
客户 A 待支付订单预留:30
客户 B 待支付订单预留:20
现存库存 On Hand = 100
已承诺库存 Reserved = 50
可售库存 Available = 50
此时仓库里仍然有 100 件货。盘点时也应该看到 100 件。但销售端不能再认为还有 100 件可卖,因为其中 50 件已经被订单承诺占用了。
这就是“预留”和“扣减”的边界。
3. 为什么不能只在支付时校验库存
很多系统早期会采用支付成功时再校验和扣库存的方案。这个方案适合一个前提:订单创建到支付之间的时间窗口很短。
零售 POS 一般符合这个前提。顾客就在收银台前,订单从创建到支付只隔几秒钟。即使并发冲突,用户通常也能接受“支付时库存不足”的提示。
但批发、订货、赊账和长周期销售订单不一样。
待支付订单可能存在几个小时、几天甚至更久。业务员已经对客户做了承诺,客户可能已经安排了后续计划,仓库也可能开始备货。如果系统仍然等到支付或出库时才校验库存,那么库存不足就会从一个技术问题变成履约、客服和经营风险。
所以更合理的链路是:
订单创建完成
-> 校验可售库存
-> 创建库存预留
-> 可售库存下降
订单取消、超时、作废
-> 释放库存预留
-> 可售库存恢复
支付、发货或出库
-> 将预留转为真实库存消耗
-> On Hand 下降,Reserved 下降
注意这里有一个关键变化:创建订单时不是扣库存,而是先占用可售库存。
4. 订单状态和预留状态要解耦
销售订单有自己的状态,比如待支付、已支付、已取消、已完成。
库存预留也应该有自己的状态,比如有效、已释放、已转实扣、部分转实扣。
不要把库存预留直接塞进订单状态里。否则后续会遇到几个问题:
- 订单待支付不代表一定预留成功。
- 订单已支付不代表库存已经真实出库。
- 订单编辑后,旧明细对应的预留需要释放,新明细需要重新预留。
- 订单取消时,业务状态变了,但库存承诺也必须被释放。
- 后续如果支持部分发货、部分取消、分仓履约,订单状态和预留状态会天然一对多。
更稳的做法是给库存预留建立独立生命周期。

订单只是触发方,库存预留层负责表达“这张订单当前占用了多少可售库存”。
5. 一期可以只做销售订单预留,但边界要留对
完整 ERP 库存体系很大,不能一次性塞进一个版本。
一期真正要解决的问题是:待支付或长周期销售订单重复销售同一批库存。
因此可以先只做这条主链路:
待支付普通销售单创建:创建预留
待支付普通销售单编辑:释放旧预留,重建新预留
订单取消或超时:释放预留
支付或出库:预留转库存消耗
暂时不做的能力要明确写在边界里:
不做完整 ATP 预计可承诺日期
不做订单优先级重分配
不做手动抢占其他订单库存
不做 Backorder 缺货接单
不做完整 stock_hold 冻结库存体系
不做采购在途直接转可售
不做客户寄售、代管库存所有权体系
这些不是不重要,而是它们不应该和第一版销售订单预留混在一起。第一版只要把可售库存口径、订单预留账、真实库存消耗边界打稳,就已经能解决大量业务风险。
6. 推荐的领域结构
我更倾向于把这件事拆成四层。

第一层是库存事实层。
它只表达真实库存余额和真实库存流水。比如商品在某仓库、某批次、某仓位当前有多少库存,发生了哪些入库、出库、反冲、调整。
第二层是库存预留层。
它表达销售订单造成的需求侧承诺。可以有一张预留主表和一张预留明细表:主表记录业务单据、状态、版本、来源;明细记录商品、仓库、批次、单位和预留数量。
第三层是可售库存服务。
订单链路不应该再直接把库存余额当成可售库存,而应该统一走可售库存服务:
query On Hand
sum Active Reserved
calculate Available for Sale
check whether the requested quantity can be promised
第四层是真实库存消耗编排。
支付、发货、出库时,它负责减少库存余额、写库存流水、处理幂等、异常和反冲。库存预留只是在这个阶段被转换,不替代库存消耗。
用一句话概括:
库存事实层回答“仓库里有什么”。
预留层回答“哪些库存已经承诺出去了”。
可售服务回答“还能不能继续卖”。
消耗编排回答“库存为什么真的变少了”。
7. 并发下不能先查后插
库存预留最危险的实现方式是:
先查 available = 100
判断 100 >= 80
插入一条预留 80
如果两个订单并发执行,它们可能同时读到 available = 100,然后都成功插入预留,最终预留 160,直接超占。
所以预留创建必须满足几个原则。
第一,预留校验和写入要在同一事务里完成。
第二,同一库存事实粒度要串行化或原子更新。粒度至少要包括租户、仓库、商品,启用批次、仓位后还要继续细化。
第三,不能只靠业务层先查再判断,要有数据库层面的并发保护。常见方式是锁住库存事实行后再汇总有效预留,或者维护可售/已预留汇总字段并通过条件更新保证不超占。
伪 SQL 可以这样理解:
UPDATE inventory_balance
SET reserved_qty = reserved_qty + :reserveQty,
version = version + 1
WHERE tenant_id = :tenantId
AND warehouse_id = :warehouseId
AND sku_id = :skuId
AND on_hand_qty - reserved_qty >= :reserveQty;
如果影响行数为 0,就说明并发下可售库存已经不足,不能继续创建预留。
如果系统不维护 reserved_qty 汇总字段,也可以在事务内锁库存事实行,然后二次汇总有效预留再写预留明细。关键不是选择哪一种表结构,而是不能让两个事务都基于同一个旧快照做承诺。
8. 编辑订单比创建订单更容易出错
销售订单创建时预留一次还比较直观。真正容易出问题的是待支付订单编辑。
比如原订单是:
商品 A:10 件
商品 B:5 件
用户编辑成:
商品 A:3 件
商品 C:8 件
这时不能只在新明细上补差,也不能只更新订单明细。稳妥的第一版做法是:
查询旧订单和旧预留
释放旧预留
替换订单明细
基于新明细重新创建预留
这几步必须在同一个事务内完成。
如果新明细预留失败,订单编辑也应该失败,旧订单和旧预留都不能被破坏。否则就会出现“订单明细已经变了,但库存预留还是旧的”这种脏状态。
如果后续系统要支持部分预留、分仓分批履约,可以再做差量算法。但第一版没有必要一上来就追求复杂差量更新。对销售订单这种明细规模通常有限的场景,全量释放 + 全量重建更容易保证正确性。
9. 支付时要把“本单预留”加回来
启用库存预留后,支付前校验的口径也要变化。
假设仓库现存 100 件。
订单 A 创建时预留了 80 件。全局可售库存变成 20 件。
当订单 A 自己发起支付时,如果系统直接用全局可售 20 去判断,就会错误地认为订单 A 的 80 件库存不足。
所以支付当前订单时,应该使用:
本单可用 = On Hand - 其他订单 Active Reserved
也可以理解成:
本单可用 = 全局 Available + 本单 Active Reserved
他人的预留不能算给本单,但本单已经占住的预留要算回来。
这也是为什么库存预留需要能按业务单据维度查询,而不能只维护一个粗粒度的 reserved_qty 汇总数字。汇总字段可以提升性能,但明细账仍然要存在,否则支付、释放、编辑和对账都会失去依据。
10. 预留转实扣要守住事务边界
支付、发货或出库时,系统要把预留转为真实库存消耗。
理想流程是:
校验订单状态
校验有效预留与订单明细一致
提交库存消耗
减少 On Hand
减少 Reserved
写库存流水
更新订单支付/出库状态
这里有两个关键点。
第一,预留和订单明细要能对得上。
如果订单明细已经被异常链路修改,而预留没有重建,支付时不能继续扣库存。更合适的处理是返回“预留与订单明细不一致”,要求用户刷新订单或重新保存,而不是把它当成普通库存不足。
普通库存不足表示:库存确实不够。
预留不一致表示:系统内的订单承诺账和订单明细账已经不一致,需要先修账。
第二,转实扣不能拆成多个没有保护的异步动作。
如果先释放预留,再扣库存失败,就会出现库存没有扣,但可售库存已经恢复,其他订单可能继续卖。
如果先扣库存,再标记预留已转换失败,就会出现库存扣了,但预留仍然占着,导致可售库存被重复压低。
第一版最稳的策略是:订单状态、库存余额、库存流水、预留状态在同一个后端事务边界内闭环。等系统有更强的事件账本和补偿能力后,再考虑拆异步。
11. 上线时最容易忽略历史待支付订单
库存预留不是一个普通功能开关。它改变的是可售库存口径。
如果系统已经存在大量待支付订单,上线后直接打开预留开关,会遇到一个问题:历史订单没有预留,但新订单开始按预留口径校验。
这会造成口径断层。
更稳的 cutover 流程是:
先上线表结构
再上线应用代码,开关默认关闭
选择商户灰度
按订单创建时间回填历史待支付普通订单预留
确认回填结果
再打开库存预留开关
开启开关时最好做门禁:
如果仍存在待支付普通订单且没有有效预留,不允许开启
关闭开关时也要做门禁:
如果仍存在有效预留,不允许关闭
否则系统很容易进入半新半旧的库存口径。
12. 报表一定要解释“库存为什么没少但不能卖”
库存预留上线后,用户最常见的疑问会是:
仓库明明还有 100 件,为什么只能卖 50 件?
如果库存列表只展示一个“库存数量”,用户会认为系统少货或算错。
所以库存查询、库存分布看板、销售订单支付库存不足弹窗,都应该逐步展示三个口径:
现存库存:100
已承诺库存:50
可售库存:50
对业务用户来说,“已承诺库存”通常比“预留库存”更容易理解。预留是技术动作,承诺是业务事实。
13. 常见反模式
第一种反模式:下单时直接扣库存。
这样虽然能避免超卖,但会污染库存流水。订单取消时还要反冲库存,如果中间涉及财务、成本或批次追溯,后续会越来越难解释。
第二种反模式:只在 Redis 里占库存。
Redis 可以用于秒杀、高并发缓冲或短期锁,但 ERP 里的订单承诺需要可审计、可回放、可对账。最终一定要落到数据库账本里。
第三种反模式:预留没有业务单据维度。
只有 SKU 级汇总,没有订单明细级预留,后续就无法回答“这 50 件到底被哪些订单占用了”,也无法准确释放、转换和回填。
第四种反模式:订单编辑只改明细,不重建预留。
这是制造幽灵预留和脏可售库存的高发路径。
第五种反模式:把预留失败混进订单状态。
订单审核、订单支付、库存预留、库存出库是不同事实。它们可以互相约束,但不应该互相替代。
14. 我会如何设计第一版
如果让我给一套 SaaS ERP 的销售订单库存预留做第一版,我会按这个顺序落地:
- 建库存预留主表和明细表,保留业务单据维度、订单行维度、仓库、商品、批次、单位、数量、状态和幂等键。
- 建可售库存服务,禁止订单链路继续直接把现存库存当可售库存。
- 订单创建成功后,在同一事务内创建预留;预留失败则订单创建失败。
- 待支付订单编辑时,全量释放旧预留并重建新预留,失败则编辑回滚。
- 取消、超时、删除待支付订单时释放预留。
- 支付、发货或出库时,将预留转换为真实库存消耗。
- 支付前校验使用“全局可售 + 本单预留”的口径。
- 上线时开关默认关闭,先做历史待支付单回填,再灰度开启。
- 库存列表逐步补充现存、已承诺、可售三个展示字段。
这套设计没有试图一次做完完整 ERP 库存体系,但它把最重要的边界立住了。
15. 结语
库存预留解决的不是“库存字段怎么减”的问题,而是“系统什么时候对客户做出库存承诺”的问题。
只维护一个库存数字时,系统只能回答仓库里还有多少货。引入 Reserved 之后,系统才能回答哪些货已经被订单占住。再通过 Available,系统才知道还可以继续卖多少。
对于 SaaS ERP 来说,这个边界非常重要。
预留不是扣减,承诺不是出库,可售也不是现存。把这三件事拆开,销售订单、库存流水、履约和财务后续才有继续演进的空间。
本文为作者原创,首发于掘金,阿里巴巴开发者社区 为同步发布版本。