《数据库存:技术负责人标准化教程:用表结构设计复制保证扣减一致性》真正要解决的,不是“库存表里还剩几个数字”,而是当两个请求同时抢最后一件商品、同一订单因超时重试两次、数据库已经扣减但接口没有返回时,系统仍然能够回答三个问题:这次扣减是否只执行了一次、余额为什么变成现在这样、异常发生后能否恢复。我的判断是,库存一致性首先是表结构问题,其次才是 SQL 问题;没有库存主表、库存流水、业务幂等和状态边界,再漂亮的扣减语句也只能解决正常路径。
很多团队第一次设计库存时,只创建一张商品表,再增加一个 stock 字段。下单时减一,取消时加一,退货时再加一。这个模型在单人录入、低并发、没有重试的演示环境里看起来完全正常,但它没有回答库存变化的来源,也无法判断某一次加减是否已经执行过。
我在做库存方案评审时,会先把“一致性”拆成四个层次,而不是直接问“库存会不会出错”。
只保证第一层,系统可能“现在看起来没错”,但无法审计;只保证第二层,流水可能完整,却不代表业务单据已经正确推进;只保证第三层,没有幂等仍然可能因重试重复扣减。因此,库存系统的最低标准不是“数量不为负”,而是余额、过程、业务状态和请求语义能够相互验证。
这里的“复制”不能简单理解为把库存数字复制到另一张表、缓存或报表系统。库存复制至少有三种不同目的:为快速查询复制当前快照,为审计保存不可变流水,为异步系统传播库存变更事件。它们的可靠性和时效性不同,不能使用同一套判断标准。
| 数据对象 | 主要职责 | 是否可作为扣减依据 | 典型一致性要求 |
|---|---|---|---|
| 库存主表 | 快速读取当前库存余额 | 可以 | 事务内原子更新 |
| 库存流水表 | 记录每一次库存变化及业务来源 | 通常不直接承担扣减 | 与主表同事务写入 |
| 缓存库存 | 降低读延迟,承载展示和预检 | 不建议单独作为最终依据 | 允许短暂延迟,但要可重建 |
| 报表或分析库 | 统计周转、销量和库存结构 | 不可以 | 最终一致即可 |
我的专业判断是:扣减只认事实库,复制库只服务于查询、分析或通知。如果团队让缓存、报表库或某个表格视图直接决定“最后一件商品能不能卖”,实际上就把一个可验证的事务问题,变成了跨系统同步问题。

一个可上线的库存模型,至少要支持快速查询、并发扣减、请求幂等和异常重建。对应到数据库结构,通常需要库存主表、库存流水表、库存预占表以及幂等记录表。业务规模再扩大后,还可以增加对账任务表、补偿任务表和库存调整单表。
这并不意味着所有系统都必须照搬六七张表。对于一个低并发的内部领用系统,库存主表加流水表可能已经足够;对于电商订单、仓储出库或多渠道销售,预占和幂等记录通常不能省。关键不是表数量,而是每一张表是否承担清楚、不可重叠的责任。
设某仓库的 SKU-A 初始可用库存为 1。请求 A 和请求 B 几乎同时到达,两个请求都先读取到 1,随后各自执行“库存减一”。如果应用采用先查询、在内存判断、再普通更新的方式,两个请求可能都认为库存充足,最终产生重复销售。
数据库事务并不会自动阻止这种问题。事务只说明一组操作具备某种原子性和隔离性,具体能否阻止两个事务同时基于旧值做决定,还取决于 SQL 写法、索引、锁机制和隔离级别。
-- 风险较高:先读后写,判断与更新不是一个原子条件 SELECT available_qty FROM inventory WHERE warehouse_id = 10 AND sku_id = 1001; -- 应用层判断 available_qty >= 1 后再执行 UPDATE inventory SET available_qty = available_qty - 1 WHERE warehouse_id = 10 AND sku_id = 1001;
这段代码最危险的地方,不是减法本身,而是“库存足够”的判断发生在更新之外。只要两个请求读取和更新之间存在交错,就会出现竞态窗口。
第二个场景更容易被忽略:数据库事务已经提交,但应用在返回响应前发生网络超时。调用方无法知道扣减到底成功还是失败,于是使用同一个订单号重新发起请求。如果系统没有幂等约束,第二次请求会再次扣减。
在生产环境中,超时、连接重置、网关重试、消费者重复投递并不罕见。问题不在于“调用方为什么重试”,而在于扣减接口是否把请求号当作业务事实的一部分保存下来。只依赖应用代码中的 if 判断,不足以实现幂等;幂等最终必须落在数据库唯一约束或可验证的状态记录上。
库存方案经常因为状态设计过于简单而失控。下单时锁定库存,支付后确认扣减,取消后释放锁定,出库后记录实际出库,这些动作的业务含义不同。如果都被命名为“扣库存”,后续开发者很容易重复执行或遗漏释放。
| 业务阶段 | 库存动作 | 是否改变实物库存 | 是否需要幂等 |
|---|---|---|---|
| 下单 | 增加锁定数量 | 通常不改变 | 需要 |
| 支付确认 | 锁定转为已确认扣减 | 视库存口径而定 | 需要 |
| 订单取消 | 释放锁定数量 | 通常不改变 | 需要 |
| 出库完成 | 减少实物库存并记录出库 | 改变 | 需要 |
| 退货入库 | 增加可用或质检库存 | 改变 | 需要 |
如果企业使用九数云或类似数据分析平台做库存看板,我建议把它放在“汇总、分析、预警”位置,而不是直接作为交易扣减的事实来源。它可以帮助管理者观察库存周转、缺货、滞销和仓库差异,但订单扣减仍应在具备事务能力的业务数据库中完成。更多产品信息可以通过 九数云官网了解,但分析平台与交易库存库的职责必须分开。
库存不一致有时并不是并发 bug,而是字段含义根本没有统一。例如采购系统按箱记录,销售系统按件扣减,仓库系统按托盘统计;或者总仓库存复制到门店后,门店把“在途库存”当成“可售库存”。这些问题用锁和事务都解决不了。
在建表之前,我通常要求团队先写出 SKU、仓库、货位、单位和库存状态的数据字典。每个数量字段都必须说明单位、是否包含锁定、是否包含质检中商品、是否允许负数,以及它的来源和更新责任人。

单表模型的优点是简单,但它把余额、来源、状态和业务关联全部压缩成一个数字。发生差异时,团队只能通过人工回忆或查询订单表猜测原因,无法精确还原“哪一笔业务导致了变化”。
如果库存主表中有 100 件,但系统无法回答其中 30 件来自哪次入库、20 件为何被锁定、5 件是否已经出库,那么这个数字即使暂时正确,也称不上可治理库存。
我更推荐把库存主表视为“当前状态快照”,把流水表视为“事实证据”。主表追求读写效率,流水表追求不可抵赖、可追溯和可重放,两者不是重复存储,而是不同查询目的下的分工。
下面这种写法比完全不判断更好,但仍然存在竞态风险:
-- 先判断 SELECT available_qty FROM inventory WHERE id = 1; -- 再更新 UPDATE inventory SET available_qty = available_qty - 2 WHERE id = 1;
更可靠的方式是把“足够”和“扣减”放在同一条条件更新中,并检查受影响行数:
UPDATE inventory SET available_qty = available_qty - :qty, updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = :warehouse_id AND sku_id = :sku_id AND available_qty >= :qty;
如果返回影响行数为 1,说明这次扣减满足条件并成功执行;如果为 0,可能是库存不足、记录不存在或条件不匹配。实际项目中仍要在事务中写流水,并结合唯一业务单号实现幂等。
事务不是魔法。两个事务都可以成功,并不意味着业务上允许它们都扣减。特别是在“先读后写”的模式下,如果锁没有覆盖到正确的记录,或者更新条件没有包含库存数量,事务仍然可能让两个请求基于同一旧值完成操作。
技术负责人需要审查四个细节:查询是否命中唯一索引,锁定的是不是库存粒度,更新条件是否包含可用数量,流水写入是否与主表更新处于同一个提交边界。缺少任意一项,都可能出现“代码看起来有事务,结果仍然不可信”的情况。
缓存非常适合承载商品详情页的库存展示、库存不足的快速预检和高频读取,但它不适合独立承担最终扣减判断。缓存可能过期、被淘汰、更新失败,也可能在数据库提交和缓存刷新之间出现短暂差异。
“先更新数据库,再删除缓存”是常见策略,但它仍然需要处理删除失败、并发读写和缓存重建。对于高价值商品或强一致出库场景,我宁愿让关键扣减直接访问数据库,也不会为了几毫秒的读取延迟牺牲事实可靠性。
普通日志记录“某用户调用了扣减接口”,但库存流水必须描述业务变化:扣减前数量、变化数量、扣减后数量、业务类型、业务单号、仓库、SKU、请求号和发生时间。没有这些字段,流水只能证明接口被调用过,不能证明库存发生了什么。
如果流水表允许修改历史记录,审计价值还会进一步下降。比较稳妥的做法是让正常业务只追加流水,修正库存通过独立的盘点调整单完成,并要求填写原因、操作人和审批信息。
分析平台中的库存数据通常经过抽取、转换、聚合和刷新。即使刷新频率很高,也不能假设它与交易库在任意时刻完全相同。报表适合观察周转率、缺货率、仓库差异和异常变化,不适合决定最后一件商品是否允许出库。
如果管理者要求“报表和业务库必须一模一样”,应先确认这是展示需求、对账需求还是交易裁决需求。展示可以接受分钟级延迟,对账需要可解释的时间点,交易裁决则必须回到事实库。

库存字段名称很容易让人误以为含义一致。实际上,“总库存”“当前库存”“可用库存”“可售库存”在不同企业里可能代表不同口径。建表前应先写出公式,并为公式中的每一项指定责任来源。
一种常见的基础模型是:
可用库存 = 实物库存 – 锁定库存 – 冻结库存
可售库存 = 可用库存 + 符合销售条件的在途库存
如果企业不允许把在途商品提前销售,就不能把在途库存加入可售库存。如果质检中的退货不能直接销售,就必须单独记录,而不是简单加回可用库存。公式不是装饰性文档,它决定了扣减时更新哪些字段,也决定了报表应该如何复制数据。
库存唯一性不能只看 SKU。实际库存往往至少由仓库和 SKU 共同决定,复杂场景还要加货位、批次、效期、序列号、库存状态和货主。
| 业务复杂度 | 建议唯一粒度 | 主要代价 |
|---|---|---|
| 单仓库、无批次 | 仓库 + SKU | 结构简单,适合快速落地 |
| 多仓库 | 仓库 + SKU | 扣减必须明确分仓策略 |
| 有批次或效期 | 仓库 + SKU + 批次 | 锁粒度增加,需处理先进先出 |
| 有货位和货主 | 仓库 + 货位 + SKU + 货主 | 查询、合并和出库分配更复杂 |
| 序列号商品 | 序列号级库存明细 | 不能只依赖数量字段 |
如果唯一粒度没有确定,后面所有锁策略都会失去对象。锁住“SKU-A”并不等于锁住“某仓库、某批次、某货位上的 SKU-A”。数据库只能锁记录,不能替你判断业务上应该锁哪一层。
我通常按三个问题选择方案:库存记录是否集中成为热点,扣减是否需要读取并计算多个字段,业务是否允许失败后短暂重试。简单的单字段扣减优先使用条件更新;需要检查复杂状态或同时写入多个关联记录时,可以使用行级锁;并发较高但允许有限重试时,可以考虑乐观锁。
不要把“乐观锁性能高”或“悲观锁最安全”当成结论。乐观锁在热点 SKU 上可能产生大量重试,悲观锁在长事务或慢查询下可能造成锁等待。真正的判断对象是竞争概率、事务长度、失败成本和业务可接受延迟。

库存主表用于快速回答当前状态,不适合承载所有历史变化。一个标准版本可以包含以下字段:
CREATE TABLE inventory (
id BIGINT PRIMARY KEY,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
total_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
available_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
locked_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
frozen_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
unit_code VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (warehouse_id, sku_id)
);这里的字段不是固定答案。是否保存 total_qty,取决于系统是否需要快速读取和是否能接受冗余字段维护。保存多个余额字段可以减少查询计算,但也增加了状态组合和对账复杂度。
数量字段建议使用定点数或最小库存单位的整数,不建议使用浮点数。重量、长度等可变单位必须统一精度和舍入规则,否则相同商品在不同系统中可能因小数处理产生差异。
库存流水不是把主表复制一份,而是记录事件。每一行应对应一个明确的库存变化动作,并关联业务单据和请求号。
CREATE TABLE inventory_flow (
flow_id BIGINT PRIMARY KEY,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
biz_type VARCHAR(32) NOT NULL,
biz_id VARCHAR(64) NOT NULL,
operation_type VARCHAR(32) NOT NULL,
change_qty DECIMAL(18, 4) NOT NULL,
before_qty DECIMAL(18, 4) NOT NULL,
after_qty DECIMAL(18, 4) NOT NULL,
request_id VARCHAR(128) NOT NULL,
operator_id BIGINT,
created_at TIMESTAMP NOT NULL,
UNIQUE (operation_type, biz_id, warehouse_id, sku_id)
);建议保留 before_qty 和 after_qty,因为它们能显著降低排查成本。仅有 change_qty 时,工程师还要根据历史记录重新计算上下文;有前后余额,就能快速判断这笔流水写入时看到的状态。
不过,前后余额本身也不是绝对真相。它必须在同一事务中由数据库当前值计算并写入,不能由客户端提交。否则调用方可以伪造一个与主表无关的“扣减前数量”。
如果业务存在“下单后暂时占用库存”的过程,建议使用库存预占表,而不是只在订单表里增加一个锁定标识。预占表可以记录订单、SKU、仓库、锁定数量、状态和过期时间。
CREATE TABLE inventory_reservation (
reservation_id BIGINT PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
reserved_qty DECIMAL(18, 4) NOT NULL,
status VARCHAR(20) NOT NULL,
expire_at TIMESTAMP,
confirmed_at TIMESTAMP,
released_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
UNIQUE (order_id, warehouse_id, sku_id)
);预占表解决的是“某个订单锁了多少库存”,库存主表解决的是“当前还剩多少可用库存”。订单取消时,系统可以根据预占记录精确释放,而不是根据订单商品数量猜测是否已经锁定。
幂等记录表的核心不是保存一堆请求日志,而是为业务动作建立唯一边界。一个订单的“锁定”“确认”“释放”是三个不同动作,不能只用订单号做全局唯一键,而应把动作类型纳入唯一范围。
CREATE TABLE idempotency_record (
id BIGINT PRIMARY KEY,
request_id VARCHAR(128) NOT NULL,
biz_id VARCHAR(64) NOT NULL,
operation_type VARCHAR(32) NOT NULL,
status VARCHAR(20) NOT NULL,
result_code VARCHAR(32),
result_payload TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (biz_id, operation_type),
UNIQUE (request_id, operation_type)
);重复请求到达时,系统先读取幂等记录。如果状态是成功,直接返回第一次处理结果;如果状态是处理中,要根据超时策略查询业务事实或进入补偿;如果状态是失败,则要判断失败是否可重试。幂等不是“重复请求不报错”,而是重复请求不产生第二次业务效果。
只要库存系统连接了订单、支付、仓储、缓存或消息队列,就必须考虑局部成功。数据库事务可以保证同库内主表和流水的一致,但不能自动保证跨系统调用成功。
对账任务表可以记录检查范围、差异数量、差异类型、重试次数和处理状态。补偿任务表可以记录需要重新发送的消息、重新刷新缓存的 SKU,或者等待人工审核的库存调整。
| 异常类型 | 可自动处理方式 | 不应自动处理的情况 |
|---|---|---|
| 消息重复投递 | 依据业务单号和动作类型幂等 | 业务单号本身不唯一或内容冲突 |
| 缓存刷新失败 | 重试、删除后回源、定时重建 | 关键扣减已经依赖错误缓存 |
| 主表与流水不符 | 锁定期间重新计算并生成对账任务 | 涉及实物差异或盘点争议 |
| 订单已取消但预占未释放 | 依据预占状态和过期时间释放 | 订单已部分出库或存在人工改量 |

在单库模型中,我建议把主表更新、流水写入、幂等状态变更放入一个本地事务。缓存删除、消息通知和报表复制放在提交成功之后处理,避免把外部系统故障拖进库存核心事务。
顺序中最容易被省略的是第十步。事务提交前不能假设数据库一定成功,缓存和消息也不能在事务尚未确认时被当作完成。否则可能出现消息已经通知“扣减成功”,数据库事务却回滚的反向不一致。
单 SKU、单仓库、只减少可用数量的扣减,可以使用条件更新。其核心是让数据库在同一条 UPDATE 中完成库存是否足够的判断与数量变化。
BEGIN;
UPDATE inventory
SET available_qty = available_qty – :qty,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE warehouse_id = :warehouse_id
AND sku_id = :sku_id
AND available_qty >= :qty;— 应用检查影响行数:
— 1:库存扣减成功
— 0:库存不足、记录不存在或条件不匹配
INSERT INTO inventory_flow (
flow_id, warehouse_id, sku_id, biz_type, biz_id,
operation_type, change_qty, before_qty, after_qty,
request_id, created_at
)
VALUES (
:flow_id, :warehouse_id, :sku_id, :biz_type, :biz_id,
'DEDUCT', -:qty, :before_qty, :after_qty,
:request_id, CURRENT_TIMESTAMP
);
COMMIT;示例中的 before_qty 和 after_qty 应来自事务内可靠读取,而不是客户端传入。若必须同时更新可用、锁定和实物库存多个字段,应先明确状态转换,再决定是一条条件更新完成,还是使用行级锁在事务内分步更新。
当一次业务操作需要先读取多个字段、判断多个状态,再同时更新预占表和库存主表时,行级锁通常更容易写对。典型流程是使用 SELECT ... FOR UPDATE 锁住目标库存记录,随后在事务内完成判断和更新。
BEGIN; SELECT available_qty, locked_qty, version FROM inventory WHERE warehouse_id = :warehouse_id AND sku_id = :sku_id FOR UPDATE; -- 业务层判断 available_qty 是否足够 -- 足够后更新库存主表、写入预占记录和库存流水 UPDATE inventory SET available_qty = available_qty - :qty, locked_qty = locked_qty + :qty, updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = :warehouse_id AND sku_id = :sku_id; COMMIT;
悲观锁的前提是事务足够短。事务中不应调用支付接口、仓储接口或远程服务,也不应等待人工确认。否则一个热点 SKU 被长时间锁住,其他订单会排队,最终表现为数据库慢、接口超时和重复重试同时发生。
乐观锁使用版本号判断记录是否被其他请求修改。请求读取版本 7 后,只有仍然是版本 7 时才能更新为版本 8。如果影响行数为 0,说明发生了竞争,需要重新读取、有限重试或直接返回库存竞争失败。
UPDATE inventory SET available_qty = available_qty - :qty, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE id = :id AND version = :version AND available_qty >= :qty;
乐观锁并不消除冲突,只是把冲突显式暴露出来。对于秒杀、热门单品或集中抢购场景,单条库存记录可能成为热点,重试会放大数据库压力。这时应进一步考虑库存分桶、队列化消费、按仓库拆分热点或提前分配库存,而不是无限增加重试次数。

以一个拥有多个仓库、多个销售渠道的零售业务为例,交易库每天产生订单扣减、取消释放、退货入库和盘点调整等流水。技术团队将经过脱敏的库存主表、库存流水和订单状态同步到九数云,制作库存周转和异常对账看板。
这个看板的价值不在于参与每一次扣减,而在于从业务全局观察“哪些库存数字不再能被解释”。例如,同一 SKU 在交易库显示可用 86 件,但根据当日入库、出库、释放和调整流水重算后应为 84 件;或者某些订单已经取消,预占记录却仍然处于有效状态。
我会优先观察以下差异,而不是只盯着库存总量:
对账时不要直接拿“所有流水相加”与主表比较,因为流水可能包含锁定和实物变化两种不同维度。应先根据库存模型划分事件,再针对可用库存、锁定库存和实物库存分别重算。
重算可用库存
= 期初可用库存
+ 可用入库
销售扣减
新增锁定
+ 取消释放
+ 可用退货
+ 盘盈调整
盘亏调整
重算锁定库存
= 期初锁定库存
+ 新增锁定
支付确认转出
订单取消释放
预占过期释放
如果系统把“支付确认”和“实际出库”视为不同阶段,就不能把两者混为同一类流水。否则对账时即使总数碰巧相等,也无法判断商品到底处于已确认、待出库还是已出库状态。
下面是一组用于方案评审的情景模拟,不是某个企业的公开统计。假设每天处理 10 万次库存动作,其中 0.2% 的请求因网络或网关原因发生重试。如果没有幂等机制,理论上就有约 200 次重复执行机会;当其中只有 5% 恰好命中库存扣减动作,也会形成约 10 次潜在重复扣减。
这个数字看起来不大,但如果集中发生在热门 SKU、促销时段或库存只有个位数的商品上,影响会非常明显。更重要的是,重复扣减往往不会立刻暴露,而是在取消、退款、盘点或出库时才被发现,追溯成本远高于提前建立幂等约束。
| 参数 | 情景模拟值 | 对设计的影响 |
|---|---|---|
| 每日库存动作 | 100,000 次 | 需要自动化对账,不适合依赖人工抽查 |
| 可能重试比例 | 0.2% | 每天约 200 次重复请求机会 |
| 扣减动作占比 | 5% | 约 10 次潜在重复扣减 |
| 热门 SKU 可用库存 | 1 至 10 件 | 少量错误即可造成超卖或出库失败 |
这组推演说明了一个常被低估的事实:幂等的价值不是减少平均错误,而是阻止低概率事件在关键库存节点上造成高损失。分析看板可以帮助定位重试集中在哪些接口、哪些渠道和哪些 SKU,但它不能替代交易库中的唯一约束。

看板发现差异后,不能只发一张截图给开发人员。每条异常都应该带上仓库、SKU、业务单号、动作类型、请求号、主表数量、重算数量和最早差异时间。这样研发可以从结果直接回到流水和接口链路,而不是再次人工筛选。
一个实用的异常记录可以包括:
这样,分析平台承担的是“发现问题、解释趋势和排序风险”的职责;业务数据库承担“执行扣减、保存事实和提供恢复依据”的职责。两者配合,而不是互相替代。

复制快照是某个时间点的库存余额,例如仓库 10、SKU-A、可用 86。复制事件则是“订单 O1001 在某时刻扣减 2 件,从 88 变为 86”。快照适合快速读取,事件适合传播变化和重放。
如果只复制快照,消费者知道现在是多少,但不知道中间经历了什么,出现延迟时也难以判断是否漏数。如果只复制事件,消费者可以重建状态,却要承担顺序、重复和丢失处理。因此,标准方案往往是主表提供当前快照,流水表提供事件证据,消息或同步任务传播变化。
库存变更事件至少应包含事件 ID、库存记录 ID、业务单号、动作类型、变化数量、提交时间和库存版本。版本号能帮助下游判断事件是否重复、是否乱序,也能帮助对账任务定位缺失的变化。
{
"event_id": "EVT-20260916-000001",
"inventory_id": 90001,
"warehouse_id": 10,
"sku_id": 1001,
"operation_type": "DEDUCT",
"biz_id": "ORDER-20260916001",
"change_qty": -2,
"after_available_qty": 86,
"inventory_version": 18,
"occurred_at": "2026-09-16T10:30:00+08:00"
}
下游接收事件时,不应假设消息只到达一次,也不应假设一定按顺序到达。处理逻辑要么具备幂等能力,要么根据版本检测缺口并触发回源重建。
如果业务先提交数据库,再直接调用消息服务,可能出现数据库成功、消息发送失败;如果先发消息再提交数据库,又可能出现消息成功、数据库回滚。比较常见的解决思路是事务消息、可靠事件表或 Outbox 模式。
以可靠事件表为例,库存主表、库存流水和待发送事件在同一数据库事务内写入。后台任务不断扫描未发送事件,发送成功后更新状态,失败则按退避策略重试。这样,即使发送服务短暂不可用,事件仍然保留在事实库中。
CREATE TABLE inventory_event_outbox (
event_id BIGINT PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
sent_at TIMESTAMP
);
Outbox 也不是天然保证“只发送一次”。消息可能已经发出,但发送结果确认前进程崩溃,后台任务随后再次发送。因此消费者仍然必须用事件 ID或业务动作做幂等处理。
展示型库存和交易型库存可以采用不同策略。商品列表页面可以读取缓存,支付前的最终库存确认回到交易库;仓库拣货则可能要求读取锁定后的事实状态,而不是一个延迟几十秒的展示快照。
| 数据用途 | 推荐来源 | 可接受延迟 | 失败处理 |
|---|---|---|---|
| 商品详情展示 | 缓存优先,必要时回源 | 秒级或分钟级 | 缓存失效后回源重建 |
| 支付前最终确认 | 交易数据库 | 尽量实时 | 库存不足直接拒绝 |
| 仓库出库执行 | 事实库与预占状态 | 实时或事务级 | 异常进入补偿和人工复核 |
| 管理看板 | 分析平台或数仓 | 分钟级至小时级 | 标注刷新时间和数据延迟 |

库存测试不能只验证“库存从 10 减到 9”。这个用例只能证明正常路径能走通,不能证明系统在竞争和失败时仍然可靠。最低限度应覆盖以下场景:
其中,最后一件库存并发测试是最有价值的基础用例。测试完成后,正确结果不是“两个请求都返回成功”,而是最多一个请求成功,库存不出现负数,成功请求只有一条有效扣减流水,失败请求能得到明确的库存不足或竞争失败结果。
库存接口的 HTTP 成功率可能达到 99.99%,但仍然存在重复扣减和流水不一致。监控应增加库存业务指标,并按仓库、SKU、渠道和动作类型切分。
| 指标 | 计算方式 | 异常信号 |
|---|---|---|
| 负库存次数 | 主表可用数量小于零的记录数 | 通常表示扣减条件、补偿或口径存在问题 |
| 重复动作命中率 | 重复幂等请求数 ÷ 总请求数 | 突然升高可能是网关重试或调用方异常 |
| 流水对账差异率 | 差异库存记录数 ÷ 检查记录总数 | 反映主表、流水或跨系统同步质量 |
| 锁等待时间 | 扣减事务等待锁的平均和P95时长 | 热点 SKU、慢事务或索引失效的信号 |
| 补偿成功率 | 成功完成补偿任务数 ÷ 补偿任务总数 | 长期偏低说明自动恢复策略不完整 |
真正有效的流水表,必须能在测试环境中重算库存。可以选定一个时间点,读取期初快照和之后的所有有效流水,分别重算可用、锁定和实物库存,再与主表比较。如果重算无法得到同一结果,就说明流水缺少动作类型、状态或业务边界。
重建工具不一定要在线执行,但必须存在。它可以用于重大故障后的恢复、历史数据迁移、盘点差异分析和新旧系统切换。没有重建能力的库存系统,实际上把所有正确性都押在“永远不会出错”这个不现实的假设上。

如果每天库存动作较少、操作人员有限、没有高并发订单,可以从库存主表、库存流水表和业务单号幂等开始。关键不是一开始引入复杂中间件,而是确保每次调整都有单据、原因和流水。
这类场景可以使用多维表格或分析平台辅助录入和看板,但要明确它们的权限、并发和备份边界。如果业务增长后开始出现多人同时操作、批量导入和频繁回退,应尽早迁移核心扣减到关系型数据库。
当订单来自多个渠道,且存在支付、取消、退款、出库和退货等状态变化时,建议引入库存预占表和幂等记录表。此时最大的风险不是单次 SQL 写错,而是不同渠道重复发送同一个业务动作。
分析平台可以在这个阶段发挥较大价值。它能够将订单、库存、仓库和渠道数据放在同一看板中,帮助识别某个渠道是否频繁重试、某个仓库是否长期出现差异、某类商品是否因单位换算产生异常。但看板仍然只提供观察和决策依据,不替代交易事务。
这类业务不应继续使用“SKU 一个库存数字”的简化模型。库存的唯一粒度需要扩展到仓库、货位、批次、效期、货主或序列号,扣减时还要加入分配规则,例如先进先出、近效期先出或指定批次出库。
如果直接在高层库存表上扣减,却没有记录实际分配到哪个批次和货位,仓库执行时仍然会出现“系统有库存但现场找不到”的问题。这不是数据库余额错误,而是库存可用性模型不够细。
热点 SKU 的核心矛盾是大量请求集中争抢同一条库存记录。此时单纯把数据库连接池调大,往往只会增加锁等待和重试风暴。技术负责人应先确认业务是否允许排队、预扣或分段分配,再选择队列化、库存分桶、分仓分片或专用扣减服务。

条件更新代码短、事务路径清晰,适合简单的数量扣减。它的不足是复杂状态转换需要额外设计,开发者必须正确处理影响行数、流水和幂等。行级锁更容易表达“先读状态、再做多个相关更新”,但锁等待会随事务长度和热点集中度上升。
如果一次操作只改变一个库存数量,我会优先评估条件更新;如果一次操作需要同时处理预占、订单状态和多个库存字段,我会更倾向在短事务中使用行级锁。两者都必须配合唯一约束、索引和超时控制。
保存 available_qty、locked_qty、total_qty 可以提升查询速度,但每增加一个冗余字段,就增加一个必须维护和对账的状态。完全依赖流水实时计算又可能导致查询成本过高,尤其是在管理看板和高频商品页面。
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 只保存流水,实时计算 | 事实单一、冗余少 | 查询和聚合成本高 | 低频、强审计、数据量可控 |
| 主表加流水 | 查询快、可审计 | 需要保证同事务更新 | 大多数库存系统 |
| 主表、流水、缓存和分析副本 | 读性能和分析能力强 | 同步、重试和对账复杂 | 多渠道、中高并发业务 |
强一致并不意味着所有数据都必须同步更新。支付前的最终库存确认、仓库出库和高价值商品扣减通常需要更强的实时性;库存趋势、周转分析和管理看板则可以接受延迟。
真正成熟的设计不是追求全链路强一致,而是按业务损失划分等级。对每一类数据写清楚:允许延迟多久、谁是最终来源、失败后如何恢复、异常由谁处理。这样,团队才能把数据库资源和工程成本集中在最关键的路径上。
表格和分析平台的优势是上线快、可视化好、业务人员容易参与。它们适合原型、内部领用、低并发库存登记和管理分析。关系型数据库的优势是事务、约束、索引、锁和程序化恢复,适合订单库存、仓储出库和多系统协同。
我不建议用“工具先进还是数据库先进”做选型,而建议看四个问题:每天有多少并发动作,是否允许超卖,是否需要自动恢复,是否存在跨系统状态转换。只要答案涉及高并发、强一致、幂等和审计,核心库存就不应只依赖人工可编辑表格。

上线验收不应只看一天的成功率。至少要覆盖正常工作日、促销或批量操作时段,并观察锁等待、重复请求、对账差异、缓存延迟和补偿积压。对于库存系统而言,错误经常在取消、退款、出库和盘点时才显现,不能因为下单接口平稳就判断设计已经完成。
我建议把验收结果分为“交易正确性”和“运营可治理性”两组。前者关注是否超卖、重复扣减和错误释放;后者关注异常是否能被发现、定位、重试和复核。只有两组都通过,库存方案才真正具备上线条件。
库存主表回答“现在还有多少”,库存流水回答“为什么变成这个数”,幂等记录回答“这次请求是否已经处理过”。如果存在下单锁库存,再增加预占表回答“哪个业务单占用了多少库存”。这几张表的价值不在于形式标准,而在于让系统从余额、证据和请求三个方向都能被验证。
复制到缓存、消息系统、分析平台或管理看板,都是为了让不同使用者以合适的成本获得库存信息。复制可以延迟,可以失败,可以重试,但必须知道自己复制的是什么、来自哪个版本、多久没有刷新,以及如何回到事实库重建。
如果一个副本无法说明来源和时间点,就不应拿它参与最终扣减。越接近交易裁决的数据,越需要数据库约束和事务;越接近分析展示的数据,越可以接受异步复制和最终一致。
我的最终判断是:库存一致性不是靠一条 SQL、一个缓存策略或一张分析报表“保证”的。它来自清晰的库存口径、正确的表结构、原子扣减、业务幂等、可追溯流水、可控复制和可执行恢复。技术负责人真正要交付的,也不是一个永远不出错的库存数字,而是一套即使出错,也能快速发现、准确解释、限制影响并恢复到可信状态的系统。
我正在把原来的 Excel 和多维表格库存台账迁移到关系型数据库,但不确定只保留一张库存表是否足够。我尤其担心扣减出错后无法追溯:到底是哪张订单、哪次重试,导致库存从 10 变成了 8?
我处理库存系统时,最先踩过的坑就是把库存设计成一张只有 sku_id 和 stock 的表。它在单人录入时看起来很简单,但一旦出现并发下单、取消订单、退货或人工盘点,最终只剩一个结果数字,没人能解释这个数字为什么变化。更稳妥的做法是至少拆成“库存主表”和“库存流水表”。
主表负责快速回答“现在还有多少”,流水表负责回答“为什么变成这个数”。如果业务存在下单锁库存,还应增加库存预占表;如果接口可能重试,则增加幂等记录表。
表主要职责不建议承担的职责 inventory保存当前可用、锁定和实物库存记录完整变更历史 inventory_flow记录每次入库、扣减、释放和盘点直接作为高频查询余额 inventory_reservation记录订单锁定及释放状态替代库存主表做最终扣减 idempotency_record防止同一业务请求重复执行保存库存余额 库存主表通常应对 warehouse_id + sku_id 建立唯一约束,避免同一仓库同一 SKU 产生两条余额记录。
数量字段还要明确单位,例如箱、件、克不能混用,否则 SQL 没有错误,业务结果仍然会错。我建议流水表至少保留 biz_type、biz_id、change_qty、before_qty、after_qty 和 request_id。
其中 before_qty 与 after_qty 很有价值:出现对账差异时,可以直接定位到具体业务单据,而不是只看到一条无法解释的“库存减少 2”。如果团队规模较小,可以先落地两张表;但只要涉及锁库存、接口重试或多系统同步,就不要为了少建几张表而牺牲审计和恢复能力。
库存系统真正的标准化,不是字段越少越好,而是每一次余额变化都能被业务单据解释。
我现在有一个库存为 1 的 SKU,两个请求可能在同一秒内同时扣减。我知道行锁、版本号和条件更新都能解决一部分问题,但不知道该怎样根据并发量、业务复杂度和数据库结构做选择。
我在测试库存扣减时,曾用两个并发请求同时抢最后一件商品。最容易出问题的写法是“先查询库存,再在应用层判断,最后执行更新”:两个请求都读到 1,都判断库存充足,随后分别写入结果。问题不在查询语句本身,而在判断和扣减之间留下了竞争窗口。
对于简单扣减,我通常优先考虑带条件的原子更新:
UPDATE inventory SET available_qty = available_qty - :qty, updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = :warehouse_id AND sku_id = :sku_id AND available_qty >= :qty;随后通过受影响行数判断结果。返回 1 表示扣减成功,返回 0 表示库存不足或记录不存在。这个方案减少了应用层读写往返,适合单库、扣减逻辑简单且不需要在扣减前执行大量业务判断的场景。如果扣减前还要同时处理预占记录、订单状态和多条库存明细,我更倾向于在事务中使用悲观锁。
它的优点是流程直观,缺点是热点 SKU 会产生锁等待。一次压测中,单个热门 SKU 的请求集中到同一行时,事务耗时会明显拉长,因此不能只看“不会超卖”,还要监控锁等待和事务持续时间。
乐观锁适合允许有限重试的场景,例如使用 version 字段:
UPDATE inventory SET available_qty = available_qty - :qty, version = version + 1 WHERE id = :id AND version = :old_version AND available_qty >= :qty;如果受影响行数为 0,就代表版本已变化或库存不足,需要重新读取并决定是否重试。我的判断是:低并发简单扣减用条件更新;事务链路较长、强一致要求高用悲观锁;读多写少且允许重试用乐观锁。高并发热点库存则不能指望换一种锁就彻底解决,还要评估队列化、库存分桶或预扣减等架构方案。
我遇到过接口已经扣减成功,但调用方因为网络超时没有收到响应,随后又用同一个订单重试的情况。现在我不确定应该把订单号、请求号还是扣减单号作为幂等键,也不知道重复请求应该返回什么结果。
库存接口最危险的一类故障,是“数据库已经提交,但客户端以为失败”。如果初始库存是 10,第一次请求已经扣减 2,响应却在返回途中超时,调用方重试后又扣减 2,库存会变成 6。这个问题无法靠前端按钮禁用解决,因为重试可能来自网关、消息队列或任务系统。
我的做法是为每个业务动作建立稳定的幂等键,并在数据库中设置唯一约束。比如订单完成扣减可以使用 order_id + operation_type,而不是单纯使用一个可能被不同动作复用的订单号。取消释放、支付确认、退货入库应分别拥有不同的操作类型。
业务动作推荐幂等键示例重复请求处理 订单锁库存订单号 + LOCK返回原锁定结果 支付确认扣减订单号 + CONFIRM返回已确认结果 取消释放订单号 + RELEASE返回已释放结果 退货入库退货单号 + INBOUND禁止再次增加库存 幂等记录不能只在应用代码里判断“是否处理过”,否则两个相同请求同时到达时,可能同时通过判断。
正确做法是让数据库唯一索引参与竞争:先尝试写入幂等记录,只有成功取得处理权的请求才执行库存变更;重复请求读取第一次处理结果。还要区分“处理中”和“已成功”。如果服务在事务提交后崩溃,幂等记录必须能反映最终结果;如果记录一直停留在处理中,后续请求就需要根据超时规则、事务状态或对账结果决定是否接管。
简单地把所有处理中记录删除,反而可能让重复扣减重新发生。建议把库存主表更新、库存流水写入和幂等结果写入放进同一个数据库事务。这样一次成功操作应同时产生一条有效业务结果和一条库存流水;重复请求只返回已有结果,不再生成新的扣减流水。上线前至少测试“响应超时后重试”和“两个相同请求并发到达”这两个场景。
我们把库存数量放进缓存后,页面读取速度变快了,但偶尔会出现数据库是 8、缓存是 10 的情况。我想知道库存扣减能不能直接读缓存,以及缓存更新失败后,怎样避免用户看到错误库存甚至继续下单。
我的判断很明确:缓存可以加速库存展示,但不应在没有可靠校验的情况下承担最终扣减依据。库存扣减是事实变更,数据库事务和库存流水通常才是最终可信来源;缓存只是这个事实的一个可能过期的副本。常见的“更新数据库后删除缓存”并不是绝对安全。数据库提交后,如果应用在删除缓存前宕机,旧缓存仍可能被读取;
如果删除缓存后又有并发读请求把旧数据重新写回缓存,也会形成短暂脏数据。因此关键不是背诵某个更新顺序,而是先定义库存数据允许多长时间不一致,以及哪些链路必须回源数据库。
使用场景是否可直接读缓存建议 商品详情页展示库存通常可以允许短暂延迟,并设置较短过期时间 支付前最终库存确认不建议回源数据库执行条件扣减 高价值商品出库不建议以数据库事务和仓储确认结果为准 报表和运营看板可以标注统计延迟,定期对账 如果缓存更新失败,我会把缓存刷新设计成可重试任务,而不是让业务事务等待缓存成功。
库存主表和流水表提交成功后,发送库存变更事件;消费者负责刷新或删除缓存,失败时进入重试队列。对强一致场景,扣减接口直接查询数据库,不让缓存命中结果决定是否成功。对账也不能只比较两个数字。
更有效的对账方法是同时检查三层关系:库存主表余额是否等于可解释的流水结果,订单状态是否与锁定或扣减状态匹配,缓存是否在允许的延迟窗口内收敛。比如发现数据库为 8、流水累计也为 8、缓存为 10,应判定为缓存陈旧;若数据库为 8、流水只能解释到 9,则应冻结自动修复并定位具体业务单据。
我建议每天做全量对账,每隔几分钟做增量异常扫描,并保留人工修正入口。修正库存时不要直接覆盖主表数字,而应生成“盘点调整”类型的库存流水,记录调整原因、操作者和审批单号。这样既能恢复余额,也不会破坏后续审计链路。


读者评论
文章把库存一致性拆成余额、流水、业务状态和请求幂等四个层次,框架比较清晰。尤其是强调库存主表与流水表职责分离,对后续审计和故障排查很有参考价值。
同意不能把缓存或报表数据作为最终扣减依据。不过实际高并发场景还要结合数据库类型、索引设计、锁等待和分库分表方案验证,单靠条件更新并不能覆盖所有性能问题。
对超时重试场景的分析比较贴近生产实践。将业务单号和请求号落到唯一约束上,比依赖应用层判断更可靠,但幂等记录的状态流转和异常补偿也需要进一步细化。
文章提醒了库存单位、仓库范围和锁定口径的重要性,这些确实容易被技术方案忽略。若能补充完整表结构示例、事务隔离级别和并发测试数据,教程的落地性会更强。