库存扣减最危险的时刻,往往不是 SQL 报错,而是 SQL 返回成功之后。一次请求可能因为网络超时被重试,消息可能被重复消费,缓存可能还停留在旧值;几分钟后,库存余额、订单状态和库存流水分别给出了三个看似合理、实际无法互相解释的结果。我的判断是:库存一致性不能只靠“把库存字段减掉”来保证,必须用库存流水把每一次变化变成可追踪、可核对、可恢复的事实。
数据库存:数据库管理员增长视角:用库存流水放大保证扣减一致性
很多库存系统的设计起点是一个数量字段:商品库存为 100,下单成功后执行 available_qty = available_qty - 1。在单线程、低并发、没有重试的演示环境里,这个模型几乎不会暴露问题。
但真实系统里,一次扣减至少会经过订单服务、库存服务、数据库、消息队列和缓存等多个环节。请求超时不代表数据库没有提交,消息重复不代表业务动作应该重复执行,缓存显示旧值也不代表库存事实已经丢失。真正需要设计的不是“如何减一”,而是如何证明这次减一只发生了一次、发生在正确的库存主体上,并且可以在事后还原。
因此,我通常会把库存系统拆成四层责任:余额表保存当前快照,流水表记录变更事实,幂等约束识别重复动作,对账机制发现无法解释的差异。四者缺一不可,但职责不能混淆。
| 对象 | 主要职责 | 适合解决的问题 | 不能单独解决的问题 |
|---|---|---|---|
| 库存余额表 | 保存当前可查询数量 | 快速查询、条件扣减、库存展示 | 解释历史变化、识别重复业务动作 |
| 库存流水表 | 保存每次数量变更及业务来源 | 审计、追溯、对账、故障定位 | 自动保证余额与流水原子一致 |
| 幂等记录 | 识别同一业务动作是否执行过 | 请求重试、重复消费、人工补偿 | 处理库存本身的容量和锁竞争 |
| 对账任务 | 定期验证不同账本之间的关系 | 发现漏记、重记、延迟和状态错位 | 替代实时事务控制 |
这也是“数据库管理员增长视角”和普通接口开发视角的区别。接口开发更关心一次调用是否返回成功,数据库管理员还要关心三个月后能否回答:这件库存为什么变成这样、由哪个订单造成、是否重复扣减、能否安全修复。

在设计表结构和事务之前,我会先写出业务不变量。没有不变量,所谓“强一致”“最终一致”很容易变成没有验收标准的口号。
这里有一个经常被忽略的细节:库存不是只有一个数字。电商、零售和仓储系统通常至少要区分物理库存、可用库存、锁定库存、在途库存和不可售库存。如果把所有概念都压缩成 stock_qty,后续的流水即使记录得很完整,也无法回答“这次变化到底影响了哪一种库存”。
流水本身只是记录工具。余额更新成功、流水写入失败时,系统仍然会不一致;流水写入成功、余额更新回滚时,也可能出现孤儿流水。因此,准确的说法不是“有流水就保证一致”,而是流水让一致性从不可见状态变成可验证状态。
没有流水时,数据库管理员只能看到“现在是 20 件”;有流水时,可以进一步看到“期初 100 件,完成 70 件扣减,发生 5 件回补,存在 15 件锁定,当前可用余额为何是 20 件”。这条事实链让错误有了定位入口,也让修复不再依赖猜测。
下面使用一个情景推演,不代表某家企业的生产数据。假设某 SKU 的可用库存为 100,订单 A 和订单 B 几乎同时请求扣减 80。如果应用先查询库存,再在应用层判断是否足够,两个请求都有可能读到 100。
随后,两个请求分别执行减法。如果 SQL 没有带条件,数据库可能先后执行两次更新,最终得到负库存;如果应用使用了“查询后更新”的乐观逻辑,也可能因为读取和更新之间存在时间窗口而发生超卖。
更隐蔽的情况是:余额更新使用了带条件的 SQL,只有一个订单实际扣减成功,但订单服务因为网络超时没有收到响应,于是自动重试。若没有幂等约束,第一次成功和第二次重试可能被当成两个动作处理。
| 时间点 | 订单 A | 订单 B | 库存余额 | 可能产生的风险 |
|---|---|---|---|---|
| T0 | 未提交 | 未提交 | 100 | 系统初始状态正常 |
| T1 | 读取到 100 | 读取到 100 | 100 | 应用层判断同时通过 |
| T2 | 执行扣减 80 | 等待或并发执行 | 20 或待提交 | 是否带条件决定结果 |
| T3 | 客户端超时 | 收到库存不足 | 20 | A 可能被自动重试 |
| T4 | 重复提交 | 无 | 可能变为负数或再次扣减 | 幂等键缺失时风险扩大 |

库存扣减常见的错误,是订单服务使用订单号,库存服务使用请求流水号,消息消费端又生成一个消费记录号。这样每个系统都有自己的编号,但没有一个编号贯穿完整链路。
我更建议把“库存业务动作”单独建模。例如订单支付后扣减库存,可以生成一个唯一的库存动作号;取消订单回补库存,则生成另一个动作号,并通过原订单号或原扣减动作号建立关联。扣减和回补不是同一个动作,不能因为它们都影响数量,就复用同一个幂等键。
建议至少保留以下关联字段:
biz_type:订单扣减、锁定、释放、入库、调拨、报损或回补。biz_id:具体业务单据号。action_id:本次库存动作的唯一编号。source_event_id:来自消息或外部系统的事件编号。idempotency_key:用于数据库唯一约束的幂等键。trace_id:用于跨服务排查请求链路。当库存流水逐渐增长,数据库管理员面对的就不再只是一次扣减是否成功,还包括流水表写入压力、索引膨胀、分区维护、冷热数据迁移和对账窗口等问题。
例如,某业务每天产生 500 万条库存流水,按每条记录平均 300 字节估算,仅数据本身每天约占 1.5 GB,尚未计算索引、页填充、事务日志和备份副本。若保留 180 天,原始流水就可能接近 270 GB。这个估算只是容量推演,实际大小要通过数据库页、索引和字段类型验证,但它足以说明:库存流水不是“顺手加一张表”,而是会伴随业务增长持续产生数据库成本。
因此,流水设计必须同时考虑写入、查询和生命周期。只为审计保留全部字段,却没有归档和分区策略,可能让线上扣减和日常对账互相争抢资源。
这是一种最容易写出来、也最容易在并发下失效的方案:
SELECT available_qty FROM inventory WHERE sku_id = :sku_id; if available_qty >= requested_qty: UPDATE inventory SET available_qty = available_qty - :requested_qty WHERE sku_id = :sku_id;
问题在于,查询和更新不是一个不可分割的动作。两个事务都可以读到相同库存,然后分别通过应用层判断。即使数据库最终按顺序执行更新,应用层做出的判断也可能已经过时。
更可靠的基础写法,是把数量条件放进更新语句,并通过影响行数判断是否成功:
UPDATE inventory SET available_qty = available_qty - :requested_qty, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_qty >= :requested_qty AND status = 'ACTIVE';
如果受影响行数为 1,说明本次扣减获得了数据库条件的认可;如果为 0,则需要区分库存不足、SKU 不存在、状态不可售还是版本冲突。不要把所有失败都返回成“库存不足”,否则运维排障时会丢失重要信号。
行锁可以保护同一库存行的并发更新,但它不能自动防止重复请求,也不能保证跨服务消息不重复,更不能替代库存流水。
如果一个事务先锁住库存行,扣减余额后调用外部支付接口,事务就可能长时间持锁;如果外部接口重试或超时,锁等待会进一步扩大。高峰期,一个热点 SKU 可能让大量请求排队在同一行上,数据库 CPU 还没有打满,业务延迟已经明显上升。
我的判断是:锁只应该保护数据库内部必须原子完成的部分。支付、通知、搜索索引刷新等外部动作,应尽量移出库存事务,通过明确的状态和事件来衔接。
变更前数量和变更后数量很有价值,但它们是当时事务视角下的快照,不一定适合直接作为唯一重算依据。并发、人工修复、批量调拨和库存初始化都可能带来特殊记录。
流水表应同时记录变更量和变更类型。例如扣减可以用负数表示,回补可以用正数表示;但“锁定”和“释放”是否影响可用库存,要根据系统定义明确处理。若所有动作都只记录一个正负数量,后续很难区分销售出库、库存盘盈和人工调账。
建议把流水分成两类信息:一类是数量事实,例如变更前后余额和变更数量;另一类是业务语义,例如动作类型、来源单据、状态和补偿关系。只有两类信息同时存在,流水才具有审计价值。
缓存可以承载热点读流量,也可以在极端场景下参与预扣,但“缓存中有库存”不等于“缓存就是最终库存事实”。缓存重启、淘汰、主从切换、网络分区和持久化延迟,都会改变它的可靠性边界。
如果业务确实需要内存层前置扣减,必须补齐至少四个机制:扣减事件持久化、失败补偿、重启恢复和数据库对账。否则系统只是把数据库里的可见问题,变成了更难追踪的内存状态问题。
异步化可以提高吞吐和隔离故障,但最终一致性必须有明确的时间边界。比如库存扣减成功后,流水消息在 5 秒内落到分析库,可能是可接受延迟;如果 30 分钟后仍未落库,就不应继续称为“最终会一致”,而应进入告警和补偿流程。
我会要求团队为每类异步动作定义三个参数:允许延迟、最大重试次数和人工介入时间。没有这三个参数,异步系统很容易把异常藏在消息堆积里。

库存展示、下单预校验、最终扣减、订单取消回补和财务结算,往往不是同一等级的一致性要求。商品详情页显示少一件库存,通常可以接受短暂延迟;真正扣减库存时,则不能只相信页面上的数字。
| 业务环节 | 建议一致性要求 | 主要事实来源 | 可接受的技术取舍 |
|---|---|---|---|
| 商品页库存展示 | 允许短暂延迟 | 缓存或读库快照 | 优先降低读压力和响应时间 |
| 下单前校验 | 接近实时 | 库存服务或专用查询接口 | 可接受极短窗口内的再次失败 |
| 最终库存扣减 | 强约束 | 带条件更新的持久化库存表 | 优先保证不超卖和业务幂等 |
| 取消订单回补 | 可追踪的最终一致 | 回补动作与库存流水 | 允许异步,但必须有超时告警和重试 |
| 财务或经营分析 | 按批次最终一致 | 库存流水或分析库 | 优先保证口径稳定和历史可重算 |
这一划分能避免一个常见浪费:为了让所有读请求都看到绝对实时库存,把数据库、缓存和消息系统都设计得极其复杂,最后却没有为最终扣减建立唯一约束。
我建议在设计文档中明确写出“事实来源”。如果余额表是实时扣减的裁决者,流水必须与余额在同一事务中写入;如果事件流是事实来源,余额表就应被视为可重建的投影,并且必须具备重放和校准能力。
两种模型都可以成立,但不能在不同故障场景下随意切换。例如平时以余额表为准,事故时又凭流水汇总直接覆盖余额,可能把尚未消费的锁定事件或重复回补再次计算进去。
更稳妥的做法是为流水定义状态机:
状态机的价值不在于增加字段,而在于让重试逻辑有依据。没有状态,系统只能通过“有没有一条记录”猜测动作进展;有状态,才能区分处理中、成功、失败和已补偿。
低并发时,一行库存记录承载所有扣减并不一定有问题;当某个 SKU 在短时间内收到大量请求时,这一行就会成为天然的串行点。即使数据库提供了正确的行锁,吞吐也不可能无限增长。
这时不能只看数据库 QPS。更应该观察单 SKU 锁等待、事务平均持有时间、死锁次数、重试比例和队列积压。一个系统可能整体 QPS 不高,但热点行等待已经让用户感知到明显延迟。
库存流水表也会随业务增长产生结构性压力。在线表应服务最近数据和高频对账,历史流水则可以按时间归档到低成本存储或分析库。归档前必须保留可验证的汇总结果,例如按 SKU、仓库、日期和动作类型形成日结快照,避免未来每次对账都扫描全部历史流水。

下面是一个业务情景案例,数据为样本推演。某零售企业有三个仓库,商品 X 的物理库存分别为 40、35、25 件,总量为 100 件。系统允许订单优先从指定仓库扣减,指定仓库不足时再尝试其他仓库。
旧方案只有一张按 SKU 汇总的库存表。订单服务先查询总可用库存,库存服务再根据仓库优先级扣减。一次大促中,订单 A 和订单 B 几乎同时进入,两个订单都判断总库存足够,但仓库分配过程发生交叉:A 锁定仓库 1 的 30 件,B 也拿到了仓库 1 的旧快照。
结果并不是简单的负库存,而是仓库维度的库存状态和总库存状态不一致。总库存表显示还有 40 件,仓库明细合计只有 25 件;如果没有流水,运维人员很难判断差异来自重复扣减、锁定未释放,还是仓库切换时漏写了一条变更。
改造时,我会把库存主体从“SKU”扩大为“SKU、仓库、批次或货位”的组合键,并把汇总库存变成可校验的结果。基础表可以按以下逻辑设计:
CREATE TABLE inventory_balance (
id BIGINT PRIMARY KEY,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
available_qty DECIMAL(18, 4) NOT NULL,
locked_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id)
);
CREATE TABLE inventory_ledger (
id BIGINT PRIMARY KEY,
action_id VARCHAR(64) NOT NULL,
biz_id VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
action_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,
action_status VARCHAR(20) NOT NULL,
trace_id VARCHAR(64),
created_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_action (action_id),
KEY idx_sku_time (sku_id, created_at),
KEY idx_biz_id (biz_id)
);这不是适用于所有数据库和业务的最终建表脚本。数量类型、主键策略、分区方式、索引顺序和字符长度都需要根据数据库产品、数据量和查询模式验证。它真正要表达的是几个设计原则:库存主体必须明确,业务动作必须唯一,数量变更前后要可见,流水必须能关联订单和请求链路。
在余额表和流水表位于同一个数据库、且扣减一致性要求较高时,我会优先采用短事务。核心步骤不是“先查再改”,而是先尝试原子条件更新,再根据更新结果决定是否写入流水。
这里有一个实现细节很重要:如果需要记录精确的 before_qty 和 after_qty,不能在高并发下简单地先查询一个值,再假设后续更新一定使用这个值。可以在事务内通过锁定读取获得变更前快照,也可以使用数据库支持的返回机制,具体取决于数据库产品。
如果数据库只方便返回受影响行数,而无法安全返回前后值,可以把流水的核心事实定义为变更量、动作身份和事务批次,之后通过余额快照或批次查询补足上下文。宁可少记录一个不可靠的字段,也不要记录一个看似精确、实际无法证明来源的字段。
一次扣减成功后,响应在网络中丢失,是最典型的重试场景。客户端或网关再次提交时,系统应该通过 action_id 或幂等键识别:第一次动作已经成功,第二次请求只返回第一次的业务结果,而不是再次改变库存。
重复消息的处理逻辑类似,但消息消费端不能只依赖内存缓存判断是否消费过。进程重启、缓存淘汰和消息延迟都会让内存判断失效。更稳妥的方式是把消费记录或动作状态放入持久化存储,并用唯一约束保证并发插入时只有一个消费者能够创建有效动作。
人工补偿也必须产生新的库存动作,不能直接在数据库里执行一条无业务编号的 UPDATE。补偿动作应关联原始异常、填写原因、记录操作者和审批信息,并在流水中标记为补偿类型。这样,修复本身不会成为下一次对账无法解释的新差异。

在上述情景中,如果只看接口成功率,可能得到一个令人满意的结果:扣减接口成功率为 99.95%。但这并不能证明库存账务健康,因为成功请求里可能包含重复扣减,失败请求里也可能存在已经提交但响应丢失的动作。
我会把观察指标拆成四组。第一组是实时交易指标,包括条件更新成功率、库存不足率和锁等待时间;第二组是账务指标,包括余额与流水差异数、重复动作数和缺失流水数;第三组是异步指标,包括消息积压量、消费延迟和补偿数量;第四组是恢复指标,包括异常发现耗时、人工定位耗时和修复后复核耗时。
| 指标 | 样本观察值 | 如何解读 | 建议动作 |
|---|---|---|---|
| 条件更新失败率 | 38% | 可能是库存不足,也可能是热点锁竞争或状态过滤 | 拆分失败原因,不要统一归类 |
| 重复动作拦截率 | 6.8% | 说明请求重试或重复投递并不罕见 | 检查调用方重试策略和幂等键覆盖率 |
| 流水落账异常率 | 0.05% | 绝对比例不高,但高峰期仍可能形成大量异常单 | 建立自动重试和告警队列 |
| 余额与流水差异 SKU 数 | 12个 | 表示至少有12个库存主体需要进一步解释 | 按时间、动作类型和服务节点切分排查 |
| 平均异常定位耗时 | 4.5小时 | 反映流水字段和查询工具是否足够完整 | 补齐 trace、动作号和补偿关联字段 |
表中的数值是样本推演,不是公开生产统计。它要表达的不是某个指标的行业标准,而是一个观察方法:库存系统不能用一个“接口成功率”代表全部一致性状况。

库存流水最少要能回答五个问题:哪个库存主体变了、变了多少、由什么业务动作触发、变化前后是什么状态、谁或哪个系统发起了变化。
在实际设计中,我不会一开始就堆叠几十个字段,而是先围绕查询和修复场景反推字段。线上排查通常需要按 SKU、仓库、订单号、动作号、时间范围和动作类型查询,因此这些字段应优先建立稳定索引或可检索能力。
建议把以下字段视为基础集合:
对于金额、重量或带小数的库存,不要默认使用浮点类型。计量精度和舍入规则必须提前定义,否则同一批流水在不同语言或不同数据库函数中计算,可能产生难以解释的小数差异。
同一数据库内,余额更新、流水写入和幂等状态写入通常应放在一个事务中。它们的关系是:余额改变但没有流水,账本缺证据;流水成功但余额没有改变,账本出现虚假事实;幂等状态成功但库存未扣减,重试时可能被错误拦截。
典型事务边界可以表达为:
BEGIN;
— 1. 创建或锁定本次库存动作
— 2. 若动作已成功,直接返回历史结果
— 3. 原子扣减库存
— 4. 根据受影响行数判断是否成功
— 5. 写入库存流水
— 6. 更新动作状态为 SUCCESS 或 FAILED
COMMIT;
但事务边界不是越大越好。事务中不应包含发送外部 HTTP 请求、等待人工确认、刷新多个无关缓存或执行复杂报表查询。事务越长,锁持有时间越长;库存系统的稳定性,很多时候不是被单条 SQL 拖垮,而是被事务里不必要的工作拖垮。
成熟的库存系统通常存在三类账:库存余额账、库存流水账和订单业务账。余额账回答“现在还有多少”,流水账回答“数量如何变化”,订单账回答“哪些业务动作应该发生”。三者应该定期互相验证。
基础对账可以从以下公式开始:
期末可用库存
= 期初可用库存
+ 入库增加
+ 取消回补
+ 人工盘盈
销售扣减
报损扣减
调拨流出
+ 调拨流入
其他已定义减少项
如果系统存在锁定库存,还需要分别对账:
物理库存 = 可用库存 + 锁定库存 + 不可售库存
可用库存 = 物理库存 – 锁定库存 – 不可售库存
这只是示意公式。不同企业可能把在途库存、质检库存、预占库存独立出来,不能在没有业务定义的情况下直接套用。
对账任务应当输出差异清单,而不是只打印一个“对账失败”。差异清单至少包括库存主体、差异数量、涉及动作数、最早异常时间、最近异常时间和建议处理类型。这样,DBA 才能把对账从一次性脚本变成可运营的治理能力。
我通常会建议把对账分为三层。第一层是实时或准实时校验,针对热点 SKU 和高价值库存,在每次动作完成后检查关键不变量;第二层是定时增量对账,按最近时间窗口扫描新增流水;第三层是全量或专项对账,用于大促后、数据库迁移后、消息积压恢复后和人工修复后。
| 对账层级 | 执行频率 | 主要目标 | 资源控制方式 |
|---|---|---|---|
| 热点实时校验 | 秒级或分钟级 | 快速发现高价值 SKU 异常 | 限制对象范围,避免全表扫描 |
| 增量对账 | 每5分钟至每小时 | 发现消息延迟和近期漏记 | 按时间窗口、分区或水位扫描 |
| 日终对账 | 每日 | 确认业务日账务完整 | 使用汇总表、只读副本或分析库 |
| 专项对账 | 按事故或变更触发 | 验证迁移、补偿和故障恢复结果 | 冻结修复范围并保留审计记录 |
如果对账发现库存差异,最危险的做法是直接执行一条修正 SQL,把余额改成流水汇总值。因为差异可能来自尚未消费的消息、延迟提交的事务、尚未完成的订单或错误的统计口径。
正确的处理顺序通常是:
库存数字可以被修正,但原始事实不应被抹掉。修复时新增一条调整流水,通常比修改历史流水更容易审计。

缓存最适合解决高频读取和热点访问,不适合在没有持久化与恢复设计的情况下单独承担最终库存账本。商品页面展示库存、活动页读取库存状态、库存预警查询,都可以考虑使用缓存降低主库压力。
但最终扣减至少要回到具备持久化约束的事实存储。页面上的“还有 3 件”只是用户决策参考,不能作为扣减成功的证明。最终能否扣减,应该由数据库条件更新、可靠的库存服务或具备完整持久化机制的前置库存层裁决。
缓存更新顺序也要明确。比较常见的做法是先提交数据库,再删除或刷新缓存;如果数据库提交成功但缓存操作失败,应依靠重试、事件通知或过期机制恢复,而不是在事务里无限等待缓存。
在极端热点场景中,所有请求直接争抢数据库同一行,确实可能难以满足峰值延迟。这时可以让缓存或内存层负责流量预扣,把请求先转换成排队中的库存动作,再由持久化层确认。
但这是一种系统级取舍,不是简单地把 SQL 换成缓存命令。需要回答:
如果团队无法回答这些问题,我不会建议直接上缓存前置扣减。先把数据库事务、幂等、流水和对账做完整,往往比引入更复杂的前置层更稳妥。
库存流水不仅服务数据库事务,也服务经营分析和运维决策。管理人员常常需要观察库存周转、缺货率、回补时延、仓库差异和异常 SKU 分布。这类查询不应长期直接压在在线交易库上。
可以将库存流水按业务日或小时同步到分析环境,再通过某数据分析平台构建库存异常看板。这里的重点不是工具名称,而是数据口径必须固定:扣减成功率是否排除幂等重复请求,库存周转是按物理库存还是可用库存计算,回补时延从订单取消开始还是从消息创建开始计算。
如果口径没有定义清楚,图表越漂亮,误导性越强。数据库管理员应当参与指标定义,而不是只负责把数据导出给分析人员。

如果业务量不大、库存主体集中在单库,建议先采用简单可靠的方案:余额表带条件更新,库存流水与余额更新放入同一事务,动作号建立唯一约束,再增加定时对账。
这个阶段不必急于引入分布式锁、复杂消息编排或缓存前置扣减。过早复杂化会增加故障面,尤其是团队还没有形成补偿和监控习惯时。
促销系统的核心矛盾通常不是普通商品的整体吞吐,而是少数热点 SKU 在极短时间内形成集中写入。此时应先测量单行锁等待和事务持有时间,确认瓶颈是否真的来自数据库,而不是把“高并发”直接等同于必须使用缓存。
如果数据库条件更新仍能满足延迟目标,保留数据库作为事实来源通常更容易维护。如果热点行已经成为明显串行点,可以考虑库存拆分、令牌化、排队或前置预扣,但必须把预扣动作持久化并纳入对账。
| 方案 | 优势 | 代价 | 适用条件 |
|---|---|---|---|
| 单行条件更新 | 实现简单,事实清晰 | 热点 SKU 存在锁竞争 | 峰值可控,强一致优先 |
| 库存分片 | 降低单行争抢 | 分配、汇总和回收复杂 | 热点明显且可拆分库存 |
| 排队扣减 | 削平瞬时峰值 | 用户等待,需处理过期订单 | 允许异步确认或排队体验 |
| 缓存前置预扣 | 吞吐高,响应快 | 恢复、补偿和对账成本高 | 团队具备可靠消息和恢复能力 |
多仓系统不能只维护一个总库存数字。扣减时要明确仓库选择策略、批次规则、局部成功处理和跨库存主体回滚方式。
如果一个订单需要从多个仓库凑齐,建议把订单库存动作拆成可追踪的子动作。每个子动作有自己的库存主体、数量和状态,主动作记录整体结果。这样,部分仓库成功、部分仓库失败时,系统可以分别补偿,而不是用一条总量流水掩盖局部差异。
对于批次和效期管理,还要把批次作为库存主体的一部分。否则库存总量虽然正确,实际拣货时却可能发现可用批次不足,造成业务层面的“库存存在但无法履约”。
余额库、订单库和分析库分离后,不应再追求所有操作都使用一个跨库事务。更现实的做法是定义主事实、事件和补偿规则,并为每个事件设置可观测的状态。
如果消息发送和数据库提交之间存在失败窗口,可以采用事务消息、可靠事件表或提交后扫描机制。选择哪一种,要看数据库和消息系统能力,以及团队能否维护重试、去重和死信处理。
事故期间最重要的是阻止差异继续扩大,而不是立刻追求架构重构。可以先暂停高风险 SKU 的自动补偿,限制人工调账入口,保留原始流水,并建立单独的事故动作号。
如果必须临时修正库存,应该先导出修复前快照、冻结相关动作、审批修正数量,然后以调整流水完成修复。事故结束后,再补充根因分析、自动化校验和回归压测。
任何“直接改余额”的动作都应该被视为高风险操作。它可以是止血手段,但不应成为常规流程。

同库同事务的优点是边界清晰,余额和流水可以一起提交或回滚,故障排查相对直接。它适合库存价值高、扣减规则明确、数据库容量可控的系统。
它的短板也很明确:事务会形成锁竞争,跨服务动作难以全部放入同一个事务,热点 SKU 的吞吐受单行更新能力约束。不能把它理解成“只要使用事务,就能无限扩展”。
异步方案可以把订单、通知、分析和库存后续处理解耦,提升系统抗峰值能力。但它把复杂度从实时事务转移到了消息状态、重复消费、延迟告警、补偿和对账。
如果业务允许几秒或几分钟的状态延迟,异步方案有较高价值;如果业务要求支付成功后立即确认可履约库存,就必须把最终扣减这一步保留在更严格的同步边界内。
缓存前置适合极端热点和高峰流量,但它要求团队具备更强的基础设施和故障演练能力。缓存节点故障、落库延迟、恢复重放和库存回补都需要可操作的机制。
我会把它看作“用运维和治理成本换取峰值吞吐”的方案,而不是免费的性能优化。如果团队还没有稳定的流水、幂等和对账体系,先引入缓存前置,通常会让问题更难定位。
流水字段越丰富、索引越多,查询和排查越方便,但写入成本、存储成本和索引维护成本也会增加。建议根据真实查询场景建立索引,不要因为“未来可能查询”就为每个字段都建索引。
可以采用在线热表和历史归档表分层:热表保留近期数据和高频索引,历史表保留完整审计字段;对账则使用按日、按 SKU、按动作类型的汇总结果缩小扫描范围。

上线后的第一周,我不会只看接口平均响应时间。更有价值的是观察长尾延迟、锁等待、重复动作和对账差异。平均值很容易掩盖少数热点 SKU 的严重问题。
| 监控类别 | 核心指标 | 异常信号 | 可能原因 |
|---|---|---|---|
| 实时扣减 | P95/P99 延迟、条件更新失败率 | 高峰期长尾明显上升 | 热点行锁竞争、连接池不足、事务过长 |
| 幂等控制 | 重复请求率、幂等命中率 | 某调用方突然升高 | 网关重试、客户端超时或消息重复 |
| 流水落账 | 流水失败数、状态停留时长 | PROCESSING 长时间堆积 | 事务异常、消费中断或状态更新失败 |
| 对账治理 | 差异 SKU 数、差异数量、对账延迟 | 连续多个周期增长 | 系统性漏记、重复扣减或统计口径变化 |
| 恢复能力 | 平均定位时长、平均修复时长 | 仍依赖人工查多张表 | 关联字段不足、补偿流程不标准 |

先不要改代码。列出当前所有影响库存的入口,包括订单扣减、支付确认、取消回补、退款回补、采购入库、仓库调拨、盘点调整和人工修复。
对每个入口记录五项内容:谁发起、修改哪张表、是否有幂等键、是否写流水、失败后如何重试。很多团队在盘点这一步就会发现,真正的问题不是数据库性能,而是某个后台脚本可以绕过库存服务直接修改余额。
优先覆盖最重要的扣减和回补动作,为每个动作生成稳定的业务身份。流水字段先满足追踪、对账和修复,不要一开始追求面面俱到。
同时补充数据库唯一约束,并使用并发测试验证:两个相同动作同时到达时,是否只有一个能够产生有效库存变化;一个成功请求重复提交时,是否返回原结果而不是再次扣减。
先从最近一小时或最近一天的流水开始做增量对账,输出差异清单。差异要分为可接受延迟、幂等命中、消息积压、事务异常和真实账务差异。
为高价值 SKU 设置更短的检查周期,为低价值长尾库存设置日终或批量检查。对账频率不应该一刀切,而应根据库存价值、交易频率和修复成本配置。
至少演练以下场景:数据库更新成功但响应超时、消息重复投递、流水写入失败、缓存不可用、消费端重启、库存回补重复执行、人工补偿后再次对账。
容量验证则要同时观察数据库写入、索引维护、锁等待、流水增长和对账耗时。压测结果应形成业务结论,例如“热点 SKU 每秒可处理多少次扣减”“消息延迟超过多少秒需要告警”“日流水达到多少后必须归档”,而不是只留下一个孤立的 QPS 数字。
一个库存系统是否成熟,不应只看它在正常情况下能否成功扣减,而应看异常发生后能否快速回答以下问题:
如果这些问题都能在几十分钟内通过系统查询回答,而不是依赖某个老员工凭经验翻日志,那么库存数据库就已经从“保存一个数字”升级为“维护一套业务账本”。
业务增长会放大库存流水量、热点 SKU 数量、消息重试次数和数据库写入压力,也会放大一个小设计缺陷的影响范围。没有幂等时,一次网络超时可能变成重复扣减;没有流水时,一条异常记录可能变成无法解释的账务黑洞;没有对账时,错误可能直到月底结算才被发现。
我的核心观点始终是:余额表负责快速回答“现在有多少”,库存流水负责证明“为什么是这个数”,幂等负责确认“这件事只发生一次”,对账负责验证“系统长期没有偏离”。
下一步不必立即重构成复杂的分布式库存平台。先选择一个高价值 SKU 或一个最常见的订单扣减链路,补齐唯一动作号、条件更新、同库流水事务和增量对账,再用超时重试、重复消息和人工补偿做故障演练。
当团队能够用同一条事实链解释余额、订单和流水,库存一致性才不再是一句架构口号,而成为数据库可以验证、运维可以监控、业务可以信任的增长基础。
我以前排查过一次库存对不上的问题:库存余额表看起来没有负数,接口也都返回成功,但订单数、扣减记录和实际可用库存始终差一笔。我原本以为是并发更新丢失,后来才发现真正的问题是余额变化没有对应的、可核对的库存流水。
库存余额是当前结果,库存流水才是变化证据。只维护一个 available_qty 字段,系统只能回答“现在还剩多少”,却回答不了“为什么是这个数”“哪笔订单扣掉的”“这次重试有没有重复扣减”。一旦出现超时、重试、取消、退款或人工补偿,单一余额字段很快就会失去解释能力。
我在库存压测和故障排查中,通常会同时核对四类数据:库存余额、库存流水、订单扣减单和回补记录。比如初始库存为 100,正常出库 30、取消回补 10、再次出库 20,那么理论余额应为 60。如果余额表显示 60,但流水缺少那笔回补记录,系统虽然暂时“算对了”,后续对账和故障恢复仍然会失败。
设计方式能回答的问题主要风险 只维护余额当前库存是多少无法追溯重复扣减和异常回补 余额加流水当前库存及每次变化原因需要处理事务、幂等和流水膨胀 余额、流水、订单关联库存变化是否有业务依据跨服务场景需要补偿和对账 我的判断是:流水表不是“为了审计才增加的表”,而是库存系统的事实链。
余额表负责高频读取,流水表负责解释和恢复,两者职责不同,不能用缓存、日志或订单表中的某个数量字段互相替代。最低限度的流水字段应包括 SKU、仓库、业务类型、业务单号、变更数量、变更前数量、变更后数量、幂等键、创建时间和处理状态。
若业务包含锁定、释放、调拨、报损和退款,还应明确区分这些动作,不能全部笼统记成“库存减少”或“库存增加”。
我最担心的场景是余额已经扣成功,但服务在写流水时超时;或者流水先落库,余额更新因为锁等待失败。很多方案只强调使用事务,却没有讲清楚事务边界到底应该覆盖哪些操作。
如果余额表和流水表位于同一个数据库,库存扣减、流水写入和幂等记录应尽量放在同一个本地事务中。典型做法是先用带条件的更新防止超卖,再根据受影响行数判断扣减是否成功,随后写入流水和幂等记录,最后统一提交。
UPDATE inventory SET available_qty = available_qty - :n WHERE sku_id = :sku_id AND available_qty >= :n;这条 SQL 的价值不只是“原子减法”,而是把库存充足性判断放进数据库的更新动作内。应用层先查询库存、再判断、再执行扣减,在并发下会产生竞态;两个请求都读到 1,最终可能各自认为自己有资格扣减。我会在测试中重点观察四个结果,而不是只看接口成功率:余额更新成功数、流水写入成功数、幂等记录成功数,以及事务回滚数。
比如并发 1,000 个请求争抢 100 件库存,最终有效扣减必须不超过 100,成功扣减流水数量必须与有效扣减订单数量一致,重复请求不能增加新的扣减流水。同库事务并不意味着所有问题都消失。事务提交后,发送消息、更新缓存和调用订单服务仍可能失败。
因此我通常把流程拆成两层:库存余额、库存流水和幂等记录属于同步事实层;缓存刷新、搜索索引更新和通知属于异步传播层。事实层提交成功后,再通过事件表、可靠消息或补偿任务传播变化。
场景推荐处理不能只依赖的手段 同库余额与流水本地事务统一提交应用层先查后改 跨库或跨服务事件、幂等消费、对账补偿假设网络调用必然成功 扣减响应超时通过幂等键查询最终状态直接再次扣减 专家判断是:事务解决的是一次提交的原子性,流水解决的是变化可解释性,补偿解决的是跨系统传播失败。
把三者混成“加一个事务就一致”,通常会在生产环境的超时和重试中暴露问题。
我曾经遇到过商品详情页显示还有库存,但用户提交订单时却被告知库存不足。产品团队把问题归因于缓存延迟,可我想知道的是:缓存到底应该参与库存扣减,还是只负责展示?两者的边界怎么划分才不会留下隐患?
库存缓存最容易被误用的地方,是把“展示库存”和“扣减依据”当成同一类数据。商品详情页的库存数字通常允许短暂延迟,但最终扣减必须依赖具备持久化能力和并发约束的事实存储,或者依赖一套有明确落库、恢复和对账机制的前置扣减层。我会把库存相关数据分成三类。
第一类是展示值,例如商品页的“库存紧张”,可以接受秒级延迟;第二类是下单前提示值,可以用于减少无效请求,但不能作为最终成功条件;第三类是扣减结果,必须由数据库条件更新、可靠的库存服务或经过完整设计的扣减队列确认。
数据用途是否适合缓存一致性要求 商品页展示库存适合允许短暂延迟 下单前库存提示适合只能作为预检查 最终扣减结果不能只依赖缓存需要原子约束和持久化记录 库存流水和幂等状态不应以缓存为唯一来源必须可恢复、可核对 缓存更新策略也要结合事务边界。
常见的“先更新数据库、再删除缓存”可以降低旧值长期存在的概率,但它无法保证删除动作一定成功;事件订阅、延迟重删和过期时间只能缩短不一致窗口,不能替代流水记录和对账。高并发热点 SKU 还有一个容易被忽略的风险:即使缓存读性能很好,最终扣减仍可能集中争抢数据库中的同一行。
我的测试习惯是同时测缓存命中率、数据库行锁等待、扣减失败率和流水写入延迟,而不是只看缓存 QPS。缓存把读压力挡住后,剩下的写竞争反而更集中,可能让数据库更早达到瓶颈。因此,缓存的正确定位是缩短读取路径、吸收展示流量和辅助削峰,而不是替代库存账本。
若确实需要在内存层预扣库存,必须明确预扣记录如何持久化、服务重启如何恢复、消息重复如何幂等,以及最终余额如何与流水重新核对。
过去我处理库存异常时,最有效的线索并不是应用日志,而是按 SKU、订单号和时间窗口重放库存流水。我想建立一套日常机制,而不是每次出事故后临时写脚本:哪些指标要监控,哪些差异属于延迟,哪些差异必须立即处理?
DBA 不应只监控数据库连接数、慢查询和磁盘使用率,还要把库存业务不变量纳入数据库治理。最基本的对账公式是:期末余额 = 期初余额 + 入库数量 + 回补数量 – 出库数量 – 报损数量 – 调拨净变化。锁定和释放如果不改变物理库存,也必须单独建模,不能直接混进出入库数量。
我建议按“实时检测、定时对账、异常修复”三层建设。实时层捕捉负库存、唯一键冲突激增、流水写入失败和锁等待;定时层按 SKU、仓库和业务日期核对余额与流水;修复层则要求保留原始异常、补偿动作、执行人和复核结果,禁止直接覆盖原余额而不留痕。
异常表现可能原因处理优先级 余额小于零并发约束失效、人工调整或回补重复高 余额无法由流水解释事务边界错误、历史数据缺失高 消息待消费但余额已更新异步传播延迟按时延阈值判断 幂等键冲突增加客户端或消息重复投递先判断是否为正常重试 流水查询变慢数据膨胀、索引过多或分区失效容量治理 对账不能把所有差异都判定为错误。
例如消息刚提交、消费端尚未处理,余额与下游订单状态可能存在几秒甚至几十秒的合理延迟。真正需要升级处理的是超过业务允许窗口仍未闭合的差异,或者余额已经无法被任何合法流水解释的差异。修复时不要直接执行“把库存加回去”这类无依据脚本。
更安全的方式是先生成差异单,关联原订单或扣减单,确认问题属于重复扣减、漏记流水、错误回补还是人工误操作,再通过一笔带原因和审批信息的调整流水完成修复。这样修复后的余额仍然能被账本解释。当流水量增长到单表难以维护时,优先考虑按时间归档或分区,并保留 SKU、业务单号和幂等键的查询能力。
分库分表可以解决容量和写入压力,却可能破坏跨库追溯,因此必须同步设计全局业务标识、对账批次和历史查询路径。对 DBA 来说,库存增长治理的终点不是更高吞吐,而是数据出问题时仍能快速定位、核对和恢复。


读者评论
文章把库存余额、流水、幂等和对账的职责区分得比较清楚,尤其是强调“请求次数不等于业务动作次数”,对处理超时重试和重复消费很有启发。不过,流水与余额如何在同一事务中落库,正文后续还可以给出更完整的实现方案。
条件更新比先查再扣减更可靠,这一点很实用。文中也提醒了行锁并不能解决重复请求和跨服务消息问题,说明库存一致性确实需要数据库约束与业务设计配合。实际落地时,还要结合热点 SKU 的锁竞争和延迟指标评估方案。
从数据库运维角度看,库存流水的容量估算、索引膨胀、分区和归档都很关键。很多系统只关注扣减逻辑,却忽略长期数据增长带来的查询和备份成本。文章的容量推演比较直观,但生产环境仍需根据字段、索引和副本情况进行实测。