库存系统:预占、扣减与一致性保障 — editorial cover photo

库存系统:预占、扣减与一致性保障

库存从下单到发货要经历预占、扣减、回补,每一步都是一致性博弈。

2025年6月19日·返回文章列表
文章大纲

库存这件事,外行人看就是减一个数字。写一个 UPDATE stock SET qty = qty - 1,看起来天经地义。真正做过电商库存系统的人都知道,这一个减号背后藏着一整套一致性博弈。可售库存、锁定库存、实物库存是三个互相联动的概念,下单要预占、支付要扣减、超时要回补、退货要入库,每一步都可能让账实脱钩。电商系统的资损,相当大一块来自库存账实不符,超卖了货发不出,或者少卖了库存虚占着卖不掉。

库存系统的设计,性能和一致性是一对永恒的张力。追求性能把库存计数全前置到 Redis,扛量是扛住了,但 Redis 和 DB 的一致性就成了新的雷。追求一致性把每次扣减都走 DB 的强校验,安全是安全了,热点商品的行锁会把并发拖垮。成熟的库存系统,是在这两端之间找到平衡,用预占模型保证不超卖,用原子扣减保证不丢更新,用对账兜底保证最终一致。

三种库存的联动关系

PlantUML
正在加载图表…
PlantUML diagram

图:三种库存联动——可售库存 = 实物库存 − 锁定库存,任何扣减回补操作都必须保持等式成立

先理清三种库存的含义。可售库存是用户在前端页面看到的那个数字,标着还剩多少能买。实物库存是仓库里实际物理存在的货量。锁定库存是用户下了单但还没付款的那部分,货被预占了但还没真正出库。

三者之间的关系是一个基本公式,可售库存等于实物库存减去锁定库存。用户看到能买 100 件,背后是仓库里有 100 件实物,没有人锁定。一旦有人下单锁了 5 件,可售就变成 95,锁定变成 5。这 5 件货还在仓库里,但不让别人再买。等这个人付了款,实物扣减 5,锁定也清零,可售维持在 95,因为实物从 100 降到了 95。如果这个人超时没付款,锁定释放,可售回到 100。

这个联动关系是库存系统一切设计的根基。任何扣减、回补、入库操作,说到底都是在调整这三个数字,并且必须保持它们之间的等式始终成立。等式一旦被打破,就会出现账实不符,要么超卖,要么库存虚占。

还有第四种库存是在途库存,采购下单了货还在路上没入库,这是实物库存的预备状态。多仓场景下每个仓独立维护自己的三种库存,调拨时在途库存会在两个仓之间流转。这里先聚焦单仓的三库存联动,多仓的复杂性后面再展开。

预占模型,下单锁货支付扣货

PlantUML
正在加载图表…
PlantUML diagram

图:库存预占模型——下单预占进锁定态,支付成功扣实物,超时释放回可售

库存的扣减不是一次性发生的,是分两个阶段。下单时预占,把库存从可售拨到锁定,防止用户犹豫期间被别人买走。支付成功才真正扣减,把锁定的库存清掉,实物库存减掉。如果超时未支付或者订单取消,锁定的库存释放回可售。

为什么非要分这两步,不能下单就直接扣实物。因为下单到支付之间有一段窗口期,通常是十几分钟到半小时。在这段时间里,如果直接扣实物,用户最后没付款,这批货就已经从实物库存里消失了,要再通过逆向流程加回去,中间的任何一步出错都会导致库存错乱。预占模型把这段窗口期的库存放在锁定态,不碰实物,用户付款了才真正消耗实物,用户不付款原样退回可售。这样实物库存的变动只发生在支付成功这个确定的时点,状态转换更少,出错的可能更小。

这个模型带来的代价是三种库存要同时维护,每次操作都要联动更新。但这点复杂度换来的是超卖防护和库存准确性的保障,值得。

防超卖的核心是原子条件扣减

库存扣减最致命的问题是超卖,卖出去了实际没货发。超卖的根因几乎都是把检查库存和扣减库存拆成了两步。

错误的写法是先 SELECT 查一下库存够不够,够了再 UPDATE 扣减。这两步之间有时间差,高并发下,A 查到库存够,B 也查到库存够,A 扣减成功,B 也扣减成功,结果库存被扣成负数,超卖。这是典型的检查与执行之间的竞态条件。

正确做法是把检查和扣减合并到一条带条件的 UPDATE 里。

UPDATE stock
SET sellable = sellable - #{qty}, locked = locked + #{qty}
WHERE sku_id = #{skuId} AND sellable >= #{qty}

WHERE sellable >= qty 这个条件是防超卖的灵魂。它让检查和扣减在数据库层面原子完成,要么这个条件成立且扣减成功,要么条件不成立扣减直接失败,不存在中间态。判断有没有扣成功,看返回的 affected rows,是 1 就是扣成功了,是 0 就是库存不足。这条 SQL 是库存防超卖的底线,任何绕开它的做法都是在制造超卖隐患。

高并发下的 Redis 前置

DB 层的带条件 UPDATE 虽然原子,但在极高并发下会退化。热点商品比如大促的爆款,几千个请求同时扣减同一行库存,这一行上的锁竞争激烈,DB 的 TPS 会成为瓶颈,扣减请求大量排队甚至超时。

解法是把库存计数前置到 Redis,用 Lua 脚本做原子的检查加扣减。Lua 脚本在 Redis 里是原子执行的,检查库存够不够和扣减在一个脚本里完成,等价于 DB 那条带条件的 UPDATE,但 Redis 单线程模型的处理速度远高于 DB。

String lua =
"local s = tonumber(redis.call('get', KEYS[1]) or 0) " +
"if s >= tonumber(ARGV[1]) then " +
" redis.call('decrby', KEYS[1], ARGV[1]) " +
" return s - ARGV[1] " +
"else return -1 end";
Long remain = jedis.eval(lua, Arrays.asList(stockKey), Arrays.asList(String.valueOf(qty)));

Redis 扣减成功代表预占成功,DB 那边通过 MQ 异步同步扣减结果。这样扣减的瓶颈从 DB 的行锁转移到了 Redis 的单线程吞吐,扛量能力上一个数量级。我在做限流这块时也用过同样的 Lua 原子脚本思路,把分布式限流的判断和计数压到 Redis 一个脚本里,效果是一样的,性能和原子性兼得。

但 Redis 前置带来了新的一致性挑战,Redis 和 DB 是两个数据源,怎么保证它们最终一致。

Redis 和 DB 的一致性兜底

PlantUML
正在加载图表…
PlantUML diagram

图:Redis 前置扣减 + DB 一致性兜底——Redis 扛并发、MQ 异步同步、对账兜底三道保障

Redis 扣减通过 MQ 异步同步到 DB,这是最终一致性的标准做法。这里最大的风险是 Redis 扣了库存,但消息没发出去,或者消费者宕机没消费成功。结果就是 Redis 少了一份库存,DB 没减,账实不符。DB 里的库存比 Redis 多,看起来还好,但 Redis 是扣减的源头,对账时两边对不上,而且如果以 DB 为准去修正 Redis,等于把已经扣掉的库存又加回去,超卖。

这个问题的解法分几层。首先消息发送和 Redis 扣减要绑在同一个逻辑里,最好用事务性消息或者本地消息表,保证 Redis 扣减成功的同时消息一定发出去。其次消费失败必须有补偿,消费者处理 DB 扣减失败要重试,重试到死信队列的要人工介入或者定时回补 Redis。最后要有对账兜底,定时任务比对 Redis 和 DB 的库存差值,差异超过阈值就告警。

对于资金或者实物这类对一致性要求极高的库存,光靠 Redis 前置还不够稳妥。更保险的做法是 Redis 扣减成功后,DB 层再做一次带条件的 UPDATE 作为最后防线。Redis 负责扛并发快速判断,DB 负责兜底强校验。两层都过了才算扣减成功。代价是每次扣减多一次 DB 操作,慢一些,但换来的是实打实的一致性保障。库存这种东西,错了损失的是真实的货,慢一点卖对,好过快一点卖错。

最危险的是扣减成功但消息丢失

Redis 扣了库存、消息没发出去,是最隐蔽也最危险的不一致场景。表面上系统还在正常跑,库存数字在 Redis 里是对的,但 DB 的库存一直没减,日终对账才发现差了一截。务必把消息发送和扣减绑死,配本地消息表或者事务性消息,再加对账兜底。这一环没做扎实,库存系统就是在埋定时炸弹。

多仓和逆向流程

多仓场景下,每个仓独立维护自己的三种库存,下单时要决定从哪个仓发货。最简单的策略是按收货地址选地理上最近且有货的仓。复杂一点的是拆单,一个订单里的商品分散在多个仓,要拆成多个子订单分别从不同仓发货。调拨是仓与仓之间的库存流转,货从 A 仓调到 B 仓,这段时间算 A 仓的在途库存,入库后变成 B 仓的实物库存。多仓让库存的状态机又多了一层在途状态,复杂性显著上升。

逆向流程,也就是退货入库,要格外谨慎。不能用户一申请退货就把库存加回去,必须等退货商品回到仓库、经过质检合格之后才入库。不合格的次品不入库,走次品处理流程,避免次品被加回可售库存又被卖给下一个用户。退款金额根据质检结果决定,全退、部分退还是不退。整个逆向流程要和正向的预占扣减一样,有清晰的状态机和原子操作,不能图快省步骤。

定期盘点是最后一道账实核对。系统库存和仓库实际盘点的实物库存比对,盘盈或者盘亏都要分析原因。盘亏可能是丢损、被盗,也可能是系统 bug 导致的账实脱钩。差异超阈值必须查根因,不能直接调账了事,直接调账会把真正的漏洞盖住。

一致性永远优先于性能

库存系统我反复强调一条原则,宁可慢一点卖对,不能快一点卖错。性能优化,Redis 前置也好、分片也好,目的是为了扛量,但前提是有一致性兜底,对账、DB 二次校验、消息补偿这些一个都不能省。见过太多团队为了把扣减 QPS 做高,把 DB 的带条件校验去了,结果超卖资损,省下来的那点性能根本填不平资损的窟窿。库存错了损失的是实打实的货,这条红线的优先级高于一切性能指标。

Continue Reading

关联文档推荐

查看全部
订单系统:状态机、逆向流程与一致性 — vibrant cover for technical blog article读写分离与主从延迟:DB 性能优化的基石 — vibrant cover for technical blog article排行榜系统:Redis ZSet 与分片计算的设计 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。