《数据库存:架构师操作手册:订单取消中的并发扣减怎么落地》真正要解决的,不是“取消订单后把库存加一”这么简单,而是要在取消、支付、发货、重试和消息乱序同时发生时,保证库存只被正确释放一次。我的判断是:订单取消中的库存回补,必须由订单状态机、数据库条件更新、幂等流水和补偿对账共同完成,单独依赖数据库锁、Redis 或消息队列都不够。
在设计任何 SQL、接口或消息之前,我通常会先写出系统不变量。不变量就是无论并发多高、消息重试多少次,都不能被破坏的业务规则。
这四条规则比“使用乐观锁还是悲观锁”更重要。因为锁只解决一部分并发访问问题,而不能回答“这次回补是否已经执行过”“这个订单是否真的占用过库存”“取消与支付谁应该成功”等业务问题。
对于单体应用或订单服务、库存服务共用一个数据库的系统,我建议先落地一套可验证的最小闭环:
这套方案不一定是吞吐量最高的方案,但它有一个很大的优势:每一次库存变化都可以解释、重放和核对。对于订单系统来说,可解释性通常比单次压测中多出的几个百分点吞吐更重要。

订单取消时到底更新哪一个库存字段,取决于下单时扣减的是什么。常见模型至少有三种。
| 库存模型 | 下单时动作 | 取消时动作 | 主要风险 |
|---|---|---|---|
| 可售库存模型 | available_stock 减少 | available_stock 增加 | 重复释放导致库存虚增 |
| 预占库存模型 | reserved_stock 增加 | reserved_stock 减少,available_stock 增加 | 两个字段更新不一致 |
| 支付后实扣模型 | 只创建预占记录 | 撤销预占,不一定涉及实物库存 | 把撤销预占误当成库存回补 |
如果团队连“扣减库存”这一句话具体指哪个字段都没有统一,后面的事务、缓存和消息设计都会产生歧义。我的建议是,在数据库字段注释、接口文档和库存流水中明确写出库存语义,禁止只使用一个含义模糊的 stock 字段承载所有状态。
假设 SKU-A 初始可售库存为 10。用户下单时占用 1 件,数据库库存变成 9。随后订单超时取消,取消接口调用库存服务时发生网络超时。订单服务无法确认库存释放是否成功,于是客户端再次提交取消,定时任务也在几分钟后扫描到这笔订单。
如果三个入口都直接执行下面这条 SQL,最终库存可能从 9 变成 12:
UPDATE sku_stock SET available_stock = available_stock + 1 WHERE sku_id = 'SKU-A';
数据库可能完全没有报错,事务也可能全部成功。问题不在数据库没有保证原子性,而在于系统把三次不同来源的请求都误认为是三次合法库存释放。
这类问题非常隐蔽。订单看起来是“取消成功”,库存也没有变成负数,应用日志甚至都是成功日志,只有在后续销售高峰、盘点或库存对账时,才会发现可售库存多出来了。
更难处理的是取消和支付之间的竞争。订单处于待支付状态时,用户点击取消;与此同时,支付渠道的成功回调也到达订单服务。两个请求都读取到“待支付”,如果使用先查后改,两个请求都可能继续执行。
结果可能出现三种错误:
第三种情况最危险,因为仅检查订单最终状态已经无法发现问题。正确做法是让状态转换本身具备竞争裁决能力,只有抢到合法状态转换的一方才能继续触发后续动作。
订单取消并不只发生在待支付阶段。有些业务允许支付后取消,也有些系统在配货前允许撤销。此时库存的含义已经变化:商品可能已经进入拣货、锁定仓位或生成出库单。
如果取消接口只判断订单是否“未完成”,而没有识别“配货中”“已生成出库单”等中间状态,就可能出现库存释放了,但仓库仍然按照原出库单发货的情况。库存问题最终会表现为仓储账实不符,而不是一条明显的 SQL 错误。

在同库事务中,可以把订单状态更新和库存回补放在一次事务中,二者提交结果一致。但在订单服务和库存服务拆分后,取消接口往往只能先完成订单状态转换,再发送库存释放事件。
因此,系统需要区分至少三个结果:
| 结果 | 含义 | 用户侧处理 | 后台处理 |
|---|---|---|---|
| 取消成功,库存释放成功 | 主流程闭环 | 正常展示已取消 | 记录成功流水 |
| 取消成功,库存释放处理中 | 订单已完成状态变更,库存尚未确认 | 不重复提交取消 | 重试、查询、对账 |
| 取消失败 | 订单没有从可取消状态转换成功 | 返回已支付、已发货等明确原因 | 不触发库存释放 |
如果接口只返回一个模糊的“操作成功”,前端、客服、运维和补偿任务都会误判。异步系统尤其需要把业务状态和库存处理状态分别建模。
下面的逻辑在低并发测试中通常表现正常:
SELECT available_stock FROM sku_stock WHERE sku_id = :sku_id; if available_stock >= quantity: UPDATE sku_stock SET available_stock = available_stock - quantity WHERE sku_id = :sku_id;
问题是,查询结果只是某一个时间点的快照。多个事务可能同时读取到同一个库存值,然后相继执行无条件更新。即使数据库隔离级别较高,也不建议把正确性寄托在应用层的“查询后判断”上。
扣减应该把业务条件放进更新语句,回补也应该把释放资格和数量上限纳入事务判断。
分布式锁可以让同一订单的多个请求尽量串行,但它并不自动解决以下问题:
我更倾向把分布式锁视为“降低竞争噪声”的工具,而不是正确性边界。正确性仍然应该由数据库唯一约束、条件更新和业务流水保证。
消息队列能解决削峰和解耦,但通常不能保证业务层面的恰好执行一次。消费者可能已经完成数据库提交,却在确认消息前宕机,于是同一消息再次投递。
如果消费端每次收到消息都直接加库存,消息可靠性越高,重复加库存的风险反而越大。正确的消费逻辑应该是:先校验幂等记录,再校验订单明细占用状态,然后执行一次库存操作并落流水。
当前库存只能回答“现在是多少”,无法回答“为什么是这个数”。当发生库存漂移时,工程师需要知道某个订单是否扣过、是否释放过、释放了几次、是哪一次重试导致异常。
没有流水表时,排查只能依赖应用日志。日志可能被采样、过期、丢失或跨服务分散保存,无法作为稳定的业务账本。库存流水不一定要永久保存所有详细上下文,但至少应该保留订单明细、操作类型、数量、前后库存和幂等键。
缓存适合承接热点访问,但订单取消后的库存释放通常需要可追溯和可校验。缓存更新成功、数据库更新失败,或者数据库成功、缓存刷新失败,都会产生短暂或长期不一致。
系统必须明确“谁是权威库存”。如果数据库是权威,就要设计缓存失效、刷新和异常重建流程;如果采用缓存预扣,则必须有数据库落账、超时回查和对账机制。不能只因为缓存里的数字变化很快,就把它当作库存账本。

这是架构设计的第一道分叉。如果订单表、订单明细、库存表和库存流水在同一个数据库实例中,最简单可靠的办法通常是本地事务。不要在还没有明确性能瓶颈之前,过早引入复杂的分布式事务。
一个典型的同库事务流程是:
如果其中任意一步失败,整个事务回滚。这样订单状态和库存释放可以在同一个提交点完成。
但同库事务也不是无限扩展的方案。订单包含多个 SKU、库存行分散在多个分片、事务持锁时间过长时,锁竞争会明显增加。此时需要重新评估明细拆分、操作顺序和异步释放。
如果业务要求用户点击取消后立即可以看到库存恢复,或者下一个用户必须马上抢到释放出来的库存,就需要更强的同步确认。否则,取消请求可以先返回“订单已取消,库存释放处理中”,通过消息异步完成。
| 业务要求 | 推荐处理 | 代价 |
|---|---|---|
| 库存必须立即可售 | 同步调用库存服务并确认结果 | 接口延迟和级联故障增加 |
| 允许秒级延迟 | 本地事务加可靠消息 | 需要重试、查询和对账 |
| 允许分钟级延迟 | 异步事件加批量补偿 | 库存短时间内可能不可售 |
这里有一个容易被忽略的取舍:异步释放并不是免费获得高性能,而是用库存可售延迟换取服务解耦和吞吐缓冲。如果业务不能接受释放延迟,就不应该仅因为“消息队列更先进”而强行异步。
对于单 SKU、整单取消的简单订单,order_id + operation_type 可能足够。但在多商品订单中,整单取消可能部分成功,或者其中一个 SKU 已经发货,另一个 SKU 仍可取消。这时订单级幂等键过粗,会导致一个明细失败后整单无法重试。
更稳妥的粒度通常是:
idempotency_key =
order_id + order_line_id + inventory_operation + operation_version
如果支持分批退款、部分取消或多次补发,必须增加操作版本或业务事件号。幂等键不是越简单越好,而是要准确描述“一次不可重复的业务动作”。
消息乱序并不总是需要全局排序。关键要看事件之间是否存在因果关系。例如同一订单明细的“扣减成功”必须先于“释放库存”,但不同订单之间通常不需要排序。
我的建议是:
局部有序通常比全局有序更适合库存系统。全局有序会把无关 SKU 的操作绑在一起,增加消息积压和故障影响范围。
悲观锁适合保护短事务内的关键行,乐观锁适合冲突不是特别频繁、失败后可以快速重试的场景,条件更新适合把单行不变量直接交给数据库判断。
在库存扣减和回补中,我通常优先考虑“条件更新加影响行数判断”,而不是先读取后加锁。原因是库存充足、释放上限和状态条件本身都可以写入 SQL,减少应用层暴露的竞态窗口。

下面是一种适合预占库存模型的简化结构。实际项目可能还要增加仓库、渠道、批次、租户和库存版本等字段。
CREATE TABLE sku_stock (
sku_id VARCHAR(64) PRIMARY KEY,
available_stock BIGINT NOT NULL DEFAULT 0,
reserved_stock BIGINT NOT NULL DEFAULT 0,
sold_stock BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL
);
字段之间还应有业务不变量。例如可售库存、预占库存和已售库存不能出现负数;如果系统还维护物理库存,则需要定义它们之间的关系,而不是依靠运维人员凭经验解释。
库存释放不能根据订单商品数量临时推算,因为订单可能发生拆单、部分取消、库存不足、改价或部分发货。订单明细至少需要记录“实际占用数量”,并区分已释放数量。
CREATE TABLE order_line (
id BIGINT PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
sku_id VARCHAR(64) NOT NULL,
ordered_quantity BIGINT NOT NULL,
reserved_quantity BIGINT NOT NULL DEFAULT 0,
released_quantity BIGINT NOT NULL DEFAULT 0,
fulfilled_quantity BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_order_line (order_id, id)
);释放数量必须受到“实际占用量减已释放量”的约束。如果只依据 ordered_quantity 回补,部分发货和部分释放场景很容易产生库存虚增。
CREATE TABLE inventory_operation (
idempotency_key VARCHAR(160) PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
order_line_id BIGINT NOT NULL,
sku_id VARCHAR(64) NOT NULL,
operation_type VARCHAR(32) NOT NULL,
quantity BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
before_available BIGINT,
after_available BIGINT,
error_code VARCHAR(64),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_line_operation
(order_line_id, operation_type, idempotency_key)
);
主键或唯一键的作用不是为了“方便查询”,而是把重复执行的判断下沉到数据库。应用层先查再插仍然可能发生并发竞态,因此推荐直接插入,捕获唯一键冲突后读取历史处理结果。
UPDATE sku_stock SET available_stock = available_stock - :quantity, reserved_stock = reserved_stock + :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_stock >= :quantity;
执行后检查影响行数。影响行数为 1,表示该 SKU 的库存条件满足并完成扣减;影响行数为 0,表示库存不足、SKU 不存在或条件没有满足。不要先查库存再根据查询结果进行无条件更新。
在同库事务中,先用条件更新确认订单或订单明细具备释放资格,再插入幂等记录,最后更新库存。对于高并发系统,操作顺序必须固定,例如所有事务都先锁订单明细,再锁 SKU 库存,避免不同代码路径以相反顺序持锁。
BEGIN;
UPDATE order_line
SET released_quantity = released_quantity + :quantity,
status = 'RELEASED',
version = version + 1
WHERE id = :order_line_id
AND reserved_quantity – released_quantity >= :quantity
AND status IN ('RESERVED', 'CANCEL_PENDING');
— 检查影响行数必须为 1
UPDATE sku_stock
SET available_stock = available_stock + :quantity,
reserved_stock = reserved_stock - :quantity,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE sku_id = :sku_id
AND reserved_stock >= :quantity;— 检查影响行数必须为 1
— 同时写入 inventory_operation 与库存流水
COMMIT;
示例中的字段和状态是简化模型,重点在于三个条件:订单明细仍有未释放的预占量、释放数量不能超过预占量、库存中的预占量足以减少。生产系统还要结合仓库和履约状态调整。
UPDATE orders
SET status = 'CANCELLED',
cancel_reason = :reason,
cancelled_at = CURRENT_TIMESTAMP,
version = version + 1
WHERE order_id = :order_id
AND status IN ('PENDING_PAYMENT', 'RESERVED');影响行数为 1 时,当前请求取得了取消资格。影响行数为 0 时,不能直接返回“取消成功”,而应重新查询订单状态,区分已经取消、已经支付、已经发货和订单不存在等情况。
在状态转换成功后再触发库存释放,可以避免取消请求在没有取得业务资格的情况下产生库存副作用。

客户端重试时可能生成新的请求 ID,服务端超时重试也可能产生新的链路 ID,消息重投还会携带新的投递标识。如果系统用请求 ID 判断重复,实际上只能防止同一次网络请求重复,无法防止同一个业务动作被多个入口重复触发。
库存释放的幂等键应该表达业务事实,例如“订单明细 1001 的取消释放第 1 版”。请求 ID、链路 ID 和消息 ID可以作为审计字段,但不应取代业务幂等键。
越细的幂等粒度,处理复杂业务的能力越强,但查询、索引和对账成本也会增加。不要在业务仍然简单时设计过度复杂的事件模型,也不要在已经支持部分取消的系统中坚持使用整单唯一键。
以“订单取消释放库存”消息为例,消费端不应收到消息后直接执行加库存。建议按照以下步骤处理:
如果插入幂等记录成功,但后续数据库更新失败,不能简单删除幂等记录后无限重试。更稳妥的方式是把记录置为失败或待重试,并保存错误类型,避免临时故障和业务拒绝被混为一谈。
幂等表至少建议区分处理中、成功、失败和不可重试失败。比如数据库连接短暂中断属于可重试错误,订单已经发货属于不可重试业务错误,重复事件属于已处理结果。
| 状态 | 含义 | 是否重试 | 推荐动作 |
|---|---|---|---|
| PROCESSING | 已经抢到处理资格 | 超时后可重试 | 查询数据库确认是否已提交 |
| SUCCESS | 库存释放已经提交 | 否 | 返回历史结果 |
| RETRYABLE_FAILED | 临时故障导致失败 | 是 | 指数退避并进入重试队列 |
| REJECTED | 状态或参数不允许 | 否 | 告警并进入人工处理 |

订单服务完成取消状态转换时,可以在同一个本地事务中写入业务事件表。后台投递程序持续扫描未发送或发送失败的事件,将取消释放事件投递给库存服务。
CREATE TABLE outbox_event (
event_id VARCHAR(64) PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(32) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
订单状态和事件表在同一事务中提交,因此不会出现“订单已经取消,但服务进程在写消息前宕机,事件完全不存在”的情况。事件投递可能重复,但库存消费端通过幂等键处理即可。
某些消息系统提供事务消息或半消息机制,可以降低消息丢失概率,但它仍然不能替代消费端幂等。消息可能因为网络重试再次到达,消费者也可能在业务处理后确认失败。
我在评审这类方案时,会把问题拆成两张表:
| 问题 | 主要负责组件 | 不能指望谁自动解决 |
|---|---|---|
| 订单状态和事件是否一起提交 | 本地事务、事件表 | 消息队列本身 |
| 事件是否最终投递 | 投递程序、重试、死信 | 数据库事务本身 |
| 事件重复消费 | 消费幂等表、唯一约束 | 消息系统的“尽量一次”承诺 |
| 订单和库存最终是否一致 | 对账和补偿 | 任意单一中间件 |
数据库连接中断、锁等待超时、临时网络故障,通常可以重试;订单已经发货、订单明细不存在、释放数量超过占用量,则更可能是业务数据问题,盲目重试只会制造更多噪声。
建议采用指数退避,例如第一次 1 秒、第二次 5 秒、第三次 30 秒,之后进入延迟队列或死信队列。具体时间要结合库存释放时效和峰值流量设定,不能直接照搬。
补偿任务不能看到“订单已取消”就直接加库存。它至少要核对订单明细、库存操作流水和当前释放数量。如果已经存在成功释放记录,只补齐缺失的事件状态,不再次修改库存。
补偿操作也要生成独立操作记录,并标注来源为自动补偿。这样后续审计可以区分用户取消、消息重试、定时补偿和人工调整。

下面用一个典型电商订单做推演。订单 O20260916001 包含 SKU-A 2 件,下单时成功预占 2 件。订单服务在超时取消时先把订单改为已取消,再向库存服务发送释放事件。
由于库存服务处理成功后网络连接中断,订单服务没有收到确认。客户端再次提交取消,超时扫描任务也在稍后发现订单已取消,于是分别产生两条释放请求。
| 时间 | 事件 | 错误实现结果 | 正确实现结果 |
|---|---|---|---|
| 10:00:00.100 | 下单预占 2 件 | 预占库存 +2 | 预占库存 +2,记录扣减流水 |
| 10:30:00.200 | 首次取消释放 | 释放 2 件 | 释放 2 件,幂等记录成功 |
| 10:30:00.260 | 客户端重复取消 | 再次释放 2 件 | 命中成功幂等记录,库存不变 |
| 10:30:05.000 | 定时任务重试 | 再次释放 2 件 | 发现明细已无未释放占用量,不再更新库存 |
这个案例中,错误实现最终多释放了 4 件,但订单页面仍然只是“已取消”。如果系统只监控订单状态和接口成功率,很难快速发现异常。只有把库存流水和订单明细释放数量放在一起对账,才能确认重复副作用。
对于预占库存模型,可以建立以下基础核对关系:
当前预占库存
= 成功预占总量
成功释放总量
已转实扣总量
当前可售库存
= 初始可售库存
+ 成功释放总量
成功预占总量
+ 其他合法调整量
这里的“成功”必须来自库存流水或操作表,而不是接口访问日志。接口日志只能说明“请求曾经到达”,不能说明事务是否提交,更不能说明这次业务动作是否已经被其他请求执行过。
在实际监控中,我不会只看库存负数。库存虚增往往比库存扣成负数更难发现,因此建议同时监控释放总量、预占总量和库存变更流水。
下面是一组用于设计评审的情景模拟,不是某个企业的生产数据。假设 1,000 个并发请求同时取消 100 个订单,其中每个订单可能被客户端、服务重试和定时任务重复触发。
| 方案 | 重复释放次数 | 最终对账差异 | 数据库锁等待 | 补偿复杂度 |
|---|---|---|---|---|
| 无条件加库存 | 最高 | 可能大于 0 | 较低 | 高,难以追责 |
| 只加分布式锁 | 中等 | 仍可能大于 0 | 中等 | 中高,依赖锁实现 |
| 条件更新加幂等表 | 接近 0 | 应为 0 | 可观测 | 中等,可定位 |
| 条件更新、幂等表加对账 | 接近 0 | 应为 0 | 可监控并优化 | 较低,异常可恢复 |
这个观察说明一个重要事实:正确性不是通过“完全不失败”实现的,而是通过“失败可识别、重复可拦截、结果可核对”实现的。

如果当前系统是单体应用,订单和库存表在同一个数据库中,而且取消请求量并不高,建议先使用本地事务、条件更新、明细级幂等表和库存流水。
不建议一开始就引入消息队列和分布式锁。组件越多,异常路径越长,团队需要维护的状态也越多。先把同库事务中的正确性闭环做出来,再用压测确认数据库是否真的成为瓶颈。
拆分后不能再假设一个本地事务可以覆盖所有动作。订单服务应在本地事务中完成订单状态转换和事件记录,库存服务消费事件并独立保证幂等。
订单取消接口可以返回“取消成功,库存释放处理中”,但必须提供查询能力,例如查询库存释放状态、事件状态和最终处理结果。否则异步化只是把用户能看到的错误变成后台不可见的错误。
热点 SKU 的竞争焦点通常集中在少数库存行上。单纯增加数据库连接数可能让锁等待更严重。此时可以考虑缓存预扣、分段库存、队列串行化或按库存分片,但每一种方案都要保留数据库落账和对账依据。
如果使用缓存预扣,取消释放还要处理缓存扣减和数据库回补之间的失败窗口。不能因为缓存中库存加回成功,就认为数据库账已经完成。
这类系统必须以订单明细、仓库和库存批次为操作粒度。整单一个库存释放事件通常不够,因为不同明细可能处于不同履约状态。
建议把库存操作对象明确为:
order_id + order_line_id + warehouse_id + inventory_batch + operation_version
同时,取消规则必须由订单状态和履约状态共同决定。已生成出库单但尚未发货的订单,可能需要先撤销出库单,再释放仓库库存,而不是直接更新可售库存。
如果用户不要求取消后立即看到库存恢复,可以选择可靠消息加补偿。这个方案更适合服务拆分和流量波动明显的系统,但要设定明确的最终一致性目标,例如正常情况下 1 秒内完成,异常情况下 5 分钟内进入补偿。
没有时效目标的“最终一致性”无法验收。研发只能说“最终会一致”,业务却无法判断什么时候应该告警。

Redis 可以承担热点库存判断、限流和快速扣减,但要明确它与数据库之间的关系。常见做法是缓存先扣、异步落库;这种设计的核心风险是缓存扣减成功而数据库落账失败。
因此至少要有:
如果团队没有能力维护这些机制,宁可先采用数据库条件更新,也不要仅为了追求峰值吞吐使用缓存预扣。
分布式锁适合在热点操作中减少同一业务对象的同时竞争,例如短时间内同一个订单重复取消。但锁应当是优化手段,不能作为唯一正确性保障。
使用分布式锁时,需要明确:
如果锁的粒度是 SKU,所有订单取消都可能被同一件热点商品阻塞;如果锁的粒度是订单,则不同入口对同一订单的互斥较好,但无法解决多个订单同时扣减同一 SKU 的问题。锁粒度本身就是业务性能决策。
数据库最适合保存当前库存、库存流水、幂等记录和订单明细占用量。它不一定要承受所有读流量,但应该具备拒绝非法状态的能力。
例如,库存表可以通过条件更新拒绝预占库存不足;明细表可以通过条件更新拒绝释放超过占用量;唯一索引可以拒绝同一操作重复写入。这些约束比应用日志中的“理论上不会发生”更可靠。
第六和第七类尤其重要,因为它们模拟了最常见的“业务已成功,但调用方以为失败”的情况。客户端和消息系统都会在这种情况下重试,如果没有幂等保护,就会把一次正常故障放大为库存异常。
压测结束后,不能只报告平均响应时间、吞吐量和错误率。库存系统的核心验收条件应包括:
普通压测主要验证系统在理想条件下的性能,故障注入则验证设计是否真正具备恢复能力。可以在以下位置主动制造失败:
每次演练都要在最后执行一次全量或增量对账,确认系统不是“日志看起来成功”,而是库存账务真的闭环。

本地事务最大的优势是结果清晰,订单状态、明细释放和库存流水可以同时提交。研发成本低,排查路径短,适合单体系统和中等规模交易系统。
它的边界也很明确:一旦订单和库存分库、分服务或跨仓库,事务范围无法自然扩展。此时继续强行把所有操作同步串起来,可能导致长事务、连接占用和级联超时。
可靠消息适合服务解耦、流量削峰和异步处理。订单服务不必一直等待库存服务完成,库存服务也可以根据自身吞吐消费。
它增加的代价包括消息积压、重复消费、乱序事件、死信处理、补偿任务和最终一致性查询。团队必须具备消息治理能力,否则系统只是从“数据库事务复杂”变成“后台状态复杂”。
缓存预扣适合热点 SKU 和高峰流量,但它通常把正确性问题从数据库前移到缓存、消息和落账链路。缓存中的数字变化很快,却不一定能解释库存为什么变化。
只有在团队已经具备库存事件账本、异步落账、缓存重建和对账能力时,缓存预扣才适合作为高峰优化方案。否则,数据库条件更新往往是更稳妥的起点。
分布式锁接入直观,可以降低某个订单或热点 SKU 的竞争强度。但锁会带来等待、过期、续期、故障转移和死锁规避问题。
它适合优化并发,不适合承担库存账务的最终正确性。即使使用锁,也必须保留条件更新和幂等流水。
| 方案 | 一致性可见性 | 峰值吞吐潜力 | 工程复杂度 | 适合团队 |
|---|---|---|---|---|
| 本地事务 | 高 | 中 | 低到中 | 单体或同库团队 |
| 可靠消息 | 最终一致 | 高 | 中到高 | 具备消息运维能力的团队 |
| 缓存预扣 | 依赖落账闭环 | 高 | 高 | 热点流量和高峰明显的团队 |
| 分布式锁 | 不独立保证 | 中到低 | 中 | 需要控制局部竞争的团队 |

上线前最值得做的一件事,是随机抽取一批已取消订单,逐条核对订单状态、订单明细占用量、幂等记录、库存流水和当前库存变化。抽查不需要覆盖所有订单,但必须覆盖重复取消、支付后取消、部分取消和消息失败重试等异常类型。

不一定。是否同步取决于库存释放时效和业务承诺。如果取消后释放出的库存必须立即被其他用户购买,就应该同步确认或采用极低延迟的库存服务。如果允许短时间库存不可售,可以异步回补,但必须明确延迟目标和补偿机制。
需要。行锁只能控制同一时刻的并发访问,不能记录某个业务动作是否已经完成。事务提交后锁会释放,重复请求仍然可以再次获得锁并执行加库存。幂等表和唯一约束负责识别“这次动作以前是否成功”。
如果下单时扣减的是可售库存,取消时确实可能需要加回可售库存。但仍然要校验订单是否实际占用过库存、是否已经释放过、是否部分发货,以及是否存在仓库和批次限制。没有这些条件的直接加法只适用于极其简单且风险可接受的场景。
应用日志用于排查调用过程,库存流水用于保存业务账务。日志可能采样、过期或分散在不同服务中,库存流水则应以数据库提交结果为准。发生库存漂移时,只有流水才能支持按订单、SKU、操作类型和时间范围重算。
没有脱离业务规则的统一答案。待支付超时取消和支付成功回调之间,通常由先成功完成状态条件更新的一方取得资格;如果支付已经成功,取消可能要进入退款流程;如果取消已经成功,支付回调则应进入关单、退款或人工处理分支。关键是不能让两个副作用都执行。
不建议简单删除。应该根据失败发生的位置判断:如果库存事务已经提交,只是响应丢失,删除记录可能导致重复释放;如果事务尚未执行,可以把记录标记为可重试。幂等记录应保存处理状态和错误原因,方便恢复和审计。
当数据库确实因为热点 SKU 写竞争成为瓶颈,并且团队能够建设异步落账、缓存重建、库存校准和故障降级机制时,再考虑缓存预扣。没有压测数据和对账能力时,缓存预扣很可能只是把数据库问题变成更难排查的数据一致性问题。
订单取消中的并发扣减,本质上是一次“库存释放资格”的竞争,而不是一次普通的数值加法。谁能取消订单、释放多少库存、释放是否已经发生过,都必须由明确的状态和数据约束决定。
如果系统规模较小,优先使用同库本地事务、条件更新、订单明细级幂等和库存流水;如果订单与库存已经拆分,采用本地事务加可靠事件表,再由库存服务消费并幂等处理;如果存在热点 SKU,再根据压测结果引入缓存预扣、分段库存或局部串行化。
我建议下一步按以下顺序执行:
架构师最终要交付的不是一条“能把库存加回去”的 SQL,而是一套能够证明库存没有多还、少还、错还,并且在失败之后可以恢复的业务账本。当每一次库存变化都能对应一个合法订单状态、一个唯一业务事件和一条可追溯流水时,订单取消中的并发问题才算真正落地。
我在一次订单系统改造中遇到过这样的问题:取消接口先查询库存,再执行加库存,结果同一个订单因超时重试被回补了两次。数据库里的库存虽然没有变成负数,但可售库存比实际盘点多了 1 件,我想知道仅靠事务和行锁到底该怎么落地。
订单取消不是简单执行 available_stock = available_stock + quantity。真正安全的顺序是:先用条件更新抢占订单状态,再根据订单明细确认这笔库存确实被占用过,最后在同一个事务中写入幂等记录和库存流水。订单状态必须使用带条件的更新,而不是先查询、后无条件修改。
一个可落地的写法是:
UPDATE orders SET status = 'CANCELLED', cancelled_at = NOW() WHERE order_id = :order_id AND status IN ('PENDING_PAYMENT', 'RESERVED');程序读取影响行数:影响 1 行,说明本次请求成功完成了状态转换;影响 0 行,则表示订单已经被其他请求取消、已经支付,或进入了不可取消状态。此时不能继续执行库存回补,否则会出现订单状态没有变化、库存却被增加的错误。库存扣减和回补都应采用条件更新。
扣减时可以写成:
UPDATE sku_stock SET available_stock = available_stock - :quantity, version = version + 1 WHERE sku_id = :sku_id AND available_stock >= :quantity;回补时不能只依赖库存当前值,还要依赖本次业务操作的幂等记录。数据库层面建议使用“库存当前表 + 库存流水表 + 操作幂等表”的组合,而不是试图用一条 SQL 解决所有业务约束。
方案能解决什么解决不了什么 单纯行锁限制同一行的同时写入不能防止请求重试造成重复回补 条件更新保证库存不会被错误扣成负数不能判断订单是否已经回补过 幂等记录加唯一约束防止同一业务操作重复执行不能替代订单状态校验 三者组合同时覆盖并发、状态和重复请求仍需处理跨服务消息与补偿 我更推荐把订单状态更新、幂等记录插入和库存回补放在同一个数据库事务中,前提是订单与库存确实在同一个数据库事务边界内。
事务提交后再返回成功,接口超时也不等于业务失败,客户端重试时应通过幂等键查询历史结果,而不是再次执行加库存。
我测试过客户端连续点击取消、网关超时重试和定时任务重复扫描三种情况,最容易被忽略的是“第一次请求其实已经提交成功,只是响应没有返回”。如果第二次请求重新走完整流程,库存就会被加两次,单靠接口层判断很难彻底挡住。
取消回补的幂等键应对应一次真实的库存业务操作,而不是简单使用请求流水号。请求流水号每次重试都可能变化,推荐使用订单明细号加操作类型,例如 order_detail_id + RELEASE_STOCK;如果同一明细允许分批取消,还要再加入退款单号或取消批次号。
数据库中可以建立库存操作表,并设置唯一约束:
CREATE UNIQUE INDEX uk_stock_operation ON stock_operation(order_detail_id, operation_type, operation_batch);处理流程建议分为四步。
第一步,在事务中尝试插入回补操作记录;第二步,如果唯一键冲突,则查询原操作结果;第三步,只有首次插入成功且订单状态转换成功时才执行库存增加;第四步,将库存更新前后的数量和处理结果写回流水。这里有一个很容易踩的坑:幂等记录不能只记录“请求到过”,还要记录“库存是否真正处理完成”。
否则,第一次请求插入幂等记录后数据库连接断开,第二次请求看到记录就直接返回成功,但库存实际上没有回补。
操作状态第二次请求应如何处理是否允许直接回补 SUCCESS返回第一次的处理结果不允许 PROCESSING查询或稍后重试不允许直接执行 FAILED_RETRYABLE按错误类型重试需重新获得处理权 FAILED_BUSINESS返回明确业务失败原因不允许 在一次压测中,我会专门把“数据库提交成功、接口响应超时”作为故障注入点,并让同一取消请求连续重试 5 次。
正确结果不是接口全部返回同样的文案,而是库存流水只有 1 条成功回补记录,后续请求都能读到同一个操作结果。如果订单存在多商品明细,幂等粒度最好放在明细级,而不是订单级。订单级唯一键会把“取消商品 A”和“取消商品 B”误判为同一次操作,也无法准确支持部分取消。
我在联调支付回调时见过一种很典型的时序:用户点击取消后,取消请求正在等待数据库锁;与此同时支付平台的成功回调到达。两边都先读取到待支付状态,如果没有明确的状态机和条件更新,订单可能同时出现取消成功和支付成功。
取消和支付不是两个可以各自成功、最后再互相覆盖的普通更新,它们是同一个订单状态机上的竞争转换。系统必须先定义业务规则:通常是谁先完成合法的状态转换谁生效,另一方读取到影响行数为 0 后进入查询、退款或人工处理流程。
例如,取消只允许从 PENDING_PAYMENT 或 RESERVED 转换,支付成功只允许从 PENDING_PAYMENT 转换:
UPDATE orders SET status = 'PAID', paid_at = NOW() WHERE order_id = :order_id AND status = 'PENDING_PAYMENT';取消操作同样使用条件更新。数据库行锁会让两个事务排队,但真正决定业务结果的是条件是否仍然成立,而不是“谁拿到了锁”。第一个事务提交后,第二个事务即使拿到锁,也必须重新依据当前状态判断,不能沿用事务开始前的旧读取结果。
先成功的状态转换后到请求推荐处理 取消成功支付成功拒绝直接标记已支付,进入支付原路退款或异常对账 支付成功取消请求拒绝普通取消,进入退款或售后流程 取消处理中支付回调查询最终状态,不用回调覆盖处理中结果 两者均失败后续重试按当前订单状态重新竞争 库存处理也要跟着状态转换走。
只有订单从可取消状态成功转为已取消,且订单明细存在有效占用记录时,才发布库存释放事件。支付回调不能只看支付平台结果就直接扣库存,还应确认订单是否仍处于允许支付成功的状态。
我建议把“订单状态转换”和“库存释放事件”拆成两个可观察结果:订单取消成功不必假装库存已经同步完成,但必须生成可靠事件或待处理记录。这样即使库存服务暂时不可用,也不会因为接口重试再次改变订单状态。验收时至少测试 4 种时序:取消先提交、支付先提交、取消事务回滚、支付回调重复。
最终应满足一个不变量:同一订单不能同时拥有有效的取消结果和有效的支付成功结果;若支付平台已经扣款而订单取消生效,必须进入可追踪的退款对账链路。
我曾经把库存释放改成异步消息,接口平均耗时明显下降,但随后发现消息重复消费和数据库短暂锁等待会让库存释放延迟几十秒。团队最初只监控消息积压,没有核对订单、库存流水和实际库存,直到运营发现某个热销 SKU 的库存长期对不上。
异步回补的优势是缩短订单接口事务、隔离库存服务故障并承接高峰流量,但它改变了“请求成功”的含义。订单取消成功只代表状态转换和可靠事件记录成功,不一定代表库存已经立刻恢复,因此接口、监控和前端展示都要接受短暂的不一致窗口。
消息体至少应包含事件 ID、订单号、订单明细号、SKU、回补数量、事件类型和业务版本。消费端不能只根据消息中的数量执行加库存,而应依次校验事件是否处理过、订单是否确实取消、该明细是否存在有效占用,以及本次回补是否超过原占用量。消费处理可以采用“业务幂等表加库存事务”的方式。
先用事件 ID 或明细级业务键建立唯一记录,再在同一事务中完成库存更新和处理状态变更;如果消费者在提交后宕机,重复消息只能读到已完成结果,不能再次增加库存。
故障场景错误做法正确动作 消息重复投递每次收到消息都执行加库存按事件 ID 或业务键幂等消费 数据库临时超时无限快速重试指数退避并设置最大次数 订单状态不允许回补把异常当作临时故障重试标记业务失败并进入人工或对账流程 消费者提交后宕机依赖队列“只投递一次”允许重复投递,由消费端保证幂等 对账不能只比较库存表里的一个数字。
我通常会建立这样的核对关系:期末理论可售库存等于期初库存,减去成功占用或扣减数量,加上成功释放数量,再加减人工调整、退货入库和盘点差异。每个加减项都必须能在库存流水中找到对应业务来源。建议按分钟监控回补延迟、重复事件数、死信数、数据库锁等待、库存负数和异常增长。
一次小规模压测中,如果 10 万条取消事件最终只有 9.9 万条回补流水,系统不应只看队列已经清空,而应立即定位剩余 100 条是重试中、业务拒绝、数据缺失还是消费失败。选型上,单库单体系统优先使用本地事务加幂等流水;订单与库存拆成独立服务后,再引入可靠消息和补偿任务;
只有当业务能接受释放延迟时才采用纯异步回补。无论哪种方案,都必须做并发压测和故障注入,验证“重复取消 5 次、重复消息 3 次、消费者中途宕机”后,库存流水仍然只产生一次有效回补。


读者评论
文章把订单取消定义为受约束的状态转换,这个角度比较准确。尤其是用影响行数裁决取消与支付竞争,比单纯依赖分布式锁更容易落地和排查。
库存模型和库存流水的区分很实用。实际项目中如果只维护一个模糊的库存字段,后续很容易出现预占、可售和实扣口径混乱,建议把字段语义提前固化。
跨服务场景下将幂等、可靠消息和对账结合起来是必要的,但文中对补偿任务的执行边界还可以进一步说明,例如如何避免补偿与正常消费同时处理。