在一次“100件库存、200个并发请求”的压测评审里,研发给出的结论是“数据库已经加了行锁,不会超卖”。我没有先看锁,而是追问了三个问题:扣减成功但订单创建失败怎么办?请求超时后重试会不会再次扣减?订单取消后库存由谁、在什么时间点补回?这三个问题没有明确答案时,“用了锁”最多只能说明某个数据库操作受控,不能说明库存、订单和支付状态最终一致。
数据库存:项目经理一页讲清:并发扣减与保证扣减一致性的关系
并发扣减解决的是“多个请求同时抢同一份资源时,不能互相覆盖”;一致性解决的是“扣减之后,库存与订单、支付、退款、缓存和消息状态仍然符合业务规则”。
这两个问题有关联,却不在同一个层次。并发控制通常发生在库存记录被修改的瞬间,关注的是同一行数据能否安全变化;一致性则贯穿一次业务交易的全过程,关注扣减、下单、支付、取消和回补之间能否互相解释。
项目经理在评审时,如果只问“用了悲观锁还是乐观锁”,往往会把问题问窄。更关键的问题应该是:库存扣减成功后,后续步骤失败了怎么办;请求重复到达时,系统如何识别它;数据库、缓存和消息出现短暂不一致时,系统怎样发现并修复。
一次库存交易通常不是一个 SQL 语句,而是一条业务链路。请求进入后,系统可能依次执行库存校验、库存扣减、订单创建、支付确认、消息投递、缓存刷新和异常对账。不同步骤由不同服务或不同存储承担时,单个数据库事务的保护范围就会变窄。
用户请求
↓
幂等校验:是否已经处理过该业务请求
↓
库存扣减:可售库存是否足够,扣减是否原子
↓
订单创建:是否生成唯一且有效的订单
↓
支付或确认:订单状态是否继续推进
↓
消息、缓存、对账:相关系统是否最终收敛
↓
取消、退款、超时:库存是否按规则回补
因此,我通常会把“扣减一致性”拆成三层:第一层是库存数量不能被错误扣减;第二层是库存变化必须与订单状态匹配;第三层是跨数据库、缓存、消息和外部服务的状态最终能够收敛。三层都能回答清楚,方案才具备上线讨论的基础。

我在技术评审中会反复使用一句话:“数据库保证了一次变化正确,不等于业务链路已经正确。” 条件更新可以保证库存足够时才扣减,但它并不会自动创建订单,也不会替系统处理超时重试,更不会自动把取消订单的库存补回来。
反过来,消息队列、补偿任务和对账机制也不能替代数据库的硬约束。如果库存表允许被扣成负数,再完善的异步补偿也只是事后修复。可靠方案通常是“数据库约束守住底线,事务或事件连接业务状态,幂等和补偿处理异常路径”。
最容易被忽略的风险,不是 SQL 写错,而是查询和修改之间存在时间窗口。假设库存只剩1件,请求A读取到库存为1,请求B几乎同时也读取到库存为1。两个请求都认为资源充足,然后分别创建订单或执行扣减,系统就可能出现超卖、覆盖更新,或者订单数量与库存数量对不上。
常见的伪代码如下:
available = SELECT available_qty FROM inventory WHERE sku_id = :sku_id; if available >= :quantity: INSERT INTO orders (...); UPDATE inventory SET available_qty = available_qty - :quantity WHERE sku_id = :sku_id;
这段逻辑的问题在于,库存判断发生在一个时间点,真正修改发生在另一个时间点。即使两条语句都单独执行成功,也不代表“判断”和“扣减”之间没有其他请求插入。项目经理不必深入每一条执行计划,但必须要求研发画出这段时序并说明竞争窗口如何被关闭。
不同业务对库存的定义并不相同,不能拿“库存扣减”四个字直接套方案。第一类是普通商品下单,库存通常在订单确认时扣减;第二类是限时活动或秒杀,系统可能先预扣额度,再在支付超时后释放;第三类是票务、座位或名额分配,资源一旦被占用,通常需要在短时间内锁定,超时后再释放。
我见过一个很典型的评审误区:研发拿普通商品库存的条件更新方案,直接用于活动名额预占;产品却要求“用户支付失败后自动释放名额”。这意味着系统需要的不只是一次扣减,而是“预占,确认,释放”的状态机。如果只盯着库存字段,必然漏掉超时任务、重复释放和回补幂等。
数据库能够提供事务、锁和约束,但“什么叫一致”必须由业务先说清楚。例如,支付成功但库存不足时,系统是拒绝支付、进入人工审核,还是允许缺货订单成立?订单取消后,库存是立即回补,还是由定时任务异步回补?缓存显示旧库存3秒是否可接受?这些都不是数据库自动决定的技术问题,而是业务规则。
如果业务规则没有定义,研发即使实现了强事务,也可能实现出一个“技术上原子、业务上错误”的系统。项目经理的价值,正是在方案进入代码之前,把这些边界变成可确认、可测试、可追责的规则。

分布式锁可以让多个应用实例在某段时间内按顺序执行,但它不是库存数量的最终约束。锁可能因为超时、网络分区、客户端异常或错误的释放逻辑失效;即便锁工作正常,数据库中的库存也可能被其他没有遵守这把锁的代码直接修改。
更稳妥的做法是把数据库条件约束作为最后一道防线。例如,扣减时要求库存大于等于本次数量,并通过受影响行数判断是否成功。分布式锁可以在复杂业务判断、热点资源协调中发挥作用,但不能替代库存表上的原子更新、唯一约束和幂等设计。
我评审分布式锁方案时,会要求回答以下问题:
事务只能保证它覆盖范围内的数据库操作一起提交或一起回滚。如果库存和订单在同一个数据库、同一个事务里,事务可以较好地处理“库存扣减成功但订单插入失败”的情况。但如果库存、订单、支付和消息分属于不同服务,单库事务就无法直接包住全部步骤。
例如,库存服务先提交扣减,随后订单服务因数据库连接池耗尽而创建失败。库存事务已经提交,原事务无法跨服务自动回滚。此时需要事件通知、可靠消息、重试或补偿任务把状态拉回正确结果,而不是简单地说“已经用了事务”。
事务还有一个容易被忽视的成本:事务持续时间越长,锁或版本占用时间通常越长。如果把调用支付、发送外部请求、等待人工确认都放在数据库事务中,系统可能在高峰期形成锁等待,最终表现为数据库连接耗尽和请求级联超时。
条件更新主要解决的是“库存数量不能被错误地扣减”。它不负责判断订单是否重复,也不负责确认用户是否完成支付。库存扣减成功后,如果订单写入失败、消息发送失败或服务返回超时,系统仍然需要知道这次扣减属于哪个业务请求,以及后续应该确认还是释放。
因此,库存扣减最好留下可追踪的业务流水,而不是只修改一个数字。流水至少应包含业务请求号、订单号或预订单号、扣减数量、操作类型、操作时间、当前状态和关联事件。没有流水时,后续对账只能比较几个汇总数字,很难定位哪一次扣减需要补偿。
网络超时是库存系统中非常危险的异常。请求方没有收到响应,不等于服务端没有执行成功。如果客户端、网关或消息消费者自动重试,而系统没有幂等控制,就可能发生同一笔业务被扣减两次。
一个可靠的幂等判断,不是单纯依赖用户ID。用户可能购买多个不同商品,也可能在不同活动中拥有不同资格。更适合使用业务请求号、订单号或由上游生成的唯一交易号,并在数据库中建立唯一约束或幂等记录。
重复请求的处理结果也应明确:如果第一次扣减成功,第二次请求应返回第一次处理结果;如果第一次正在处理中,第二次请求应进入查询或等待,而不是再次执行扣减;如果第一次已经失败,第二次是否允许重新提交,需要由业务定义。

我建议项目经理在评审文档首页先写不变量,而不是先写技术名词。对于一个可售库存场景,最少应明确以下内容:
不变量的价值在于,它可以直接转化为测试断言和监控指标。比如“库存不能小于零”对应数据库约束和压测校验;“同一请求最多一次扣减”对应唯一键和幂等测试;“取消后必须释放”对应状态机测试和回补对账。
第二步是画边界:库存和订单是否在同一个数据库?扣减成功后是否同步创建订单?缓存是否只是读取加速,还是被当作扣减入口?支付是否由外部系统完成?消息是否允许延迟?
如果库存和订单同库,通常可以优先考虑同一事务内的条件扣减和订单创建。如果库存与订单分库或分服务,就需要设计状态流转和可靠事件。若业务允许短暂延迟,可以采用最终一致;若涉及不可逆的支付确认或稀缺座位,则需要更谨慎地设计确认顺序。
| 判断问题 | 答案为“是”时的含义 | 项目经理应继续追问 |
|---|---|---|
| 库存与订单是否同库 | 可以考虑用本地事务覆盖扣减和下单 | 事务范围、锁等待和失败回滚是否有测试 |
| 是否允许短暂状态不一致 | 可以考虑可靠消息与补偿机制 | 允许延迟多久、如何告警、如何对账 |
| 是否存在高热点单行 | 需要重点评估数据库行竞争 | 是否分片、分桶、预扣或削峰 |
| 请求是否可能自动重试 | 必须把幂等作为强制设计项 | 唯一业务键是什么,重复请求返回什么 |
| 库存是否需要取消回补 | 库存实际是一个状态机,而非简单减法 | 释放触发者、超时时间和重复回补处理 |
常见的错误顺序是:团队先决定使用某种锁,再努力证明它适合业务。正确顺序应当是先明确并发规模、资源粒度、失败容忍度和一致性边界,再选择条件更新、事务、乐观锁、消息或补偿等手段。
如果一次请求只是从单个库存字段扣减一个数量,原子条件更新往往比引入复杂锁体系更直接。如果扣减前需要执行多个同库判断,且业务要求一起提交,可以考虑事务和行锁。如果跨服务且允许延迟,就必须投入幂等、可靠事件和对账能力。技术复杂度应当由业务风险驱动,而不是由架构潮流驱动。

下面用一个示例场景说明评审过程。商品可售库存为100件,同时有200个请求到达,每个请求购买1件。这里的数字是为了复现并发逻辑的情景模拟,不代表某个具体线上项目的真实统计。
在测试开始前,我会要求团队把“成功”定义清楚:最多100个请求可以完成库存扣减;成功订单数不得超过100;库存不能小于0;失败请求不得留下有效订单;同一个业务请求重复提交时,不能产生第二次扣减。
如果系统使用预占模式,还要增加一个条件:预占成功但在规定时间内未支付的订单必须释放库存;释放动作重复执行时,库存不能被加回两次。这样,测试才覆盖了从扣减到回补的完整生命周期。
方式一是先查询再更新。在并发冲突较高时,多个请求可能同时看到库存充足。如果更新语句没有再次带库存条件,或者订单插入先于库存扣减,系统就可能产生订单与库存不匹配。即使最终库存字段没有出现负数,也不能据此判断没有超卖,因为覆盖更新可能隐藏了部分扣减。
方式二是条件更新。典型 SQL 将“库存足够”和“扣减数量”放在一次更新中:
UPDATE inventory SET available_qty = available_qty - :quantity, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_qty >= :quantity;
执行后要检查受影响行数。行数为1,表示本次扣减成功;行数为0,表示库存不足或记录不存在。项目代码不能仅依赖返回值为“执行成功”,因为 SQL 执行成功不等于业务扣减成功,真正要判断的是是否有目标记录被条件更新。
方式三是条件更新加幂等记录,再连接订单状态。系统先依据业务请求号判断是否处理过,再执行受约束的扣减,并保存库存流水。订单创建和消息投递根据状态推进,异常时由重试或补偿任务继续处理。这个方案的代码和运维成本更高,但它能回答“这次扣减属于谁”和“失败后如何恢复”两个关键问题。
在测试报告中只写“最终库存为0”,信息远远不够。假设初始库存100,最终库存0,订单数量也是100,看起来没有问题,但可能存在10次重复扣减后又被某些失败流程抵消的情况。没有过程流水,就无法判断系统是否真正遵守了幂等和状态规则。
我建议至少记录以下四组数据:
这类数据观察的重点不是追求某个漂亮的成功率,而是验证不变量是否一直成立。一个系统即使99.9%的请求成功,只要剩余0.1%会造成库存永久丢失或重复扣减,项目上线后仍可能形成持续性的财务和履约风险。

第一个反例是响应超时。服务端可能已经扣减成功,但客户端因为网络问题没有收到结果,随后自动重试。测试必须故意制造响应延迟,检查重复提交是否返回原结果。
第二个反例是订单写入失败。可以在库存扣减成功后模拟订单数据库不可用,观察库存是否保留了可追踪的待处理状态,还是直接变成一笔无人认领的扣减。
第三个反例是重复回补。对同一个取消订单重复发送释放消息,检查库存是否只增加一次。很多系统在扣减路径上做了幂等,却忽略了取消、退款和补偿路径同样会重复执行。
条件更新的优势是边界清晰。数据库直接判断可用库存是否大于等于本次扣减数量,满足条件才修改记录。它减少了“先读后写”的竞争窗口,通常适合单库、单库存记录、扣减规则简单的场景。
它的局限也很明确:条件更新本身不负责订单幂等,不负责跨服务一致性,也不负责库存回补。项目经理不能看到一条正确 SQL 就结束评审,而要继续问扣减成功后的业务关联和失败处理。
悲观锁通常适用于必须先读取库存,再基于读取结果进行多项判断,并且这些判断需要在同一个事务中完成的场景。它的优点是逻辑直观,缺点是并发请求会等待同一资源,长事务还可能引发锁等待、死锁和连接池压力。
使用悲观锁时,我会重点审查事务边界。锁内只应放必要的数据库操作,不应在持锁期间调用支付接口、远程服务或等待消息。否则,外部服务的慢响应会被放大成数据库层面的排队。
乐观锁一般通过版本号或旧值条件判断并发冲突。更新时如果版本已变化,当前更新失败,应用层再决定返回失败还是重试。它不会像悲观锁那样长时间阻塞其他请求,但在高热点库存上可能出现大量版本冲突。
乐观锁的关键不在“是否重试”,而在“重试是否有边界”。无限重试会把高峰期的竞争转化为更大的数据库压力;无条件重试还可能绕过业务幂等。项目方案应明确最大重试次数、退避策略、失败提示和监控阈值。
当库存、订单和支付分属不同服务时,可靠消息和补偿机制通常比强行扩大事务边界更现实。库存服务完成本地事务后发布事件,订单服务消费事件并推进状态;如果消费失败,则按规则重试,超过次数进入人工或自动补偿流程。
这种方式的代价是状态不会永远同步发生。项目经理必须把“最终一致”具体化,例如允许库存展示延迟几秒、订单状态最多多久进入可查询状态、补偿任务多久扫描一次、异常超过多少分钟必须告警。没有时间边界的最终一致,只是一句模糊承诺。
| 方案 | 最适合解决 | 主要收益 | 主要代价 | 不应单独承担的职责 |
|---|---|---|---|---|
| 条件更新 | 单次库存数量约束 | 简单、直接、容易验证 | 热点行仍可能竞争 | 跨服务订单一致性 |
| 悲观锁 | 锁内复杂判断与同库提交 | 逻辑清晰、失败边界明确 | 锁等待、死锁、吞吐下降 | 外部支付状态同步 |
| 乐观锁 | 并发冲突检测 | 冲突较少时性能较好 | 高冲突下重试放大压力 | 自动解决重复请求 |
| 消息补偿 | 跨服务状态收敛 | 解耦、可重试、可追踪 | 存在延迟、重复消费和运维成本 | 替代数据库硬约束 |

一个数字只能回答“现在看起来还剩多少”,不能回答“为什么是这个数字”。在需要回补、对账和异常定位的系统中,建议同时保留库存汇总和库存流水。汇总表用于高频查询,流水表用于追踪每次增加、扣减、冻结、释放和调整。
库存流水中的业务字段应尽可能完整,包括业务请求号、订单号、商品规格、变动类型、数量、变动前数量、变动后数量、操作来源、状态和时间。对于补偿任务,还要记录原始事件号和补偿批次,避免一条异常被多个任务重复修复。
不要把“订单已创建”简单等同于“库存已扣减”。更稳妥的状态设计可能包括待预占、已预占、待支付、已确认、已取消、待释放和已释放等状态。具体名称可以不同,但必须能表达库存动作处于哪个阶段。
例如,订单处于“已预占”时,库存可以被视为冻结而非最终销售;订单进入“已支付”后,冻结库存转为已售;订单超时关闭后,冻结库存释放为可售。每个状态变化都应有唯一触发条件和可重复执行规则。
很多团队只为下单接口设置幂等,却没有为支付回调、取消通知和库存回补设置幂等。实际上,回调可能重复,消息可能重复消费,补偿任务也可能重跑。任何会改变库存数量或状态的动作,都应该有自己的业务唯一键。
一个简单的幂等记录可以采用以下逻辑:
BEGIN;
— 业务请求号必须唯一
INSERT INTO inventory_operation
(request_id, sku_id, operation_type, quantity, status)
VALUES
(:request_id, :sku_id, :operation_type, :quantity, 'PROCESSING');— 如果唯一键冲突,查询原操作结果,不再重复扣减
UPDATE inventory
SET available_qty = available_qty - :quantity
WHERE sku_id = :sku_id
AND available_qty >= :quantity;— 根据受影响行数更新操作状态
— 1:SUCCESS
— 0:INSUFFICIENT_STOCK 或 NOT_FOUND
COMMIT;
示例只展示核心思路,不代表可以直接复制到所有数据库。实际实现还要根据数据库隔离级别、唯一键冲突处理、事务异常和并发访问方式进行验证。
对账应当是库存系统的日常能力,而不是事故发生后的临时动作。至少可以建立三个方向的核对:库存汇总与流水之和是否一致;订单占用量与库存冻结量是否一致;已完成、已取消和待处理事件是否都能找到对应业务记录。
对账结果最好分级处理。数量差异较小且在允许窗口内,可以进入自动重试;涉及支付成功但库存不足、库存扣减但订单不存在等高风险差异,应立即告警并暂停相关自动补偿,避免错误扩大。

这类场景不必一开始就引入复杂的分布式锁或多套缓存扣减链路。优先采用数据库条件更新,必要时将库存扣减、订单写入和库存流水放入同一事务中,同时建立请求号唯一约束。
项目推进时重点确认以下事项:
当大量请求集中修改同一个库存记录时,数据库行锁即使逻辑正确,也可能成为性能瓶颈。此时不能只增加数据库连接数,因为更多连接会让竞争更激烈,最终表现为锁等待上升、超时增加和重试风暴。
可以考虑在入口做限流和排队,将无资格、重复请求和明显超出库存的流量尽早挡住;也可以采用预扣、分桶或分片等方式降低单行热点。但无论前面使用何种削峰机制,数据库最终仍应保留数量约束和业务流水,防止缓存或队列异常导致错误扣减。
压测指标不能只看吞吐量,还应同时观察成功扣减数、库存负数、重复订单、锁等待、数据库 CPU、连接池使用率和补偿积压。吞吐量很高但异常没有闭环,不代表系统更可靠。
跨服务场景要先明确谁是库存事实源。通常库存服务负责数量和库存流水,订单服务负责订单状态,其他服务通过事件获知变化。不要让订单服务和库存服务都可以直接修改同一份库存汇总,否则对账会变得非常困难。
推荐把事件设计成可追踪、可重放且可幂等处理的形式。事件应携带业务请求号、订单号、库存操作号、事件类型和版本信息。消费方如果已经处理过相同事件,应返回已处理结果,而不是再次改变业务状态。
外部支付系统不适合被放在数据库锁内等待。更合理的方式通常是先形成明确的订单和库存状态,再通过支付结果推动状态机变化。支付回调必须幂等,支付成功、支付失败、回调延迟和回调重复都要有测试用例。
如果支付成功后发现库存状态异常,系统必须预先定义处理策略,例如进入人工审核、触发退款、转为缺货订单或使用替代资源。这个策略需要产品、财务、客服和研发共同确认,不能在故障发生时临时决定。

强一致通常意味着更严格的提交边界、更少的中间状态和更高的同步等待成本。它适合资源稀缺、错误代价极高、业务不能接受超卖或错配的场景,例如不可重复销售的座位、有限名额和高价值库存。
最终一致通常允许短暂中间状态,通过消息、重试和补偿让系统在一段时间后收敛。它适合业务能够容忍几秒或几分钟延迟的场景,但必须明确延迟上限和异常处理责任。最终一致不是“不保证一致”,而是把一致性的实现从同步提交转移到状态收敛。
条件更新加本地事务的维护成本低,开发团队容易理解和排查,适合业务边界简单的场景。引入缓存扣减、消息队列、分桶库存和补偿平台后,系统吞吐和扩展性可能提高,但运维、监控、重放和对账成本也会同步增加。
我通常不建议为了应对一个尚未出现的高并发问题,提前搭建完整的分布式库存体系。更稳妥的做法是先用最小可靠方案守住业务不变量,再用压测数据证明瓶颈在哪里。没有锁等待、数据库 CPU、请求峰值和异常比例等证据时,复杂化往往只是增加故障面。
缓存和异步机制能够降低数据库压力,但它们会增加状态延迟。用户看到的库存数字可能不是最新值,后台补偿可能在几秒后才完成。项目经理必须确认这种延迟是否会改变用户决策,是否会造成付款后缺货,是否需要在页面上使用“正在确认”而不是直接显示最终成功。
越是复杂的架构,越需要可解释性。每一个库存变化都应该能够回答“谁在什么时间,以什么业务原因,改变了多少”。如果系统性能提高了,却无法在事故后还原一次扣减的完整路径,运营和财务风险可能反而增加。
| 决策维度 | 偏同步、强约束方案 | 偏异步、最终一致方案 | 项目经理应确认的业务条件 |
|---|---|---|---|
| 用户等待时间 | 通常更长,但结果更快确定 | 响应可更快,但可能处于处理中 | 用户能否接受处理中状态 |
| 跨服务覆盖 | 跨服务实现成本较高 | 更容易通过事件连接 | 允许多长时间内完成收敛 |
| 故障恢复 | 依赖事务回滚和同步失败返回 | 依赖重试、补偿、死信和对账 | 异常是否有明确负责人和告警 |
| 系统复杂度 | 初期较低,热点下可能扩展困难 | 组件更多,长期运维成本更高 | 团队是否具备消息和补偿运维能力 |
| 错误代价 | 适合超卖代价高的资源 | 适合能够接受短暂差异的业务 | 一次错配的财务和用户影响是多少 |

测试不能只模拟“库存足够、数据库正常、请求只来一次”的理想路径。至少要覆盖库存刚好耗尽、一次扣减多个数量、多个商品同时扣减、同一用户重复点击、网关重试、数据库超时和服务进程重启等情况。
一致性测试要同时核对数量、状态和关联关系。不能只查库存表,也不能只查订单表。建议在每轮压测结束后执行全量或抽样对账,确认库存汇总、库存流水、订单状态和消息处理记录能够互相对应。
| 验收对象 | 必须满足的条件 | 不通过时的风险 |
|---|---|---|
| 库存数量 | 不小于0,且汇总值与流水可解释 | 超卖、库存丢失、财务对账异常 |
| 有效订单 | 不超过成功占用库存的业务上限 | 付款后无法履约或人工赔付 |
| 业务幂等 | 同一业务请求只产生一次有效结果 | 重复扣减、重复订单、重复回补 |
| 消息处理 | 失败可重试,重复可识别,异常可告警 | 状态长期不收敛,问题难以定位 |
| 补偿任务 | 有执行记录、重试上限和人工介入入口 | 自动修复反而造成二次错误 |
建议把以下指标写进压测和上线验收文档,而不是等故障后再补监控:库存扣减接口的成功率、业务失败率、P95和P99响应时间、数据库锁等待、死锁次数、连接池占用、消息积压、补偿耗时、对账差异数和库存流水缺失数。
这些指标需要和业务阈值绑定。例如,“消息积压1000条”本身未必说明故障,关键是积压持续多久、涉及多少支付成功订单、是否超过业务允许的状态收敛时间。指标必须能够触发动作,而不是只作为监控大盘上的装饰。
库存事故发生后,最危险的做法是直接批量修改库存数字。正确的补偿应当尽量通过带业务原因的库存调整流水完成,并保留审批人、原始事件、调整数量和执行时间。这样既方便追踪,也避免在修复过程中进一步破坏账。
项目经理需要提前明确:谁接收告警,谁判断是否暂停交易,谁批准人工调整,客服如何解释,财务如何核对,研发如何回放事件。技术方案只有在故障发生时仍然可执行,才算真正完成。

库存的事实来源是什么?可售、冻结、已售和在途库存是否区分?库存数量是否允许人工调整?如果允许,调整是否产生流水?没有资源定义,就无法判断扣减是否正确。
库存判断和扣减是否是同一个原子操作?如果使用锁,锁的粒度、租期、释放机制和超时行为是什么?如果使用乐观锁,冲突后重试几次?如果使用队列,排队超时的请求如何处理?
唯一业务请求号由谁生成?数据库是否有唯一约束?重复请求返回第一次结果,还是返回“处理中”?订单、支付回调、消息消费、取消释放和人工补偿是否分别具备幂等规则?
库存扣减成功、订单创建失败时如何处理?订单创建成功、库存扣减失败时如何处理?缓存与数据库不一致时谁是事实源?消息投递失败是否重试?重试失败是否进入死信或补偿任务?
能否按照业务请求号查到库存流水、订单状态、消息记录和补偿记录?能否统计库存扣减成功但订单不存在的数量?能否发现同一请求重复处理?能否在业务允许的收敛时间内完成告警?
如果一个方案只能回答“用了行锁”“用了事务”“用了消息队列”,却回答不了这些问题,我会把它判定为技术组件齐全、业务闭环不足。项目经理真正要验收的不是组件清单,而是每一种失败都存在可执行的结果路径。
并发扣减的核心,是让同一份库存资源在竞争条件下按照规则发生变化。条件更新、悲观锁、乐观锁和队列都可以参与解决这个问题,但它们的覆盖范围不同。数据库原子操作通常是数量安全的底线,不能被缓存、脚本或后台任务绕开。
保证扣减一致性,必须把库存、订单、支付、取消、消息、缓存、流水、补偿和对账放到同一张业务地图中。最终一致也必须有明确的时间边界、状态定义和修复机制。没有监控和对账的“最终一致”,很可能只是暂时没有被发现的错误。
我认为,库存一致性评审最有价值的判断,不是“这个方案用了什么锁”,而是“当最坏的事情发生时,系统能否证明没有重复扣减,能否找回每一笔库存变化,能否让订单和库存重新收敛”。
并发控制解决“不能同时扣错”,一致性解决“扣完之后整条业务链不能对不上”。把这两句话分别落实为数据库约束、幂等规则、状态机、监控指标和补偿流程,项目经理才真正拥有了一页可以推动研发、测试和运维共同执行的方案。
我在评审库存方案时,经常看到需求文档把“并发安全”和“数据一致性”写成同一个验收项。研发说已经加了事务或锁,我却不确定这是否意味着库存、订单、支付状态都不会出问题,这两个概念到底应该怎样区分?
不是一回事,但两者有直接关系。并发扣减解决的是多个请求同时修改同一份库存时,不能互相覆盖、重复使用库存或把库存扣成负数;一致性解决的是库存扣减之后,订单、支付、取消、缓存和消息状态之间仍然符合业务规则。
可以把它们理解成两个层次:并发控制是“这一件库存能不能被两个请求同时拿走”,业务一致性是“库存被拿走后,系统里的订单和后续状态能不能解释得通”。前者通常依赖条件更新、行锁、乐观锁或事务,后者还需要幂等、状态机、消息重试、补偿和对账。
我在库存方案评审中不会只问“用了什么锁”,而会追问三个结果:成功订单数是否超过可售库存;库存扣减成功但订单创建失败时如何回补;请求超时重试后是否会重复扣减。只要这三个问题没有答案,单纯说“数据库已经加锁”是不够的。
问题并发扣减是否能解决是否还需要一致性设计 库存不能变成负数通常可以需要确认异常回滚和数据修复 两个请求不能重复占用同一库存可以部分解决需要幂等防止重试重复扣减 订单创建失败后库存自动恢复不能自动解决需要事务、事件或补偿机制 数据库与缓存最终一致不能解决需要缓存更新、重试和对账 项目经理可以记住一句话:并发扣减解决“不能同时扣错”,一致性保证“扣完之后整条业务链不能对不上”。
我以前看到过很多代码先查询库存,判断库存大于购买数量后,再执行更新。单元测试都能通过,但在活动高峰期我担心多个请求会同时读到同一个库存,想知道条件更新究竟解决了哪一部分问题,还有哪些坑?
“先查询、后更新”的问题不在于查询本身,而在于查询结果和更新动作之间存在竞争窗口。假设只剩 1 件库存,请求 A 和请求 B 都先读到库存为 1,随后它们都通过业务判断,系统就可能生成两个订单,或者发生更新结果互相覆盖。
更稳妥的做法是把库存是否充足的判断放进更新条件,让数据库在同一个受约束的更新动作中完成判断和扣减:
UPDATE inventory SET sellable_qty = sellable_qty - :quantity WHERE item_id = :item_id AND sellable_qty >= :quantity;执行后不要依赖“没有抛异常”判断成功,而应检查受影响行数。受影响行数为 1,表示扣减成功;为 0,表示库存不足、商品不存在或条件未满足,具体原因还要结合业务查询和错误码区分。在这类方案的并发压测中,建议使用一个库存为 100、并发请求为 200、每次购买 1 件的示例场景。
理想结果不是“200 个请求都返回成功”,而是最多 100 个请求完成有效扣减,最终库存为 0,且成功订单数不超过 100。
下面是三种实现的关键差异: 实现方式主要风险项目验收重点 先查后改判断与更新之间存在竞争窗口并发下是否超卖、是否覆盖更新 条件更新只能约束单次扣减影响行数、负库存、失败订单处理 条件更新加幂等和事务跨服务故障仍可能产生延迟重试、回补、消息和对账 但条件更新不是全能方案。
它通常能解决“库存不能被扣成负数”,却不能自动保证订单创建成功、支付失败后库存回补,也不能防止同一个请求因超时重试而再次扣减。因此,数据库原子更新应当被视为库存一致性的底座,而不是完整业务方案。
我参与过一次订单与库存拆分的项目,研发认为两个服务都加了事务,整体就能保持一致。后来遇到订单创建成功但库存扣减超时、库存已经扣减但消息没有投递的情况,我想知道事务和分布式锁的边界分别在哪里。
数据库事务只能保证它覆盖范围内的操作具备原子提交或回滚能力。如果库存和订单位于同一个数据库、同一个事务边界内,事务可以较好地保证两步操作一起成功或一起失败;但一旦跨越不同数据库、缓存、消息队列或外部支付服务,单个本地事务就无法天然覆盖所有参与者。分布式锁也不是一致性的替代品。
它主要用于限制多个实例同时进入某段临界区,却不能保证持锁服务宕机后的业务恢复,也不能阻止绕过锁直接写数据库的程序,更不能自动撤销已经成功的库存扣减。我判断这类方案是否可靠,通常会画一张失败时序图,而不是只看架构图。例如:库存扣减成功,订单服务响应超时,调用方重试;
如果没有业务请求号和唯一约束,第二次请求可能再次扣库存。又如订单创建成功,库存服务超时,订单就需要进入待确认、取消或补偿状态,而不能长期停留在“已支付但没有库存”的模糊状态。
故障场景本地事务能否自动解决必须补充的设计 同库内库存扣减与订单写入失败通常可以回滚合理控制事务边界和锁等待 库存库成功,订单库失败不能可靠事件、重试或补偿任务 数据库成功,缓存更新失败不能缓存失效、重建和监控 请求超时后重复提交不能幂等键、唯一索引和原结果返回 消息重复投递不能消费者幂等和消费记录 更实用的判断方式是区分“事实记录”和“派生状态”。
库存数据库通常应作为扣减事实来源,订单和缓存可以通过事务、可靠事件或补偿机制逐步达到一致。项目经理必须要求方案明确:异常由谁发现、多久发现、如何重试、重试多少次仍失败时如何人工介入。
我不负责写库存代码,但需要判断研发方案能不能上线。过去的验收经常只做普通下单测试,结果上线后才发现重复提交、消息积压和取消订单不回补等问题,项目经理应该用什么清单来验收?
项目经理验收时,不要把“接口返回成功”当成唯一结果。真正需要验证的是业务不变量:库存不能小于零,成功订单不能超过可售库存,同一个业务请求不能重复扣减,异常订单最终能够被识别和处理。我建议先用一个可计算的测试案例:初始可售库存 100,发起 200 个并发请求,每个请求购买 1 件。
测试结束后应同时检查数据库库存、成功订单数、扣减流水数和用户收到的结果,不能只看接口成功率。
测试场景应观察的结果未通过时的风险 200 请求抢 100 件库存成功扣减和有效订单不超过 100超卖或订单库存不匹配 同一请求重复提交只产生一次扣减,重复请求返回原结果重复扣库存、重复下单 扣库存后订单服务超时有明确待处理、回补或补偿状态库存永久丢失 订单取消或支付失败按规则释放库存,且可追踪可售库存逐渐减少 消息重复或延迟投递消费幂等,状态不会反复跳转重复回补或错误扣减 数据库锁等待和死锁有超时、重试上限和告警高峰期请求堆积 除了功能结果,还要看四类运行指标:负库存数量、库存与订单对账差异、补偿任务积压量、锁等待和数据库慢查询。
尤其是对账差异,很多系统在接口层看起来正常,但库存流水、订单状态和实际可售数已经逐渐偏离。上线前至少应留存库存状态流转图、订单与库存时序图、幂等规则、异常补偿说明、压测报告、监控告警清单和回滚方案。
若研发只能回答“用了事务”“加了锁”,却说不清失败后的恢复路径,我会把方案判定为并发控制完成、业务一致性未完成。


读者评论
文章把“并发安全”和“业务一致性”分开讲得比较清楚,尤其是扣减成功但订单创建失败、请求超时重试这两类场景,确实是实际项目中容易遗漏的风险。
条件更新和受影响行数判断适合作为库存底线,但文章也提醒了不能只依赖数据库字段,这一点很实用。库存流水、订单号和操作状态如果缺失,后续对账确实会比较困难。
关于分布式锁的分析比较客观。锁能控制访问顺序,却无法约束绕过锁的任务,也不能自动处理锁超时和服务崩溃,方案评审时应同时检查数据库约束。
文章对预占、支付确认和超时释放的状态划分较有参考价值。不过不同业务对支付失败、库存回补时机的要求差异很大,落地前还需要明确业务规则和异常处理边界。
把幂等、消息、补偿和对账放到同一条链路中分析,能帮助项目经理避免只看单条SQL。若能再补充事务消息或具体表结构示例,工程落地会更直观。