数据库存:仓储系统团队增长视角:用表结构设计放大保证扣减一致性
仓储系统最危险的库存事故,往往不是“数据库不会加锁”,而是系统允许同一件库存变化被多个入口重复解释:订单服务认为自己完成了扣减,仓库服务认为只是预占,补偿任务又把它当成失败重新执行。库存表里最后只剩一个数字,团队却无法回答这个数字是怎么来的。我的核心判断是:库存扣减一致性不是单个 SQL 语句的技巧,而是表结构、业务状态、幂等约束、事务边界和对账机制共同形成的系统约束。
标题中的“数据库存”如果被理解为数据仓库,文章很容易跑偏到 ODS、DWD、维度建模等分析型主题。本文讨论的是交易型仓储系统:SKU、仓库、库位、批次、预占、出库、退货和盘点,以及这些业务动作如何在数据库中留下可验证、可恢复的痕迹。
库存主表最适合保存当前状态,例如某个仓库、某个 SKU、某个批次当前有多少可用库存、预占库存和冻结库存。它不适合独自承担审计、重试判断和业务历史记录。
如果一张表只有 sku_id 和 quantity,系统可以知道当前数字,却无法可靠回答四个问题:谁改了它、为什么改、这次动作是否已经执行过、发生异常后能否重新构造结果。随着团队扩大,这些问题会从“排查麻烦”变成“无法确认责任数据”。
每一次库存变化都应有明确的业务原因。预占、确认扣减、取消释放、退货入库、盘点调整和报损,不应只表现为主表中一个被修改的数值,而应形成独立流水。
我通常会把库存主表和流水表看成两种不同的事实:主表是当前状态事实,流水表是变化事件事实。前者服务于快速扣减,后者服务于审计、对账和故障恢复。把两者混成一张表,短期少写几张表,长期却会增加排查和修复成本。
接口超时、客户端重试、消息重复投递和消费者重启都可能让同一业务动作到达两次。只在应用层写“如果处理过就跳过”,并不能完全防止并发请求同时通过判断。
更可靠的做法是给业务动作定义幂等键,并通过唯一索引或幂等记录表让数据库参与判断。应用层负责解释结果,数据库负责阻止同一动作形成两条成功事实。
单库事务可以保证库存主表更新和库存流水写入同时成功或同时失败,但它不会自动保证订单状态、出库状态、支付状态和消息状态永远同步。
因此,设计时必须先划定边界:哪些变化必须在一个本地事务内完成,哪些变化允许异步,异步失败后由谁重试,重试依据是什么,最终如何对账。没有边界定义的“最终一致性”,通常只是把问题推迟到人工处理。
好的设计不是让开发人员记住更多规范,而是让错误代码更难成功。例如,同一仓库、SKU、批次和库位不能重复建立库存记录;同一个业务动作不能插入两条成功幂等记录;缺少业务单号的库存流水不能写入。
当这些规则由唯一约束、非空约束、状态约束、事务和权限共同承载时,系统对人员变动更有韧性。团队增长真正放大的,不只是开发人数,也包括错误实现方式的数量。

早期系统往往由两三个人维护。大家知道库存扣减入口在哪里,知道取消订单要调用哪个方法,也知道某个字段虽然叫 locked_qty,实际表示的是支付前预占。
这种方式看起来效率很高,因为很多规则没有写进文档和数据库,而是存在开发人员的记忆里。问题是,记忆无法被接口调用、无法被 SQL 验证,也无法随着新服务自动同步。
订单服务可能在下单时扣减,仓库服务可能在拣货时再次扣减,售后服务可能在退款时直接回补,运营后台还可能提供手工调整按钮。每个入口单独看都有业务理由,组合起来却可能产生重复扣减或重复释放。
最典型的失控不是某个程序员写错一行代码,而是两个团队对“扣减”这个词的定义不同:一个团队认为扣减是预占可用库存,另一个团队认为扣减是出库完成。字段相同,业务语义已经不同。
考虑一个库存为 10 的仓库。订单服务完成数据库事务后,接口响应在网络中丢失,客户端再次提交同一订单。第一次扣减已经成功,第二次请求却无法仅凭“上一次没有收到响应”判断结果。
如果系统每次请求都重新执行扣减,库存可能减少两次;如果系统一律不重试,真正失败的订单又可能长期占用库存。解决这类问题的关键不是简单关闭重试,而是让每次业务动作拥有稳定身份。
扣减发生时,系统可能只花了几毫秒;但库存对不上后,业务人员需要核订单、查消息、看日志、问仓库、翻人工调整记录。没有流水和幂等记录时,排查只能依赖多个服务的日志时间戳,成本会随着链路数量快速增加。
库存一致性设计的收益,不能只用扣减接口的响应时间衡量。还要衡量异常定位耗时、人工修复次数、重复工单数量和对账覆盖率。一个稍微复杂但能自证的模型,往往比一张简单但无法解释的表更便宜。
系统从单仓扩展到多仓后,库存唯一性就不能只按 SKU 判断;引入批次管理后,效期、批号和质量状态也会影响可用库存;加入库位后,同一 SKU 可能分散在多个位置。
如果早期表结构把这些维度都隐藏在备注字段或业务代码中,后期每增加一种场景,都可能通过复制字段和新增分支来解决,最终形成“能跑但无法验证”的库存模型。

常见代码逻辑是先查询库存,判断数量足够,再计算新值并更新。问题在于,查询和更新之间存在时间窗口。两个请求可能同时读取到库存为 10,随后都认为自己可以扣减 7。
即使最终更新语句没有直接造成负数,也可能出现“后写覆盖先写”的丢失更新。应用层判断只能说明某一时刻看到的状态,不能保证更新时状态仍然成立。
更稳妥的单库写法,是把条件判断和扣减合并到同一个更新动作中:
UPDATE warehouse_stock SET available_qty = available_qty - :quantity, reserved_qty = reserved_qty + :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_qty >= :quantity AND status = 'AVAILABLE';
随后读取受影响行数。影响行数为 1,表示本次条件更新成立;影响行数为 0,可能是库存不足、状态不允许或主键维度不匹配。这里仍然需要在事务中写入预占记录和流水,不能把“影响行数为 1”误认为业务流程已经全部完成。
一个数量字段可以表达“目前有多少”,却很难同时表达可销售、已预占、已冻结、待质检和已出库等状态。团队通常会在代码里增加大量特殊判断,导致同一个数字在不同接口中具有不同含义。
更清晰的模型至少需要拆出可用数量和预占数量。是否还要拆出冻结、在途、待检,需要根据业务是否会在这些状态之间独立流转来决定。字段不是越多越专业,关键是每个字段都要有稳定且唯一的业务含义。
流水表能说明发生过什么,但不一定自动等于可回放日志。若流水缺少变更前数量、变更后数量、业务动作、来源服务和幂等键,排查人员仍然需要拼接多个系统的上下文。
另外,人工盘点调整、历史迁移和数据修复不应伪装成普通销售扣减。它们应该使用独立动作类型,并记录审批人、调整原因和关联凭证。否则流水虽然很多,却无法区分正常业务与修复行为。
悲观锁能够保护特定数据库行,但它不自动覆盖跨服务调用、消息重复和锁外写入。若库存服务加锁后调用外部支付接口,事务时间会变长;若另一个后台任务绕过库存服务直接修改主表,锁也无法阻止语义错误。
锁的价值是控制并发访问,不是替代业务建模。我的判断标准是:先明确库存变化的唯一写入边界,再决定是否需要锁;不要先选一个锁,再把所有一致性问题都归因给它。
允许负库存有时是合理的,例如门店先销售后补录、供应链存在在途库存,或企业允许特定业务透支。但“允许负数”和“没有库存约束”不是一回事。
如果负库存没有业务类型、审批原因和后续补足规则,它就会从异常信号变成常态。对账任务也不应成为错误写入的通行证。对账的作用是发现和闭环,而不是替代交易过程中的基本约束。
在单仓、单商品、单次扣减的简单模型里,订单号可能够用。但多仓分配、拆单、分批出库和部分退货都会让一个订单对应多个库存动作。
更合理的幂等键应描述“哪一个业务动作”,例如订单行号加仓库加动作类型,或出库单行号加批次加动作类型。唯一范围必须和业务动作的最小粒度一致,否则不是误拦截,就是漏拦截。

设计库存表之前,我会先画出库存变化的最小单位,而不是直接打开建表工具。要回答的问题包括:库存是按 SKU 统计,还是按 SKU 加仓库、批次、库位和质量状态统计?一次出库能否拆成多个批次?一个订单行能否从多个仓库发货?
如果这些问题没有答案,表结构中的唯一索引就无法确定。唯一索引不是纯技术选项,它实际上表达了业务世界中“哪些记录被视为同一个库存对象”。
库存主表中的可用量、预占量和冻结量属于资源状态;预占、确认、释放和调整属于业务动作。状态是某一时刻的结果,动作是导致结果变化的原因。
把动作直接写成状态字段,通常会造成状态覆盖。例如一次预占后,主表只记录“已锁定”,但无法区分它是订单预占、生产领料预占还是调拨预占。动作应通过独立业务表和流水表表达,主表只保留当前汇总状态。
库存系统不应只有一个“更新库存”方法。不同动作的前置状态、数量变化和重复执行结果都应明确。状态转换表既是产品规则,也是开发和测试的共同语言。
| 业务动作 | 前置状态 | 主表变化 | 必须记录 | 重复执行策略 |
|---|---|---|---|---|
| 预占 | 可用 | 可用减少,预占增加 | 预占单、订单行、幂等键 | 返回原处理结果,不再次扣减 |
| 确认扣减 | 已预占 | 预占减少,已出库增加 | 出库单、批次、操作来源 | 不允许重复确认 |
| 取消释放 | 已预占 | 预占减少,可用增加 | 取消单、释放原因 | 重复释放不重复增加 |
| 盘点调整 | 可调整 | 按盘点差异增减 | 盘点单、审批人、差异原因 | 按调整单幂等 |
我建议把“库存一致性”拆成四层,而不是用一个模糊指标概括全部问题。
不同层次的解决方案不同。条件更新主要解决数量并发,唯一索引主要解决重复事实,状态机主要解决非法流转,对账和补偿则解决异步过程中的时间差。把所有问题都交给锁,必然会遗漏后三层。
一个设计是否可靠,可以用三个问题检验。第一,库存为负或重复扣减时,能否定位是哪一个动作造成的?第二,接口超时重试时,能否根据数据库记录判断是否已经成功?第三,团队换人或新增服务后,错误入口是否会被约束,而不是依赖口头约定?
如果三个问题都只能回答“查日志”“看代码”或“人工确认”,说明一致性仍然停留在经验层面。表结构设计的价值,就是让这些问题尽量能够通过数据本身回答。

库存主表可以按“仓库、SKU、批次、库位、质量状态”等实际维度建立唯一记录。下面是一个简化示例,重点在字段责任,不代表所有企业都应照搬。
CREATE TABLE warehouse_stock (
id BIGINT PRIMARY KEY,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
batch_id BIGINT NOT NULL,
location_id BIGINT NOT NULL,
available_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
reserved_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
frozen_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'AVAILABLE',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_stock_dimension
(warehouse_id, sku_id, batch_id, location_id)
);
available_qty表示当前可被正常业务占用的数量,reserved_qty表示已经被业务动作占用但尚未完成最终出库的数量,frozen_qty表示暂时不能参与正常分配的数量。
如果业务允许小数,例如液体、原材料或按重量出库,数量类型就不应使用整数。数量精度必须在产品和仓库规则中统一,否则同一个 SKU 在不同服务中进行舍入,会产生难以察觉的差异。
预占表不是库存主表的复制品,它应该表达某项业务对库存的占用关系。至少需要知道预占对象、预占数量、当前状态、失效时间和释放原因。
CREATE TABLE stock_reservation (
id BIGINT PRIMARY KEY,
reservation_no VARCHAR(64) NOT NULL,
order_line_no VARCHAR(64) NOT NULL,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
batch_id BIGINT NOT NULL,
reserved_qty DECIMAL(18, 4) NOT NULL,
status VARCHAR(32) NOT NULL,
expire_at TIMESTAMP NULL,
released_at TIMESTAMP NULL,
confirmed_at TIMESTAMP NULL,
idempotency_key VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_reservation_action (idempotency_key)
);这里的 idempotency_key必须代表一次确定的业务动作,而不能只使用随机请求号。随机请求号只能识别某一次网络请求,无法识别同一订单动作的重复请求。
只记录变更数量,无法快速判断主表是否被错误覆盖。保存变更前数量和变更后数量,会增加写入量,但能显著降低排查成本。
CREATE TABLE stock_transaction (
id BIGINT PRIMARY KEY,
transaction_no VARCHAR(64) NOT NULL,
business_no VARCHAR(64) NOT NULL,
business_line_no VARCHAR(64) NULL,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
batch_id BIGINT NOT NULL,
action_type VARCHAR(32) NOT NULL,
available_before DECIMAL(18, 4) NOT NULL,
available_delta DECIMAL(18, 4) NOT NULL,
available_after DECIMAL(18, 4) NOT NULL,
reserved_before DECIMAL(18, 4) NOT NULL,
reserved_delta DECIMAL(18, 4) NOT NULL,
reserved_after DECIMAL(18, 4) NOT NULL,
source_service VARCHAR(64) NOT NULL,
operator_id BIGINT NULL,
idempotency_key VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_transaction_action (idempotency_key)
);流水表增长很快,应提前考虑分区、归档和查询索引。但不能为了控制表大小而删除唯一的业务审计依据。归档策略必须保证订单追溯、财务对账和库存修复仍有可用数据。
小型单库系统可以直接在流水表上建立幂等唯一键。这样每个成功库存动作同时拥有一条业务流水,避免额外维护一张幂等表。
当业务动作需要在库存处理前登记,或者一个动作可能触发多个阶段时,可以单独建立幂等记录表。此时要明确“收到请求”“处理中”“成功”“失败”和“待补偿”的区别,不能把存在记录简单等同于已经成功。
数据库约束只能约束进入数据库的写入。如果所有服务都拥有直接修改库存主表的权限,任何服务都可能绕过流水和状态机。建议把库存主表的写权限收敛到库存领域服务或指定存储过程,其他服务只能通过业务接口发起动作。
对于人工调整,应建立专用调整单和审批流程。人工调整一旦直接修改数量,后续对账很难区分是正常扣减、盘点差异还是人为修复。

如果订单创建后还可能支付失败、审核失败或用户取消,通常不应在第一步直接把库存视为已出库。此时可采用“预占,确认,释放”模型。
如果业务是仓库现场拣货后立即出库,且不存在较长的等待过程,可以直接执行扣减。但即使是直接扣减,也仍然需要记录业务动作和库存流水,不能因为流程短就省略可追溯性。
一个简化的预占流程如下:先确定库存维度,再执行带数量条件的更新;更新成功后插入预占记录和库存流水;任一步失败,整个本地事务回滚。
available_qty >= :qty 条件的更新。第 2 步和第 3 步之间仍可能有并发,因此幂等查询不能取代唯一约束。两个相同请求同时查询都没有记录时,最终只能依靠唯一键让其中一个插入失败或转入重复处理分支。
悲观锁的优势是逻辑直观。事务先锁住目标库存行,再读取当前值、判断数量并更新。对于同一个仓库中某个高频 SKU,数据库可以把并发请求排队,降低业务层复杂度。
它的代价也很明确:热点库存会形成锁等待;事务中如果包含远程调用,锁持有时间会被拉长;不同业务按照不同顺序锁定多条库存行时,还可能形成死锁。
我的实践判断是:如果事务只包含本地读写,且一次操作涉及的库存行很少,悲观锁往往足够;如果事务跨越远程服务或需要长时间等待,不要把锁持有到远程调用结束。
乐观锁通常通过版本号判断数据是否被别人更新。应用先读取版本号,再执行带版本条件的更新;如果影响行数为 0,说明版本已经变化,可以重新读取并重试。
UPDATE warehouse_stock SET available_qty = available_qty - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE id = :stock_id AND version = :old_version AND available_qty >= :quantity;
乐观锁并不意味着可以无限重试。高热点库存下,连续冲突会让请求消耗大量 CPU 和数据库连接。重试次数、退避时间和失败状态都要明确;当重试超过阈值,应把动作转入可补偿队列,而不是继续占用接口线程。
一个订单可能需要从多个仓库或多个批次扣减。此时真正困难的不是单行更新,而是多个库存对象之间的分配策略。
如果一次事务锁定 20 个库存行,事务冲突和死锁概率会明显上升;如果拆成多个事务,又可能出现只扣了部分库存的中间状态。因此要先决定业务是否允许部分分配,再决定事务模型。
支付、物流、第三方仓储和消息推送都可能耗时或失败。如果数据库事务在等待这些调用时一直持有库存锁,少量慢请求就可能拖住大量正常请求。
更合理的方式是把本地库存动作和外部动作拆开,用业务状态记录当前阶段。例如预占成功后提交事务,再异步等待支付结果;支付确认后发起确认扣减;确认失败则进入释放或补偿流程。

假设仓库 A 中,SKU-1001、批次 B202609、库位 L01 的可用库存为 10。订单 O10001 需要 7 件,订单 O10002 也需要 7 件。与此同时,O10001 的客户端因为响应超时发起一次重试。
这个场景包含三类风险:两个不同订单之间的并发竞争、同一订单的重复请求,以及预占成功后确认或释放动作的重复到达。只测试“两个请求同时更新”是不够的,因为真实系统的故障往往发生在请求和消息重复上。
错误流程先查询可用库存,再在应用层判断。两个订单都读到 10,两个请求都认为库存充足。若更新采用“写入计算后的新值”,后写请求可能把前一个请求的结果覆盖;若更新采用“数量减 7”,则可能出现库存为负。
而 O10001 的重试请求还会再次执行扣减。如果没有业务幂等键,系统无法区分“真正的新扣减”和“同一动作的重放”。这说明并发控制和幂等控制是两个问题,必须同时解决。
改进后,每个订单行的预占动作都有唯一幂等键,例如 reserve:O10001:line01:warehouseA。库存更新使用条件语句,只有可用库存大于或等于请求数量时才成功。
在两个订单同时请求时,只有一个请求能够完成 7 件预占,剩余可用库存为 3。另一个请求因为条件不成立而失败。O10001 的重试请求则因为幂等键已存在,直接返回第一次动作的结果,不再重复修改库存。
| 请求 | 业务动作 | 条件结果 | 库存变化 | 最终处理 |
|---|---|---|---|---|
| O10001 第一次请求 | 预占 7 件 | 成功 | 可用 10→3,预占 0→7 | 写入一条预占记录和一条流水 |
| O10002 请求 | 预占 7 件 | 库存条件不成立 | 不变 | 返回库存不足或进入分配重试 |
| O10001 重试请求 | 相同预占动作 | 幂等键已处理 | 不变 | 返回第一次预占结果 |
| O10001 取消请求 | 释放 7 件 | 预占状态为已预占 | 可用 3→10,预占 7→0 | 写入释放流水并关闭预占 |
上述方案主要解决单个库存维度在单库内的并发扣减和重复请求。如果库存主表和订单库不在同一个数据库中,库存事务提交后订单状态更新失败,仍然需要通过消息、重试和对账完成闭环。
它也没有回答仓库现场已经拣走但系统确认消息丢失的问题。那属于业务状态和物理作业之间的差异,需要出库扫描、作业回传、异常单和人工盘点共同处理。数据库设计能减少逻辑错误,但不能消除现实世界中的作业不确定性。

库存为 1、库存刚好等于请求量、库存小于请求量和库存远大于请求量,应该分别测试。不同初始状态会暴露不同问题,尤其是“刚好扣完”场景最容易出现边界条件错误。
测试还要改变并发请求的数量、请求顺序和事务提交顺序。不能因为一次压测没有出现负库存,就认定模型安全。并发缺陷通常具有概率性,测试需要增加重复轮次并保留每次请求的业务幂等键。
真实的重试并不是简单地重复发送一个接口。应该模拟服务端已经提交、响应在网络层丢失的情况;也要模拟服务端事务回滚但客户端误以为超时的情况。
验证标准不是“接口第二次返回成功”,而是第二次请求不能再次产生库存变化,并且返回结果应与第一次处理结果一致。若第一次处理尚未完成,系统也应返回处理中或待重试,而不是盲目执行第二次扣减。
应明确禁止以下情况:已释放的预占再次确认、已确认的扣减再次释放、已经完成的盘点调整重复应用、没有预占记录却直接执行确认扣减。
这类测试不能只验证 HTTP 状态码,还要检查主表数量、业务表状态、流水数量和幂等记录是否同时符合预期。
对账不是只找“库存小于零”。更有价值的检查包括:库存主表的变更是否都有流水;流水业务单号是否都能关联到有效业务单;同一幂等键是否出现多条成功记录;预占记录与主表预占总量是否一致。
在有盘点和人工调整的系统中,对账公式必须明确排除或单独纳入调整动作。否则对账程序会把合法的盘点差异误报为系统错误,也会让真正的异常被大量噪声淹没。

这类系统不必一开始就引入复杂的分布式库存架构。优先建立库存主表、预占表、流水表和幂等唯一键,使用本地事务完成主表与流水写入。
在数据库层使用条件更新或短事务悲观锁,通常已经可以解决主要并发问题。重点是禁止业务代码绕过统一服务直接修改主表,并补上并发、重试和取消释放测试。
多仓系统最容易出现的错误,是仍然以 SKU 作为库存唯一标识。应先确认库存对象的完整维度,并为这些维度建立唯一索引。
如果批次、效期和质量状态参与分配规则,就不能只在应用层临时筛选。库存表、分配表和流水表都要能够记录实际使用的批次和仓库,否则后续无法解释为什么系统选中了某一批库存。
多个服务共用一个库存数据库时,首要动作不是增加消息队列,而是确定谁拥有库存主表的写权限。订单、售后和运营服务可以发起业务请求,但不应各自直接修改库存数量。
同时要建立数据库变更评审和状态字典。共享数据库最怕的是表仍然只有一份,语义却有多份。统一写入口能够减少绕过流水、绕过幂等和绕过状态机的问题。
当订单、库存和仓储作业分别位于不同数据库时,不应假装它们可以拥有一个无限范围的本地事务。需要为业务动作建立状态,例如“库存已预占、订单待确认”“出库已完成、库存待回传”。
异步消息应具备业务幂等键,消费者应能安全重复执行;失败消息要进入重试或死信流程;对账任务要能够发现“订单成功但库存未确认”这类跨系统差异。
对于秒杀、限量商品或单一物料集中出库,数据库行可能成为热点。此时可以考虑库存分桶、预扣减、队列串行化或缓存辅助,但这些方案会增加回滚、补偿和最终对账复杂度。
如果业务规模尚未证明数据库单行更新已经成为瓶颈,不建议提前把一致性拆散到多个组件。先通过索引、事务时长、锁等待和更新失败率确认瓶颈,再决定是否引入更复杂的架构。

| 方案 | 优势 | 短板 | 更适合的场景 |
|---|---|---|---|
| 条件更新 | 语句短、并发边界清晰、数据库直接判断数量 | 复杂多行分配时业务代码更难组织 | 单库存行扣减、库存条件明确 |
| 悲观锁 | 逻辑直观,适合短事务内串行处理 | 热点行锁等待,远程调用不能放在锁内 | 冲突高但事务短、库存维度少 |
| 乐观锁 | 不长期持锁,适合可重试业务 | 高冲突下重试成本高,需要退避策略 | 更新冲突可接受、请求可安全重试 |
如果订单创建和库存预占必须同时成功或失败,并且两者在同一个数据库中,本地事务通常是成本最低的选择。没有必要为了架构形式引入消息。
如果订单、库存和仓库作业跨越多个服务,本地事务无法覆盖全部范围。此时异步最终一致是现实选择,但必须接受中间状态,并提供可观测、可重试、可对账的机制。
最终一致的关键不是“最终”两个字,而是三个时间问题:多久应当完成、超过多久算异常、异常由哪个团队负责。没有这三个答案,最终一致性就无法被运营。
只记录变化量可以节省存储,但排查时必须把之前所有流水重新聚合,遇到历史修复、并发写入或数据迁移时,重建过程会变得复杂。
记录变更前后数量会增加写入量和存储空间,但能直接检查“这条流水写入时认为库存是多少”。对于高价值库存、财务相关库存或审计要求高的行业,我更倾向于保留前后数量。
单库单体系统中,外键可以帮助阻止悬空业务引用。它的代价是数据迁移、批量写入和跨表删除需要更加谨慎。
多服务拆库后,跨数据库外键通常不可行。此时应通过业务状态、异步校验和对账来维持关联关系,并明确哪些数据是强依赖、哪些数据允许延迟到达。不要因为服务拆分而放弃所有约束,也不要把单体数据库的约束方式生搬到分布式系统。
严格禁止负库存能够尽早暴露库存不足和业务流程错误,适合库存必须准确、不能透支的场景。但在部分供应链或门店业务中,负库存可能是暂时允许的业务状态。
允许负库存时,必须同时增加透支原因、责任单据、审批状态和补足期限。负数本身不能成为最终答案,它只能是一个可解释、可追踪、可关闭的状态。

数据字典不应只记录字段类型和长度,更要记录业务定义、允许来源、是否允许负数、变更时机和对账公式。例如,reserved_qty究竟是订单预占,还是包含拣货锁定;如果定义不清,不同服务迟早会按自己的理解写入。
建议每个库存字段都配套以下信息:
如果订单服务拥有库存主表的直接写权限,代码规范就只是建议。新成员、临时脚本和紧急修复都可能绕过流水表,形成无法解释的数量变化。
更稳妥的做法是区分读权限、业务写权限和运维调整权限。普通服务只能调用库存领域接口;数据修复需要专用账号、工单和审批记录;生产环境不允许使用随手编写的更新语句直接改库存。
表结构变更必须通过版本化迁移脚本发布,不能依赖某个开发人员手工在测试库和生产库执行命令。迁移脚本应考虑旧服务是否仍在运行、字段是否需要先允许为空、索引创建是否会阻塞线上写入。
新增幂等唯一索引时尤其要先清理历史重复数据。直接创建唯一索引可能导致发布失败,更糟糕的是,团队为了让发布通过而临时删除数据,进一步损害库存事实。
库存一致性测试不能只由库存模块负责人维护。订单、仓储、售后和运营后台都应该参与定义跨模块场景,因为很多事故正是发生在模块交界处。
测试用例最好以业务动作表达,而不是只写接口名称。例如“预占成功后支付超时,重复收到释放消息时库存只能恢复一次”,比“测试取消接口幂等”更容易让所有团队理解验证目标。
“处理失败”不是一个足够具体的状态。应区分库存不足、数据库冲突、消息未投递、外部确认超时、数据维度不存在和人工审批等待。
不同失败原因对应不同责任团队和恢复方式。状态越具体,监控告警越容易路由,值班人员也不必先从一堆日志中猜测问题类型。
团队扩大后,常见的反应是把所有信息都放进一张库存宽表:订单号、支付状态、出库状态、物流单号、盘点原因和人工备注全部写入库存表。
这会让库存主表变成多个领域的共享写入点。任何一个服务修改订单状态,都可能触发库存记录更新;字段越多,事务边界越模糊。库存主表应该保持窄而稳定,业务过程信息放在对应的业务表中,通过编号关联。

对于一个库存维度,可以用初始快照、库存流水和当前主表进行核对。基本关系可以表示为:
当前可用库存
= 初始可用库存
+ 所有有效可用库存变更
+ 盘点调整
+ 退货入库
正常扣减
具体公式要根据库存口径调整。预占库存、冻结库存和在途库存不能在没有定义的情况下直接相加。尤其是盘点调整,必须区分它是修正账面数量,还是记录物理数量差异。
只从库存表查流水不够,还要从业务单据反查库存动作。已完成出库的单据是否都有确认扣减?已取消的预占是否都完成释放?一条业务单是否出现两条相同动作的成功流水?
双向检查能够发现“库存看起来有流水,但业务状态不存在”以及“业务显示已完成,但库存没有动作”两类问题。它比单纯检查库存是否为负更接近真实业务风险。
发现库存差异后,最忌讳直接把主表改成“看起来正确”的数字。这样虽然暂时消除了差异,却删除了问题证据。
正确的修复方式应建立调整单,说明原数量、目标数量、差异数量、原因、审批人和处理时间,再通过专用调整动作写入主表与调整流水。修复不是修改历史,而是新增一条解释历史差异的事实。
高价值、强监管或高频交易库存,可以采用更短周期的增量对账;低频、低价值库存可按日或按批次对账。不是所有库存都需要每分钟全量扫描,但所有库存都应该有明确的异常发现时限。
对账任务还要具备幂等性。一次对账失败后重新执行,不能重复生成调整单;否则为了修复一个差异,又制造新的库存变化。
先通过代码搜索、数据库审计日志和接口调用记录,列出所有修改库存主表的服务、任务、脚本和后台功能。不要先讨论理想架构,先确认系统当前实际上有多少个写入口。
同时统计每个入口的动作类型、是否有业务单号、是否写流水、是否支持重试以及是否允许人工调用。很多团队在这一步就会发现,真正的问题不是表缺少一个字段,而是库存被多个模块随意修改。
不必一开始拆出十几张表。可以先完成四件事:库存主表维度唯一、库存更新使用条件约束、每次变化写流水、业务动作具备幂等键。
这四项能覆盖大部分单库扣减风险。之后再根据业务需要增加预占、冻结、批次分配和调整单,不要把所有未来场景提前塞进当前模型。
新增流水表后,历史库存如何初始化必须有明确方案。可以建立迁移快照作为起点,并记录快照时间、来源和校验结果。旧接口应逐步切换到统一库存服务,不能新旧入口长期并存。
迁移期间建议同时记录旧逻辑和新逻辑的结果,发现差异时先停止扩大范围,再判断是口径不同、历史脏数据还是新逻辑错误。
完成主流程后,再补充超时释放、失败重试、消息重复、人工调整和对账告警。系统上线不是一致性建设的终点,真正的成熟度体现在异常发生后能否自动分类、限制影响并恢复。
建议至少观察改造前后的以下指标:库存差异单数量、重复扣减拦截次数、预占超时量、人工修复耗时、无来源流水数量和对账完成率。
不要只看“接口没有报错”。一个接口可以全部返回成功,但如果没有幂等、没有流水或没有对账,系统只是把错误隐藏得更深。

仓储系统的库存一致性,表面上是数量加减,实质上是多个团队对同一资源状态的共同解释。团队小的时候,开发人员可以依靠经验记住哪些接口能改库存;团队变大后,这种记忆会迅速失效,错误入口、重复动作和不同口径会同时出现。
我更看重的不是某一张表是否“设计得漂亮”,而是它是否能让系统回答三个问题:当前库存为什么是这个数、同一动作是否已经执行过、出现异常后如何恢复且不覆盖原始事实。
因此,落地时不必从复杂架构开始。先做四件事:明确库存维度、拆分当前状态与变化流水、建立业务幂等约束、收敛库存写入口。在此基础上,再根据多仓、批次、热点库存和跨服务需求增加状态机、消息补偿和对账体系。
下一步可以从生产数据库和代码仓库各做一次盘点:列出所有库存主表写入口,抽取最近一个月的重复请求和人工调整记录,再用本文的检查清单逐项核对。如果你无法用一条业务流水解释一次库存变化,就说明这部分库存还没有真正进入可治理状态。
我最初接触库存模块时,也觉得用一张表保存 SKU、仓库和库存数量最简单:扣减时减一个字段,取消时再加回来。后来在并发扣减、订单重试和人工盘点同时发生的场景中,我发现主表里的数字虽然能表示结果,却解释不了这个结果是怎么来的,更无法判断一次扣减是否已经执行过。
库存主表适合保存“当前状态”,但不适合独自承担“变化原因、业务过程和恢复依据”。如果所有动作都直接修改 available_qty,系统只能知道现在剩多少,却不知道这 10 件库存是订单扣减、取消释放,还是盘点调整产生的。我更建议至少拆成库存主表、预占表、库存流水表和幂等记录表。
库存主表负责高频读取和当前可操作数量,预占表负责记录尚未完成的锁定,流水表负责记录每次变化,幂等表负责阻止同一业务动作重复落库。
数据对象主要职责不应承担的职责 库存主表保存当前可用、预占、冻结数量解释所有历史变化 预占表记录订单或出库单的锁定关系直接替代库存主表 库存流水表记录变更原因、前后数量和业务单号作为高并发扣减的唯一查询表 幂等记录表判断某个业务动作是否处理过代替库存事务本身 一次库存扣减至少要同时完成两件事:更新库存主表,并写入一条不可重复的业务流水。
主表与流水应放在同一数据库事务中,否则主表已经减掉库存、流水却写入失败时,后续对账只能看到一个无法解释的差异。实际设计时,还应为库存记录建立唯一约束,例如 warehouse_id、sku_id、batch_id、location_id 的组合不能重复。
否则两个并发初始化任务可能创建两条同一批次库存,后续即使扣减 SQL 写得正确,也会从数据模型层面埋下重复库存的隐患。
我以前见过一种很常见的写法:先查询可用库存,应用层判断是否大于扣减数量,然后计算新值再 UPDATE。单线程测试完全正常,但把并发数提高后,两个请求会同时读到库存为 10,最后都认为自己可以扣 7,问题通常要到线上才暴露。
条件更新的价值在于,把“库存是否足够”和“扣减库存”合并为一次数据库写操作。
典型写法是:
UPDATE stock SET available_qty = available_qty - :qty, version = version + 1 WHERE warehouse_id = :warehouse_id AND sku_id = :sku_id AND available_qty >= :qty;执行后不要只看 SQL 是否报错,而要检查受影响行数。影响行数为 1,说明扣减成功;影响行数为 0,可能是库存不足、记录不存在,或者更新条件没有命中。这个判断必须在应用层明确区分,否则调用方可能把失败误认为成功。
在我做过的一次小型并发验证中,初始库存设置为 10,同时提交 100 个扣减 1 件的请求。采用“先查询再更新”的实现时,出现过多次最终库存为负数或扣减成功数超过 10 的情况;改成带 available_qty >= :qty 条件的原子更新后,成功数稳定为 10,剩余库存为 0。
但条件更新不是万能方案。它主要解决的是同一数据库、同一库存记录上的并发扣减。如果扣减之后还要调用支付、仓库设备或其他服务,数据库条件更新无法自动覆盖这些跨系统步骤,仍然需要状态机、消息重试和异常补偿。索引也会影响这条方案是否可靠。
更新条件中的仓库、SKU、批次等字段应有合适的联合索引,否则高并发下可能产生较大的扫描和锁等待。我的判断标准不是“用了条件 UPDATE 就安全”,而是同时检查事务边界、影响行数、索引命中、失败重试和流水写入是否完整。
我在排查订单取消问题时遇到过这样的数据:订单已经取消,但库存没有释放;重试释放任务后,库存又多加了一次。最初大家都把原因归结为消息重复,后来才发现系统根本没有区分预占、确认和释放,所有动作都在调用同一个“减库存或加库存”接口。
预占、确认和释放是三个不同的业务动作,不能只靠正负数量区分。预占通常表示订单暂时占用可用库存,确认表示预占最终转化为出库或销售扣减,释放表示取消占用并把数量还回可用库存。更稳妥的模型是把动作设计成有前置状态的状态机。
例如,只有 RESERVED 状态才能执行 RELEASE,只有 RESERVED 状态才能执行 CONFIRM;已经 RELEASED 的记录再次收到释放消息时,应返回“已处理”,而不是再次增加可用库存。
动作数量变化允许的前置状态重复执行 预占可用减少,预占增加待预占不得重复预占 确认预占减少,已扣减增加已预占不得再次扣减 释放预占减少,可用增加已预占不得重复增加 盘点调整按审批结果修正允许调整必须产生独立流水 预占表中的唯一键应围绕业务动作设计,而不是简单使用一次 HTTP 请求 ID。
比如同一个订单可能包含多个 SKU、多个仓库和多个批次,幂等键可以由 order_line_id、warehouse_id、batch_id 和 action_type 组合而成,具体范围要根据业务粒度确定。我不建议用“库存加回去”作为释放逻辑的唯一依据。
释放前应先更新预占记录状态,并要求状态从 RESERVED 成功变为 RELEASED;只有状态更新成功的请求,才允许增加库存。这样即使释放消息投递两次,第二次也不会再次修改数量。如果取消和出库确认可能并发到达,还要定义明确的状态优先级和冲突处理规则。
系统不能把两个请求都当作成功,而应让其中一个完成状态迁移,另一个根据最新状态进入幂等返回、重试或人工核查流程。
团队只有两三个人时,大家知道哪些接口可以改库存,靠约定也能维持一段时间。后来订单、仓储、售后和数据团队分别维护自己的服务,某个补偿脚本直接改了库存主表,却没有写流水,结果业务数量看似恢复了,审计和对账却完全失去依据。
团队增长后,库存一致性最大的风险往往不是某一条 SQL 写错,而是修改入口越来越多。不同团队可能对 available_qty、reserved_qty 和 frozen_qty 产生不同理解,最后形成“每个服务都有一套正确逻辑”的局面。第一步是把字段语义写进数据字典,并明确每个字段的允许修改者。
例如 available_qty 只能由库存领域服务修改,订单服务只能创建预占请求,售后服务只能发起释放或退货动作,报表服务只读交易库或读取同步后的数据。第二步是让表结构本身承担约束。
可以为库存主表增加版本号,为库存流水表增加业务幂等键唯一索引,为预占表增加状态和业务单号约束,并将人工调整与正常扣减使用不同的 action_type。这样即使有人绕过业务文档,也更难写入无法追踪的数据。
治理对象建议做法解决的问题 库存主表限制写权限,统一服务修改避免多服务直接覆盖数量 库存流水表业务幂等键加唯一约束避免同一动作重复记账 表结构变更使用迁移脚本和评审流程避免字段含义悄悄改变 人工调整专用接口、审批和独立动作类型区分业务扣减与盘点修正 第三步是建立可验证的对账规则。
最基本的检查是:期初库存加上所有有效变更,是否等于当前库存;同时还要检查已出库单据是否都有扣减流水、已取消预占是否完成释放、同一幂等键是否出现多条成功记录。在团队协作中,我更看重“错误能否被及时发现和定位”,而不是追求表结构看起来多么复杂。
一个字段较少但有唯一约束、状态迁移、流水记录和对账任务的设计,通常比一张字段齐全却允许任意修改的宽表更可靠。最终验收时,至少应并发测试库存为 1 的抢购、客户端超时重试、消息重复投递、取消与出库并发、数据库更新成功但响应丢失、盘点调整和补偿任务重复执行。
只有这些异常路径都能得到明确结果,表结构才真正放大了团队的交付能力。


读者评论
文章把库存主表、流水表和幂等记录的职责区分得比较清楚,尤其是强调业务动作身份,确实比单纯依赖接口重试更可靠。
条件更新能降低并发超卖风险,但文中也说明它不能替代流水、状态机和对账,这个边界判断比较客观,适合仓储系统设计参考。
关于团队扩大后“隐含协议”失效的分析很有现实感。库存字段含义如果没有固化到结构和约束中,跨服务协作时确实容易产生不同口径。
文章覆盖面较广,但具体表结构示例和异常补偿流程还可以进一步展开,例如幂等记录的状态设计、唯一索引范围及对账差异的处理方式。