《数据库存:后端工程师标准化教程:用库存锁定复制保证扣减一致性》真正要解决的,不是“如何把库存字段减一”,而是如何让库存扣减在并发、重试、支付超时、主从延迟和服务故障下仍然可解释、可恢复、可对账。我在排查库存问题时,最常见的事故并不是数据库完全崩溃,而是某个看似合理的环节破坏了边界:应用层先查后改、重复请求没有幂等号、关键判断读了只读副本,或者把“锁定成功”误当成“最终售出”。
库存系统至少要区分总库存、可用库存、锁定库存和已售库存。总库存描述物理或业务上的总量;可用库存表示当前可以被新订单占用的数量;锁定库存表示已经分配给订单、但尚未完成最终交易的数量;已售库存表示已经完成确认的数量。
在最简单的商品模型中,可以用下面的关系检查数据是否出现明显偏差:
总库存 = 可用库存 + 锁定库存 + 已售库存
这不是所有仓储系统都适用的唯一公式。例如,在途库存、残次库存、渠道配额和门店安全库存可能单独建模。但无论字段如何设计,库存状态必须能够解释每一件商品目前处于什么业务状态,否则后续的扣减、释放和对账都会变成猜测。
高并发场景下,不要把“库存是否充足”的判断放在应用代码中,再单独执行减库存操作。正确的最小方案,是将条件判断和扣减合并成一条带条件的更新语句,并以数据库返回的受影响行数作为结果。
UPDATE inventory SET available_stock = available_stock - ?, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ? AND available_stock >= ?;
如果这条 SQL 影响 1 行,说明本次扣减成功;如果影响 0 行,可能是库存不足,也可能是商品不存在或条件不匹配。生产代码不能只返回一个“失败”,而要结合查询结果、商品状态和业务日志给出明确错误码。
这条 SQL 可以保证单条库存记录的原子扣减,但不能自动保证订单、支付、消息和库存流水的全链路一致。这是本文最重要的边界。
| 机制 | 主要解决的问题 | 不能自动解决的问题 |
|---|---|---|
| 条件原子更新 | 并发扣减和负库存 | 支付后确认、消息丢失、重复业务请求 |
| 数据库事务 | 同一数据库内多条操作的原子性 | 跨服务、跨数据库和第三方支付一致性 |
| 库存锁定 | 支付前预占资源,避免重复销售 | 超时释放、重复确认和补偿失败 |
| 请求幂等 | 避免同一业务动作重复生效 | 并发冲突本身和副本延迟 |
| 主从复制策略 | 控制写入后的读取路径和旧读风险 | 业务状态机和异常补偿 |
| 对账机制 | 发现已经发生的数据偏差 | 阻止所有偏差产生 |
我更倾向于把库存一致性看成一组“防线”,而不是一个技术名词。第一道防线是数据库原子性,第二道防线是业务状态和幂等,第三道防线是消息补偿,第四道防线是监控与对账。只有这几层组合起来,系统才具备生产可恢复性。

假设 SKU-A 只剩 1 件,两个请求几乎同时进入服务。请求 A 读取到库存 1,请求 B 也读取到库存 1;随后 A 将库存更新为 0,B 也按照自己读取到的旧值将库存更新为 0。数据库最终可能显示为 0,但两个订单都已经认为自己购买成功。
这个例子有一个容易被忽略的细节:库存最终没有变成负数,并不代表没有超卖。如果库存更新语句是“把库存设置成读取值减一”,两个请求都可能写入 0;库存字段表面正常,订单数量却已经超过真实可售数量。
-- 有风险:应用层先查到 stock,再把结果写回去 SELECT available_stock FROM inventory WHERE sku_id = ?; -- 可能被两个并发请求分别执行 UPDATE inventory SET available_stock = ? WHERE sku_id = ?;
更可靠的方式是让数据库在更新时重新判断当前值。数据库行锁或原子更新会让并发请求围绕同一条记录形成可观察的顺序,而不是让每个请求依据自己的旧快照直接覆盖结果。
如果用户提交订单后还要等待支付,系统不能在下单时简单地把库存理解为已售。此时更合理的模型是:下单成功时从可用库存转入锁定库存;支付成功时从锁定库存转入已售库存;订单取消或超时未支付时,从锁定库存释放回可用库存。
— 下单时锁定库存
UPDATE inventory
SET available_stock = available_stock – ?,
locked_stock = locked_stock + ?,
version = version + 1
WHERE sku_id = ?
AND available_stock >= ?;
这里的关键不是字段多,而是每次状态变化都要有明确的业务凭证。例如,锁定动作应关联订单号,确认动作应关联支付流水号,释放动作应关联取消原因和释放任务编号。没有业务凭证的库存变化,后续很难判断它是正常操作、重复操作还是人工修复。
很多系统采用主库写入、只读副本查询的架构。请求在主库中完成库存锁定后,如果紧接着将查询路由到副本,而复制线程尚未追平,应用就可能读到锁定前的旧库存。
这种情况并不一定代表写入失败,而是代表写入成功和副本可见不是同一个时间点。如果这个旧读只用于商品详情页展示,通常可以接受短暂延迟;如果它被用于判断能否继续扣减、是否允许支付或是否可以释放库存,就可能造成业务错误。

分布式锁可以降低同一 SKU 的并发进入数量,但它并不能替代数据库条件更新。锁可能因为客户端宕机、网络分区、租约超时或误配置而失效;即使锁没有问题,重复请求在不同时间重新获得锁后仍可能再次扣减。
我的判断标准很简单:如果去掉分布式锁后,数据库仍能依靠条件更新拒绝无效扣减,那么锁是性能和冲突控制手段;如果去掉锁后数据立刻可能被写坏,说明数据库兜底不足。
对于单库单行库存,优先考虑数据库原子更新,往往比在链路中增加一个外部锁服务更容易验证。分布式锁更适合保护跨多条记录的短事务操作、降低热点冲突,或控制某一类非数据库资源的并发访问。
SELECT ... FOR UPDATE 的作用是锁住事务期间的记录,让其他事务等待或失败。它适合在一个短事务内读取库存、校验条件并完成多表写入,但不适合把数据库事务一直保持到用户支付完成。
用户可能几分钟后才支付。如果把数据库行锁持有几分钟,其他请求会排队,连接池、锁等待和事务日志都会受到影响。正确做法是把“数据库锁”与“业务库存锁定”分开:数据库行锁只保护一次短暂的状态转换,锁定状态则通过订单记录和超时任务持续管理。
乐观锁通常通过版本号判断记录是否被修改。并发冲突时,更新影响 0 行,应用可以重新读取并重试。但无限重试会把一次库存冲突放大成数据库压力,尤其是在秒杀热点 SKU 上,失败请求会不断回到数据库。
我通常建议为重试设置上限,并区分库存不足、版本冲突和数据库异常。版本冲突可以短暂退避后重试;库存不足应立即返回;数据库连接异常则交给基础设施和任务系统处理,不能全部伪装成“再试一次”。
一种典型事故是:库存扣减事务已经提交,但服务在返回响应前发生超时。客户端没有收到成功结果,于是重新提交同一个订单。若系统没有请求幂等号,第二次请求可能再次扣减库存。
因此,接口的“响应是否送达”和数据库的“业务是否提交”是两个独立事件。幂等设计必须以业务请求为中心,而不能依赖客户端是否看到了上一次响应。
读主库能减少复制旧读,但它也可能带来主库压力,不能解决重复扣减、错误补偿和状态转换冲突。如果扣减 SQL 没有条件,所有请求都读主库仍然可能把库存写成错误结果。
正确顺序是先确保写操作具备原子性,再按照查询用途选择主库或副本。强一致查询走主库,展示和统计查询可以走副本;这是一种业务分级,而不是简单的“全主库”或“全副本”。
TCC、Saga 和可靠消息解决的是跨服务资源协调问题,并不会自动保证所有异常场景都能恢复。TCC 需要处理空回滚、重复确认、重复取消和悬挂;Saga 的补偿动作也可能无法完全还原原状态。
如果订单和库存本来就在同一个数据库中,先把本地事务、唯一约束、库存流水和状态机做好,通常比直接引入复杂框架更稳。复杂方案只有在边界确实跨越服务和数据库后才有价值。

“一致性”在库存系统中至少有四个层次。第一是数量一致性,即可用库存不能被扣成负数,扣减数量必须符合条件。第二是动作一致性,同一个订单不能重复锁定或重复释放。第三是状态一致性,订单状态和库存状态之间要有明确的转换关系。第四是跨系统一致性,订单服务、库存服务、支付服务和消息系统最终能够收敛。
| 问题表现 | 优先机制 | 验证方式 |
|---|---|---|
| 两个请求同时扣最后一件 | 条件原子更新或短事务行锁 | 并发压测后检查扣减成功数与库存流水 |
| 同一订单被处理两次 | 幂等号、唯一约束、状态机 | 重复提交同一请求并检查业务影响次数 |
| 取消订单后库存未恢复 | 释放任务、可靠消息、补偿 | 模拟消息失败并观察最终对账结果 |
| 写成功后查询仍显示有库存 | 关键读取走主库或等待复制追平 | 记录写入位点、读取节点和读取时间 |
| 订单与库存长期不一致 | 跨服务事务策略和对账 | 按订单号汇总库存流水与订单状态 |
下单即扣减、支付后扣减和预占后确认是三种常见模式。下单即扣减流程短,库存实时性强,但未支付订单会占用库存,需要定时释放。支付后扣减不会产生大量预占,但支付成功和库存不足之间的协调更复杂。
预占后确认通常适合库存稀缺、支付流程较长、不能接受重复售卖的业务。它的代价是状态更多、超时任务更多、对账复杂度更高。不能只因为“库存锁定”听起来更稳,就在所有场景中使用。
| 模式 | 优点 | 主要代价 | 适用场景 |
|---|---|---|---|
| 下单即扣减 | 链路短,库存判断清晰 | 未支付订单占用库存,需要释放 | 普通电商、库存量相对充足 |
| 支付后扣减 | 减少无效预占 | 支付成功时可能库存不足 | 允许支付后确认或人工处理的业务 |
| 预占后确认 | 支付期间资源被明确保留 | 状态和补偿较多 | 票务、稀缺商品、预约资源 |
库存锁定并不一定要锁住整个商品表。常见粒度包括 SKU、仓库加 SKU、渠道加 SKU 和订单明细。粒度过大,会把互不冲突的请求变成排队;粒度过小,则可能导致一次订单需要协调大量记录。
例如,同一 SKU 在三个仓库都有库存时,直接锁定 SKU 总记录实现简单,但无法充分利用仓库维度的并发能力。若业务要求就近发货,就需要将库存拆成“仓库、SKU”维度,并额外处理多个仓库的分配顺序。
库存方案评审时,我不会只看架构图是否完整,而会要求团队回答四个问题:扣减成功的唯一判据是什么?重复请求如何被识别?支付超时后由谁释放?主库写入后关键查询从哪里读?如果这四个问题不能用字段、SQL、状态和任务说清楚,方案通常还没有达到可上线程度。

只保留一个 stock 字段,在商品展示场景中可能足够,但在需要锁定和释放的系统中会丢失业务语义。至少应明确可用、锁定和已售的变化方向,并考虑是否需要版本号。
CREATE TABLE inventory (
sku_id BIGINT NOT NULL PRIMARY KEY,
total_stock INT NOT NULL,
available_stock INT NOT NULL,
locked_stock INT NOT NULL DEFAULT 0,
sold_stock INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE inventory_operation (
operation_id VARCHAR(64) NOT NULL PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
operation_type VARCHAR(20) NOT NULL,
quantity INT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
库存流水表的主键不是装饰。它可以用请求号、订单号加动作类型或独立操作号形成唯一约束,使“锁定、确认、释放”成为可以查询和重放的业务事件。
如果库存表更新成功,但库存流水写入失败,后续对账就无法判断这次变化来自哪个订单。反过来,如果流水先写入、库存更新失败,而系统又把流水误认为成功,也会形成另一种偏差。
在同一个数据库中,库存表和操作流水应尽量放入同一事务。先利用唯一约束判断操作是否已经执行,再执行条件更新;更新成功后写入操作状态,最后提交事务。
BEGIN;
— 先尝试写入业务操作,operation_id 必须唯一
INSERT INTO inventory_operation
(operation_id, order_id, sku_id, operation_type,
quantity, status, created_at, updated_at)
VALUES
(?, ?, ?, 'LOCK', ?, 'PROCESSING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);— 只有库存充足时才允许锁定
UPDATE inventory
SET available_stock = available_stock - ?,
locked_stock = locked_stock + ?,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE sku_id = ?
AND available_stock >= ?;— 只有 affected_rows = 1 时,才能将流水置为成功
UPDATE inventory_operation
SET status = 'SUCCESS',
updated_at = CURRENT_TIMESTAMP
WHERE operation_id = ?
AND status = 'PROCESSING';
COMMIT;实际项目中还要处理插入唯一键冲突。如果操作号已经存在,不能简单地再次执行库存更新,而要读取原操作状态:已成功则直接返回成功,处理中则进入查询或补偿流程,失败则根据业务规则决定是否允许重新发起。
支付确认不应该无条件执行“锁定库存减一、已售库存加一”。如果确认消息重复投递,无条件更新会造成已售库存重复增加。正确做法是先判断对应操作是否处于可确认状态,再让状态转换和数量变更在同一事务中完成。
BEGIN; -- 只有锁定成功且尚未确认的操作才能进入确认流程 UPDATE inventory_operation SET status = 'CONFIRMING', updated_at = CURRENT_TIMESTAMP WHERE operation_id = ? AND operation_type = 'LOCK' AND status = 'SUCCESS'; -- 只有上一步影响 1 行,才允许执行库存状态转换 UPDATE inventory SET locked_stock = locked_stock - ?, sold_stock = sold_stock + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ? AND locked_stock >= ?; UPDATE inventory_operation SET status = 'CONFIRMED', updated_at = CURRENT_TIMESTAMP WHERE operation_id = ? AND status = 'CONFIRMING'; COMMIT;
如果业务需要支持并发取消和确认,还要在订单状态层设置唯一的合法转换。例如,订单一旦进入已支付,就不允许超时任务再执行释放;超时任务发现状态已改变时,应记录“无需释放”,而不是继续修改库存。
库存表是当前结果,库存流水是变化过程。只看当前结果无法知道库存为什么变成 37,也无法判断某一次释放是否执行过。库存流水至少应记录订单号、SKU、动作类型、数量、请求号、原状态、新状态、操作时间和执行节点。
对账时可以从订单和库存流水两个方向核对:订单显示已锁定的数量,是否存在对应的锁定流水;订单已支付的数量,是否已经完成确认;已取消或超时的订单,是否存在释放成功记录。发现差异后,再进入自动补偿或人工审核。

对于“某个 SKU 扣减数量不超过当前可用库存”这一类规则,原子更新通常是最直接的选择。它将判断和更新交给数据库完成,应用只需要检查影响行数,代码路径短,容易测试,也不需要先把库存值读到应用内存中。
它的不足是:当一次操作涉及复杂分配,例如同时扣减多个仓库、赠品和主商品,单条 SQL 可能不够表达。这时不能把复杂逻辑硬塞进一个字段更新,而应明确事务范围,必要时使用短事务行锁或拆分为可补偿的步骤。
BEGIN; SELECT available_stock, locked_stock FROM inventory WHERE sku_id = ? FOR UPDATE; -- 在同一事务内完成业务校验、库存变化和操作流水写入 COMMIT;
悲观锁的价值是让读取后的多步操作处于一个受保护的临界区。它比较容易理解,但并发请求会等待,热点 SKU 可能形成锁队列。事务中不能包含远程 HTTP 调用、用户支付等待或复杂计算,否则锁持有时间会被不可控地拉长。
UPDATE inventory SET available_stock = available_stock - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ? AND version = ? AND available_stock >= ?;
乐观锁通过版本号避免覆盖他人更新。它不一定阻塞,但冲突高时会产生大量失败重试。对于热点库存,我会重点观察版本冲突率、平均重试次数、数据库 CPU 和连接池等待,而不会只看接口平均响应时间。
| 方案 | 数据库行为 | 优点 | 风险 | 推荐边界 |
|---|---|---|---|---|
| 条件原子更新 | 条件满足才更新 | 实现短、结果明确 | 复杂多记录逻辑表达有限 | 单 SKU 或单库存记录扣减 |
| 悲观行锁 | 冲突请求等待 | 多步逻辑直观 | 锁等待和死锁风险 | 短事务、多字段协同修改 |
| 乐观锁 | 版本不符则失败 | 不长期阻塞 | 高冲突时重试放大压力 | 冲突可控、可以有限重试 |
| 应用层分布式锁 | 请求先争抢外部锁 | 可保护跨资源临界区 | 租约、故障和锁误用复杂 | 作为辅助限流,不替代数据库兜底 |

用户连续点击只是最容易观察到的重复请求来源。更常见的情况是网关在响应超时后自动重试,消息队列因为消费确认失败再次投递,定时补偿任务与正常回调同时执行,或者服务执行成功后在写日志阶段崩溃,恢复后又把未完成任务重新提交。
这些请求在技术上可能来自不同线程、不同机器甚至不同进程,但在业务上可能都是同一个动作。库存服务需要使用稳定的业务幂等键,例如订单号加动作类型、支付流水号或调用方生成的请求号。
只在应用内存中保存“已经处理过的请求”,重启后记录就会丢失;只在缓存中保存幂等状态,也要面对过期、淘汰和写入失败。生产系统应至少使用数据库唯一约束作为最终兜底。
CREATE UNIQUE INDEX uk_inventory_operation
ON inventory_operation (order_id, operation_type);这个约束意味着同一个订单不能重复执行同一种库存动作。若业务允许同一订单分批扣减,则不能机械地使用订单号加动作类型,而应将批次号或明细号纳入幂等键。
| 错误类型 | 是否建议重试 | 处理方式 |
|---|---|---|
| 库存不足 | 通常不重试 | 返回明确业务失败,避免无意义数据库压力 |
| 版本冲突 | 有限重试 | 短暂退避,超过次数后进入失败或排队 |
| 数据库连接超时 | 谨慎重试 | 先查询幂等流水,确认上一次是否已提交 |
| 消息消费重复 | 允许重入 | 依靠唯一键和状态机返回原处理结果 |
| 释放任务失败 | 应重试 | 使用延迟队列、死信队列和人工告警 |
最危险的重试,是在不知道第一次是否成功的情况下直接再执行一次扣减。遇到超时,正确动作通常是“查询操作状态后决定”,而不是“默认失败再做一次”。
即使每个动作都具备幂等性,确认和释放仍可能乱序到达。比如支付确认晚到,订单超时任务已经执行释放;随后确认消息再次到达,就不能简单地把库存转为已售。
因此需要订单状态机控制合法顺序。可以规定:待支付允许释放,已支付允许确认,已取消拒绝确认;如果消息到达顺序不符合状态机,就进入待核查状态,而不是直接修改库存。

商品详情页展示“剩余库存约 20 件”,通常允许短暂延迟,因为它主要服务于用户浏览。扣减前判断库存是否充足、支付确认前判断锁定状态、释放前确认订单是否已支付,则属于决策读取,错误结果会改变业务动作。
我在设计读写分离时,会先为每个查询标注用途,而不是只按接口名称判断。一个名为“查询库存”的接口,可能既被前端展示调用,也被下单服务内部调用;如果不区分读场景,路由策略很容易被复用错。
| 查询用途 | 推荐读取节点 | 可接受延迟 | 原因 |
|---|---|---|---|
| 商品详情页库存展示 | 副本或缓存 | 秒级或业务可接受范围 | 展示不应阻塞核心扣减链路 |
| 扣减前库存判断 | 主库 | 尽量为零 | 这是产生业务决策的关键输入 |
| 支付后的订单库存状态 | 主库 | 尽量为零 | 确认动作需要最新状态 |
| 运营统计与报表 | 副本或数仓 | 分钟级甚至更长 | 统计查询不应抢占主库事务资源 |
最简单的策略是:在一次写入后的短时间内,强制该用户或该业务请求读取主库。更严格的策略是记录主库提交对应的复制位点,读取副本前确认副本已经追平该位点。不同数据库和复制组件的具体实现不同,但设计原则是一致的:不能假设副本提交速度永远快于业务请求的下一次读取。
如果业务允许最终一致,可以直接返回操作结果,并在页面展示“处理中”;如果业务要求立即显示最终库存,就不要在确认响应中依赖副本查询结果。响应可以使用事务提交时已经确定的结果,后续展示再通过普通查询更新。
复制延迟是动态指标,可能因为网络抖动、批量写入、长事务、磁盘压力和副本负载而变化。至少应监控延迟分位数、最大延迟、连续超阈值时间和受影响的读请求数量。

如果订单表、库存表和库存流水表可以放在同一个数据库,并且业务能够接受短事务,那么本地事务往往是最容易证明和维护的方案。它减少网络调用、消息重试和跨服务状态不一致,排障时也能直接查看同一个事务边界内的记录。
如果团队为了“未来可能拆分”而提前把一个本地事务拆成多个服务,通常会把简单问题变成复杂问题。服务拆分带来的独立扩展价值,必须足以抵消消息、补偿、幂等和对账成本。
库存锁定成功后,可以通过本地消息表或 Outbox 记录需要通知订单服务的事件。库存更新和消息记录放在同一数据库事务中,后台投递程序再将消息发送到消息系统。这样可以避免“库存已经提交,但发送消息前服务宕机”的窗口。
BEGIN;
— 1. 更新库存并写入库存流水
— 2. 同事务写入待发送事件
INSERT INTO outbox_event
(event_id, event_type, aggregate_id, payload, status, created_at)
VALUES
(?, 'INVENTORY_LOCKED', ?, ?, 'PENDING', CURRENT_TIMESTAMP);
COMMIT;投递程序发送成功后更新事件状态;发送超时则重试。消费方必须幂等,因为消息系统通常需要在可靠性和重复投递之间做取舍。只要业务动作能够重复执行而不重复产生效果,最终一致方案就可以具备较强的工程可用性。
TCC 的 Try 可以对应库存预占,Confirm 对应支付成功后的确认,Cancel 对应订单取消后的释放。但库存服务必须提前设计空回滚、幂等和悬挂处理,否则框架提供的接口数量越多,异常状态越难排查。
例如,Cancel 可能先于 Try 到达。此时系统不能凭空增加可用库存;应该记录取消意图,等 Try 到达时识别该资源已经被取消,或者让协调器重新驱动状态。Confirm 重复到达时,也必须返回第一次成功结果而不是再次修改数量。
在长流程业务中,Saga 通过每一步的补偿动作回退之前的业务动作。例如订单创建后锁定库存,支付失败后释放库存。但补偿动作可能受到人工介入、外部系统状态变化和资源不可逆操作影响,因此它提供的是业务层最终收敛,而不是数据库意义上的瞬时回滚。
| 方案 | 一致性特征 | 实现成本 | 异常处理重点 | 适用判断 |
|---|---|---|---|---|
| 本地事务 | 同库内强一致 | 低 | 事务超时、锁等待 | 资源边界可放在同库 |
| 本地消息表 | 跨服务最终一致 | 中 | 重复消费、消息积压 | 业务允许短暂延迟 |
| TCC | 业务预留与确认 | 高 | 空回滚、悬挂、重复调用 | 资源动作可明确拆成三步 |
| Saga | 长流程补偿收敛 | 中高 | 补偿失败、人工修复 | 流程长且动作具备补偿路径 |

下面用一个普通电商 SKU 做推演。初始总库存为 100 件,其中可用库存 100 件、锁定库存 0 件、已售库存 0 件。每个订单最多购买 2 件;下单后 15 分钟未支付自动释放;支付成功后执行确认。
这组数字是为了说明流程的情景模拟,不是某个平台的真实业务数据。推演的价值不在于 100 件这个数字,而在于每一个变化都有可以核对的前后状态。
假设 80 个用户同时提交订单,每个订单购买 2 件,总需求为 160 件。数据库条件更新最多允许 50 个订单锁定成功,剩余 30 个订单由于可用库存不足而失败。
此时应检查三个结果:锁定成功订单数是否为 50;可用库存是否为 0;库存流水中锁定数量之和是否为 100。若订单成功数为 50,但流水只有 98 件,说明应用返回结果和数据库事实已经脱节。
50 个已锁定订单中,40 个支付成功,10 个超时未支付。支付确认后,40 个订单对应的 80 件从锁定库存转入已售库存;10 个订单对应的 20 件释放回可用库存。
初始状态:
available_stock = 100
locked_stock = 0
sold_stock = 0
并发锁定后:
available_stock = 0
locked_stock = 100
sold_stock = 0
40 个订单确认、10 个订单释放后:
available_stock = 20
locked_stock = 0
sold_stock = 80
最终检查公式仍然成立:100 = 20 + 0 + 80。更重要的是,80 件已售库存必须能关联到 40 个支付成功订单,20 件可用库存必须来自 10 个有效释放动作。
假设其中一个锁定事务已经提交,但接口响应在网络层丢失。客户端发起相同请求,库存服务通过请求号或订单号查到原操作已成功,于是返回原结果,不再执行第二次锁定。
如果没有幂等记录,第二次请求会把可用库存再减少 2 件。最终库存总量可能仍然没有变成负数,但已锁定数量会超过订单对应的实际需求,这类错误在页面上通常很难第一时间发现,只能依靠流水对账暴露。

出现库存异常时,第一步不是直接修改库存,而是确定 SKU、订单号、请求号和异常发生时间。没有时间窗口,就无法把订单日志、数据库流水、消息记录和复制延迟放在同一条时间线上。
建议按照以下顺序收集信息:
如果当前可用库存异常,可以将所有库存动作按操作时间排序,重建目标 SKU 的数量变化。重点寻找三类记录:没有对应订单的库存变化;同一个订单重复出现同一种动作;订单已经取消但库存仍处于锁定状态。
对于已经发生的偏差,不建议直接执行一条“库存加一”来掩盖问题。修复动作也必须生成独立的调整流水,记录操作人、原因、审批单号和修复前后的数量,否则下一次对账仍然无法解释差异来源。
第一个是库存扣减失败原因分布。库存不足、版本冲突、数据库异常和幂等命中应该分开统计。第二个是补偿成功率和补偿耗时,它直接反映系统能否从异常中恢复。第三个是库存对账差异数量,长期为零并不一定代表没有问题,也可能代表对账任务本身没有运行。
| 指标 | 观察意义 | 异常信号 |
|---|---|---|
| 条件更新成功率 | 判断请求是否真正完成库存动作 | 成功率突然升高但订单量不变,可能存在重复统计 |
| 幂等命中次数 | 衡量重复请求和重试规模 | 短时间激增,可能是网关超时或消息重复 |
| 版本冲突率 | 衡量乐观锁竞争程度 | 持续升高,说明热点集中或重试策略不合理 |
| 锁等待时间 | 衡量悲观锁造成的排队 | 长尾升高,可能存在长事务或热点行 |
| 复制延迟分位数 | 衡量副本旧读窗口 | 关键查询仍走副本时,业务风险随之扩大 |
| 对账差异量 | 衡量业务事实是否收敛 | 持续不为零,说明补偿或状态转换存在缺口 |
优先使用条件原子更新、订单幂等号、库存流水和定时对账。下单即扣减可以减少状态数量,但必须建立超时关闭订单和释放库存的任务。关键扣减走主库,商品详情展示可以读取副本或缓存。
这个场景不需要一开始就引入 TCC。先验证 SQL 受影响行数、唯一约束和释放任务是否可靠,通常已经能覆盖大部分核心风险。
重点关注热点行锁、数据库连接池和失败重试放大。可以在应用层做库存预热、请求排队、限流或令牌控制,减少无效请求直接打到数据库;但最终扣减仍应由数据库条件更新兜底。
如果单 SKU 成为极端热点,可以评估库存分片、预扣减桶、分段库存或异步排队。这些方案会牺牲部分实时性和实现简单性,必须通过压测验证,而不是看到“热点”两个字就直接拆库存。
建议采用预占后确认。预占动作必须绑定用户、订单、资源位置或时间段,并设置明确的过期时间。释放任务要具备重试和幂等能力,过期任务不能只依赖单台应用实例的内存定时器。
如果资源具有位置属性,例如座位或房间,库存粒度不能只停留在一个总数上。需要对具体资源编号加唯一约束,防止两个订单分别锁定同一个座位。
先明确分配规则,再决定事务粒度。若一个订单可以从任意仓库发货,可以先确定仓库再执行单仓扣减;若订单需要跨仓拆分,则必须考虑多记录锁定顺序和死锁风险。
多渠道库存还要定义渠道配额、共享库存和回收规则。一个渠道释放的库存是否立即回到公共池,不能由技术团队自行推断,必须在产品和供应链规则中明确。
先建立库存服务内部的本地一致性,再选择跨服务方案。订单服务发起锁定后,应接收明确的成功、失败或处理中状态;支付成功后通过可靠事件触发确认;超时取消则通过补偿事件触发释放。
无论使用消息最终一致、TCC 还是 Saga,都要配套幂等、重试、死信、监控和对账。没有这些配套能力,分布式事务只是把错误从一个事务里搬到了多个状态表中。
将查询分为决策查询、结果查询、展示查询和统计查询。决策查询走主库;写入后短时间内的结果查询可以使用主库或会话粘滞;展示查询和统计查询可以走副本。复制延迟超过阈值时,应自动降级关键路由,而不是继续假设副本新鲜。

压测时应记录请求数、成功扣减数、失败原因、库存流水数量、订单状态和最终库存。单看 HTTP 200 并不能判断库存正确,因为接口可能返回成功但落库失败,也可能重复请求都返回成功。
最小测试集合包括:两个请求竞争最后一件、多个请求同时锁定多件、同一请求重复提交、数据库更新后响应前进程退出、消息重复消费、取消与确认并发到达。
提交前故障和提交后故障的处理方式完全不同。事务提交前进程退出,数据库通常会回滚;事务提交后进程退出,业务动作可能已经生效,客户端却不知道结果。测试必须在这两个时间点分别注入故障。
条件更新是否走正确索引,会影响锁范围和执行效率。SKU 主键或唯一索引应能够快速定位目标记录;如果条件中包含仓库、渠道和批次,应检查组合索引是否符合实际查询条件。
不要只凭“这是一条 UPDATE”判断它足够快。需要在目标数据库版本、目标数据量和接近生产的并发下观察执行计划、锁等待、事务日志增长和连接池占用。
可以人为制造一条缺失释放流水,观察对账任务是否识别;也可以让补偿任务第一次失败,观察重试、告警和死信处理。对账系统如果只能生成一份报表,却没有修复流程,遇到真正事故时仍然需要人工从数据库里猜答案。

单行库存的非负扣减可以保持简单:一条条件更新、影响行数判断和明确错误码,通常比多层锁和复杂事务更容易维护。库存流水也不必一开始就设计成庞大的事件平台,但必须能够追踪订单、动作、数量和状态。
普通商品展示也可以接受副本延迟,不必为了页面上的一个库存数字让所有请求都读取主库。只要页面文案和业务规则允许短暂误差,系统就能把主库资源留给真正的决策请求。
幂等键、唯一约束、释放任务和对账属于库存系统的基础设施,不应被视为“以后再补”。这些功能平时不一定显眼,但事故通常发生在响应丢失、消息重复和任务重启之后。
任何会改变库存数量的动作,都应该有来源、有操作者或系统身份、有业务单号、有前后状态。没有流水的库存调整,即使今天看起来数值正确,未来也无法证明它为什么正确。
当业务确实跨越多个数据库、库存资源需要预留后确认、流程允许短暂不一致且必须自动补偿时,可以引入可靠消息、TCC 或 Saga。引入前要先完成故障清单和状态机设计,明确每一个动作的正向操作、逆向补偿、幂等键和最大重试次数。
如果团队无法回答“Cancel 在 Try 之前到达怎么办”“确认消息重复怎么办”“副本落后时读取什么”“补偿长期失败谁处理”,就不应该急于上线复杂分布式事务框架。框架可以减少重复开发,但不能替团队做业务决策。
| 优先级 | 判断问题 | 推荐做法 |
|---|---|---|
| 第一优先级 | 会不会超卖或重复扣减 | 条件原子更新、唯一约束、幂等流水 |
| 第二优先级 | 异常后能不能恢复 | 消息重试、释放任务、死信和对账 |
| 第三优先级 | 关键读取是否可能读旧数据 | 主库路由、会话粘滞或复制位点策略 |
| 第四优先级 | 是否需要更高并发 | 限流、排队、热点拆分和异步化 |
| 第五优先级 | 是否真的跨越事务边界 | 评估本地消息、TCC 或 Saga |
库存系统最容易犯的错误,是把“库存一致性”包装成一个宏大的分布式事务问题,却没有先把最基本的数据库操作做好。对于单条库存记录,带条件的原子更新往往就是最重要的第一步;对于支付前后的资源变化,库存锁定和状态机比一把长时间持有的数据库锁更合适;对于重复请求,幂等键和唯一约束比应用层标记更可靠;对于读写分离,关键决策必须避开不可控的副本旧读。
我的独特判断是:一套库存方案是否成熟,不看它使用了多少中间件,而看它能否把每一次数量变化还原成一条有业务凭证的事实链。你应该能够回答谁锁定了库存、何时锁定、为什么确认、何时释放、是否重复执行,以及当前库存值能否由流水重新计算出来。
下一步可以按照以下顺序落地:
只要这条路径走对,库存一致性就不再是“出了问题再查数据库”,而会变成一套可以设计、测试、监控和恢复的后端工程能力。
我以前把库存扣减写成“先查询 available_stock,再在业务代码里减一”,在低并发测试中一直正常,直到把并发数提高后才发现问题。想请教一下,数据库条件更新、悲观锁和乐观锁到底应该怎么选,才能真正避免超卖?
我的判断是:单库库存扣减,默认优先使用“带条件的原子更新”,不要把库存判断和扣减拆成两次数据库操作。最小实现如下: UPDATE inventory SET available_stock = available_stock – ?
, version = version + 1 WHERE sku_id = ?AND available_stock >= ?;然后直接检查受影响行数:影响 1 行表示扣减成功,影响 0 行表示库存不足或条件不再成立。这个判断必须放在数据库更新结果上,不能依据应用层之前查到的库存值。
我做过一组本地并发测试:初始库存设为 20,使用 100 个并发请求。先查后改的实现会让多个请求同时读到相同库存,最终出现库存被扣成负数或成功订单数超过 20;原子更新实现则只有 20 次更新成功,其余请求均返回库存不足。测试关注的是正确性,不是吞吐量,不能把这个结果直接当成生产性能指标。
三种方案的选择可以这样判断: 方案适合场景主要代价 条件原子更新一次扣减或少量字段联动复杂业务流程表达能力有限 SELECT FOR UPDATE读取后还要在同一事务内执行多步数据库操作高冲突时请求排队,事务时间过长会放大锁等待 乐观锁冲突可重试、读多写少的场景重试过多会增加数据库压力 需要特别注意,原子更新只能保证这次库存变更不会越过条件线,并不等于订单、支付和消息链路全部一致。
它是库存一致性的第一层,不是整套分布式事务方案。
我在做下单和支付分离的业务时遇到过一个实际问题:用户下单后长时间不付款,如果下单就把库存算成已售,取消订单时很难解释销售数据;如果不扣库存,又会让其他用户继续购买同一件商品。库存锁定和最终扣减在数据库里应该怎样划分?
我更推荐把“可售资源被占用”和“交易已经完成”拆成两个状态,而不是用一个 stock 字段包打天下。
常见字段包括 total_stock、available_stock、locked_stock 和 sold_stock,并保持一个可校验的关系:总库存通常等于可用库存、锁定库存和已售库存之和,具体还要看是否存在损耗、在途或人工占用。
下单时只做锁定,不直接增加 sold_stock: UPDATE inventory SET available_stock = available_stock – ?, locked_stock = locked_stock + ?
, version = version + 1 WHERE sku_id = ?AND available_stock >= ?;支付成功后,再把 locked_stock 转为 sold_stock;订单超时或取消,则把 locked_stock 释放回 available_stock。
每个动作都必须带业务请求号或订单号,否则支付回调重试、取消任务重试,都可能重复确认或重复释放。我踩过的坑是只设计了“锁定库存”,却没有设计锁定记录的过期时间和状态。结果定时任务重复扫描订单时,无法判断某个锁定是否已经释放。
更稳妥的做法是建立 inventory_reservation 表,保存 order_id、sku_id、quantity、expire_at 和 status,并对订单号与动作类型建立唯一约束。
两种模型的差异很明确: 模型优点风险 下单即扣减流程短,实时库存简单取消、超时、支付失败都需要补偿 下单先锁定能区分占用和成交,适合支付耗时较长的业务需要处理过期释放、重复回调和锁定泄漏 我的选择标准不是“哪种更高级”,而是看库存是否需要跨越一个较长的业务等待期。
只要下单与支付之间可能间隔数分钟,锁定模型通常比直接视为售出更容易对账和解释。
我曾经遇到过扣减接口已经返回成功,但紧接着刷新页面却显示库存仍然充足的情况。后来发现写请求去了主库,查询请求却被路由到了只读副本;这种读写分离该怎么设计,哪些库存查询绝不能走副本?
主从复制解决的是读扩展和数据冗余,不会自动提供“写入后立刻从任意节点读到最新值”的保证。写主库成功后,复制日志还需要经过传输、落盘和回放,窗口很短时,副本仍可能返回旧库存。典型时序是:请求 A 在主库把可用库存从 1 更新为 0;请求 A 随后查询副本,副本还没有回放这次变更,于是返回 1。
如果这个查询结果还被业务代码当成“是否可以继续购买”的依据,就可能出现展示、下单和实际扣减互相矛盾。我的经验是把库存读取分成两类,而不是简单地说“查询都走副本”。是否还有库存、扣减后的确认、支付前的库存状态校验、订单提交后的强一致查询,都应优先走主库或具备明确一致性保证的节点。
商品详情页的库存展示、运营报表和非关键统计,才适合接受短暂延迟后读取副本。
可以采用以下路由规则: 读取类型建议节点允许的延迟 库存资格判断主库不接受普通副本旧读 扣减结果确认主库或同一写会话尽量保证写后读 页面库存展示副本、缓存均可通常允许短暂延迟 对账与报表副本或数仓按任务时效要求决定 我不建议为了页面上的一个库存数字,强行让所有读请求都访问主库;这样会牺牲读扩展能力。
更合理的做法是让“决定能不能卖”依赖原子扣减结果,让“给用户看的库存”明确标注为展示数据,两者不要共用同一套一致性假设。
我一开始以为数据库事务提交成功后,接口只要返回失败,客户端重试一次也没关系,后来发现库存已经扣了两次。现在订单、库存和消息又分属不同服务,我想知道幂等、消息补偿和 TCC 等方案应该按什么顺序引入?
锁解决并发冲突,事务保证一组数据库操作的原子性,幂等则解决同一个业务动作被执行多次的问题。这三者不能相互替代:即使数据库行锁使用正确,只要客户端因响应超时重复提交,后续请求仍可能再次扣减库存。
我通常会为每次库存动作生成 request_id,并在库存流水表中建立唯一约束:
CREATE TABLE inventory_deduction_log ( request_id VARCHAR(64) PRIMARY KEY, order_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, quantity INT NOT NULL, status VARCHAR(20) NOT NULL );处理请求时,先在同一本地事务中写入幂等记录,再执行库存更新。若 request_id 已存在,系统应返回之前的处理结果,而不是再次执行扣减。对于消息消费,还要结合订单号、消息编号和状态机,避免“消息重试一次、库存动作执行一次”的错误假设。
我建议按复杂度递增的顺序设计: 业务边界优先方案原因 订单和库存同库本地事务加条件更新链路短,排障和回滚最简单 跨服务但允许短暂不一致本地消息表或 Outbox 加重试对账实现成本低于强协调方案 必须预留并协调多个服务资源TCC 或 Saga需要明确确认、取消和补偿边界 只有在确实跨越多个数据库或资源系统时,我才会评估 TCC、Saga 等方案。
引入它们之前,必须先解决幂等、空回滚、悬挂、补偿失败和告警,否则只是把一个简单的本地一致性问题升级成更难排查的分布式状态问题。生产上还应保留库存流水、订单状态日志和定时对账任务。
我的判断标准是:系统不仅要能在正常路径上扣对库存,还要能回答“这次库存为什么变化、失败后是否补偿、补偿是否成功、最终是否需要人工介入”。


读者评论
文章把库存扣减、库存锁定和最终售出区分得比较清楚,尤其是强调条件更新与受影响行数,对避免“先查后改”导致的超卖很有帮助。
主从复制旧读的案例很贴近实际。很多系统确实会把副本查询结果误当成最新状态,文章对展示查询和关键业务判断的边界说明得比较准确。
关于支付超时和重复请求的部分值得关注。库存事务已经提交但接口返回失败时,如果没有幂等号,重试确实可能造成重复扣减,这个风险容易被忽略。
文章没有把分布式锁或分布式事务当成万能方案,而是强调数据库原子更新、状态机、补偿和对账的组合。不过实际落地时,还需要补充监控指标和故障演练细节。