《数据库存:产品技术团队复盘框架:库存扣减如何定位并发冲突》真正要解决的,不是“库存系统应该加哪一种锁”,而是当两笔订单都显示成功、库存流水却只记录一笔时,团队如何用请求、事务、锁、幂等和消息证据还原现场。我的判断是:库存异常复盘最容易犯的错误,是先讨论方案,后寻找事实;正确顺序应当是先确认异常口径,再重建并发时间线,最后根据失效机制选择修复手段。
很多团队把超卖理解为库存字段出现负数。但在实际系统中,库存不为负并不代表没有超卖。只要实际成交订单数超过了可售数量,或者两个订单都拿到了“扣减成功”的业务结果,系统就已经出现了库存控制失效。
因此,我在复盘时不会先问“库存最后是多少”,而会先问三个问题:哪一笔请求被判定为成功?判定成功时数据库是否真正完成提交?同一个业务动作是否可能被执行了两次?这三个问题分别对应数据库更新结果、事务边界和幂等控制。
库存异常的本质,通常不是减法算错,而是系统在错误的时间、基于错误的状态,确认了一次业务成功。
“库存不准”是业务描述,不是技术结论。它至少可以拆成超卖、少卖、重复扣减、库存账实不一致和回补错误五类。不同类型的证据完全不同,如果一开始分类错误,后续很容易把幂等问题误判为并发问题。
| 异常类型 | 业务表现 | 优先检查对象 | 常见根因 |
|---|---|---|---|
| 超卖 | 实际成交量超过可售库存 | 库存扣减 SQL、事务、成功判断 | 先查后改、条件更新失效、缓存预扣与落库不一致 |
| 少卖 | 有库存却大量下单失败 | 锁等待、重试、热点 SKU | 锁竞争、超时、错误回滚或过度预占 |
| 重复扣减 | 同一个订单或请求扣了两次 | 业务幂等键、重试记录、消息消费 | 网络超时重试、重复消费、唯一约束缺失 |
| 账实不一致 | 订单、库存表、流水和缓存数字不同 | 数据链路、异步消息、补偿任务 | 消息失败、缓存覆盖、事务拆分 |
| 回补错误 | 取消或退款后库存变多或变少 | 释放逻辑、状态机、幂等控制 | 重复回补、状态逆转、补偿任务重复执行 |
这张分类表的价值在于,它把“库存异常”转换成了可以查询的证据。比如,数据库库存只扣了一次,但同一订单产生了两笔成功记录,重点就不应该放在数据库锁,而应转向订单创建幂等和重复请求。

一次可复用的库存复盘,不能只产出一页事故总结。至少要有异常订单清单、库存流水对账表、技术时间线、关键 SQL 或执行计划、缓存和消息记录、根因分类表,以及带负责人和验收指标的整改清单。
为了理解并发冲突,不需要先搭建复杂的促销系统。设某个 SKU 初始可售库存为 1,用户 A 和用户 B 在几乎同一时刻提交订单。两个请求都查询到库存为 1,随后分别执行扣减和订单创建。
如果扣减语句是“把库存减 1”,却没有把“库存必须大于等于购买数量”写入更新条件,数据库只会执行一个数值变化动作,并不会替业务层判断这次购买是否合法。即使库存最终为 0,也不能说明其中一笔订单没有越过可售边界。
下面的时序只是简化示意,但它非常适合在复盘会上帮助产品、开发和测试统一语言:
时间 ──────────────────────────────────────────────>
请求 A:读取 stock=1 ──> 业务判断充足 ──> 扣减 ──> 创建订单成功
请求 B:读取 stock=1 ──> 业务判断充足 ──> 扣减 ──> 创建订单成功
数据库: stock=1 ──> stock=0
业务结果: 可能出现两笔“成功”
这里要特别谨慎:仅凭“两个请求都读到 1”,还不能证明两个数据库更新都成功。下一步必须检查更新影响行数、事务提交结果和订单幂等记录。并发现场需要证据闭环,不能用一张时序图直接代替数据库事实。
很多复盘争论并非源于代码,而是源于大家讨论的库存不是同一件事。产品可能说“页面显示还有 10 件”,仓储说“实物只剩 6 件”,订单服务说“可售库存为 8 件”,数据库库存表则记录“现货库存 10、锁定库存 2”。如果不先统一口径,任何技术结论都可能建立在错误对象上。
| 库存口径 | 含义 | 典型变化时机 | 复盘风险 |
|---|---|---|---|
| 实际库存 | 仓库或门店当前可盘点数量 | 入库、出库、盘点 | 不一定等于线上可售数量 |
| 可售库存 | 系统允许用户继续购买的数量 | 扣减、释放、风控冻结 | 最接近下单判断,但规则最复杂 |
| 锁定库存 | 已经被订单占用但尚未完成最终交易的数量 | 下单、支付超时、取消 | 释放失败会形成少卖 |
| 预占库存 | 为活动、渠道或批次提前保留的数量 | 活动开始、渠道分配 | 容易与普通库存重复计算 |
| 缓存库存 | 用于快速读取或高并发扣减的副本 | 预扣、刷新、重建 | 可能滞后或被旧数据覆盖 |
我建议复盘的第一张白板不要画锁,而要先写出库存公式。例如:可售库存等于实际库存减去锁定库存,再减去渠道预占库存。只要这个公式没有经过产品、仓储和研发共同确认,技术团队就无法判断究竟是并发冲突,还是库存口径本身重复计算。
单线程测试通常验证的是“输入 1,库存从 10 变成 9”。它没有覆盖两个请求共享同一旧状态、请求超时触发重试、事务提交晚于接口响应、消息重复消费等真实线上条件。
库存并发问题往往还具有热点特征。普通 SKU 可能每分钟只有几次扣减,限量商品却可能在几十秒内接收数千次请求。平均吞吐量看起来不高,并不能掩盖某一个 SKU 行上的锁竞争和版本冲突。

负库存确实可能由并发更新造成,但也可能来自回补重复执行、初始化数据错误、批量导入覆盖、库存单位换算错误或人工修正。反过来,没有负数也可能存在超卖,因为订单成功状态和库存字段并不总是由同一个事务决定。
如果团队把“负库存”直接等同于“缺少悲观锁”,就会忽略更关键的事实:当前 SQL 是否带有库存条件?业务层是否正确判断影响行数?订单创建和库存扣减是否允许部分成功?这些问题通常比“选哪种锁”更接近根因。
事务可以保证一组数据库操作满足特定的原子性和隔离要求,但它不会自动把跨服务调用、缓存写入、消息发送和外部支付纳入同一个安全边界。即使库存扣减事务本身没有问题,订单服务重试或消息重复消费仍可能产生业务重复。
还要看事务覆盖了什么。如果库存查询在事务 A 中,订单创建在事务 B 中,或者库存扣减完成后事务提前提交,后续业务失败时没有补偿,那么“用了事务”只是技术名词,不是有效保护。
分布式锁只能保护获得锁的那一段代码,而且前提是所有修改库存的入口都遵守同一把锁。如果后台补库存、定时释放、人工修正或另一个服务绕过锁直接更新数据库,系统仍然可能出现并发冲突。
锁还会带来新的成本:锁服务故障、锁过期、业务执行超时、客户端重入、锁粒度过大和热点排队。复盘时必须回答“锁保护了哪一个资源、从什么时候开始保护、什么时候释放、异常退出时如何处理”,否则“已经加锁”没有分析价值。
条件更新可以避免库存被扣到不允许的范围,但它不能识别同一业务请求是否已经处理过。用户点击两次、网关超时重试、消息重复消费,都会让同一个订单产生两次扣减尝试。
因此,安全扣减至少需要两层控制:数据库条件负责判断库存状态,业务幂等负责识别同一业务动作。前者回答“还有没有库存”,后者回答“这次动作是不是已经做过”。
应用日志中的“执行扣减成功”可能只是代表 SQL 发起成功,并不代表数据库真的更新了一行。对于条件更新和乐观锁,影响行数为 0 可能是库存不足,也可能是版本冲突;如果代码没有区分这两类结果,业务就可能错误返回成功或错误提示。
我在复盘中会把“SQL 执行成功”和“业务扣减成功”明确分成两列。只有数据库提交成功、影响行数符合预期、库存流水写入成功、业务状态转换成功,才可以把它定义为一次完整扣减。

“活动库存被抢空了”“用户说买到了但仓库没货”“页面还有库存却下不了单”,这些都是入口描述。复盘负责人需要把它们改写成带有时间和对象的命题。
命题越具体,排查越容易。例如“某 SKU 在 10:00:00 至 10:00:03 产生 18 笔订单成功,但库存流水只有 12 笔扣减”,就比“库存系统有问题”更有行动价值。
最小排查单元不是一条日志,而是一条业务链。建议用订单号、请求 ID、SKU、扣减流水号和消息 ID,把订单、库存、支付、缓存和消息记录关联起来。
| 字段 | 排查目的 | 异常信号 |
|---|---|---|
| order_id | 确认订单是否重复创建 | 同一业务单号对应多个库存动作 |
| request_id | 追踪一次 HTTP 或 RPC 调用 | 同一请求 ID 被处理多次 |
| deduct_id | 区分每次库存变更 | 扣减流水缺失或重复 |
| affected_rows | 确认数据库实际更新行数 | 影响行数为 0 却返回成功 |
| version | 识别乐观锁冲突 | 版本过期但业务未失败 |
| transaction_id | 还原事务边界和提交关系 | 库存和订单不在同一事务或缺少补偿 |
| retry_count | 识别超时后的重复执行 | 同一业务动作多次重试 |
如果系统现在没有这些字段,不要把“日志不全”写成复盘结论就结束。它应该转化为一项可执行整改:为库存扣减增加统一业务流水号,并确保每一条库存变更都能关联到订单、请求和操作来源。
并发问题的关键不只是“谁先到达服务”,而是“谁先读、谁先拿锁、谁先写、谁先提交”。应用日志时间、数据库执行时间和消息消费时间可能来自不同机器,必须确认时区、时钟同步和时间戳含义。
还原时建议至少记录以下节点:
当两笔请求的时间线重叠时,还需要判断它们是否真正操作了同一条记录。不同仓库、不同批次、不同库存表或不同 SKU 映射,都可能让表面上相同的商品实际落到不同资源上。

推荐优先检查库存更新语句,而不是先浏览大量业务日志。一个安全性较高的单行库存扣减,通常会把库存条件直接放入更新语句,并以影响行数作为结果依据:
UPDATE inventory SET stock = stock - :quantity, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND stock >= :quantity;
如果返回影响行数为 1,通常表示本次条件满足且记录发生了更新;如果返回 0,则至少说明本次更新没有完成,原因可能是库存不足、SKU 不存在、条件不匹配或事务状态异常。应用不能把“SQL 没抛异常”直接等同于“库存扣减成功”。
需要版本控制时,可以将版本号加入条件:
UPDATE inventory SET stock = stock - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND version = :expected_version AND stock >= :quantity;
这类语句的重点不是版本号本身,而是系统是否正确处理版本冲突。版本不匹配时,应该明确返回“库存状态发生变化,需要重新判断”,而不是无条件重试。没有最大重试次数的乐观锁,可能把一次业务冲突放大成数据库压力。
库存扣减的安全边界至少包括库存读取、库存判断、库存更新和扣减结果确认。如果业务还要求订单和库存必须同步成功,那么订单状态写入也必须被纳入同一事务,或者通过可靠消息和补偿机制建立明确的一致性方案。
需要重点检查以下代码和配置:
“事务边界清晰”不等于“所有操作都放进一个超长事务”。事务过长会增加锁等待、死锁和连接占用。我的判断原则是:把必须原子完成的数据库动作放在最小事务中,把外部调用放到事务外,并用幂等、消息和补偿处理跨系统状态。
| 方案 | 主要解决的问题 | 优势 | 代价与边界 |
|---|---|---|---|
| 条件更新 | 单行库存原子判断与扣减 | 实现简单、数据库约束明确 | 不能单独解决重复订单和跨系统一致性 |
| 乐观锁 | 识别读取后的版本变化 | 冲突结果清楚,不需长时间持锁 | 热点 SKU 重试会放大压力 |
| 悲观锁 | 保护多步读取、判断和更新 | 临界区控制直观 | 锁等待、死锁和事务时长需要治理 |
| 按 SKU 串行化 | 极端热点资源的顺序处理 | 冲突可控,状态顺序清晰 | 会牺牲实时性,需要处理积压和失败 |
| 幂等约束 | 重复请求和重复消费 | 能阻止同一业务动作重复生效 | 需要稳定的业务唯一键和状态设计 |

下面采用脱敏的情景案例进行说明。某零售团队在一次限量活动后发现:页面显示某款商品已经售罄,但订单系统中有 1,248 笔支付成功订单,库存流水显示扣减 1,236 笔,仓储系统最终可履约数量少于应发数量。
这组数据不能直接证明“有 12 笔并发超卖”。因为支付成功订单与库存扣减流水之间的差额,也可能来自消息延迟、流水写入失败、订单重复、人工修正或仓储分配尚未完成。复盘的第一步,是把差额拆成可验证的业务集合。
| 观察项 | 数量 | 初步含义 |
|---|---|---|
| 支付成功订单 | 1,248 笔 | 业务侧已经确认交易成功的订单数 |
| 库存扣减流水 | 1,236 笔 | 库存服务记录的扣减动作数 |
| 流水缺失订单 | 12 笔 | 需要继续核验事务、消息和补偿,不等于已确认超卖 |
| 重复请求订单 | 7 笔 | 存在客户端或网关重试迹象 |
| 消息延迟订单 | 5 笔 | 库存流水可能尚未完成异步落库或对账 |
在这个案例中,12 笔差额最终被拆成 7 笔重复请求线索和 5 笔消息延迟线索。它们仍然需要进一步核验,但至少说明“支付订单减库存流水”不能直接当作并发冲突数量。

进一步检查库存服务的代码后,团队发现接口日志统一打印“库存扣减完成”,但没有记录数据库影响行数。原有逻辑大致如下:
SELECT stock FROM inventory WHERE sku_id = :sku_id; if stock >= quantity: UPDATE inventory SET stock = stock - :quantity WHERE sku_id = :sku_id; return success;
这段逻辑有两个明显风险。第一,查询和更新之间存在竞态窗口。第二,更新语句没有再次确认库存条件,应用层判断的 stock 可能已经过期。更严重的是,代码没有检查更新是否真正影响了一行,也没有把扣减流水的唯一键与订单绑定。
修复后的最小逻辑应当至少做到:库存条件下沉到更新语句、根据影响行数判断结果、用订单号或扣减业务号建立幂等约束,并在失败时明确区分库存不足、版本冲突和系统异常。
UPDATE inventory SET stock = stock - :quantity, version = version + 1 WHERE sku_id = :sku_id AND stock >= :quantity; if affected_rows == 1: record_deduct_event_once(order_id, sku_id, quantity); return success; else: return stock_insufficient_or_conflict;
这段示例仍然不是完整架构方案。若订单创建、支付和库存扣减分属不同服务,还需要说明事件如何可靠发送、重复消费如何处理,以及库存扣减成功但订单创建失败时怎样补偿。
在该情景案例中,7 笔重复请求的共同特征是:同一个订单号在 800 毫秒内出现两次扣减调用,第一次请求在网关处超时,第二次请求由客户端或网关自动重试。库存服务没有稳定的幂等键,只把每次调用都视为新请求。
这类问题常被误认为“两个用户同时抢同一个库存”。实际上,它更接近同一个业务动作被执行两次。即使把数据库锁设计得非常严密,如果没有唯一约束或幂等记录,重复请求仍然会不断进入扣减流程。
建议将“请求唯一性”和“资源并发性”分开处理:
剩余 5 笔订单在库存表中暂时找不到流水,但消息系统显示扣减事件已投递,消费端存在延迟。这里不能因为对账时没有查到流水,就直接补扣库存,否则可能造成重复扣减。
正确的操作是先给异常记录打上“待核验”状态,查询消息消费结果、消费重试次数、消费者提交位点和落库事务。只有确认消息丢失、消费失败且不存在已完成扣减时,才允许进入补偿流程。补偿本身也必须幂等,不能让一次人工处理变成第二次库存事故。

对单行库存而言,最优先检查的是“判断和扣减是否在数据库的一次更新中完成”。下面这种先查询、后更新的写法,需要特别警惕:
SELECT stock FROM inventory WHERE sku_id = :sku_id; UPDATE inventory SET stock = stock - :quantity WHERE sku_id = :sku_id;
如果更新语句没有包含 stock 大于等于购买数量的条件,那么应用层拿到的库存判断可能已经过期。更稳妥的方式是:
UPDATE inventory SET stock = stock - :quantity WHERE sku_id = :sku_id AND stock >= :quantity;
但这仍然只是数据库层面的第一道防线。团队还要检查影响行数是否被读取、是否在异常分支错误返回成功,以及扣减流水是否与这次更新处于正确的事务关系中。
库存更新通常按 SKU 或 SKU 加仓库维度定位记录。若查询条件没有对应索引,数据库可能扫描更多记录,锁范围扩大,锁等待时间上升。对于需要锁定记录的语句,索引不仅影响速度,也会影响并发时的资源竞争范围。
排查时应保留执行计划,而不是只看代码中的 SQL 字符串。需要确认:
悲观锁适合“先读取、再判断、再进行多步更新”的场景。例如,扣减动作需要同时调整可售库存、锁定库存和库存明细,且这些字段必须在一个事务中完成,那么在事务内锁定正确的记录可能更直观。
BEGIN; SELECT stock, locked_stock FROM inventory WHERE sku_id = :sku_id FOR UPDATE; -- 在事务内完成库存判断、扣减和锁定数量调整 UPDATE inventory SET stock = stock - :quantity, locked_stock = locked_stock + :quantity WHERE sku_id = :sku_id; INSERT INTO inventory_flow (deduct_id, sku_id, quantity, flow_type) VALUES (:deduct_id, :sku_id, :quantity, 'DEDUCT'); COMMIT;
这里的关键不是出现了 FOR UPDATE,而是查询、更新和流水写入是否真的在同一个事务中完成。如果 SELECT 在事务中,后续 UPDATE 在事务外,锁已经释放;如果锁定的是明细记录,更新却写入汇总记录,也可能保护错对象。
事务隔离级别决定了并发事务之间的可见性和部分锁行为,但它不能代替业务幂等,也不能自动修复跨服务一致性。很多库存事故的直接原因,是代码没有检查影响行数或重试没有唯一键,而不是隔离级别选错。
复盘中可以记录隔离级别,但不要把它写成没有证据的根因。只有当团队能够用最小复现程序证明:在当前数据库、存储引擎、索引和隔离配置下,某种读写顺序导致了错误结果,隔离级别才具备根因解释力。

产品团队需要把库存规则说清楚,而不是只提交“库存不准确”的反馈。至少要确认下单时扣减还是支付时扣减,取消订单是否释放,支付超时如何处理,以及一个订单拆成多个仓库时库存成功的业务定义。
如果商品允许超卖预售,或者库存本身是展示库存而非履约库存,也必须在复盘中明确。技术团队不能在没有业务规则的情况下,擅自把所有差额都当作事故。
研发的复盘材料应当回答四个问题:哪个请求在什么状态下读取了数据?哪个 SQL 或消息动作改变了数据?系统依据什么返回成功?为什么原有保护机制没有阻止错误结果?
“已增加锁”“已优化 SQL”“已增加重试”都不是完整结论。完整结论必须说明保护对象、有效边界、失败处理和验收方式。例如:“将库存条件下沉到更新语句,并把影响行数为 0 作为扣减失败;通过单 SKU 500 并发、重复请求和消息重复消费三组测试验证。”
库存测试至少应覆盖正常扣减、库存刚好够用、库存不足、重复提交、接口超时重试、消息重复消费、取消回补、服务重启和数据库异常。高并发测试还要特别关注热点 SKU,而不是只用大量不同 SKU 平均分散流量。
| 测试场景 | 应观察的结果 | 通过标准 |
|---|---|---|
| 库存为 1,并发请求 2 次 | 成功订单、扣减流水、最终库存 | 最多一笔扣减成功,结果可解释 |
| 同一订单重复提交 3 次 | 幂等记录、库存流水数量 | 只产生一次有效扣减 |
| 扣减成功后订单服务超时 | 订单状态、消息状态、补偿状态 | 最终状态可恢复,补偿不重复 |
| 消息重复投递 | 消费者处理次数和业务变更次数 | 可重复消费但业务只生效一次 |
| 取消订单重复回补 | 释放流水和库存变化次数 | 一次取消最多释放一次 |
库存异常发生后,日志采样、数据库审计和消息记录不能立即被滚动覆盖。运维需要明确关键表的保留周期,保证能够回看事故窗口内的请求、事务、锁等待和消费记录。
监控也应从单一的“库存小于 0”扩展到业务链路。建议关注库存扣减失败率、影响行数为 0 的比例、锁等待时间、死锁次数、订单与库存流水差额、重复请求比例、回补失败率和补偿任务积压。

第一优先级不是立刻重构库存服务,而是停止异常继续扩散。可以根据业务允许程度暂停热点 SKU、关闭活动入口、降低限流阈值、停止有风险的补偿任务,并保留当前数据库、缓存和消息现场。
止损动作必须记录开始时间和结束条件。临时关闭入口虽然能够降低新增异常,但也可能造成少卖和用户体验损失,因此不能把“暂停销售”当作长期方案。
此时要优先检查回补、批量导入、人工修正和库存单位换算。若订单数量与扣减流水一致,负库存可能来自初始化错误或释放逻辑,而不一定是两个并发请求同时扣减造成的。
可以按 SKU、仓库和操作来源拆分库存变化。尤其要检查是否有定时任务在订单取消后重复执行,或者某个接口同时修改了库存汇总和明细,导致汇总值被重复扣减。
这类现象更像锁竞争、数据库连接池耗尽、热点 SKU 排队或下游响应变慢。不要直接通过无限重试提高成功率。重试会让同一时间窗口内的请求数量增加,甚至把锁等待进一步放大。
建议先观察锁等待、数据库 CPU、连接池占用、单 SKU 请求分布和重试次数,再决定是否限流、缩短事务、调整索引或将热点资源改为队列化处理。
应优先修复幂等约束。数据库层可以为业务扣减号建立唯一索引,应用层则需要在重复请求到达时返回第一次处理结果,而不是简单返回“重复请求失败”。如果业务处于处理中状态,还要有明确的查询和恢复机制。
CREATE UNIQUE INDEX uk_inventory_deduct
ON inventory_flow (deduct_id);— 处理逻辑:
— 1. 先按 deduct_id 查询是否已成功
— 2. 已成功则返回原结果
— 3. 未处理才执行库存条件更新
— 4. 将库存流水和业务状态写入事务
先确认谁是库存事实源。若数据库是主事实源,缓存应通过明确的刷新、失效或事件机制跟随数据库变化;若缓存承担预扣职责,则必须定义落库失败、缓存重建、消息丢失和回滚的处理路径。
不要在没有核对流水的情况下直接用数据库覆盖缓存,也不要在缓存显示有库存时直接判定数据库错误。正确做法是先导出两个数据源的版本、更新时间和变化来源,再决定重建还是补偿。

如果库存扣减只是单行减法,条件更新通常是更小、更容易审计的安全边界。数据库直接判断 stock 是否足够,应用根据影响行数返回结果,适合多数普通库存场景。
如果扣减需要同时读取多个字段、更新多张库存表、写入流水并执行复杂状态判断,悲观锁可能更易于保证临界区内的一致性。但它要求事务足够短、索引正确、加锁顺序统一,否则性能成本可能超过收益。
| 判断条件 | 更适合条件更新 | 更适合悲观锁 |
|---|---|---|
| 业务动作 | 单 SKU、单行、单次扣减 | 多字段或多记录协同更新 |
| 热点程度 | 低到中等冲突 | 需要严格控制临界区 |
| 延迟目标 | 希望快速失败 | 允许短时间排队 |
| 开发复杂度 | 更低 | 需要精确设计事务和锁顺序 |
| 主要风险 | 错误处理影响行数 | 锁等待、死锁、事务过长 |
乐观锁适合冲突率可控、失败后可以快速返回或有限重试的业务。它的优势是冲突可量化,版本不匹配可以直接产生监控指标。但如果一个限量 SKU 在短时间内承受大量竞争,失败重试会让数据库承受更多无效请求。
队列化或按 SKU 串行化适合极端热点资源。它把并发竞争转化为排队,通常能降低数据库瞬时压力,但用户需要接受排队等待,系统还要处理消息积压、顺序、重复和失败恢复。

缓存预扣可以吸收高峰流量,减少数据库热点行的直接竞争,但它会把库存一致性问题从数据库内部扩展到缓存、消息、落库和补偿链路。只有当团队具备可靠事件、幂等消费、对账和恢复能力时,缓存预扣才值得采用。
数据库主扣减的链路更容易理解和审计,适合库存规模和流量仍在数据库可承受范围内的系统。它的缺点是高峰时数据库会成为瓶颈,需要通过热点拆分、限流、批量策略或队列缓冲解决。
库存控制不是纯技术问题。有些业务愿意接受少量预售,用换货、延期发货或补偿换取转化率;有些业务对库存准确性极其敏感,例如门店即时履约或稀缺资源预约。不同业务不能共用一套成功定义。
团队需要把取舍写进规则:什么情况下允许预售,什么情况下必须硬失败,库存不足时是排队、候补还是直接拒绝,支付成功但无法履约时如何赔付。只有规则明确,技术指标才有意义。
| 项目 | 填写内容 |
|---|---|
| 发生时间 | 精确到分钟,必要时精确到秒 |
| 影响对象 | SKU、仓库、渠道、订单和用户范围 |
| 异常类型 | 超卖、少卖、重复扣减、账实不一致或回补错误 |
| 发现方式 | 用户反馈、库存告警、订单对账或仓储拦截 |
| 业务影响 | 受影响订单数、商品数量、金额和履约影响 |
| 恢复状态 | 止损时间、修复时间、数据校正时间和遗留风险 |
事故摘要要尽量使用事实数字,不要使用“影响较大”“大量用户”“系统不稳定”等没有边界的描述。如果数量尚未确认,应明确标注“待核验”,不要为了让报告看起来完整而填入未经验证的数字。
根因分类不等于责任归属。一个“没有幂等键”的问题,往往同时涉及接口设计、数据库约束、测试覆盖和监控缺失。复盘应当找出机制缺口,而不是把所有结论压缩成某个人改错了一行代码。
| 整改项 | 负责人 | 截止时间 | 验收指标 | 回滚方案 |
|---|---|---|---|---|
| 将库存条件下沉到更新 SQL | 库存服务负责人 | 具体日期 | 影响行数为 0 时不再返回扣减成功 | 保留旧接口开关,灰度切换 |
| 增加扣减业务号唯一约束 | 订单服务负责人 | 具体日期 | 重复请求只产生一条有效流水 | 先影子校验,再启用约束 |
| 补齐锁等待和重试监控 | 运维负责人 | 具体日期 | 异常发现时长从分钟级降到目标范围 | 保留原告警规则 |
| 增加单 SKU 热点压测 | 测试负责人 | 具体日期 | 并发、重复提交和消息重试场景通过 | 在预发布环境回归 |

库存系统压测的关键不是每秒处理了多少请求,而是高并发下业务结果是否仍然可解释。至少需要同时统计成功扣减数、失败扣减数、最终库存、库存流水数、重复流水数、订单状态和补偿数量。
例如,初始库存为 100,发送 1,000 次购买 1 件的请求,理论上最多只能有 100 次有效扣减。若系统允许预售,需要明确预售数量和履约状态;若不允许预售,成功订单数、有效扣减流水数和最终库存必须满足预先定义的关系。
每组实验都要固定 SKU 数量、初始库存、购买数量、请求分布和重试策略。否则不同方案之间的结果无法比较。尤其要区分平均延迟和 P95、P99 尾延迟,库存热点问题往往首先表现为少量请求极慢。
库存系统最有价值的测试不是某个接口返回 200,而是业务不变量没有被破坏。常见不变量包括:有效扣减总量不能超过可售库存;同一扣减业务号最多产生一条有效流水;一次取消最多释放一次;订单状态和库存状态之间存在可追溯关系。
这些不变量可以在压测结束后通过 SQL 或离线脚本校验。校验结果应进入发布验收,而不是只由测试人员在报告中描述“未发现明显问题”。
-- 示例:检查同一扣减业务号是否重复生效 SELECT deduct_id, COUNT(*) AS flow_count FROM inventory_flow WHERE created_at >= :start_time AND created_at < :end_time AND flow_type = 'DEDUCT' GROUP BY deduct_id HAVING COUNT(*) > 1; -- 示例:检查扣减后库存是否低于允许下限 SELECT sku_id, warehouse_id, stock FROM inventory WHERE stock < 0;
修复后,条件更新可能让库存不足或版本冲突的失败数量上升。这不一定是系统变差,可能是原来被错误判定为成功的请求,现在被正确拒绝了。判断修复是否有效,不能只看成功率下降,而要看错误是否被准确分类、用户是否得到合理反馈、异常订单是否减少。

如果这五个问题没有回答清楚,报告即使写了锁、缓存、队列、事务和最终一致性,也仍然可能是一篇技术名词齐全、但无法指导行动的总结。
库存系统真正需要长期建设的,不只是某一个并发控制方案,而是一条可审计的证据链:每一次扣减都有业务号,每个业务号都能关联订单和请求,每次数据库更新都有影响行数,每条消息都有投递与消费状态,每次补偿都有唯一记录。
这条证据链的意义在于,系统出问题时,团队不必依赖某个开发人员记得代码细节,也不必通过猜测判断是数据库、缓存还是消息出了问题。事实会沿着业务号和时间线被重新拼起来。
我的最终判断是:库存并发治理的第一步不是加锁,而是让系统能够证明每一次成功为什么成功。当产品规则、数据库结果、业务幂等、消息状态和补偿记录可以被同一条链路串起来,团队才有能力区分真正的并发冲突、重复执行、异步延迟和库存口径错误,也才能在性能、一致性和用户体验之间做出有依据的取舍。
我遇到过库存没有变成负数,但实际成交量已经超过可售库存的情况。最初大家都认为是数据库并发冲突,后来发现其中一部分请求是客户端超时后的自动重试,另一部分才是真正的扣减竞态。我想知道,复盘时应该用什么证据把这两类问题区分开?
不要先看库存字段是否小于 0,而要先把订单、扣减流水和请求记录放到同一张对照表里。库存为 0 只能说明某个时刻的字段结果,不能证明只有一笔业务成功,也不能证明每次扣减都只执行了一次。我在一次脱敏并发压测中,用初始库存 1、并发请求 2 的场景复现过两种完全不同的异常。
第一种是两个请求都读取到库存 1,随后都执行扣减;第二种是同一个请求因为超时被重试两次,订单接口没有幂等控制,最终产生两笔业务记录。两者表面上都像“超卖”,但修复方向完全不同。
现场表现优先判断重点证据 两个 request_id 不同,读取版本相同并发竞态事务时间线、版本号、锁等待 同一 request_id 对应多次扣减重复请求或重复消费幂等键、重试次数、消息消费记录 只扣一次但订单有两笔订单创建幂等失效订单唯一索引、业务状态变更日志 缓存扣减两次,数据库扣减一次缓存与数据库不一致缓存写入、落库消息、补偿任务 实操时至少关联五个字段:order_id、request_id、deduct_id、sku_id 和幂等键。
再补充请求开始时间、SQL 执行时间、事务提交时间、affected_rows 和 retry_count,才能判断是两个请求同时竞争,还是一个请求被重复执行。我的判断标准是:如果两个独立请求在同一库存版本上竞争,且数据库更新结果与业务成功结果一致性异常,才优先归类为并发冲突;
如果同一个业务标识出现多次执行,则先查幂等和重试。只有先完成分类,后续选择条件更新、锁或消息去重才不会治错问题。
我们线上出现过订单成功、库存流水却缺失的情况,应用日志里只有一句“扣减成功”,很难还原当时发生了什么。我不确定是日志字段不够,还是数据库事务边界有问题。想请教一套实际排查顺序,避免团队一上来就凭猜测修改代码。
我排查这类问题时,不会从某一条报错日志开始,而是先建立一条以扣减流水为中心的事件时间线。因为应用日志里的“成功”可能只是进入了后续流程,并不一定代表数据库事务已经提交。第一步是锁定异常样本,至少取出一个库存异常 SKU、一个受影响订单和一个相邻的正常订单。
随后按 request_id、order_id、deduct_id 和 sku_id 做交叉查询,确认它们是否真的属于同一次业务操作,而不是多个异步流程被错误拼接。
排查阶段要查的内容要回答的问题 业务层订单状态、扣减流水、取消记录到底有几次业务成功 应用层请求日志、重试日志、异常栈是否发生超时或重复调用 数据库层SQL、affected_rows、事务提交与回滚数据库是否真的完成扣减 并发层锁等待、死锁、执行计划是否存在锁竞争或锁范围异常 消息层生产、投递、消费、确认记录是否重复消费或消费失败 第二步是还原时间线。
假设请求 A 在 10:00:00.120 读取库存,请求 B 在 10:00:00.126 读取库存,A 在 10:00:00.145 更新,B 在 10:00:00.150 返回成功,那么还要继续确认 A、B 是否读取了同一个版本、更新影响行数分别是多少,以及返回成功时事务是否已经 commit。
第三步是检查事务边界。我重点看三个容易被忽略的位置:库存查询是否在事务内、库存更新后是否提前提交、订单创建和消息发送是否依赖一个已经结束的事务。很多团队以为使用了行锁就安全,但如果锁在库存更新后就释放,后面的订单创建仍然可能失败或重复。
建议把日志验收标准写进整改项:每次扣减必须记录业务幂等键、库存变更前后值、版本号、affected_rows、事务结果和重试次数。日志不是越多越好,而是要能够回答“谁在什么时候,以什么版本,实际改变了多少库存”。
团队讨论库存问题时,经常把“加悲观锁”“改成乐观锁”“放到消息队列”当成三个答案,但没有人说明各自解决的到底是哪一类风险。我想知道在单 SKU、高热点商品和复杂订单流程下,应该如何做技术选型,而不是照搬某个架构方案。
我的经验是,库存方案不能按技术名词选择,而要按临界区的形状选择。单行库存的原子扣减、需要多步校验的库存交易、极端热点 SKU,以及跨服务的最终一致性问题,实际上是四个不同问题。
对于单 SKU、单仓、一次扣减的场景,我通常优先考虑条件更新,因为它把库存判断和扣减合并成数据库的一次原子操作:
UPDATE inventory SET stock = stock - ?WHERE sku_id = ?AND stock >= ?;此时必须把 affected_rows = 1 作为扣减成功的唯一依据。不能因为 SQL 没有抛异常,就把业务标记为成功;库存不足时,条件不满足同样可能只是影响行数为 0。
方案更适合的场景主要风险我的判断 条件更新单行库存原子扣减后续订单与库存不一致默认优先尝试 乐观锁冲突可检测且允许失败重试热点商品重试放大压力先测冲突率再采用 悲观锁读取、校验、扣减必须连续完成锁等待、死锁、延迟抖动控制事务长度和锁范围 队列串行化极端热点 SKU 或需要顺序处理消息积压、重复消费、用户等待适合牺牲实时性换稳定性 乐观锁不是“更高级的锁”。
我曾在热点 SKU 压测中看到,冲突率超过 30% 后,失败重试带来的数据库请求量明显高于原始流量,接口平均延迟没有下降,反而因为重试出现尖峰。因此,采用乐观锁时必须设置最大重试次数,并让重试和业务幂等绑定。
悲观锁适合必须先读取、再校验多个字段的场景,但要确认查询命中正确索引,事务内不能包含远程调用、支付请求或长时间计算。队列则适合把同一 SKU 的请求排队处理,但它并不会自动解决消息重复、消费失败和库存回补问题。
选型前至少做三组压测:库存为 1 的极限竞争、热点 SKU 占比 20% 的混合流量、扣减成功后订单服务故障。比较成功率、锁等待、重试次数、消息积压和数据对账差异,通常比争论哪种方案“更可靠”更有决策价值。
以前参加库存事故复盘时,会议很容易变成“某个接口没加锁”或“测试覆盖不够”的结论,开发、产品和测试各自解释一遍,最后只留下几条没有截止时间的任务。我想建立一套不追责个人、但能明确机制缺陷和验收标准的复盘框架。
有效复盘的关键不是把所有人召集起来,而是让讨论始终围绕“事实、失效机制、验证方式”展开。我通常把会议拆成会前准备、现场还原和会后验收三个阶段,避免一开始就讨论责任归属或直接拍板技术方案。会前先准备四类材料:业务影响、技术时间线、异常数据和变更记录。
业务影响要写清受影响 SKU、订单数、超卖或少卖数量;技术时间线要精确到请求、事务、消息和恢复节点;异常数据则必须包含订单、扣减流水、库存快照和缓存状态。
阶段必须产出合格标准 会前异常样本、时间线、变更清单至少能还原一笔异常订单 会中事实与推测分离每个判断都有日志或数据支撑 根因确认失效机制说明哪个保护机制为何没有生效 会后整改任务与验收指标有负责人、截止时间和回滚方案 会议现场建议按这个顺序提问:发生了什么?哪些事实已确认?哪些仍是推测?
本来应该阻止问题的机制是什么?它在哪个环节失效?修复后如何证明它生效?例如,“库存扣减接口没有加锁”不是完整根因,完整结论应该是“更新语句未携带库存条件,且影响行数未参与成功判断,导致库存不足时仍创建订单”。整改项必须能被测试或监控验证。
比如把“加强幂等”改成“以 deduct_id 建立唯一约束,同一扣减请求重复提交 10 次只能产生一条流水”;把“优化锁”改成“热点 SKU 压测下,P99 锁等待低于设定阈值,死锁自动重试不超过 1 次”。
最后要增加一次故障注入或回归压测,至少覆盖四种场景:两个请求同时扣减库存 1、扣减后订单服务超时、消息重复消费、取消订单重复回补。没有这些验证,复盘文档只是解释过去,不能证明系统已经具备防止同类问题再次发生的能力。


读者评论
文章把“库存异常”拆成超卖、少卖、重复扣减等不同类型,这个分类很实用。尤其强调先看影响行数、事务提交和幂等记录,避免一遇到问题就简单归因于锁。
文中对库存口径的区分比较到位,可售库存、锁定库存和实际库存如果没有统一定义,技术排查确实容易失焦。建议实际落地时再补充一份统一的库存公式和数据字典。
条件更新与业务幂等分别解决库存边界和重复执行问题,这个判断很准确。很多系统虽然避免了负库存,却没有防止超时重试导致重复扣减,文章对此提醒有现实意义。
复盘材料清单较完整,特别是把请求、事务、数据库、缓存和消息放到同一条时间线上。不过文中部分压测数据属于情景模拟,实际使用时仍需结合业务流量和数据库配置验证。