库存扣减“偶尔对不上”,通常不是因为某一条 SQL 写错了,而是因为系统只保存了“现在剩多少”,却没有保存“为什么变成这个数字”。在我参与库存与订单系统评审时,最容易被低估的恰恰不是库存表本身,而是库存流水:库存主表负责在高并发下快速回答当前余额,库存流水负责证明每一次冻结、实扣、解冻、补偿和人工调整确实发生过。扣减一致性不是“库存数字正确”这么简单,而是库存余额、业务状态、流水事实和重复请求能够相互解释。
如果一个订单支付成功后库存少了 3 件,技术负责人应该能回答四个问题:这 3 件何时被占用?之前是否已经冻结?是哪一个业务动作完成了实扣?如果支付回调重复到达,为什么没有再扣 3 件?回答不了这些问题,系统即使暂时没有超卖,也不能称为可验证的一致性系统。
库存主表通常保存 SKU、仓库、门店和当前库存余额,服务于商品详情页、下单校验、库存扣减等实时操作。它追求的是读写效率,字段数量不宜过多,更新路径也必须足够短。
库存流水表承担的是另一种职责。它记录每次库存变化的业务事实,包括变化类型、业务单号、变更数量、变更前后余额、执行时间和幂等标识。它的价值不在于让一次扣减更快,而在于让系统之后能够解释、核对和修复这次扣减。
| 对象 | 主要回答的问题 | 典型读写场景 | 不能替代的职责 |
|---|---|---|---|
| 库存主表 | 当前可用、冻结或实物库存是多少 | 实时查询、条件扣减、库存锁定 | 无法单独解释历史变化原因 |
| 库存流水表 | 库存为什么增加或减少 | 追溯、对账、审计、异常定位 | 不适合直接承担所有高并发余额更新 |
| 订单表 | 订单当前处于什么业务状态 | 下单、支付、取消、履约 | 订单状态不等于库存已经完成对应动作 |
| 库存动作表 | 某个冻结、实扣或解冻动作是否已经执行 | 幂等判断、重复消息处理 | 不能代替库存余额和完整流水 |
我更倾向于把这四类数据看成四本账:库存主表是余额账,库存流水是变化账,订单表是业务状态账,库存动作表是执行凭证账。四本账不一定都要拆成独立物理表,但职责必须被明确,否则开发人员很容易拿订单状态去推断库存状态,或者拿一条日志去代替真正的库存流水。

第一个层面是余额一致性,即库存主表中的可用库存、冻结库存和实际库存满足预先定义的关系。例如,系统规定可用库存不能小于零,那么每一次扣减都必须经过数据库条件判断,而不是先查询、再在应用层判断。
第二个层面是动作一致性,即一次业务动作只能产生一次有效库存变化。支付回调重复到达两次,不能形成两条有效实扣;订单取消任务与人工取消同时执行,也不能释放两遍。
第三个层面是状态一致性,即订单状态与库存动作之间存在合法的状态转换。例如,订单已经完成实扣后,重复收到“释放冻结库存”事件,系统应拒绝这次非法转换,而不是机械地执行加库存。
第四个层面是可追溯一致性,即发生异常时,可以从流水、业务单据和动作记录中找出差异来源。没有可追溯性,系统只能知道“错了”,却不知道“错在哪一步”。
条件更新能够防止多个并发请求同时拿走同一份可用库存,但它不能自动防止重复支付通知,也不能处理跨服务消息丢失,更不能证明订单已经完成了合法的状态转换。
因此,完整链路应至少包括:数据库原子更新、库存流水写入、业务动作幂等、状态机校验、异常重试和定期对账。把其中任何一个环节单独包装成“库存一致性方案”,都是过度简化。
以一个可售商品为例,用户下单 3 件,系统可能先冻结 3 件;支付成功后,冻结库存转为已售或实扣库存;支付失败、订单取消或超时未支付,则释放这 3 件库存。表面上只有三个动作,实际上每个动作都可能被重试、延迟、重复投递或乱序执行。
支付成功通知可能在客户端已经超时后才到达,订单取消任务可能恰好在支付通知前触发,仓储出库消息又可能晚于支付消息几个小时。系统如果只按消息到达顺序处理,就会把“晚到的合法事件”误判成当前应该执行的动作。
| 业务事件 | 正常库存动作 | 可能的重复或乱序情况 | 必须保护的约束 |
|---|---|---|---|
| 订单创建 | 可用库存转冻结库存 | 客户端重试导致重复冻结 | 订单只能有一次有效冻结 |
| 支付成功 | 冻结库存转实扣库存 | 支付回调重复到达 | 同一订单只能完成一次实扣 |
| 订单取消 | 冻结库存释放回可用库存 | 取消接口和超时任务同时执行 | 同一冻结只能释放一次 |
| 出库完成 | 实物库存减少或完成履约扣减 | 仓储回传重复、延迟 | 仓储动作与交易库存口径必须一致 |
| 人工补偿 | 按审批结果调整库存 | 补偿脚本重复执行 | 必须绑定补偿单和审批凭证 |
第一种是真正扣减成功:库存主表完成了原子更新,库存流水也成功写入,动作记录被提交,订单状态同步进入合法阶段。
第二种是库存更新成功,但应用在提交后响应超时。客户端看不到成功结果,随后重新发起请求。如果系统没有幂等键,第二次请求会再次扣减。
第三种是应用层收到数据库异常,但数据库事务实际上已经提交。重试机制把这次请求当作失败重新执行,最终产生重复库存动作。这个场景尤其容易发生在网络抖动、连接池超时或服务实例切换期间。
所以,不能把“接口返回成功”当成唯一事实来源。对于库存动作,业务单号、动作类型和处理状态必须能够在服务端被查询和确认。
低并发下,先查询再更新的代码可能长期没有表现出问题,因为两个请求很少在同一时间窗口竞争同一个 SKU。大促、秒杀、直播间集中下单时,竞争窗口被放大,应用层判断和数据库更新之间的空隙就会变成真实的超卖机会。
另一个原因是平时业务动作少,补偿脚本和人工操作不容易重叠;订单量上涨后,自动关单、支付回调、仓储回传和人工客服操作同时发生,动作重复和消息乱序的概率都会增加。

流水表只能记录发生过的动作,不能自动阻止错误动作发生。若重复支付回调产生两条“实扣”流水,那么这张表反而会完整地记录一个错误。
正确做法是让流水具备结构化约束。至少要记录业务单号、动作类型和幂等键,并根据业务规则建立唯一约束。例如,同一订单的“冻结”动作只能成功一次,同一订单的“实扣”动作只能成功一次,同一冻结记录的“解冻”动作也只能成功一次。
我在评审流水表时,不会只看有没有 amount、type、created_at 这些字段,而会追问:这条流水能否证明它对应哪个业务动作?能否判断它是否已经被补偿?能否在重试时被唯一识别?如果不能,它更像普通操作日志,不是库存事实表。
理论上可以通过汇总所有流水计算余额,但在交易链路中,每次查询都聚合完整历史记录,成本和延迟通常不可接受。库存流水不断增长,还会带来分区、冷热数据、索引和聚合一致性问题。
更现实的做法是主表保存当前余额,流水保存变化事实,二者通过事务或可靠事件保持关联。流水可以在对账时重算余额,也可以在主表损坏时帮助恢复,但不应让所有下单请求都依赖全量流水聚合。
“支付成功即扣减”并不是通用规则。电商销售库存、仓库实物库存、门店可售库存和预售库存可能采用不同口径。
如果系统要防止商品被重复卖出,通常在下单阶段就需要锁定可售库存;如果系统要反映仓库真实出库,可能要等拣货、出库或发货节点才减少实物库存。关键不是哪个节点看起来更标准,而是每一个库存字段必须有唯一、稳定、可解释的业务定义。
| 模型 | 下单时 | 支付成功时 | 出库时 | 适用取舍 |
|---|---|---|---|---|
| 下单直接扣可用库存 | 可用库存直接减少 | 主要更新订单状态 | 减少实物或履约库存 | 实现简单,但取消和支付失败需要补偿 |
| 下单冻结、支付实扣 | 可用转冻结 | 冻结转已售 | 按仓储口径再处理 | 交易表达清晰,但冻结超时管理复杂 |
| 出库时扣实物库存 | 锁定销售额度 | 完成资金确认 | 实际减少实物库存 | 适合仓储核算,但前台可售口径需要另建模型 |
库存表和流水表在同一个数据库中,可以通过本地事务保证一起提交或一起回滚。但订单服务、支付服务、仓储系统和消息系统通常不共享这个事务。
如果库存提交成功后消息发送失败,订单可能不知道库存已经变化;如果消息发送成功但库存事务回滚,消费者又可能开始错误处理。因此,跨系统场景需要可靠事件、Outbox、消费幂等和补偿对账,而不是简单地把所有代码包在一个事务注解里。
库存口径不清时,“零”可能代表可用库存为零,也可能代表实际库存为零。一个仓库可能有 10 件实物,但其中 8 件已被其他订单冻结,那么可用库存只有 2 件。
如果前台读取的是 physical_qty,扣减条件使用的是 available_qty,报表又按照 sold_qty 统计,三个系统看到的“库存”就会不同。技术负责人首先要定义库存的业务口径,其次才是设计字段和 SQL。

库存设计最有效的评审方法,不是先画表,而是先写出任何时刻都不能被破坏的规则。以“可用库存、冻结库存、实物库存”为例,可以定义:
不变量的价值在于,它让评审从“字段看起来齐不齐”变成“错误能不能被系统拒绝”。例如,冻结动作的本质不是把 available_qty 减 3、frozen_qty 加 3,而是必须同时满足库存充足、业务单据未冻结、动作状态允许这三个条件。
订单状态和库存动作状态不能混为一谈。订单从待支付变成已支付,并不自动证明库存实扣已经成功;库存动作从冻结变成已释放,也不意味着订单一定已经关闭。
我建议至少为库存动作定义明确的生命周期:
这样做的好处是,重试不会直接再次执行库存加减,而是先查询动作是否已经完成。即使第一次请求响应超时,第二次请求也能根据动作记录返回原处理结果。
扣减时机应由业务损失函数决定。对于稀缺商品,超卖造成的赔付、客诉和品牌损失通常高于短暂占用库存的机会成本,提前冻结更合理。对于库存充足、订单取消率高的商品,长时间冻结会降低可售效率,需要设置更短的释放策略。
对于仓储业务,销售库存和实物库存最好不要用一个字段强行表达。前台关心“还能不能卖”,仓库关心“实际还有多少可拣货”,财务关心“已经确认销售多少”,这些问题对应不同的库存口径。
| 业务特征 | 一致性风险 | 建议的流水粒度 | 建议的补偿机制 |
|---|---|---|---|
| 库存少、并发高、商品价值高 | 重复扣减和超卖风险高 | 每个业务动作一条明细流水 | 强幂等、实时告警、分钟级对账 |
| 库存多、并发低、允许人工修正 | 实时竞争风险较低 | 动作明细加人工调整流水 | 日对账、审批补偿 |
| 多仓、多门店、跨系统履约 | 库存口径和消息乱序风险高 | 按仓库、门店、单据拆分流水 | 可靠事件、状态版本、差异工单 |
| 批次、效期、序列号管理 | 错误分配可能造成履约和合规问题 | 流水必须带批次或序列号 | 批次级对账和禁止跨批次抵扣 |

下面使用一个情景案例说明。某零售业务有 SKU-A,仓库初始实物库存 100 件,当前可售库存 100 件,冻结库存 0 件,已确认销售库存 0 件。系统采用“下单冻结、支付后实扣、取消后解冻”的交易模型。
这里的 100 件不是所有业务都必须采用的库存口径,而是为了展示变化过程。真实系统中,还可能扣除质检不合格品、已分配未拣货、在途调拨和安全库存。写方案时必须把这些扣除项明确列入可售库存公式。
示例公式如下:
可售库存 = 实物库存 – 冻结库存 – 已分配库存 – 安全库存
订单 ORD-1001 创建时,系统使用条件更新将可售库存从 100 减到 97,将冻结库存从 0 增加到 3。这个动作必须和“冻结流水”在同一个本地事务中提交。
UPDATE inventory SET available_qty = available_qty - 3, frozen_qty = frozen_qty + 3, updated_at = CURRENT_TIMESTAMP WHERE sku_id = 'SKU-A' AND warehouse_id = 'WH-01' AND available_qty >= 3;
如果数据库返回受影响行数为 1,说明余额条件满足且更新成功;如果返回 0,可能是库存不足,也可能是 SKU、仓库或数据版本不匹配。应用不能简单地把所有 0 都转成“库存不足”,应结合查询和错误码判断具体原因。
对应流水至少应包含以下信息:
| 字段 | 示例值 | 作用 |
|---|---|---|
| 流水号 | INV-FLOW-0001 | 唯一识别一条库存变化事实 |
| 业务单号 | ORD-1001 | 关联产生库存占用的订单 |
| 动作类型 | FREEZE | 区分冻结、实扣、解冻和调整 |
| 变更数量 | 3 | 记录本次业务动作涉及的数量 |
| 变更前可用库存 | 100 | 帮助追溯动作发生前的状态 |
| 变更后可用库存 | 97 | 帮助核对本次更新结果 |
| 幂等键 | ORD-1001-FREEZE | 防止相同冻结动作重复执行 |
支付回调到达后,系统不应直接执行“冻结库存减 3、已售库存加 3”。第一步应检查 ORD-1001 的实扣动作是否已经完成,第二步校验当前订单和库存动作状态是否允许实扣,第三步才更新余额并写入流水。
UPDATE inventory SET frozen_qty = frozen_qty - 3, sold_qty = sold_qty + 3, updated_at = CURRENT_TIMESTAMP WHERE sku_id = 'SKU-A' AND warehouse_id = 'WH-01' AND frozen_qty >= 3 AND EXISTS ( SELECT 1 FROM inventory_action WHERE action_key = 'ORD-1001-FREEZE' AND status = 'COMPLETED' );
实际 SQL 是否使用 EXISTS、锁、版本号或其他方式,取决于数据库和表结构。重要的不是语法形式,而是实扣动作必须建立在“确实存在一笔未完成冻结”的事实之上。
如果支付通知重复到达,第二次请求应在动作记录或唯一幂等键处被识别。系统可以返回“已处理”,而不是再次执行库存变更。幂等的目标不是让重复请求报错,而是让重复请求得到稳定、可解释的结果。
如果订单仍处于待支付状态,且冻结动作已经完成,那么取消动作可以把冻结库存释放回可用库存。解冻同样不能只执行一条加法 SQL,必须验证这笔冻结尚未被实扣或释放。
UPDATE inventory SET available_qty = available_qty + 3, frozen_qty = frozen_qty - 3, updated_at = CURRENT_TIMESTAMP WHERE sku_id = 'SKU-A' AND warehouse_id = 'WH-01' AND frozen_qty >= 3;
这条 SQL 仍然不完整,因为它没有体现“这 3 件属于哪个订单”。在多个订单同时冻结同一 SKU 时,单纯按照数量解冻可能释放错误对象。因此,更稳妥的做法是把冻结记录拆成订单级占用明细,并让解冻动作引用具体冻结记录。
| 时间 | 业务动作 | 可用库存 | 冻结库存 | 已售库存 | 幂等键 |
|---|---|---|---|---|---|
| 10:00:01 | 冻结 3 件 | 97 | 3 | 0 | ORD-1001-FREEZE |
| 10:05:18 | 支付成功,实扣 3 件 | 97 | 0 | 3 | ORD-1001-COMMIT |
| 10:05:21 | 重复支付通知 | 97 | 0 | 3 | ORD-1001-COMMIT |
第三行不是一条新的有效库存流水,而应当是一条幂等命中记录,或者被动作表直接拦截。通过这张表,排查人员能看出支付通知确实到达了两次,但库存只完成了一次实扣。

假设 10:06:00 订单超时任务发出取消事件,10:06:01 支付系统发出成功通知。若取消先执行,库存可能被释放;支付后到达时,如果订单仍允许支付确认,系统可能需要重新冻结、实扣或进入人工处理。
这类情况不能只用“谁先到谁生效”解决。系统应定义合法状态转换,例如待支付可以转已支付,也可以转已取消,但已取消不能无条件转已支付;如果确实存在支付成功晚于取消的业务争议,还需要支付退款或订单恢复规则。
库存流水在这里的作用,是把实际执行过的动作固定下来。它不能替业务决定最终结果,但可以让状态机知道“已经释放过多少”“是否已完成实扣”,从而避免重复加减。
对于库存主表和库存流水位于同一个数据库的场景,我通常建议采用以下顺序。顺序不是绝对规则,但每一步的职责都必须明确。
如果第 3 步成功、第 5 步失败,事务应整体回滚。如果第 7 步之后消息发送失败,不能回滚已经提交的本地库存事务,而应由 Outbox 或可靠消息机制负责重新投递。
库存主表需要保证同一 SKU、仓库或门店维度只有一条当前余额记录。库存流水需要保证流水号唯一,并且保留业务动作的唯一识别信息。库存动作表则要让相同动作键不能被多个并发请求同时成功创建。
CREATE TABLE inventory_action (
id BIGINT PRIMARY KEY,
action_key VARCHAR(128) NOT NULL,
sku_id VARCHAR(64) NOT NULL,
warehouse_id VARCHAR(64) NOT NULL,
action_type VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
quantity DECIMAL(18, 3) NOT NULL,
created_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP NULL,
UNIQUE KEY uk_action_key (action_key)
);
CREATE TABLE inventory_flow (
id BIGINT PRIMARY KEY,
flow_no VARCHAR(64) NOT NULL,
action_key VARCHAR(128) NOT NULL,
business_no VARCHAR(64) NOT NULL,
sku_id VARCHAR(64) NOT NULL,
warehouse_id VARCHAR(64) NOT NULL,
action_type VARCHAR(32) NOT NULL,
delta_available DECIMAL(18, 3) NOT NULL,
delta_frozen DECIMAL(18, 3) NOT NULL,
delta_sold DECIMAL(18, 3) NOT NULL,
before_available DECIMAL(18, 3) NOT NULL,
after_available DECIMAL(18, 3) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_flow_no (flow_no)
);示例结构中的字段只是最小参考,不应直接复制到所有业务。批次库存需要增加批次号和效期,序列号库存需要记录具体序列号,按店铺分仓的场景需要把库存维度纳入唯一键。
条件更新不是执行完就结束,应用必须读取数据库返回的受影响行数。返回 1 通常表示更新条件满足;返回 0 可能表示余额不足、动作已完成、库存记录不存在或版本冲突。
如果代码把“受影响行数为 0”统一转换成“库存不足”,排查会非常困难。用户看到库存不足,实际上可能是重复请求;运营看到订单未扣库存,实际上可能是仓库维度传错。错误码应尽量区分余额不足、动作重复、状态非法和数据不存在。
有些团队认为流水只记录 delta_qty 就够了,因为变更后余额可以通过汇总计算。对于高价值库存,我建议保留变更前后余额。
变更前后余额能快速判断某次动作执行时看到的库存状态,也能帮助发现并发更新、人工脚本和数据迁移造成的异常。它不会替代重算,但能缩短排查时间。需要注意的是,余额快照必须与库存更新在同一事务中写入,否则快照本身也可能失真。

订单服务知道订单状态,支付服务知道支付结果,库存服务知道库存动作。任何一个服务都不应该通过猜测另一个服务的状态来执行库存变更。
例如,库存服务收到“支付成功”事件后,应检查订单或事件中的业务版本,而不是根据订单当前展示状态直接扣减。支付成功事件应包含稳定的支付单号、订单号、事件版本和发生时间,库存服务则通过自己的动作表记录是否已经处理。
一种常见失败是:库存事务已提交,但发送库存变更事件时服务崩溃。此时订单服务和仓储服务可能永远不知道库存已经改变。
Outbox思路是把待发送事件作为一条记录和库存变化放入同一个本地事务。事务提交后,由独立投递程序读取 Outbox 记录并发送消息,发送成功后标记已发送。即使投递失败,也可以重试;消费者再通过幂等键避免重复处理。
本地事务:
更新库存主表
写入库存流水
写入库存动作
写入 outbox_event
提交事务
异步投递:
查询未发送 outbox_event
发布库存事件
发布成功后更新 sent_at
失败则按退避策略重试
Outbox不能保证消息一定只发送一次,网络重试仍可能造成重复投递。因此,可靠投递和消费幂等必须一起设计。
只判断订单是否已支付,无法区分“支付成功事件已经处理”和“支付成功事件尚未处理”。订单状态是业务状态,不是库存动作的执行凭证。
更稳妥的做法是让消费者以 action_key 或 event_id 为幂等依据。处理前查询动作记录,处理时利用唯一约束抢占动作,处理后写入完成状态。并发消费者即使同时拿到同一事件,也只能有一个获得执行资格。
事件系统保证消息送达,不一定保证跨主题、跨分区或跨服务的全局顺序。支付成功和订单取消先后到达时,库存服务必须根据合法状态转换判断,而不是相信消息的到达顺序。
常见做法包括为订单或库存动作增加版本号。只有事件版本大于当前已处理版本时才推进状态;对于版本缺失或不符合业务顺序的事件,先进入待处理状态,等待前置事件或进入人工补偿队列。
| 异常类型 | 直接重试是否安全 | 需要记录什么 | 推荐处理方式 |
|---|---|---|---|
| 客户端超时 | 只有携带同一幂等键才安全 | 动作键、原处理结果 | 查询动作状态并返回既有结果 |
| 消息重复 | 不安全 | 事件 ID、消费状态 | 唯一约束拦截重复消费 |
| 消息乱序 | 不安全 | 事件版本、当前状态 | 状态机校验或延迟处理 |
| 库存不足 | 通常不安全 | 失败原因、当时余额 | 进入业务失败或补偿流程 |
| 跨库提交失败 | 视动作状态决定 | 本地事务结果、事件投递状态 | 可靠重试和对账修复 |

库存对账至少分三个层次。第一层是主表内部校验,例如可用库存、冻结库存和实物库存是否满足公式。第二层是主表与流水校验,判断从期初余额加上期间流水后,是否等于期末余额。第三层是库存与业务单据校验,检查订单、支付、出库和退货是否都有对应库存动作。
高价值 SKU 或大促场景不应等到月底才对账。可以按分钟、小时或订单完成批次进行增量检查,把差异尽快暴露出来。对账频率应与库存风险和修复窗口匹配。
假设某 SKU 在仓库 WH-01 的期初可用库存为 100,期间发生冻结 20、解冻 5、其他释放 2,则期末可用库存应为:
期末可用库存
= 期初可用库存
冻结数量
+ 解冻数量
+ 其他可用增加数量
其他可用减少数量
如果主表显示 82,而流水重算得到 84,差异并不意味着一定是数据库丢了两件库存。还可能是人工调整未记流水、统计口径不同、流水重复、迁移数据没有期初快照或某个动作跨仓库写错。对账的第一步是分类差异,而不是马上执行“加 2”。
我不建议只设置一个“库存异常数”总指标。总数无法告诉负责人问题来自重复回调、主表更新失败、流水漏写还是仓储延迟。监控指标要直接对应可执行动作,才能形成告警、定位和修复闭环。
当库存流水量较大时,单靠数据库查询很难让运营、财务和技术共同查看。可以把库存主表、流水表、订单表、支付结果和出库单进行关联,建立按 SKU、仓库、业务动作和时间区间筛选的库存差异看板。
以九数云为例,它更适合承担分析层和协同排查层的工作,而不是替代交易数据库完成扣减。技术团队可以把经过脱敏和权限控制的库存快照、库存流水、订单状态与仓储结果同步到分析环境,再配置差异明细、冻结超时、重复动作和人工调整看板。
这种分工很重要:交易数据库负责“扣得快、扣得准”,分析平台负责“看得全、查得明白”。如果把分析查询直接压到交易库上,大促期间复杂聚合可能反过来影响扣减链路;如果完全没有分析层,异常排查就会长期依赖研发临时写 SQL。
| 看板模块 | 核心字段 | 使用者 | 触发的行动 |
|---|---|---|---|
| 库存余额差异 | 主表余额、流水重算余额、差异数量 | 技术、财务 | 定位漏记、重复记账或口径差异 |
| 冻结超时 | 订单号、冻结时间、冻结数量、当前订单状态 | 运营、客服 | 释放库存或处理异常订单 |
| 重复动作 | 业务单号、动作类型、重复次数、首次处理时间 | 技术 | 检查回调、重试和消费逻辑 |
| 人工调整 | 调整单号、操作人、审批人、调整原因 | 仓储、财务 | 复核高频调整和权限风险 |
| 仓储与交易差异 | 交易库存、拣货库存、出库数量、回传时间 | 供应链、技术 | 处理跨系统延迟和履约差异 |

最危险的补偿方式是看到主表少 2 件,就直接把库存加 2。这样做可能暂时让余额看起来正确,却掩盖了真实原因。如果原来的扣减动作稍后重试,库存又会多出 2 件。
安全的补偿应先生成补偿单,说明差异来源、影响范围、审批人和修复方式,再通过独立的 COMPENSATE 动作写入库存流水。补偿也必须有自己的幂等键,不能因为脚本重新运行而重复调整。
如果订单量不大、库存价值不高、系统暂时是单体应用,可以先实现库存主表、流水表和动作幂等三件套。不要一开始就引入复杂分布式事务,但也不要省掉幂等键和前后余额。
最小方案应包括:
这个方案适合快速上线,但要明确边界:如果未来接入支付回调、仓储系统或多个库存地点,必须继续增加可靠事件、消费幂等和跨系统对账。
高并发场景中,库存事务应尽量只完成必要的本地操作,不要在持有数据库锁时调用支付、远程仓储或复杂的用户服务。
推荐流程是:在本地事务内完成动作抢占、条件更新、流水写入和 Outbox 写入;事务提交后再异步通知其他系统。这样能够缩短锁持有时间,也避免远程服务响应慢导致库存行长时间被占用。
对于热点 SKU,单行库存记录可能成为锁竞争热点。可以按仓库拆分、按库存桶分片,或使用预分配库存,但这些优化会增加汇总和对账复杂度。分片不是免费性能,它把单点锁竞争转化为多份库存协调问题。
多仓业务最容易发生的错误,是库存扣减没有带上库存地点。SKU-A 在仓库一有 5 件,在仓库二有 10 件,若只按 SKU 更新,系统可能把两个仓库的余额混成一笔。
库存主表、流水表和动作表都应明确仓库、门店或库存组织维度。调拨、锁定、释放和出库动作要说明来源地点与目标地点,调拨不能被简单记录成一笔“增加”或“减少”,而应能够关联成对的出入库流水。
食品、药品、化妆品和有批次管理要求的商品,即使总数量一致,也可能因为扣错批次而导致履约或合规问题。
这类业务的流水至少要包含批次号、效期、库存状态和分配策略。冻结的是具体批次,实扣时也应从同一批次完成转换;如果允许换批,必须产生明确的换批动作,而不是直接修改原流水。
部分零售或供应链系统允许负库存,因为销售先发生、补货后入账,或者仓库数据回传存在延迟。允许负库存并不等于放弃控制。
如果业务允许负库存,应定义最大负数、适用商品范围、审批权限和回补规则。负库存流水必须可追溯到销售、盘点或仓储延迟原因,并设置告警。否则,“允许负库存”很容易变成“系统不再关心库存是否正确”。

| 方案 | 一致性表现 | 性能特征 | 实施复杂度 | 适用场景 |
|---|---|---|---|---|
| 库存主表与流水同库事务 | 本地数据强一致 | 链路短、延迟可控 | 低到中 | 单体或同库库存服务 |
| 本地事务加 Outbox | 本地强一致,跨服务最终一致 | 异步扩展性较好 | 中 | 订单、库存、仓储分服务场景 |
| 分布式事务 | 理论上可提高跨库一致性 | 锁和协调成本较高 | 高 | 边界明确、强一致收益明显的少数场景 |
| 纯消息驱动库存 | 依赖幂等、顺序和对账 | 吞吐高,实时性取决于消费速度 | 中到高 | 可接受短暂最终一致的业务 |
我的判断是,库存主表与库存流水同库事务通常应该作为基础方案。跨服务部分优先采用本地事务加 Outbox,再用消费幂等和对账闭环。不要因为“分布式事务听起来更强”就直接引入它,很多库存问题并不是缺少全局锁,而是缺少清晰的动作状态和可补偿设计。
明细流水按每次业务动作记录,追溯能力强,适合高价值、强审计和需要按单据核对的库存。缺点是数据量增长快,需要索引治理、分区和归档策略。
汇总流水按时间窗口或批次汇总,存储成本低、查询速度快,但丢失了订单级细节,发生差异时很难定位到具体动作。它适合低风险库存或作为明细流水的统计层,不适合替代原始事实记录。
如果团队担心流水表过大,可以采用“在线明细加离线归档”的方式,而不是一开始就只存汇总。原始流水是后续重算和争议处理的重要依据,删除它带来的风险通常比存储成本更高。
悲观锁适合竞争集中、更新逻辑复杂且必须保证严格顺序的场景。它的优点是行为直观,缺点是热点行容易排队,事务时间过长时可能出现锁等待。
乐观锁通过版本号判断数据是否被其他请求修改,冲突时重试。它适合冲突概率较低或可以接受有限重试的场景,但库存热点 SKU 的冲突率可能很高,盲目使用乐观锁会造成大量重试和放大数据库压力。
条件更新本身可以看作一种轻量的原子竞争控制。对于简单数量扣减,它往往比“先查、加锁、再计算”更简洁;对于批次分配、组合商品和多行库存扣减,则需要更谨慎地评估锁范围和事务边界。

当库存流水仍处于几万条、查询维度单一时,直接使用数据库报表可能足够。但当数据需要同时按 SKU、仓库、订单、支付、出库、时间和动作状态进行交叉分析时,交易库会逐渐承受不适合它的查询压力。
这时可以把分析平台作为查询和协作层。以九数云为例,可以将脱敏后的库存余额、库存流水、订单和仓储数据进行关联,制作库存差异、冻结超时、补偿结果和人工调整看板。关键是明确它的边界:分析平台帮助发现和解释问题,不直接替代交易数据库执行扣减。
如果团队没有专门的数据工程人员,分析平台还可以减少研发反复编写临时 SQL 的成本。但数据同步延迟、字段口径和权限隔离必须写入方案,不能因为有了可视化图表就把离线数据当作实时库存。
第一个问题:如果同一个支付通知到达两次,系统能否保证库存只变化一次?如果答案是“靠调用方保证”,方案还不成熟。
第二个问题:如果库存主表与流水表出现差异,能否在一个小时内定位到具体订单、动作和服务?如果只能翻应用日志,方案缺少可追溯性。
第三个问题:如果库存扣减成功但接口超时,客户端重试时服务端能否返回原结果?如果只能再次执行,方案存在重复扣减风险。
第四个问题:如果订单取消和支付成功乱序到达,系统是否有明确的合法状态转换?如果只能按消息到达顺序处理,方案无法应对真实分布式环境。

库存改造最忌讳一开始就推翻所有表和流程。更稳妥的做法是选一条真实业务链路,例如“下单冻结,支付实扣,取消解冻”,用一个 SKU 和一个库存地点完成端到端验证。
先记录当前主表余额,再为每个动作补充幂等键和流水,随后做重复请求、并发扣减、支付延迟和取消乱序测试。测试通过后,再扩展到多仓、批次、退货和人工补偿。
每个阶段都应保留旧流程与新流程的对照指标,例如扣减失败率、重复动作数、冻结超时量、主表流水差异和人工调整次数。只有指标持续改善,才能证明改造不是把问题从一个模块搬到了另一个模块。
第一类是重复演练:让同一订单的冻结、支付和取消请求分别发送两次,确认库存只发生一次有效变化。
第二类是超时演练:在库存事务提交后人为阻断接口响应,观察客户端重试是否命中原动作结果。
第三类是乱序演练:让支付成功、订单取消和仓储回传以不同顺序到达,确认状态机不会执行非法库存动作。
如果系统没有经过这三类演练,所谓“支持幂等”“支持最终一致”往往只是设计文档中的描述,并没有被真实验证。
库存差异不应只由研发团队背负。技术负责动作执行和数据可靠性,运营负责订单规则,仓储负责实物盘点,财务负责销售和退货口径。差异看板应让这些角色看到同一份事实,并明确每类差异由谁确认、谁审批、谁修复。
如果使用九数云等分析平台制作协同看板,建议将“差异数量”之外的字段也展示出来,例如首次出现时间、最后一次动作、关联订单、库存地点、处理状态和责任团队。单纯展示一个红色数字,不能帮助团队完成闭环。
库存流水与扣减一致性的关系,可以用一句话概括:库存主表负责把余额更新正确,库存流水负责让余额变化可证明,幂等和状态机负责阻止动作重复或越权,对账和补偿负责处理系统不可避免的异常。
因此,设计库存系统时不要只问“这条 SQL 能不能防超卖”,还要继续问:如果请求重试怎么办?如果消息乱序怎么办?如果主表和流水不一致怎么办?如果库存需要人工修复,修复是否有凭证?如果分析平台看到异常,能否追到具体业务单据?
下一步可以从现有系统中抽取最近一个月的库存流水,按冻结、实扣、解冻、退货和人工调整分类,统计每类动作的重复次数、失败次数和平均处理时延。先用数据找出最常出错的动作,再优先补齐该动作的幂等约束、流水字段和对账规则。
真正成熟的库存系统,不是永远不会出现异常,而是异常出现后能够被发现、被解释、被限制影响范围,并且可以依据完整流水安全恢复。扣减是一次动作,流水是一条证据链;只有把动作和证据放进同一个一致性设计里,库存才真正可控。
我在一次电商库存重构中遇到过一个很典型的问题:库存主表显示还剩 7 件,但运营追查订单时,发现实际已经有 10 件被扣走。团队当时只盯着扣减 SQL,直到补上库存流水,才定位到重复支付回调和一次人工补偿同时生效。
库存主表和库存流水解决的是两个不同问题。库存主表回答“现在还剩多少”,库存流水回答“为什么会变成这个数字”。前者服务实时读写,后者服务追溯、对账、补偿和审计。因此,库存流水并不会直接阻止超卖,但它是验证扣减是否正确的重要证据。
没有流水时,即使主表余额异常,也很难判断问题来自重复扣减、漏记、错误解冻,还是人工调整。
数据对象主要职责典型使用场景 库存主表保存当前库存余额查询可用库存、执行原子扣减 库存流水保存每次库存变化事实对账、排障、补偿、审计 订单状态保存业务单据所处阶段判断冻结、实扣、解冻是否合法 我的判断是:库存一致性不能简单等同于“库存字段没有负数”。
更可靠的标准是,库存主表、库存流水和业务单据能够互相解释,并且经过汇总后可以验证余额是否成立。例如初始可用库存为 100,冻结 3 件、实扣 3 件、再取消另一笔冻结 2 件后,系统应能从流水重建出当前余额,而不是只能相信主表里某个孤立的 97。
我曾经测试过一个“先查询、再扣减”的方案:初始库存只有 3 件,同时发起 20 个每次购买 1 件的请求。查询结果经常都显示库存充足,最终不是超卖,就是多个事务互相覆盖更新,所以我想知道条件更新到底解决了什么。
带条件的原子更新是防止并发超卖的基础,但不是完整的一致性方案。它解决的是多个请求同时竞争同一库存时,库存充足判断与扣减动作可能被拆开的问题。
UPDATE inventory SET available_qty = available_qty - :qty, frozen_qty = frozen_qty + :qty WHERE sku_id = :sku_id AND available_qty >= :qty;在这个测试里,库存为 3、并发请求为 20 时,数据库最终只允许 3 个请求更新成功,其余请求通过受影响行数为 0 判断库存不足。关键点不是“执行了 UPDATE”,而是把库存条件放进同一条原子语句。
但条件更新没有处理四类问题:同一个订单重复提交、支付回调重复到达、库存更新成功后流水写入失败,以及跨服务消息丢失或乱序。也就是说,它只解决了“同一时刻争抢库存”的问题。实际落地时,我会把以下动作放进同一个数据库事务:先校验业务幂等键,再执行条件更新,随后写入库存流水和库存动作记录。
任一步失败都回滚,否则就可能出现库存已经减少,却找不到对应流水的半一致状态。如果库存更新和订单、支付不在同一个数据库中,则不能继续假设一个本地事务可以覆盖全部链路。此时还要配合可靠事件、消费幂等、重试机制和定期对账。
我在联调支付回调时遇到过重复通知:同一订单的支付成功事件在 30 秒内到达两次。第一次把冻结库存转成已售库存,第二次又执行了一遍,虽然主表没有立刻出现负数,但已售数量被多算了,我想知道流水该怎样参与幂等。
库存流水不能只记录“减了几件”,还必须记录这次变化对应的业务动作和唯一幂等标识。推荐至少组合记录业务单号、动作类型和动作版本,例如订单号加“支付实扣”只能成功一次。
动作库存变化幂等判断 冻结 3 件可用 -3,冻结 +3订单号 + 冻结 支付实扣 3 件冻结 -3,已售 +3订单号 + 实扣 取消解冻 3 件冻结 -3,可用 +3订单号 + 解冻 我的做法是为库存动作建立唯一约束,并在事务中先插入动作记录,再执行对应库存变化;
如果动作已存在,就直接返回之前的处理结果,而不是再次扣减。这样比单纯在代码里判断“是否处理过”更可靠,因为数据库约束能挡住并发请求。还要限制合法状态转换。例如已经实扣的订单不能再解冻,已经解冻的订单不能再次解冻,已取消的订单也不能因为迟到的支付消息重新实扣。
流水中的动作类型和前置状态,正好可以作为这类校验的依据。我尤其不建议把流水 ID 当作唯一幂等键。流水 ID通常每次重试都会重新生成,无法识别同一个业务动作。真正应该稳定的是业务动作键,例如“订单号-动作类型-商品行号”。
我们曾经每天人工导出库存表和订单表做核对,发现一个 SKU 的主表库存是 58,但相关订单、退款和人工调整记录加起来并不能得到这个数字。后来我才意识到,库存流水不仅要保存数量,还要设计一套可执行的对账口径。
库存对账首先要固定统计边界,不能把可用库存、冻结库存、已售库存和物理库存混在一起。不同业务口径下,计算公式可能不同,但每个口径都应能从期初余额加上期间流水推导出期末余额。例如某 SKU 的可用库存期初为 100,期间发生冻结 12、解冻 5、补货入库 20,则期末可用库存应为 113;
如果其中 7 件冻结已经实扣,就不能再次把这 7 件算作可用库存减少,否则会重复计算。
检查项目应发现的问题处理方式 主表与流水汇总余额无法由流水重建标记差异并冻结人工修正权限 业务单号重复动作同一订单重复扣减或解冻依据幂等键回滚或补偿 长期冻结记录订单超时但库存未释放进入超时释放任务 动作缺失有实扣却没有冻结,或有解冻没有冻结检查消息、事务和人工操作 实际系统中,我会把变更前数量和变更后数量也写入流水,而不只保存变更数量。
这样可以快速发现流水链断裂:上一条流水的变更后余额,应该与下一条相关流水的变更前余额在同一库存维度上衔接。需要注意,流水对账是发现和定位问题,不是自动修复问题。补偿必须生成新的“补偿”流水,不能直接修改历史记录,否则系统虽然暂时对上了,却失去了完整的审计链。
技术负责人评审方案时,可以用一个简单标准判断:删除库存主表后,能否依据期初快照和完整流水重建余额;如果完全不能,说明流水更像日志,而不是可验证的库存事实账。


读者评论
文章把库存主表、库存流水、订单状态和库存动作拆开讲,职责边界比较清楚。尤其是强调流水不只是日志,而是可追溯的业务证据,这对排查重复扣减和异常补偿很有帮助。
文中对支付回调重复、取消与支付乱序、事务提交后响应超时等场景的分析比较贴近实际。不过具体落地时,还需要结合业务确定库存口径,以及对账和补偿任务的执行频率。
认同不能只依赖一条条件更新 SQL。数据库原子扣减能防止部分并发问题,但跨服务消息、幂等控制和状态机校验同样重要。文章如果再补充表结构或伪代码示例,实践参考价值会更高。