“接口成功了 10,000 次,库存只减少了 9,732 件;更奇怪的是,库存流水却显示扣减了 10,000 件。”这是我在排查并发扣减问题时最先关注的信号。很多团队会立即把原因归结为“数据库没加锁”,但真正造成账实不一致的,往往是丢失更新、重复重试、事务边界、缓存延迟和统计口径同时叠加。并发扣减不是简单的加减法,而是一条由请求、事务、库存表、流水表、订单状态、消息和缓存共同组成的执行链路。
本文用一个可复现的库存场景,拆解架构师如何在较短时间内判断数字究竟错在数据库、业务代码,还是错在上下游同步。
数据库存:架构师快速排查:并发扣减为何会导致账实不一致
我处理这类问题时,第一件事不是查看锁配置,而是把“账实不一致”拆成四种不同的问题:库存表当前值是否正确,库存流水是否完整,订单成功数量是否正确,缓存或页面展示是否滞后。它们看起来都是“库存不对”,但每一种对应的根因和修复方式完全不同。
例如,库存表从 100 减到 80,流水表也记录了 20 次扣减,订单表显示 20 个订单成功,但缓存仍然显示 85。这不是数据库扣减错误,而是缓存更新延迟。相反,如果库存表从 100 减到 90,库存流水记录了 20 次成功扣减,订单也有 20 个成功订单,那么更可能是覆盖写或非原子更新。
架构师真正要排查的不是“有没有锁”,而是一次业务扣减是否只被执行一次、是否只被确认一次,以及所有记录是否采用了同一套成功口径。
假设库存初始值为 10,请求 A 和请求 B 同时扣减 1。两次请求都先读取库存,再由应用程序计算新值,最后把结果写回数据库,就可能出现下面的执行顺序:
时间 请求 A 请求 B
T1 读取 stock = 10
T2 读取 stock = 10
T3 计算新值 stock = 9
T4 计算新值 stock = 9
T5 UPDATE stock = 9
T6 UPDATE stock = 9
业务上发生了两次扣减,理论库存应为 8;数据库最终却是 9。请求 A 的更新结果被请求 B 覆盖,这就是典型的丢失更新。它的危险之处在于,每一条 SQL 单独看都可能执行成功,数据库也没有报错,接口还可能全部返回成功。
这也是为什么只看接口返回码无法判断扣减是否正确。接口成功只能证明某个执行路径认为自己成功了,不能证明库存变化量、流水数量和订单数量彼此相等。

库存表通常保存某一时刻的余额,库存流水保存变动历史,订单表保存业务状态。三者的更新频率、事务边界和统计规则可能不同,因此不能把任何一本账直接当成最终事实。
| 数据对象 | 代表什么 | 最常见的不一致表现 | 优先检查方向 |
|---|---|---|---|
| 库存表 | 当前可用量或账面余额 | 扣减次数与余额变化不相等 | 是否存在覆盖写、条件更新缺失 |
| 库存流水 | 每次入库、锁定、扣减、释放记录 | 流水重复、漏记或状态未确认 | 事务边界、幂等键、补偿任务 |
| 订单表 | 业务订单是否成立 | 一单多扣或多单一扣 | 重试、重复消费、状态机 |
| 缓存 | 面向读取的临时视图 | 页面显示与数据库不同 | 失效顺序、异步更新、缓存重建 |
如果团队没有先定义“实”到底是数据库余额、仓库盘点数,还是某个报表中的汇总数,那么后面的所有排查都可能在争论口径。技术人员说库存正确,业务人员说库存不对,双方都可能有证据,只是比较了不同的数据对象。
在开发环境里,测试人员通常按“创建订单,扣减库存,查询结果”的顺序逐个执行。每一次请求都完整结束后,下一次才开始,数据库自然不会出现两个事务同时读取同一个旧值的情况。
但在线上,真正的并发窗口可能只有几毫秒。请求 A 读取库存与提交更新之间,恰好插入请求 B,就足以造成覆盖写。普通功能测试很难稳定撞上这个时间窗口,更无法覆盖提交成功但响应超时、消费者重复投递、服务实例重启等异常路径。
我更倾向于用“并发屏障”而不是单纯提高线程数做复现:让 100 个线程先同时到达读取点,再释放它们继续执行。这样可以把原本随机的竞争窗口变成可控实验,通常比盲目把压测并发从 100 提到 10,000 更容易发现问题。
一次扣减请求通常经历客户端、网关、应用服务、数据库和消息系统多个环节。数据库事务可能已经提交,但响应在返回客户端时超时。客户端或网关看见超时后再次发起请求,服务端如果没有幂等控制,就会把同一笔业务当成两笔新的扣减。
这类问题和丢失更新的表现相反:丢失更新通常是“成功请求多于库存实际减少量”,重复执行通常是“库存实际减少量多于订单成功量”。如果没有请求编号、业务单号和数据库流水号,两个问题很容易被混为一谈。
如果数据库扣减完成后,通过消息异步更新缓存、报表或仓储系统,那么短时间内数字不同并不一定是故障。关键是要区分:这次差异是否会在约定时间内自动收敛,还是因为消息丢失、重复消费、状态回滚而永久留下。
我通常会把一致性问题按时间划分。秒级差异首先看消息延迟和缓存失效;分钟级差异要看消费积压、重试和死信;跨日仍然对不上,则应优先检查漏记、重复记账和补偿任务,而不是继续调大缓存刷新频率。

库存变化可能由下单锁定、支付扣减、取消释放、售后回补、人工调整、盘点修正和定时补偿共同产生。如果只检查支付扣减接口,而没有把全部库存变更类型放在同一条流水链路里,最终很可能找不到差额来源。
例如,订单取消逻辑把锁定库存释放了一次,补偿任务又根据订单状态重复释放一次,最终库存会比理论值多 1。此时扣减 SQL 完全正确,错误发生在释放动作的状态机和幂等设计中。
事务可以保证事务内部的操作具备原子提交或回滚能力,但它不会自动把“先读后写”的业务逻辑变成并发安全。两个事务都读取 10,再分别计算并写入 9,即使它们各自都完整提交,仍然可能得到错误结果。
事务解决的是“这一组操作是否一起成功”,并发控制解决的是“多个事务同时操作时如何协调”。这两个问题有关联,但不能互相替代。判断事务是否有效,必须看它覆盖了哪些语句、使用了什么隔离级别、更新是否带条件,以及是否存在事务外的消息和缓存操作。
SELECT ... FOR UPDATE 可以让当前事务锁住目标行,后续事务等待锁释放,从而避免多个请求同时修改同一行。但它只能保护被锁住的数据库记录,不能保护事务之外的缓存写入、消息重复消费,也不能防止同一个业务单号被执行两次。
此外,悲观锁会把热点库存变成串行队列。若事务内包含远程调用、复杂计算或慢 SQL,锁的持有时间会明显增加,最终表现为锁等待、连接池耗尽和接口大量超时。超时又会触发重试,重试进一步放大数据库压力,形成“锁等待,超时,重试,更高并发”的恶性循环。
乐观锁适合冲突概率较低的场景。它通过版本号或旧值条件判断更新时数据是否被别人修改,如果更新影响行数为 0,就让调用方重试或失败。
但在秒杀、热门商品或高集中度配额场景中,冲突概率可能非常高。大量请求不断读版本、更新失败、重新读取和重试,数据库虽然没有长时间持有锁,却承受了更多 SQL 次数和 CPU 消耗。此时乐观锁不一定更快,甚至会把数据库冲突转化为应用重试风暴。
缓存具有低延迟优势,但它通常不承担完整的交易事实记录。缓存扣减成功后,数据库写入失败、服务重启或消息丢失,都可能让缓存和数据库长期分叉。
如果业务允许最终一致,可以采用缓存预扣加数据库异步确认,但必须设计失败回补、消息重试、重复消费保护和定期对账。如果业务不允许出现负余额或资金差异,就不能只凭“缓存操作是原子的”来证明整体业务安全。
数据库约束可以阻止部分非法结果,例如禁止库存小于 0,但它不能解释为什么同一笔订单被执行两次,也不能补写缺失的流水。约束能阻止错误继续扩大,却不等于修复已经发生的账务差异。
在我看来,库存系统至少需要三层防线:数据库层防止不合法余额,业务层保证请求幂等,运营层通过对账发现漏记和重复记账。只做其中一层,系统仍然存在明显缺口。

我会先把某个 SKU、账户或配额对象的初始值、最终值和所有变更流水拉出来,建立最基本的守恒关系:
期末余额 = 期初余额
+ 入库总量
+ 回补总量
+ 人工调整增加量
扣减总量
锁定总量
其他减少量
如果库存表期末余额与流水计算结果不一致,问题集中在数据库更新、流水写入或补偿逻辑。如果库存和流水一致,但订单成功量对不上,问题集中在订单状态和扣减结果确认。如果前三者都一致,只有页面或报表不同,才应该转向缓存、搜索索引或数据同步链路。
| 校验关系 | 结果 | 优先怀疑 |
|---|---|---|
| 成功订单数 = 扣减流水数 = 库存减少量 | 三者相等 | 数据库交易链路大概率正常,检查缓存和报表 |
| 成功订单数 = 扣减流水数,但库存减少量较少 | 余额少减 | 覆盖更新、汇总更新、并发读改写 |
| 库存减少量大于成功订单数 | 余额多减 | 重复请求、重复消息、幂等失效 |
| 库存减少量等于订单数,但流水较少 | 流水缺失 | 事务拆分、异常吞掉、跨库写入失败 |
| 数据库正确,页面错误 | 展示延迟 | 缓存失效、异步更新或查询读错数据源 |
很多团队只按时间统计接口调用次数,这对判断重复扣减帮助有限。更有效的方法是以订单号、支付流水号、请求 ID 或消息 ID 为主键,查看同一笔业务是否出现多条扣减记录、多个事务提交或多次消费成功。
建议至少保留以下字段:业务单号、请求编号、消息编号、SKU、扣减数量、执行尝试次数、数据库影响行数、事务开始时间、提交时间、操作者和幂等状态。没有这些字段,事后只能通过时间戳和日志文本猜测,排查效率会非常低。
条件更新失败通常不会抛出数据库异常,只会返回影响行数为 0。若业务代码没有检查这个返回值,可能把“没有扣减成功”误认为“已经扣减成功”,随后继续创建订单或发送成功消息。
因此,影响行数应当成为扣减接口的明确业务结果。影响行数为 1,才代表目标记录完成了一次符合条件的更新;影响行数为 0,需要区分库存不足、版本冲突、记录不存在还是条件字段不匹配。
我通常会画一条从请求进入到业务完成的时间线,并在每个节点标注是否处于同一事务:创建扣减幂等记录、更新库存、写库存流水、更新订单状态、发送消息、删除缓存,分别在哪一步提交。
如果库存表和流水表在同一个数据库中,通常可以用本地事务保证两者一起提交。若订单、库存和流水分属不同数据库或不同服务,就不能简单依赖本地事务,需要明确使用可靠消息、事务消息、状态机或对账补偿。
不同数据库、不同隔离级别以及不同 SQL 写法,对读到的数据版本和锁行为可能有差异。普通查询、当前读、加锁读、更新语句并不是同一类操作。不能只根据“我用了事务”判断两个并发请求一定能看到最新值。
排查时要记录数据库类型和版本、事务隔离级别、表引擎、索引结构、执行计划以及具体 SQL。尤其要确认 WHERE 条件是否命中唯一索引或高选择性索引。锁住错误的范围,或者因为缺少索引导致大量记录被扫描,都会让预期的并发控制失效或变慢。

下面是一个常见但危险的伪代码。它把库存读取、计算和写回拆成了三个步骤,应用层拿着旧值计算新值,最后用计算结果覆盖数据库字段。
stock = SELECT stock FROM inventory WHERE sku_id = 1001; if stock < quantity: return "库存不足"; new_stock = stock - quantity; UPDATE inventory SET stock = new_stock WHERE sku_id = 1001;
在单线程下,这段代码通常表现正常。初始库存为 100,连续执行 100 次,每次扣减 1,最后结果大概率为 0。问题一旦进入并发环境,多个请求可能读取相同库存,随后把不同请求的计算结果写成同一个值。
为了让实验稳定复现,可以在读取完成后增加一个短暂等待,或者使用并发屏障让所有线程先读取再统一写回。下面是一组情景模拟数据,用于说明现象,不代表任何特定生产系统的统计结果。
| 并发线程数 | 业务成功次数 | 库存理论减少量 | 库存实际减少量 | 丢失扣减次数 |
|---|---|---|---|---|
| 2 | 2 | 2 件 | 1 件 | 1 次 |
| 10 | 10 | 10 件 | 4 至 7 件 | 3 至 6 次 |
| 50 | 50 | 50 件 | 8 至 16 件 | 34 至 42 次 |
| 100 | 100 | 100 件 | 12 至 25 件 | 75 至 88 次 |
这组结果的关键不是某一个具体数字,而是一个稳定规律:并发越集中,读改写之间的重叠越多,覆盖写造成的实际减少量就越可能低于业务成功次数。如果实验中没有人为扩大竞争窗口,结果会受到机器负载、网络延迟和数据库调度影响,因此应把它视为现象验证,而不是性能基准。

更稳妥的做法是让数据库直接完成扣减和库存下限判断,把“读取旧值,计算新值,写入新值”合并为一次原子更新。
UPDATE inventory SET stock = stock - :quantity, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND stock >= :quantity;
执行后必须检查影响行数。影响行数为 1,说明本次更新满足条件;影响行数为 0,只说明没有完成扣减,不能直接判断为库存不足。实际系统中还应进一步查询 SKU 是否存在、版本是否冲突、是否被下架或是否处于冻结状态。
条件更新解决的是并发覆盖和负库存风险,但它不自动写入库存流水,也不自动防止同一订单重复调用。如果一次请求重试两次,而两次都满足库存条件,那么库存仍然会被扣减两次。因此,条件更新必须与业务幂等配合。
乐观锁可以通过版本号识别并发修改。请求读取库存时同时读取版本号,更新时要求版本号仍然等于读取到的值:
UPDATE inventory SET stock = stock - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND stock >= :quantity AND version = :version;
如果两个请求都读取到版本 10,请求 A 先更新成功后版本变为 11,请求 B 再执行时影响行数为 0。请求 B 不能把失败简单转换为“再试一次”,因为它还需要重新确认订单状态、库存数量和本次扣减是否仍然有效。
乐观锁的一个常见坑是代码虽然读取了 version,却没有把 version 放进 UPDATE 的 WHERE 条件。此时数据库并没有真正执行乐观并发控制,版本号只是一个被更新的普通字段。
假设业务先执行库存更新,再调用另一个服务写库存流水。如果库存更新成功,流水服务调用超时,业务代码把异常吞掉并返回成功,就会出现库存减少但流水缺失。
反过来,如果流水先写入,库存更新失败,而流水状态没有标记为失败或撤销,就会出现流水多于库存变化。两种情况都说明:库存余额和变更流水没有被同一套一致性机制覆盖。
| 执行顺序 | 中途故障点 | 可能结果 | 修复重点 |
|---|---|---|---|
| 先更新库存,再写流水 | 流水服务超时 | 库存减少,流水缺失 | 本地事务、可靠事件或可重试写入 |
| 先写流水,再更新库存 | 库存条件更新失败 | 流水存在,库存未减少 | 流水状态机和失败状态确认 |
| 库存与流水同库同事务 | 事务提交前异常 | 两者一起回滚 | 检查事务是否真的覆盖全部 SQL |
| 库存与流水跨库 | 一边提交、一边失败 | 跨库账务分叉 | 可靠消息、事务协调或对账补偿 |
假设订单号为 A10086,第一次请求已经完成扣减,但客户端没有收到响应。第二次请求携带相同订单号再次进入服务。如果数据库只按照 SKU 和库存条件更新,而不检查订单号是否处理过,那么两次请求都会被视为合法扣减。
一种常见的幂等设计是为业务单号建立唯一约束,并让幂等记录与库存更新处于同一个本地事务中。第一次请求插入成功并完成扣减,第二次请求插入幂等记录时触发唯一冲突,随后读取第一次处理结果,而不是再次执行扣减。
BEGIN;
INSERT INTO inventory_deduction_record
(order_id, sku_id, quantity, status)
VALUES
(:order_id, :sku_id, :quantity, 'PROCESSING');
— order_id 已存在时,不再重复扣减
UPDATE inventory
SET stock = stock - :quantity
WHERE sku_id = :sku_id
AND stock >= :quantity;— 只有影响行数为 1 时才写入 SUCCESS
UPDATE inventory_deduction_record
SET status = 'SUCCESS'
WHERE order_id = :order_id;
COMMIT;这段示例还需要根据实际数据库和框架补充异常处理。尤其要注意“幂等记录已写入但事务回滚”“业务处理成功但响应丢失”“幂等记录状态卡在 PROCESSING”等边界情况。幂等不是加一个字段就完成,而是一套可恢复的状态机。

对于一个 SKU 对应一行库存、扣减逻辑简单、业务需要同步返回结果的场景,我通常优先考虑条件更新。它把库存变化交给数据库完成,减少应用层持有旧值的机会,代码路径也相对短。
推荐的核心写法是:
UPDATE inventory SET available_stock = available_stock - :quantity, sold_stock = sold_stock + :quantity WHERE sku_id = :sku_id AND available_stock >= :quantity;
上线前必须确认四件事:扣减数量不能为负数,影响行数必须被检查,SKU 条件必须命中正确索引,库存流水必须在明确的一致性边界内生成。若扣减成功后还要更新订单、发送消息和删除缓存,应进一步设计提交后的事件处理,而不能把所有动作塞进数据库事务。
悲观锁的思路是先锁住当前库存行,再在事务中读取、判断、扣减和写流水:
BEGIN; SELECT available_stock FROM inventory WHERE sku_id = :sku_id FOR UPDATE; -- 应用层判断库存是否足够 UPDATE inventory SET available_stock = available_stock - :quantity WHERE sku_id = :sku_id; INSERT INTO inventory_log (sku_id, quantity, action, order_id) VALUES (:sku_id, :quantity, 'DEDUCT', :order_id); COMMIT;
它的优点是逻辑直观,库存判断和流水写入容易放在一个事务里。缺点是同一 SKU 的请求会排队,事务持有锁的时间直接决定吞吐和延迟。
使用行锁时,我会特别限制事务内的操作:不调用外部 HTTP,不等待消息返回,不执行复杂报表查询,不做大范围无索引扫描。远程调用必须移到提交之后,否则外部服务的慢响应会被放大为数据库锁等待。
乐观锁更适合编辑类库存、低频配额变更或并发冲突可接受的业务。它把冲突显式暴露给应用,由应用决定失败、重试或提示用户。
采用乐观锁时,不要无限重试。建议设置最大重试次数、指数退避和随机抖动,并把重试次数、失败原因和最终结果写入日志。否则,当库存热点集中时,大量失败请求会同时重新读取和更新,最终造成数据库请求数远高于原始业务请求数。
当一个 SKU 在极短时间内聚集大量请求时,即使单行条件更新是安全的,也可能成为数据库热点。队列串行化可以按 SKU、账户或分片键把扣减请求分配到固定消费者,由消费者顺序处理。
这个方案把数据库竞争转移为消息积压和处理延迟。它适合允许异步确认、可以接受排队的业务,不适合必须在几十毫秒内同步告诉用户最终结果的场景。
队列并不能天然提供幂等。消费者重启、消息重新投递、消费成功但确认失败,都会造成重复消息。消息 ID、业务单号、处理状态和唯一约束仍然必须保留。
对超高并发库存,可以把总库存预先分配到多个分片,或者把每个可售单位转换成令牌。请求先获取令牌,再异步完成订单确认。这样可以降低单行热点,但代价是库存回收、令牌过期、订单取消和异常补偿更加复杂。
我不会仅凭“吞吐量更高”就推荐这类方案。需要先确认数据库单行是否真的已经成为瓶颈,是否能接受库存确认延迟,是否有成熟的消息监控和对账能力。很多团队在业务规模尚未达到瓶颈时提前引入分片库存,最后复杂度远高于收益。

第一步是暂停高风险的自动补偿,不要在没有确认差额来源的情况下反复执行回补任务。重复补偿是最容易把一个小问题扩大成大范围账务错误的操作。
第二步是冻结受影响 SKU 或账户的非必要变更,保留现场数据,包括库存表快照、流水、订单状态、请求日志、消息记录、数据库锁日志和缓存值。排查前直接修正库存,会破坏最重要的证据。
第三步是按业务单号重建变更序列。对于每一笔扣减,确认它是否有唯一业务意图、是否执行多次、是否收到多个成功结果,以及每次执行影响了几行。只有完成这一步,才能判断是少扣、重复扣还是流水缺失。
短期止血可以把无条件覆盖写改为带条件的原子更新,并严格检查影响行数。若业务不允许库存不足时继续创建订单,还要让订单状态依赖扣减结果,而不是依赖接口是否进入了扣减方法。
中期修复需要补充并发测试,覆盖热点 SKU、不同扣减数量、库存恰好为 0、库存只剩 1、事务超时和响应丢失等场景。不要只用“并发 100 次都返回成功”作为测试结论,还要核对最终库存和流水守恒。
应为业务动作选择稳定的幂等键。订单扣减一般使用订单号或订单明细号,支付扣款使用支付流水号,账户余额变更使用账务流水号,不能使用每次重试都会变化的随机请求编号作为唯一业务依据。
幂等记录的状态至少要能表达处理中、成功、失败和可重试。对于长时间停留在处理中的记录,需要有超时回收或人工核验机制。简单地把“已处理”写成一个布尔字段,无法覆盖事务中断和服务重启后的恢复过程。
先确定哪个系统是最终事实来源。若数据库库存是事实来源,缓存、报表和下游系统都应围绕数据库变更事件同步,而不是各自计算一套库存。若仓储系统才是实物事实来源,则数据库库存必须能够接受盘点调整和差异对账。
跨系统同步需要设置可观测指标,包括消息发送成功率、消费延迟、重试次数、死信数量、重复消费数量和对账差异数量。没有这些指标,系统只能在业务投诉后被动发现错误。
不要为了消除几秒钟的展示延迟而贸然把所有读请求改成强一致查询。先确认业务对库存展示的容忍度,再决定使用缓存短 TTL、主动失效、版本号校验或关键页面直读数据库。
对于支付确认页、订单提交页和库存锁定结果页,通常需要更严格的读一致性;对于商品列表、运营看板和趋势报表,可以接受一定延迟。不同页面不必共享同一种一致性等级。

悲观锁和本地事务通常更容易理解和验证,但热点行会限制吞吐。队列、分片和预扣可以提高入口承载能力,却会引入异步确认、消息积压和补偿复杂度。
如果业务是账户余额、资金额度或不可逆的扣款,我会优先选择可证明、可审计的强一致路径,而不是为了追求极低延迟引入难以对账的异步链路。若业务是普通商品库存,并且允许短时间排队,可以考虑队列化或预扣方案。
重试可以提高短暂冲突下的成功率,但每一次重试都可能增加数据库负载。尤其是乐观锁场景,重试越积极,冲突越严重,最终可能出现成功率上升但整体延迟和数据库 CPU 同时上升的情况。
建议根据失败原因区分处理:库存不足不应重试,版本冲突可以有限重试,数据库连接断开需要先确认事务是否提交,消息处理失败可以进入延迟队列。所有重试都要带上业务幂等键,并设置最大次数。
实时对账能更快发现差异,但会增加查询、存储和计算压力。对高价值账务,可以按交易完成、订单关闭和日终三个节点分层校验;对普通库存,可以采用分钟级增量对账加日终全量对账。
| 业务类型 | 建议一致性等级 | 建议对账频率 | 主要取舍 |
|---|---|---|---|
| 账户余额、资金额度 | 强一致 | 交易级校验加日终全量 | 延迟和吞吐让位于可审计性 |
| 热门商品库存 | 核心扣减强一致,展示允许短延迟 | 分钟级增量加日终全量 | 控制热点压力,接受展示差异 |
| 优惠券或配额 | 扣减强一致,发放异步化 | 批次级校验 | 提升发放吞吐,增加任务治理 |
| 运营报表 | 最终一致 | 小时级或日级刷新 | 降低查询成本,接受统计延迟 |
一个方案是否适合,不只取决于理论性能,还取决于团队能否持续维护。引入消息队列、分片库存、事务事件和补偿系统后,系统中可观测节点会明显增加。若团队没有统一的业务 ID、消息追踪、死信处理和对账工具,复杂方案很容易变成新的不一致来源。
我的判断标准是:先用最小机制解决已确认的问题,再根据压测数据决定是否升级架构。单行条件更新能解决的问题,不必一开始就上分布式锁;数据库行锁能满足吞吐要求时,也不必为了“架构看起来先进”而强行异步化。

每次扣减至少需要能够回答五个问题:谁发起的,扣哪一个对象,扣了多少,执行了几次,最终是否成功。对应字段可以是 request_id、order_id、sku_id、quantity、attempt、status、affected_rows、transaction_id 和 message_id。
如果跨服务调用,还应保留全链路追踪编号,并确保同一个业务号在订单服务、库存服务、消息消费者和对账任务中保持一致。日志文本可以帮助人工阅读,但不能代替结构化字段和可查询索引。
并发测试不应只断言 HTTP 状态码。测试结束后,至少执行以下校验:
理论库存
= 初始库存
+ 入库数量
+ 回补数量
成功扣减数量
锁定数量
其他减少数量
并且:
理论库存 = 库存表当前值
成功扣减数量 = 有效扣减流水数量
成功订单数量 = 唯一业务扣减数量
如果系统存在缓存,还需要记录缓存版本或更新时间,判断它是短暂落后还是已经停止同步。测试必须在事务提交成功但响应丢失、消息重复投递和消费者重启等场景下执行,否则最危险的重复扣减问题很可能仍然没有被覆盖。
其中最有价值的不是单独看错误率,而是看“业务成功数,唯一扣减数,库存变化量”这组三角关系。错误率为 0 并不代表数据正确,因为重复执行和覆盖写都可能在接口层表现为成功。

我建议至少模拟六类故障:数据库提交后响应丢失,数据库连接在事务中断开,消息发送成功但确认丢失,消费者处理成功但 ACK 失败,缓存删除失败,以及补偿任务重复执行。
每次故障注入后都要检查三件事:业务是否重复扣减,最终状态是否能够收敛,人工是否能根据日志和流水还原过程。只要有一个场景只能通过直接改库存字段解决,就说明系统的恢复设计仍然不完整。
对于高价值扣减,流水不应只保存“扣了 1 件”这个结果,还应保存动作类型、业务号、前置状态、后置状态、操作来源、处理版本和关联事件。这样在出现差异时,可以按时间顺序重放变更,而不是只比较两个最终数字。
审计流水也要防止重复写入。建议使用业务动作唯一键,例如“订单号+动作类型+明细号”,并区分业务重试与人工补偿。人工补偿必须留下操作者、原因、审批依据和原始差异,不能直接执行一条没有上下文的 UPDATE。
stock = stock - quantity 形式。stock >= quantity 的库存下限条件。
如果一次请求扣减 5 件,条件必须是 stock >= 5,而不是只判断 stock >= 1。这类错误在单件商品测试中很难发现,却会在批量采购、组合商品和批量配额扣减时直接造成负库存或数量差异。
一个订单可能包含多个 SKU。如果事务 A 按 SKU 1、SKU 2 的顺序加锁,事务 B 按 SKU 2、SKU 1 的顺序加锁,就可能形成死锁。处理多行库存时,应统一锁顺序,缩短事务时间,并对死锁异常设计有限重试。
余额类业务通常还涉及冻结金额、可用金额、已用额度和账务流水。只更新 available_balance 而不记录业务流水,短期看似正确,长期无法解释余额来源。对于资金场景,账务流水和余额快照应当具备可审计关系,不能依赖缓存或报表反推。
影响行数为 1,只能说明某一条 UPDATE 修改了目标行。若后续订单状态更新失败,或者消息发送失败,整个业务仍可能处于半成功状态。因此,扣减成功后的业务确认必须有明确状态机,不能把数据库 UPDATE 的成功直接等同于订单最终成功。
生产环境直接执行 UPDATE inventory SET stock = ... 是最后手段,不应成为日常补偿方式。正确做法是创建一条可审计的调整流水,通过专门的调整接口或脚本完成,并记录原始值、目标值、调整原因、审批人和关联对账单。
并发扣减导致账实不一致,表面上是一个数字问题,实际是一个执行语义问题:业务认为某次扣减成功,数据库是否只执行了一次;数据库认为余额发生变化,流水和订单是否同步承认这次变化;页面展示一个数字时,它是否仍然代表当前事实。
因此,排查顺序应该是:先统一统计口径,再按业务单号去重,随后核对库存变化、流水数量和订单状态,最后才深入 SQL、锁、事务、消息和缓存。这个顺序能避免团队一开始就在“悲观锁还是乐观锁”的争论里浪费时间。
最值得坚持的一条原则是:防并发错误靠前置控制,发现遗漏靠对账,解释差异靠审计流水。条件更新可以避免丢失更新,幂等设计可以减少重复扣减,事务或可靠事件可以保证变更传播,而对账机制则负责处理所有不可预见的异常。只有把这四层能力组合起来,库存、余额或配额系统才不会因为一次超时、一次重试或一次服务重启而悄悄失去可信度。
我最近在排查一个库存问题:两个请求都返回扣减成功,但库存只减少了一次,订单数量和库存流水也对不上。我原本以为数据库开启了事务就不会出现这种情况,想知道到底是哪一步发生了数据覆盖。
最常见的原因不是“数据库不会扣减”,而是应用先读取库存,再在应用层计算新值,最后把计算结果覆盖回数据库。
例如初始库存为 10,请求 A 和请求 B 几乎同时执行以下流程: 时间请求 A请求 B T1读取库存 10 T2读取库存 10 T3计算得到 9 T4计算得到 9 T5写入库存 9 T6写入库存 9 业务上已经成功处理了两次扣减,库存理论上应该是 8,但最终字段却是 9。
这就是典型的丢失更新:后提交的旧计算结果覆盖了先提交的结果。我实际排查时不会先问“要不要加锁”,而是先检查 UPDATE 是否类似 UPDATE inventory SET stock = ?WHERE sku_id = ?。如果问号里的值来自此前的 SELECT,就要高度怀疑存在覆盖写。
更稳妥的写法是让数据库基于当前值完成扣减:
UPDATE inventory SET stock = stock - :quantity WHERE sku_id = :sku_id AND stock >= :quantity;然后必须检查影响行数。影响行数为 1,才表示扣减成功;影响行数为 0,可能代表库存不足、商品不存在,或者 WHERE 条件没有命中。事务可以保证这条 SQL 的执行边界,但不能修复“先读、后算、再覆盖”的错误业务模型。
我已经把库存扣减改成了带 stock >= quantity 条件的 UPDATE,压测时也没有明显超卖。但线上仍然出现库存字段、库存流水和订单状态不一致的情况,我不确定这是不是数据库并发问题,还是事务边界设计错了。
条件更新主要解决的是“同一库存行被并发覆盖”的问题,但它不等于整个扣减流程具备一致性。库存扣减通常还包含订单状态变更、流水写入、优惠占用和消息发送,这些动作如果不在同一个可靠的一致性边界内,仍然会出现账实不一致。例如扣减流程可能是: 库存表扣减成功;写入库存流水时数据库连接超时;
接口返回失败,业务方稍后重试;重试请求再次扣减或重新写流水。最终可能出现库存少了两次、订单只有一条,或者库存只少了一次但流水少记一条。这里的关键不是“UPDATE 是否原子”,而是一次业务操作中的多个结果是否能被同一个业务单号准确关联和幂等处理。
我排查时会把四组数据放在同一张核对表里,而不是只看库存字段: 数据对象重点检查典型异常 库存表余额变化量字段少扣或多扣 库存流水扣减记录数量和总量重复记录或漏记 订单表最终成功订单数订单状态回滚或重复成功 请求日志request_id、order_id、重试次数同一业务请求执行多次 如果库存表和库存流水必须强一致,且两者位于同一个数据库,通常可以将扣减、流水写入和幂等记录放进同一事务。
如果跨数据库或跨服务,就不能继续用“加一个本地事务”来解决,而要设计可靠事件、事务消息或可重入的对账补偿机制。我的判断标准是:条件更新负责防止并发覆盖,幂等机制负责防止重复执行,事务或可靠消息负责处理多步骤提交,对账机制负责发现已经发生的遗漏。它们解决的是四个不同问题,不能互相替代。
团队里有人建议所有扣库存的代码都使用 SELECT FOR UPDATE,也有人认为乐观锁性能更好,还有人直接采用条件 UPDATE。我不想只凭经验选方案,应该根据哪些实际指标判断哪一种更适合自己的业务?
我不会把“哪种锁最好”当成一个脱离场景的问题。真正需要先确认的是:单个 SKU 是否是热点、扣减是否必须同步返回、业务能否接受重试、库存是否跨服务,以及失败后的补偿成本有多高。
三种常见方案的差异可以这样理解: 方案优势主要代价更适合 条件更新SQL 简洁,减少读改写窗口热点行仍有竞争,复杂校验不易表达单表扣减、同步下单、规则简单 悲观行锁事务内逻辑直观,强一致性较强锁等待、死锁、连接池占用并发可控且必须同步确认的场景 乐观锁不长期持有锁,适合短事务高冲突时重试放大数据库压力冲突率中等、可接受失败重试的场景 如果只是“库存不少于扣减数量”这一条判断,我通常优先考虑条件更新: UPDATE inventory SET stock = stock – :quantity WHERE sku_id = :sku_id AND stock >= :quantity;
如果扣减前还必须读取多项数据,并且这些数据必须在同一个事务视图中保持稳定,才考虑 SELECT … FOR UPDATE。但要特别注意,锁的持有时间不能包含远程调用、复杂计算或等待用户操作,否则很容易把一个库存问题变成数据库锁等待问题。乐观锁并不天然更快。
我在压测设计中会重点观察版本冲突率、重试次数、数据库 CPU 和 P99 延迟。如果冲突率已经很高,失败重试会把一次业务请求变成多次 UPDATE,最终可能比短时间行锁更差。建议先用真实热点数据做压测,而不是只测平均并发。重点记录单个 SKU 的请求集中度、锁等待时间、死锁次数、重试率和成功延迟。
选型应以这些数据为依据,而不是依据“乐观锁性能好”或“加锁最安全”这种脱离场景的结论。
我现在面对的现象是页面显示库存 7,数据库库存 8,流水统计却显示已经扣减了 2 次。研发、测试和运维各自看了自己的日志后都有不同结论,我想建立一套不用猜、可以快速定位的排查顺序。
这类问题最忌讳先改代码再找证据。我的排查顺序是先固定一个业务时间窗口,再用 order_id、request_id、sku_id 和消息 ID 把一次扣减链路串起来,最后比较“应该扣多少”和“实际扣多少”。
可以先按下面的现象分类: 现象优先怀疑第一条证据 成功请求数为 2,库存只减少 1丢失更新或覆盖写检查是否先 SELECT 后 UPDATE 订单成功 1 条,库存减少 2重试、重复消费、幂等失效查同一订单的扣减流水和请求次数 库存和流水一致,但页面数字不同缓存或异步同步延迟对比缓存更新时间和数据库提交时间 库存正确但流水少记录事务边界或流水写入失败查数据库错误、回滚和补偿日志 接着做一笔最小化对账。
设初始库存为 100,在同一时间窗口内,数据库理论库存应满足: 理论库存 = 初始库存 – 成功扣减总量 + 成功回补总量这里的“成功扣减总量”不能直接拿接口调用次数代替,必须根据扣减 SQL 的影响行数、订单最终状态和幂等记录共同确认。
接口超时尤其容易误导人,因为超时只说明调用方没有及时收到结果,不代表数据库事务没有提交。如果页面库存比数据库旧,先查缓存写入时间、失效时间和重建逻辑;如果数据库库存比流水少,先查是否存在覆盖式更新;如果流水多于订单成功数,则优先检查消息重复消费、补偿任务重复执行和订单回滚后流水未冲正。
我建议在线上保留一条可检索的业务链路日志,至少包含 request_id、order_id、sku_id、扣减数量、尝试次数、SQL 影响行数、事务结果和消息处理结果。没有这些字段时,团队只能依赖时间戳和模糊日志拼接链路,排查速度通常会明显变慢。最后要区分“账面不一致”和“实物不一致”。
数据库、流水、订单之间的数字不一致,是软件账务问题;数据库与仓库真实盘点数量不同,则还要继续检查出入库、盘点、退货和人工操作,不能把所有差异都归因于并发扣减。


读者评论
文章把“账实不一致”拆成库存表、流水、订单和缓存四种口径,这一点很实用。实际排查时确实不能只看接口成功数,否则很容易把缓存延迟误判成数据库扣减错误。
并发屏障复现丢失更新的思路比较有价值,比单纯提高压测线程数更容易稳定暴露问题。不过实际方案还应结合数据库隔离级别和执行计划验证。
文中对幂等、重复重试和异步消息的分析比较全面。库存系统不能只依赖行锁,最好同时建立业务单号、流水记录、消息重试和定期对账机制。