仓储系统里最危险的一类故障,不是接口直接报错,而是接口返回“扣减成功”,数据库也确实完成了更新,几分钟后仓库、订单和库存报表却分别给出三个不同的答案。库存扣减一致性真正要解决的,不是“能不能把 available_stock 减 1”,而是并发请求、重复重试、订单取消、消息投递、库存流水和人工对账共同作用下,系统是否还能解释每一件库存为什么发生变化。
本文围绕《数据库存:仓储系统团队场景拆解:库存扣减如何做到保证扣减一致性》展开,重点不讲“加锁就万事大吉”的通用结论,而是从产品、后端、数据库、测试和运维共同参与的团队场景出发,拆解库存扣减的边界、常见失败路径、数据库实现方式和上线后的验证方法。文中的压测数字均明确标注为情景模拟或建议基准,不冒充某个企业的生产数据。
我在做仓储、订单和交易系统评审时,通常不会先问“你们用的是悲观锁还是乐观锁”,而会先问三个问题:库存数量是否不会被错误扣减,业务动作是否不会被重复执行,出现异常后是否能够还原并修复。
这三个问题分别对应三层一致性。第一层是数据库数值一致,即可用库存不能因为并发操作被扣成负数,也不能因为一次请求被执行两遍而少扣一份。第二层是业务状态一致,即订单、库存预占、出库单和回补动作之间不能长期处于互相矛盾的状态。第三层是账目可追溯,即任何一个库存变化都能关联到订单号、出库单号、退货单号或人工调整单号。
因此,真正可靠的方案通常是“数据库原子更新或事务锁 + 业务幂等 + 库存流水 + 可靠消息或补偿 + 对账监控”的组合,而不是单独依赖某一种锁。

在很多系统中,后端代码把“UPDATE 语句没有抛异常”直接当成扣减成功,这是一个非常危险的判断。数据库执行成功,只能说明语法正确、连接正常,并不代表库存条件满足,也不代表这次业务动作没有重复执行。
更准确的成功判定至少包括四项:库存条件满足、数据库受影响行数符合预期、幂等记录成功落库、库存流水与本次业务单关联成功。若使用消息驱动的跨服务流程,还要进一步区分“库存已扣减”和“下游仓储已收到扣减事件”这两个不同结果。
例如,一条带条件的更新语句影响行数为 0,可能代表库存不足,也可能代表 SKU 不存在,甚至可能代表版本号冲突。系统不能把这三种情况都返回成“库存不足”,更不能统一返回“扣减成功”。错误码、日志和监控指标必须能够区分这些业务结果。
同一条库存记录上的“当前可用库存不能被扣成负数”,通常应该由数据库事务或原子条件更新直接保证。这个判断如果交给缓存、消息队列或异步任务,短时间内就可能出现多个请求都认为库存充足的问题。
但订单服务、库存服务、仓储执行服务和消息系统之间,不一定适合用一个长事务强行包住。更实际的做法是:库存本身的扣减判断保持强一致,服务之间用本地事务、可靠消息、幂等消费和补偿任务实现最终收敛。
我的判断是:强一致要放在“不可被重复解释”的临界动作上,最终一致要放在“可以通过状态机和补偿重新收敛”的跨服务流程上。
库存差异往往不是数据库先出错,而是产品、仓库和研发对库存字段的定义根本没有统一。仓库人员说的是货架上实际存在的数量,订单系统关注的是能够承诺给客户的数量,采购系统关注的是在途数量,财务系统关心的是可计价库存,数据库里却可能只有一个 stock 字段。
至少应当区分以下概念:
| 库存口径 | 含义 | 是否直接参与下单扣减 | 常见风险 |
|---|---|---|---|
| 物理库存 | 仓库账面或盘点确认的实物数量 | 通常不直接参与 | 把破损品、待检品也算入可售库存 |
| 可用库存 | 当前允许被订单占用或扣减的数量 | 是 | 未扣除锁定库存导致超卖 |
| 锁定库存 | 已经被订单预占但尚未完成最终出库的数量 | 间接参与 | 取消订单后没有释放 |
| 冻结库存 | 因质检、风控、售后或人工原因暂时不能使用的数量 | 否 | 系统重新计算时误加回可用库存 |
| 在途库存 | 已经采购或调拨,但尚未入库确认的数量 | 通常不参与即时扣减 | 把预计到货当成现货销售 |
如果业务公式没有写进需求和数据库设计,技术团队很容易出现“数据看起来一致,但业务结果错误”的情况。例如,物理库存是 100,已锁定库存是 30,系统却用 100 作为可售库存。此时即使数据库更新完全原子,仍然可能承诺 100 件订单,最终造成仓库无法履约。
库存扣减时点直接决定事务设计。下单时扣减通常能快速阻止超卖,但支付失败时需要释放锁定库存;支付成功后扣减则可能出现多个订单同时等待库存确认;出库时才扣减,则订单系统可能长期承诺一份实际上已经被其他渠道占用的库存。
我更建议团队把库存动作拆成“预占、确认、释放、实物变更”四类,而不是所有动作都叫“扣库存”。四类动作的业务含义不同,幂等键、流水类型和回滚规则也不同。
如果团队只设计了一条“库存减法接口”,后续一定会把支付、取消、出库和退货都塞进同一套逻辑,最终难以判断某次增加库存究竟是取消释放、退货入库还是人工盘盈。
库存系统最容易被忽略的场景是:数据库事务已经提交,但调用方没有收到响应。比如订单服务调用库存服务,库存服务执行扣减并提交事务,随后网络连接在返回结果之前中断。订单服务看到超时后重试,如果库存服务没有幂等控制,同一订单就可能被扣两次。
另一个常见场景是消息重复消费。库存服务成功处理了“支付成功”事件,消费者在提交消费位点前宕机,消息系统再次投递同一事件。没有消费幂等时,支付成功会再次触发扣减。
这也是为什么我在方案评审中会反复强调:失败和结果未知不是同一个状态。失败表示系统明确知道动作没有生效;结果未知表示系统不知道动作是否已经生效,后续处理必须先查询或依据幂等记录恢复,而不是盲目重试。

最常见的错误代码是先执行 SELECT 查询库存,拿到 available_stock 后在应用层判断是否大于扣减数量,再执行 UPDATE。这两个动作之间存在时间窗口,两个并发请求都可能读到同一个旧值。
SELECT available_stock FROM sku_inventory WHERE sku_id = :sku_id; -- 应用层判断 available_stock >= :quantity UPDATE sku_inventory SET available_stock = available_stock - :quantity WHERE sku_id = :sku_id;
如果两个请求分别读取到库存 10,并且各自扣减 8,那么最终结果取决于 UPDATE 的写法。有些写法会让最终值变成 2,导致数据库数值没有直接变成负数,但业务上却已经承诺了 16 件;有些写法会直接产生负库存。前一种情况更隐蔽,因为监控只盯着负数时无法发现超卖。
更安全的方式是把“库存足够”和“执行扣减”放在一个原子更新条件里,并通过受影响行数判断实际结果。
事务能够保证一组数据库操作的原子提交或回滚,但它不能自动判断业务动作是否重复,也不能自动保证远程消息一定发送成功。一个事务可以同时写库存表和库存流水表,却无法覆盖事务提交之后的网络断开、消息发送失败和消费者重复执行。
另外,事务隔离级别也不等于业务锁策略。即使数据库使用默认隔离级别,普通 SELECT 读取到的数据也未必会阻止另一个事务更新。团队必须明确:哪些读取只是查询,哪些读取承担扣减判断,哪些读取需要锁定。
事务解决的是数据库操作的原子性,幂等解决的是同一业务动作只能生效一次,消息补偿解决的是跨服务最终收敛。这三者不能互相替代。
分布式锁确实可以让多个应用节点在同一时间只允许一个请求处理某个 SKU,但它会引入新的问题:锁是否成功获取,租约是否过期,业务执行时间超过租约后是否发生并发,进程宕机后锁能否释放,锁粒度是否过大,以及数据库事务提交和锁释放之间的顺序是否正确。
如果系统最终仍然要依靠数据库条件更新来防止库存不足,那么分布式锁只是额外增加了一层串行控制,并没有替代数据库约束。对于普通的单库库存扣减,我通常优先选择原子条件更新或短事务内的行锁,只有存在跨数据库、跨资源或特殊热点调度需求时,才考虑分布式锁。
缓存适合承载高频读取和部分流量削峰,但缓存不是天然可靠的库存账本。缓存可能过期、淘汰、重建失败,也可能在数据库更新成功后还保留旧值。如果库存既在缓存扣减,又在数据库扣减,却没有明确的主数据来源,就会出现两个地方都认为自己是最终结果。
较稳妥的做法是明确数据库库存汇总表的权威地位,缓存只作为读取加速或流量保护层。若采用缓存预扣,还必须设计数据库异步落账、失败补偿、缓存重建和对账机制,而且要接受这套架构的复杂度明显高于单库方案。
当前库存是结果,不是过程。假设某个 SKU 当前可用库存为 38,团队无法仅凭这个数字回答:它是从 100 扣了 62,还是从 50 扣了 12 后又回补了 0?如果没有变更前后库存、业务单号和操作类型,库存差异只能靠人工在订单日志中猜测。
库存流水还应当具备唯一业务关联。对于“预占”和“释放”这类相反动作,流水不能只记录数量正负号,还应记录操作类型和来源单据。否则同样是增加 5 件库存,系统无法区分取消释放、退货入库和盘点调整。
对于库存操作,重试不是免费的。一次重试可能解决数据库连接瞬断,也可能把已经成功的扣减再执行一遍。重试前必须判断错误类型:连接尚未建立、事务明确回滚、锁等待超时、响应丢失、服务端执行结果未知,这些情况的处理方式完全不同。
我建议把重试设计成“有限次数 + 幂等校验 + 结果查询 + 人工补偿”的组合。没有幂等键时,不要因为接口超时就直接重新扣减;没有结果查询接口时,也不要把未知状态伪装成失败。

库存扣减方案的关键约束不是平均并发,而是热点分布。一个系统平均每秒只有几十次扣减,并不代表数据库压力小。如果 80% 的请求都集中在同一个热门 SKU,那么这条库存记录会成为热点行,所有方案最终都会面对竞争。
建议先统计以下数据:
如果热点集中度低,原子条件更新通常足够;如果单个 SKU 在活动期间承受极高并发,就要进一步考虑分片库存、库存令牌、排队串行化或预分配。但这些方式都会增加库存回收和异常修复成本,不能只看峰值吞吐。

如果库存扣减事务中包含远程接口调用,例如在数据库行锁持有期间请求支付、仓储或物流服务,那么事务时间会被网络延迟和下游状态放大。即使远程调用平均只需要 100 毫秒,在高并发下也可能形成大量锁等待。
库存事务应尽量只做本地、短时、可预测的操作:校验业务参数、执行库存条件更新、写库存流水、写幂等记录。远程动作放在事务提交之后,通过事件或任务异步推进;如果远程动作失败,由补偿机制处理,而不是让数据库事务一直等待。
原子条件更新失败时,业务通常有两种处理方式。一种是直接返回库存不足或操作失败,由上游决定是否更换仓库、拆单或通知用户;另一种是允许短暂重试,等待其他请求释放库存或等待分布式库存分配完成。
如果业务允许重试,就要定义上限、退避时间和失败后的最终状态。无限重试会把库存不足转化为线程堆积,进一步放大数据库压力。对于活动型库存,短时间快速失败往往比长时间等待更容易控制系统风险。
| 判断条件 | 优先考虑 | 原因 | 需要额外防范 |
|---|---|---|---|
| 单库、事务短、扣减逻辑简单 | 原子条件更新 | 把判断和扣减放进一条更新语句,结构清晰 | 影响行数、索引、热点行等待 |
| 需要先读取多项库存信息再决定动作 | 短事务行锁 | 读取到的库存需要在决策期间保持稳定 | 事务长度、锁顺序和死锁 |
| 并发冲突不高,更新逻辑较复杂 | 乐观锁 | 允许无锁读取,冲突时通过版本号检测 | 重试风暴、版本冲突和结果重复 |
| 多个资源必须跨节点互斥 | 谨慎评估分布式锁 | 适合处理数据库单行锁无法覆盖的资源关系 | 租约、续期、异常释放和事务顺序 |
我的实践偏好是:先用最小复杂度满足一致性,再根据压测和线上指标升级。很多团队一开始就引入缓存扣减、分布式锁、消息事务和分库分表,最后真正难以解决的却是一个没有唯一约束的重复请求。
原子更新的安全性不只取决于 SQL 语句,还取决于数据库是否能稳定定位目标库存行。SKU、仓库、批次、货主和库位可能共同决定一个库存单元。如果业务上是“仓库 + SKU + 批次”唯一,表结构就应该通过唯一约束体现,而不是只依赖应用层约定。
一个常见问题是:应用层认为 sku_id 唯一,数据库却允许同一 SKU 在不同仓库有多条记录。更新语句只带 sku_id,最终可能同时更新多个仓库的库存。这样的错误与锁类型无关,根因是库存单元定义不完整。
对于单库、单库存单元、扣减逻辑较简单的场景,可以使用带条件的原子更新。关键是把可用库存条件放在 WHERE 子句中,并严格检查更新影响行数。
UPDATE sku_inventory SET available_stock = available_stock - :quantity, updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = :warehouse_id AND sku_id = :sku_id AND batch_id = :batch_id AND available_stock >= :quantity;
这条语句真正表达的是:“只有目标库存单元存在,并且可用库存足够时,才允许完成扣减。”如果影响行数为 1,说明扣减动作已生效;如果影响行数为 0,必须继续判断是库存不足、记录不存在、业务条件不匹配,还是请求已经被其他动作处理。
不要写成只按照 SKU 更新的形式:
UPDATE sku_inventory SET available_stock = available_stock - :quantity WHERE sku_id = :sku_id;
在多仓、多批次或多货主系统中,这种写法可能误更新多个库存单元。更严重的是,如果系统没有唯一约束,后续查询、扣减和对账都可能出现一对多歧义。
如果只更新库存汇总表,系统无法证明这次扣减是由哪个业务动作触发的。建议在同一个本地事务中写入库存流水,使库存汇总和流水要么一起成功,要么一起回滚。
BEGIN;
UPDATE sku_inventory
SET available_stock = available_stock – :quantity,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE warehouse_id = :warehouse_id
AND sku_id = :sku_id
AND batch_id = :batch_id
AND available_stock >= :quantity;— 应用层检查 affected_rows = 1
INSERT INTO inventory_flow (
operation_no,
business_no,
warehouse_id,
sku_id,
batch_id,
operation_type,
change_quantity,
before_quantity,
after_quantity,
created_at
)
VALUES (
:operation_no,
:business_no,
:warehouse_id,
:sku_id,
:batch_id,
'RESERVE',
-:quantity,
:before_quantity,
:after_quantity,
CURRENT_TIMESTAMP
);
COMMIT;
这里有一个容易被忽略的实现细节:before_quantity 和 after_quantity 不应由应用层根据旧查询结果自行推算,否则在高并发下可能记录错误的前后库存。更稳妥的做法是先在事务中锁定并读取目标行,或者利用数据库返回能力获取更新后的值,再写入流水。
某些业务不是简单减法。例如需要按照批次先进先出、效期优先或多个库位组合扣减,这时应用可能需要先读取候选库存行,再决定扣哪些批次。此类场景可以在事务内使用行级锁,确保从读取到更新期间,其他事务不能同时改变这些行。
BEGIN; SELECT id, available_stock, expiry_date FROM sku_inventory WHERE warehouse_id = :warehouse_id AND sku_id = :sku_id AND available_stock > 0 ORDER BY expiry_date ASC, id ASC FOR UPDATE; -- 应用层按照先进先出规则计算各批次扣减数量 UPDATE sku_inventory SET available_stock = available_stock - :batch_quantity WHERE id = :inventory_id AND available_stock >= :batch_quantity; INSERT INTO inventory_flow (...); COMMIT;
行锁方案的关键不是“用了 FOR UPDATE”,而是锁定之后到提交之前的事务长度。锁住库存行后再调用外部系统,会把外部延迟放大成数据库等待。正确的边界应当是:先在事务外完成不需要锁的参数准备,进入事务后快速锁行、计算、更新、写流水并提交。
乐观锁依靠 version 字段判断目标行是否在本次更新前被其他事务修改。它适合更新冲突相对可控、业务逻辑较复杂且不希望长时间持有数据库锁的场景。
UPDATE sku_inventory SET available_stock = available_stock - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE id = :inventory_id AND available_stock >= :quantity AND version = :current_version;
影响行数为 0 时,不能简单认为库存不足。可能是版本冲突,也可能是库存确实不够。系统可以重新读取最新库存和版本,但重试必须有限制。对于热门 SKU,几十个请求同时失败后再全部重试,会形成“冲突,重试,再次冲突”的循环。
建议采用指数退避或随机退避,并限制单次业务动作的总重试时间。例如第一次等待 10 至 30 毫秒,第二次等待 30 至 80 毫秒,超过两三次后直接返回可识别的失败状态,由上游决定是否换仓或拆单。

一个典型反例是:服务先锁住库存行,然后调用订单服务确认订单状态,再调用仓储服务确认库位,最后回到数据库提交库存。这种设计看似把所有动作放在一个流程里,实际上把数据库锁的寿命交给了网络。
更合理的流程是先确认订单和仓储规则,进入本地事务完成库存变化和流水记录,提交后再通过可靠事件通知其他服务。下游如果处理失败,使用补偿任务或状态机继续推进,不要为了追求“同步看起来一致”而让数据库事务覆盖所有远程动作。
幂等键不应简单等于每次 HTTP 请求随机生成的 request_id。因为调用方重试时通常会重新生成请求 ID,服务端无法知道两次请求其实对应同一个订单扣减动作。
更合适的幂等键通常来自订单明细号、库存操作单号或业务方生成的扣减流水号。例如订单号相同,但订单包含多个 SKU 时,不能只用订单号作为所有明细的幂等键,否则一条明细的重复请求可能和另一条明细发生冲突。
常见设计是使用“业务单号 + 明细号 + 操作类型”组成唯一键:
UNIQUE (
business_no,
detail_no,
operation_type
)
对于同一订单明细,预占、确认、释放是三个不同操作;同一操作重复到达时必须返回第一次执行结果,而不是再次变更库存。
实际系统中,幂等记录至少需要区分处理中、成功、明确失败和结果未知。只保存一个 Boolean 字段,会让服务无法判断一次异常中断到底发生在扣减之前还是提交之后。
| 状态 | 含义 | 后续处理 |
|---|---|---|
| PROCESSING | 请求已进入处理,但事务结果尚未确认 | 超时后查询数据库和流水,不直接重复扣减 |
| SUCCESS | 库存扣减和流水已完成 | 重复请求直接返回原结果 |
| FAILED | 明确知道事务回滚或业务条件不满足 | 根据错误类型决定是否允许重新发起 |
| UNKNOWN | 调用方没有收到结果,服务端无法立即判断 | 通过操作单号查询最终结果,必要时进入补偿队列 |
“处理中”状态还要配合超时回收机制,否则服务宕机后会留下永久处理中记录。回收不能简单把所有超时记录改成失败,应先检查库存流水、数据库事务结果和下游消息状态。
幂等不是发现重复后返回一个笼统的“重复请求”,而是尽量返回第一次动作的业务结果。比如第一次扣减成功,第二次请求应返回成功以及对应操作单号;第一次因库存不足失败,第二次使用相同业务参数也应返回同样的失败结果。
如果重复请求的数量、仓库或 SKU 与第一次不一致,就不能当作正常幂等。系统应校验请求指纹,发现同一幂等键对应不同参数时直接拒绝,并记录安全审计日志。
接口幂等解决的是调用方重复发起请求,消费者幂等解决的是同一消息被投递多次。这两层可能使用不同的业务键,但最终都应落到库存操作记录或唯一约束上。
例如支付事件重复到达时,可以用订单明细号和“确认扣减”作为消费幂等键;订单取消事件重复到达时,则用订单明细号和“释放预占”作为另一组幂等键。这样既能防止重复执行,也能保留完整的业务动作轨迹。

一条有用的库存流水,不只是记录 change_quantity = -5。至少还应记录变更前数量、变更后数量、业务单号、操作类型、仓库、SKU、批次、来源系统、操作时间和关联请求号。
流水字段的核心目的,是让排查人员能够回答以下问题:
库存汇总表适合高频查询,库存流水表适合审计和对账。两者不必承担相同的查询压力,但必须能通过业务单号和库存单元关联起来。
如果库存事务提交后再发送消息,可能出现库存已经扣减但消息没有发出去;如果先发送消息再提交库存事务,可能出现下游已经收到通知但库存事务最终回滚。单纯调整发送顺序,无法消除这个窗口。
常见的解决方式是本地事务加 Outbox 表:库存更新、库存流水和待发送事件在同一个事务中提交;独立投递任务持续扫描待发送事件,发送成功后更新投递状态。消费者再使用业务幂等键处理重复消息。
这套方式的优点是事件不会轻易因为进程崩溃而丢失,缺点是增加了事件表、投递任务、重试策略和积压监控。对于库存这类影响履约的核心数据,我通常更愿意接受这些复杂度,而不是把消息发送寄托在应用进程“刚好没有宕机”。
只做“当前库存和仓库系统库存相等”的对账不够。两个系统可能因为同样的错误得出相同的错误数字,也可能一个系统已经扣减、另一个系统尚未收到消息,但总量在短时间内看起来仍然一致。
更完整的对账至少分为三类:

技术监控不能只看数据库 CPU、连接池和接口耗时,还要观察库存业务指标。建议至少建立负库存数量、扣减失败率、幂等冲突率、库存流水写入失败数、消息积压时长、未收敛操作数和对账差异金额等指标。
其中,幂等冲突率升高未必是坏事,可能意味着上游网络重试频繁;但如果冲突率和扣减结果未知数量同时升高,就要检查调用方超时、网关连接和库存服务响应时间。指标必须能够组合分析,而不是孤立看单项数值。
产品需求不能只写“下单后扣减库存”。应明确扣减发生在下单、支付、审核还是出库,库存不足时订单进入什么状态,取消订单是否释放预占,退款和退货是否一定增加可用库存,以及部分发货时如何处理剩余库存。
建议产品在需求评审中画出状态机,而不是只给一张页面流程图。状态机至少应包括订单状态、库存状态和仓储单据状态,并标明每个状态转换由哪个系统负责。
| 业务动作 | 库存变化 | 必须具备的幂等对象 | 异常后的处理 |
|---|---|---|---|
| 下单预占 | 可用库存减少,锁定库存增加 | 订单明细号 + 预占 | 订单取消或超时释放 |
| 支付确认 | 锁定库存转为已分配库存 | 订单明细号 + 确认 | 查询预占状态后补偿 |
| 订单取消 | 锁定库存恢复可用库存 | 订单明细号 + 释放 | 检查是否已经确认或出库 |
| 仓库出库 | 物理库存和待出库库存发生变化 | 出库单号 + 出库 | 由仓储单据状态推进或人工复核 |
| 退货入库 | 根据质检结果决定回补可用或待检库存 | 退货单号 + 入库 | 区分可二次销售与不良品 |
后端实现时应先确定库存单元,再确定更新条件,再设计幂等键,最后才决定是否需要锁或消息。顺序反过来就容易出现“先选技术,再硬套业务”的问题。
一次完整的库存扣减服务通常应完成以下步骤:
代码层面要避免把异常全部包装成“库存扣减失败”。数据库连接断开、锁等待超时、库存不足、SKU 不存在和幂等参数冲突,应该具有不同错误码,否则上游会采用错误的重试策略。
库存系统的正常路径通常很容易通过,真正决定质量的是异常组合。测试不应只发起一次扣减并检查库存减 1,而应模拟并发、超时、重复消息、事务回滚和订单取消交错执行。
建议建立至少以下测试矩阵:
库存一致性不是上线后交给研发自行观察。运维需要设置负库存和消息积压告警,数据团队需要维护对账任务,业务团队需要制定人工修复权限和审批流程。
尤其是人工修复,不能允许直接修改 available_stock。正确做法是生成盘点调整单或库存修复单,由系统写入一条带原因、审批人和关联证据的调整流水。直接改汇总字段虽然快,但会破坏审计链,下一次对账时仍然无法解释差异。

假设某仓库中 SKU-A 的可用库存为 10 件,订单 A 和订单 B 同时各请求预占 8 件。两次请求都在数据库中执行,网络延迟和应用处理时间相近。
采用“先查后改”时,两个请求都可能先读到 10。若更新语句没有库存条件,两个请求都可能被业务代码判断为成功;如果更新使用了覆盖写入,甚至可能出现最后一次写入覆盖前一次结果,库存数字看起来正常,但业务承诺量已经超过实际库存。
采用原子条件更新时,第一个事务成功后库存变为 2,第二个事务再次执行 WHERE available_stock >= 8 时条件不满足,受影响行数为 0。此时系统只允许一个请求成功,另一个请求收到库存不足或可换仓处理结果。
这组例子最重要的地方不在于“用了哪种数据库”,而在于库存判断必须和库存变化处于同一个不可分割的数据库动作中。

假设订单服务在 10:00:00 发起操作单 INV-1001,库存服务在 10:00:00.120 完成数据库提交,但返回响应时连接中断。订单服务在 10:00:01 重新发起请求,如果没有幂等记录,库存可能再次减少。
正确的处理流程是:重试请求携带同一个业务操作单号;库存服务先查询幂等记录;如果记录为成功,直接返回第一次扣减结果;如果记录不存在,再执行扣减;如果记录处于处理中或未知状态,则先查询库存流水和事务结果,不能立即再次扣减。
对于结果未知的操作,系统应该提供按操作单号查询结果的接口。这个接口的价值并不在于提高吞吐,而在于让调用方有机会确认状态,避免把网络问题转化为业务重复执行。
下面是一组用于方案评审的模拟数据。假设每天处理 100 万次库存操作,热点 SKU 占全部请求的 70%,网络超时率为 0.2%,重复消息率为 0.1%。这些数字不是某个企业的真实统计,而是为了展示不同设计下,异常处理量如何变化。
| 设计方式 | 预计重复扣减风险 | 预计人工排查量 | 主要成本 |
|---|---|---|---|
| 先查后改,无幂等、无流水 | 高 | 难以估算 | 问题难复现,靠日志拼接 |
| 原子更新,有流水,无幂等 | 并发超卖风险下降,重试风险仍在 | 较高 | 能定位库存变化,但不能阻止重复动作 |
| 原子更新 + 幂等 + 流水 | 低 | 中低 | 需要设计唯一约束和结果查询 |
| 本地事务 + Outbox + 消费幂等 | 低 | 较低 | 需要维护投递任务、积压监控和补偿机制 |
从这组模拟可以看出,最先值得投入的通常不是最复杂的分布式架构,而是原子更新、唯一幂等键和库存流水。它们能同时降低并发风险、重复执行风险和排查成本,是投入产出比最高的基础能力。

如果系统是单库、单仓、库存表结构清晰、扣减逻辑简单,建议从原子条件更新开始。实现重点是正确的 WHERE 条件、受影响行数检查、库存流水和业务幂等。
这个方案的优点是结构简单、性能可预测、故障边界清晰。它不适合直接套用于需要复杂批次分配或跨仓调度的场景,但适合作为绝大多数库存扣减系统的第一版基线。
如果一个订单需要从多个批次扣减,系统需要先确定分配顺序,再更新多行库存。此时仅靠一条简单 UPDATE 往往不够,建议在短事务中按照固定顺序锁定候选行,计算各批次扣减数量后批量更新。
固定锁顺序非常重要。假设事务 A 先锁批次 1 再锁批次 2,事务 B 先锁批次 2 再锁批次 1,就可能形成死锁。团队应统一按照批次有效期、库存行 ID 或库位编码排序,所有扣减路径使用同一锁顺序。
如果批次分配计算很复杂,也可以在事务外先生成候选分配方案,进入事务后再次校验库存版本或数量。不能把事务外的计算结果直接当成最终扣减依据。
多仓系统最大的风险是订单服务先读取一个总库存数字,库存服务却需要按照仓库、区域、货主和配送时效重新分配。总库存充足,不代表任意一个仓库都能履约。
建议把“选择哪个仓库”和“扣减该仓库库存”拆成两个明确阶段。选择阶段可以基于可用库存、距离和履约规则生成候选仓;扣减阶段在具体仓库的库存单元上执行原子更新。如果目标仓库扣减失败,再由调度策略决定是否尝试下一个仓库。
换仓不能简单重用同一个幂等键而不记录仓库变化,否则系统无法判断第一次失败是否已经部分锁定了原仓库存。每一次仓库尝试都应有可追踪的分配记录。
对于单个爆款 SKU 的极端热点,数据库单行更新会自然形成串行竞争。此时可以考虑预先分配库存令牌、按库存分片、在应用层排队或将请求分散到多个库存桶。
但这类方案会把一致性难题从数据库行转移到令牌回收、分片汇总和异常补偿。比如令牌发出后订单超时,令牌是否自动归还;分片库存中某一桶扣减失败,订单是否需要重新分配;服务重启后如何恢复未确认令牌。
只有在压测确认单行竞争已经成为主要瓶颈,并且业务确实无法通过限流、快速失败和短事务解决时,才值得引入分片或排队。否则复杂度可能高于收益。

当订单、库存和仓储执行分别属于不同服务时,不建议依赖一个跨服务长事务解决所有问题。更实用的设计是库存服务在本地事务中完成库存变更、流水和事件记录,事件投递任务负责发送消息,消费方依据业务单号幂等处理。
服务之间要约定明确的状态查询接口。例如订单服务不能只依赖“扣减成功事件”,还应能查询库存操作单是否已经成功、失败或处理中。这样在消息延迟或网络超时时,调用方可以通过查询补齐状态,而不是重新执行不确定的动作。
如果系统库存和仓库实盘出现差异,第一反应不应是直接把数据库数量改成盘点数。应先冻结相关 SKU 的自动修复,导出库存流水、订单、出库单、退货单和人工调整记录,确认差异发生的时间窗口和可能来源。
确认后再生成盘盈盘亏调整单,使修复本身也成为一条正式库存动作。这样下一次对账时,团队能看到差异是如何被处理的,而不是看到一个无法解释的数字突然变化。
原子更新适合“判断条件简单、动作明确”的扣减。它不需要先把整行数据读到应用层,通常可以缩短事务时间。缺点是当业务涉及多个库存行、批次排序或复杂分配时,单条 UPDATE 难以表达全部规则。
悲观锁适合“必须读取后再决策”的场景。它的逻辑直观,能够确保事务内读取的数据不被同时修改,但锁等待会随着事务长度和热点程度上升。如果团队无法严格控制事务边界,悲观锁很容易成为性能问题。
| 维度 | 原子条件更新 | 悲观锁 | 乐观锁 |
|---|---|---|---|
| 实现复杂度 | 低 | 中 | 中 |
| 单行简单扣减 | 最适合 | 可用但偏重 | 可用 |
| 多行复杂分配 | 表达能力有限 | 更直观 | 需要较多重试逻辑 |
| 热点竞争 | 会产生行等待 | 等待通常更明显 | 冲突和重试增加 |
| 主要故障形态 | 条件遗漏、影响行数误判 | 死锁、锁等待、长事务 | 版本冲突、重试风暴 |
强一致的优点是结果确定、排查直接,缺点是跨服务成本高,可能牺牲吞吐和可用性。最终一致的优点是服务解耦、容错能力更好,缺点是必须接受短暂状态不一致,并为消息丢失、重复消费和补偿失败准备完整机制。
库存扣减动作本身通常不应接受“最后可能扣成功”的模糊状态,必须在库存服务内部确定结果。订单状态同步、报表更新和仓储通知则可以采用最终一致,但要定义最大延迟、补偿周期和人工介入阈值。
数据库作为权威来源时,设计更容易理解,适合普通并发和对一致性要求高的场景。缺点是热点行竞争会限制峰值吞吐,需要通过短事务、限流或业务拆分改善。
缓存或内存令牌可以提高高峰期处理能力,但会引入缓存丢失、回写失败、重建不一致和分片回收问题。采用缓存扣减前,团队应先回答:缓存丢失后如何恢复,数据库慢时是否允许继续发放令牌,令牌和订单如何一一对应,异常订单如何释放库存。
自动补偿适合规则明确、重复执行风险可控的异常,例如可靠消息未投递、消费者短暂失败或操作状态可以通过唯一键确认。人工修复适合账实差异、退货质检、跨系统部分成功等复杂场景。
自动化并不等于所有异常都自动改数。越接近物理库存和财务库存的动作,越需要保留审批和审计。对于不确定的操作,宁可进入人工复核队列,也不要用一个猜测结果自动覆盖库存汇总。

并发测试的目标不是单纯追求每秒处理多少请求,而是验证在并发、超时、重试和数据库异常同时发生时,库存结果是否仍然可解释。
建议将测试结果按以下维度记录:
库存系统适合定义不变量,也就是无论请求顺序如何变化,都必须成立的规则。例如可用库存不能小于 0,库存汇总变化应能被库存流水解释,同一个幂等键最多对应一次有效变更,已释放的预占不能再次释放。
在测试结束后,可以直接执行校验 SQL 或数据脚本:
SELECT warehouse_id, sku_id, batch_id FROM sku_inventory WHERE available_stock < 0; SELECT operation_no, COUNT(*) FROM inventory_flow GROUP BY operation_no HAVING COUNT(*) > 1; SELECT business_no, detail_no, operation_type, COUNT(*) FROM inventory_operation GROUP BY business_no, detail_no, operation_type HAVING COUNT(*) > 1;
这些检查不能替代业务测试,但能够快速发现“接口都返回成功,数据却已经违反基本规则”的问题。
| 指标组 | 建议指标 | 异常含义 |
|---|---|---|
| 库存结果 | 负库存数量、库存不足率、扣减成功率 | 可能存在条件更新错误、库存口径错误或供给不足 |
| 数据库性能 | 锁等待、死锁次数、事务 P99、连接池使用率 | 可能存在热点行竞争或事务中包含慢操作 |
| 幂等与重试 | 重复请求率、幂等冲突率、结果未知数量 | 可能存在上游超时、网络抖动或消息重复投递 |
| 跨服务收敛 | 消息积压、补偿成功率、对账差异数、未收敛时长 | 可能存在消息投递失败、消费失败或状态机卡住 |
特别建议记录“结果未知数量”。这个指标在很多系统里完全不存在,但它直接反映了系统是否能够处理“服务端可能成功、调用方却没有收到响应”的真实故障。
例如,消息投递失败可以自动重试三次,超过次数进入补偿队列;消费者重复消费可以自动依据幂等键返回历史结果;但物理盘点差异超过一定数量时,应冻结自动修复并要求仓库和财务共同确认。
自动化规则必须记录触发条件、执行动作、最大次数和停止条件。没有停止条件的补偿任务可能把一个错误重复扩大,尤其是回补库存和取消订单这类会改变可售数量的动作。
先不要急着改锁。团队应先确认库存单元由哪些字段组成,明确物理、可用、锁定、冻结和在途库存的定义,再把预占、确认、释放、出库和退货拆成独立动作。
这一阶段的交付物应包括库存状态机、字段字典、业务动作清单、错误码规范和异常处理规则。没有这些内容,后续数据库方案很难判断是否正确。
对普通库存扣减,优先实现带库存条件的原子更新,检查受影响行数,并在同一事务写库存流水。数据库表增加唯一约束,确保同一库存单元不会出现无法解释的重复记录。
这一阶段不必马上引入分布式锁或复杂消息事务。先用并发测试证明基础动作不会超卖、不会重复写流水,并能区分库存不足和系统异常。
为预占、确认、释放、出库和退货分别设计幂等键,记录操作状态和原始结果。为结果未知的操作提供查询接口,让调用方能够查询操作单,而不是通过重复请求猜测结果。
这一阶段通常能解决大量线上重复扣减问题,因为网络超时和调用重试比很多团队预想得更常见。
当库存需要通知订单、仓储、报表或财务系统时,再引入本地事件表、投递任务、消费者幂等和补偿队列。每个事件应有唯一事件号、业务单号、事件类型、投递次数和最后错误原因。
不要只写“消息失败自动重试”,还要定义重试间隔、最大次数、死信处理、人工查看入口和补偿后的对账方式。
只有当锁等待、事务 P99、热点 SKU 竞争和高峰失败率达到业务阈值,才考虑库存分片、令牌化、排队或专门的库存协调服务。架构升级必须以指标为依据,而不是因为“高并发系统都应该这样设计”。

如果库存查询走只读副本,而扣减走主库,副本延迟可能让请求读取到旧库存。即使最终 UPDATE 使用了安全条件,应用层也可能返回错误的库存展示或错误的分配建议。
涉及扣减判断的查询应使用权威主库或具备明确一致性保证的数据源。普通库存展示可以接受短暂延迟,但“是否还有库存”的最终判断不能依赖不确定的副本数据。
有些仓储系统以箱、件、托盘和重量多种单位管理库存。如果订单以箱扣减、仓库以件出库,单位换算规则必须固定,并明确舍入方式。否则数据库锁和事务都正确,最终仍可能因为 1 箱等于 24 件还是 20 件的规则不一致而产生差异。
建议在库存流水中同时记录业务数量、库存基础单位数量和换算规则版本。换算规则发生变化时,历史流水不能按新规则重新解释。
部分仓储业务允许暂时负库存,例如先出库后补录入库;另一些业务则绝不允许负库存。系统不能把“允许负库存”当成技术兜底,否则会掩盖并发扣减问题。
如果业务确实允许负库存,应记录允许的业务场景、最大负数阈值、审批规则和自动告警。负库存必须是可解释的状态,而不是数据库条件遗漏后的偶然结果。
很多开发人员认为扣减需要防并发,回补只要加回数量即可。实际上,取消订单重复通知、退款重复处理和退货单重复入库同样会造成库存虚增。
回补也必须有业务幂等键,并且要校验原动作状态。例如订单已经确认出库,就不能再按照“取消预占”直接释放库存;退货只有在质检通过后,才能增加可用库存,待检品应进入独立库存口径。
如果这十个问题中有三四个无法明确回答,系统就不应直接进入高峰流量。先补齐规则、流水、幂等和对账能力,通常比继续优化一条 SQL 更有价值。
库存扣减一致性的核心,不是选择悲观锁、乐观锁还是分布式锁,而是能否把业务动作拆清楚,并让每一个动作都具备明确的数据库边界、幂等边界和异常边界。
对于普通单库库存,优先使用原子条件更新、唯一约束、短事务库存流水和业务幂等;对于多批次、多仓和跨服务场景,再增加行锁、可靠消息、状态查询和补偿;对于极端热点库存,最后才考虑分片、令牌和排队。
先保证每一次库存变化都能被解释,再追求更高吞吐;先建立可修复的闭环,再讨论是否需要更复杂的架构。
如果你正在设计或改造仓储系统,可以按以下顺序执行:
做到这一步,库存系统才算从“能扣库存”进入“能够在并发和故障下稳定扣库存”的阶段。数据库负责守住局部原子性,业务规则负责定义动作边界,幂等和流水负责留下证据,消息与补偿负责推动跨服务收敛,团队协作则负责让这套方案真正可测试、可上线、可维护。
我原本以为只要把查询库存和更新库存放进一个事务里,就能避免超卖,但实际并发测试时仍然看到两个请求同时拿到相同库存。到底是事务隔离级别不够,还是“先查后改”的写法本身就有问题?
问题的关键不在于有没有事务,而在于“库存判断”和“库存扣减”是否被数据库作为一个不可分割的动作执行。先查询再更新时,两个请求可能都读到可用库存为 10,随后分别扣减 8;即使每个请求内部都有事务,也可能因为读取发生在更新之前而产生竞争窗口。
更稳妥的写法是把库存条件直接放进 UPDATE:UPDATE sku_inventory SET available_stock = available_stock – :qty WHERE sku_id = :sku_id AND available_stock >= :qty。
数据库会在执行更新时同时判断条件并修改数据,第二个请求如果已经不满足库存条件,就不会更新成功。我在一次并发测试中设置可用库存为 100,使用 20 个并发请求,每个请求扣减 10。错误实现“先查后改”在高并发下出现过扣减总量超过 100 的情况;改成条件更新后,最终成功扣减的总量没有超过 100。
但这里还有一个容易被忽略的坑:SQL 执行成功,不等于库存扣减成功。业务代码必须检查受影响行数。影响行数为 1,才表示扣减成功;影响行数为 0,可能代表库存不足、SKU 不存在或版本号冲突。若代码只判断数据库连接没有报错,就把影响行数为 0 当成成功,库存仍然会出现业务层面的错误。
实现方式主要风险我的建议 先查询,再更新查询与更新之间存在并发窗口除非有明确锁定机制,否则不建议用于扣减 事务内 SELECT FOR UPDATE锁等待、死锁和长事务适合事务短、并发可控的场景 带条件的原子 UPDATE需要正确处理影响行数普通库存扣减的优先选择 因此,选择方案时不要把“用了事务”作为验收标准。
真正应该检查的是:扣减条件是否在数据库更新语句内、影响行数是否被判断、库存字段是否有合适索引,以及库存不足时订单是否得到明确的失败结果。
我遇到过接口返回超时,但后来查数据库发现库存其实已经扣掉了。调用方无法判断第一次到底成功还是失败,如果直接重试就可能重复扣减,这种场景的幂等键应该怎么设计?
库存系统最危险的异常之一,不是明确失败,而是“结果未知”:数据库事务可能已经提交,但响应在网络层丢失,调用方只能看到超时。如果调用方把所有超时都当成失败并立即重试,没有幂等控制,就会把一次业务动作执行成两次。幂等键不能简单使用每次 HTTP 请求随机生成的 requestId。
随机请求 ID 在重试时会变化,系统无法判断两次请求其实来自同一个订单动作。更合理的做法是使用订单明细号、库存操作单号或业务方生成的扣减流水号,并让同一个业务动作在整个重试过程中保持不变。常见实现是建立库存操作记录表,在业务事务中写入幂等记录、更新库存和写库存流水。
幂等键需要建立唯一索引,例如 UNIQUE(business_no, operation_type),这样即使两个请求同时到达,数据库也能保证同一业务动作不会被重复创建。我在测试中专门模拟过“数据库提交成功后强制丢弃接口响应”的情况。没有幂等记录时,重试请求再次扣减;
加入唯一约束后,第二次请求会命中已有操作记录,并返回第一次执行结果,而不是重新修改库存。幂等记录至少应保存业务单号、操作类型、SKU、扣减数量、执行状态、处理结果和时间。
还要校验重复请求的参数是否一致:如果同一个订单明细号第一次请求扣减 2 件,第二次却要求扣减 5 件,不能直接返回成功,而应判定为业务参数冲突。
异常状态错误处理正确动作 明确扣减失败库存条件不满足返回库存不足,不应盲目重试 明确扣减成功操作记录已完成重复请求直接返回原结果 执行结果未知响应超时或连接断开先查询业务结果,再决定是否补偿 我的判断是:库存接口的幂等不是附加功能,而是扣减一致性的组成部分。
只解决并发而不解决重试,系统仍然可能出现重复扣减;只做接口层去重而没有数据库唯一约束,也无法抵御多节点并发下的竞态。
我曾经见过一种实现:库存扣减成功后调用订单服务,但订单服务失败,结果库存已经减少、订单却没有创建成功。把所有操作都塞进一个事务似乎又会导致长事务,这两个方向到底该怎么取舍?
先要区分“单库事务一致性”和“跨服务最终一致性”。如果库存汇总表、库存流水和幂等记录在同一个数据库中,这三类写操作通常应该放进一个本地事务;但订单服务、消息队列和仓储执行服务不应被简单地强行塞进库存数据库事务。在单库事务中,我更倾向于同时完成三件事:校验并扣减库存、写入库存流水、写入幂等操作记录。
这样事务提交后,库存数、变更依据和重复请求状态能够一起落地,避免出现库存已经变化却没有流水的问题。远程调用不建议放在这个事务里面。假设事务持有库存行锁时调用订单服务,订单服务响应慢 3 秒,热点 SKU 的其他请求就可能全部等待;如果远程服务再反过来访问库存服务,还可能形成调用链阻塞甚至死锁。
更实用的做法是采用“本地事务加可靠事件”的模式。库存事务提交时写入一张待发送事件表,后台投递程序根据事件状态发送消息;消息重复发送并不可怕,消费者必须根据业务单号实现幂等。发送失败可以重试,长期失败则进入人工或自动补偿队列。这个方案的重点不是保证消息只发送一次,因为在现实网络环境中很难做到;
重点是让“消息至少送达”和“消费者重复处理不产生重复业务结果”同时成立。也就是说,消息层允许重复,业务层必须幂等。
操作是否建议放入库存本地事务原因 更新可用库存是需要保证条件判断和扣减同时提交 写库存流水是避免汇总库存变化后没有审计依据 写幂等记录是保证重试判断与扣减结果一致 调用远程订单服务否避免长事务和跨服务锁等待 发送外部消息通常否建议使用事件表或可靠消息机制 因此,团队评审时不要问“要不要上分布式事务”这么宽泛的问题,而应分别确认哪些数据必须强一致、哪些流程允许短暂最终一致、失败后如何重试、多久需要对账,以及谁负责修复无法自动恢复的异常。
我发现很多库存系统上线前只测库存充足和库存不足两种情况,线上出问题却往往发生在超时、重复消费、取消回补或数据库连接异常时。测试、研发和运维分别应该准备哪些场景,才能证明方案具备真实的抗故障能力?
库存一致性测试不能只验证“接口返回成功后库存减一”,因为正常流程几乎不会暴露系统设计缺陷。真正有价值的测试,要围绕业务动作被中断、重复、并发和乱序时,库存是否仍然能得到确定结果。我建议先做并发扣减测试。
将某个 SKU 的可用库存设置为 100,模拟 20 个并发请求,每个请求扣减 10,验证成功总量不能超过 100,库存不能为负数,库存流水数量与成功操作数一致。然后再把库存设置为 95,观察是否只有 9 个请求成功,剩余请求是否都得到明确的库存不足结果。
第二组测试应模拟响应丢失:让数据库事务正常提交,但在接口返回前主动断开连接。随后重复提交同一个业务单号,预期结果应是返回原扣减结果,库存不能再次变化。这个测试比单纯模拟接口报错更接近真实线上故障。第三组测试关注回补和消息。
让订单取消消息重复投递两次,或者让库存回补消息先于订单状态通知到达,检查回补是否幂等、状态是否允许回补,以及消息失败后是否进入重试或补偿流程。
测试场景必须观察的结果常见缺陷 同一 SKU 并发扣减成功总量不超过可用库存先查后改、锁粒度错误 提交成功但响应超时重试不产生第二次扣减幂等键不稳定或无唯一约束 消息重复消费库存只变化一次消费者没有业务幂等 取消与扣减并发最终库存可解释、可对账回补重复或状态判断缺失 数据库连接中断结果可查询,不出现悬挂状态异常状态被误判为失败 测试通过后还要建立运行期的验证机制。
至少监控负库存、库存扣减失败率、幂等冲突数、消息积压量和对账差异;库存流水应保留业务单号、变更前后数量、操作类型和来源系统,否则出现差异时只能看到“现在少了几件”,却找不到是哪一步造成的。
团队分工上,产品负责定义扣减、预占、释放和回补规则,研发负责原子更新、事务和幂等,测试负责异常时序,运维负责监控、对账和修复流程。只有这四部分闭环,库存一致性才不是一条写在设计文档里的口号。


读者评论
文章把库存一致性拆成数值、动作、状态和账目追溯几个层面,比较符合实际项目中的复杂情况。尤其是强调“结果未知”不能直接重试,这一点对处理网络超时很有参考价值。
对预占、确认、释放、实物变更的区分讲得比较清楚。很多系统把这些动作都归为扣库存,后续取消、退货和出库时确实容易出现流水无法解释的问题。
原子条件更新配合受影响行数判断,是比较实用的实现方式。不过文章也说明了它只能解决单库层面的并发问题,消息重复和跨服务补偿仍需要额外设计。
内容覆盖面较广,但部分实现细节还可以继续深入,例如幂等记录与库存更新的事务边界、消息表结构以及对账修复的具体流程。作为方案评审材料更合适。