☰
分布式事务实战:订单与库存扣减的最终一致性方案解析
2026/10/7 11:01:27 网站建设 项目流程

下单扣库存这个事,做后端的人早晚要遇上。你辛辛苦苦把订单服务拆出来、库存服务独立部署,结果线上突然来一次“订单创建成功但库存没扣”,或者更尴尬的“库存扣了但订单根本没生成”,两边一比对全是脏数据。分布式事务、分布式事务一致性这些词听起来像高深的中间件理论,但落到订单与库存分布式事务这种具体场景里,本质就是一句话:多个服务各自拿着自己的数据库事务,怎么合起来还能像同一个事务一样不出错。这篇文章我尽量用实际能落地的思路把这层窗户纸捅破,适合正在做微服务改造、被数据一致性折磨的后端同学,也适合想选型但被各种方案绕晕的架构师。

1. 先搞明白:分布式事务到底卡在哪

1.1 订单与库存的一个小例子,把问题说透

先看一个最简单的下单流程:用户在商城里点“提交订单”,订单服务在订单库里插入一条订单记录,库存服务在库存库里扣减一个商品库存。

单体时代这根本不是问题,订单表和库存表放同一个数据库,一个本地事务包住两条SQL就行:

BEGIN; INSERT INTO orders(...) VALUES(...); UPDATE stock SET stock = stock - 1 WHERE product_id = ...; COMMIT;

任何一步失败,整个事务回滚,数据不会出现中间状态。

一旦服务拆开,订单表和库存表落在两个独立的数据库里,事务边界也被拆开了。下单接口变成先调订单服务再调库存服务,或者通过消息异步触发库存扣减。这时候最怕的就是“只做了一半”:订单写进去了,库存没扣成;或者库存扣了,订单创建失败。两个事务之间没有原子性,这就是分布式事务要解决的第一个核心问题——跨服务的原子提交。

很多初学者以为“分布式事务”是个框,往里套个什么中间件就完事了。但你先得承认一个现实:网络会超时、服务会宕机、消息会丢失,强一致在分布式环境下是非常昂贵的。后面所有方案,本质上都是在“一致性、可用性、性能”之间做取舍。

1.2 CAP定理:一致性、可用性、分区容错性三选二

分布式系统绕不开CAP。C是一致性,A是可用性,P是分区容错性。网络分区(Partition)在真实环境里一定会发生,所以P是必须选的,剩下的C和A你只能二选一作为优先保障。

拿订单和库存来说:如果优先保证一致性,那当订单服务和库存服务之间网络抖动时,下单接口必须停下来等两边确认,期间整个功能不可用,这就是牺牲了可用性。如果优先保证可用性,那网络抖动时订单照常创建,库存先放一放,用户看到下单成功,但后台数据可能短暂不一致,等网络恢复后再慢慢补齐。

这个取舍没有对错,只有场景适不适合。电商下单这种场景,用户点了一下按钮,你让他等十秒才出结果,他大概率直接卸载App。所以大多数互联网业务宁可选择短暂不一致,也要保证核心链路可用,然后靠补偿机制把数据追平。

1.3 BASE理论:最终一致性才是分布式场景的主流

BASE理论是CAP里选择可用性之后衍生出来的一套实践思路:基本可用(Basically Available)、软状态(Soft State)、最终一致(Eventually Consistent)。

翻译成人话就是:我不保证你查到的数据永远是刚写完的最新值,但我保证经过一小段时间之后,数据一定能收敛到一致状态。整个过程允许有中间状态,这中间状态就是“软状态”。

说个我自己的感受:很多同学一听到“最终一致”就皱眉,觉得这是和稀泥。但实际上,从用户视角看,下单后库存扣减延迟两三秒是完全无感知的。真正要命的不是短暂不一致,而是扣库存和订单状态永远对不上,还没有任何机制去修复。“最终”不是“放任不管”,而是用补偿和对账去兜底,最终收敛。

2. 主流方案横评,少走弯路

2.1 2PC/3PC:教科书里的方案,生产环境慎用

两阶段提交(2PC)是分布式事务最经典的理论模型,分准备阶段和提交阶段。协调者问所有参与方“能不能提交”,大家都说“能”,才统一发提交指令;有一个人说“不能”,就统一回滚。

理论上很完美,但落地就暴雷。最大的问题是同步阻塞:准备阶段事务资源一直被锁住,参与者必须等协调者的最终指令,这个等待期间数据库连接、行锁全被占着,并发稍微一高系统直接瘫。另一个硬伤是协调者单点,协调者一旦宕机,所有参与者卡在中间状态,不知道到底是提交还是回滚。

3PC多加了一个预提交阶段,减少了阻塞窗口,但同样没法根治。所以我的建议很直接:除非你的系统内部多个数据库还在同一个机房、流量很小、并且已经用了XA数据源,否则别在生产环境硬上2PC。它更适合用来理解分布式事务的原理,而不是拿来解决问题。

2.2 TCC:Try/Confirm/Cancel,业务侵入高但最灵活

TCC把每个事务操作拆成三个阶段:Try预留业务资源,Confirm确认执行业务,Cancel释放预留资源。

还是库存这个例子。Try阶段不是直接扣库存,而是把库存“冻结”一部分,比如商品有100件,用户要买3件,Try就把可用库存改成97、冻结库存加3。Confirm阶段确认订单成立,把冻结的3件真正扣掉。Cancel阶段关单失败,把冻结的3件释放回可用库存。

这种做法的好处是控制力最强,状态机由业务自己定义,不受底层资源锁的限制,性能比2PC好太多。坏处也明显:每个参与方都得实现三个接口,而且Try、Confirm、Cancel都得满足幂等,开发成本非常高。TCC适合那种事务链路短、对一致性要求高、业务状态容易拆分的场景。你要是整个系统几十个接口全部套TCC,光是幂等和补偿代码就能写到你怀疑人生。

2.3 Saga:长流程业务的务实之选

Saga的核心思想是把一个大事务拆成一组本地事务,每个本地事务都有对应的补偿事务。比如订单流程:创建订单 -> 扣库存 -> 扣余额 -> 发积分。如果扣余额失败,就反向执行:补偿扣库存、补偿创建订单。

Saga分两种:事件编排(Choreography)和集中编排(Orchestration)。事件编排是各个服务通过监听对方的完成事件来触发下一步,服务之间完全解耦,但流程隐晦,出了问题很难排查。集中编排是有一个协调服务专门负责调度每一步,流程清晰,补偿逻辑集中管理,我推荐团队用这种。

Saga的适用场景是长事务、跨很多服务、业务流程本身就有明确的先后顺序。它不要求实时强一致,只要最后业务能走完,失败能退回去就行。缺点是编码量大,补偿逻辑多了以后调试比较难受。

2.4 本地消息表:不引中间件也能做最终一致性

在说RocketMQ事务消息之前,必须先聊聊本地消息表,因为事务消息本质上就是它的工业级升级版。

方案很简单:在业务数据库里建一张消息表,业务操作和消息写入放在同一个本地事务里。比如创建订单时,同时往消息表插入一条“待发送”的扣库存消息。本地事务保证:要么订单和消息都成功,要么都失败。然后由一个定时任务扫描消息表,把状态为“待发送”的消息投递到MQ,消息发出后更新状态。下游消费成功后再回调更新状态。

这个方案的优点是不需要额外引入分布式事务中间件,利用的是“同一个数据库本地事务”的天然原子性。缺点是消息表会随着业务量增长变得很大,定时任务扫描有延迟,消息状态维护也得自己做。作为兜底方案,它反而很实用。

2.5 事务消息:把“本地消息表”下沉到MQ

RocketMQ的事务消息把本地消息表的逻辑收敛到了MQ内部。发送方先发送一条“半消息”(half message),这条消息对消费者不可见;然后发送方执行业务本地事务;事务成功就提交消息,事务失败就回滚消息。如果MQ长时间没收到提交或回滚指令,会反向询问发送方事务状态,这就是事务回查。

对比本地消息表,事务消息的好处是消息生命周期由MQ托管,回查机制更成熟,不用自己写定时任务扫表。但它的前提是你得接受引入RocketMQ这类支持事务消息的中间件,并且对延迟有一定容忍度。

我直接给一张表总结五种方案的取舍:

方案一致性强度业务侵入可用性风险复杂度适合场景
2PC强一致低协调者阻塞风险高中极少用,理论为主
TCC强一致,可控高,三个接口无全局锁,较好高短链路、高一致性控制
Saga最终一致中高,补偿事务好中高长流程、跨多服务
本地消息表最终一致低好,依赖定时任务低无MQ事务能力时的兜底
事务消息最终一致低好,依赖MQ中异步化核心链路首选

3. 订单扣库存实战:选型与落地细节

3.1 一致性目标拆解:先判断能不能接受最终一致

动手写代码前,我习惯先问三个问题:用户操作完成后,多久之内必须看到完整结果?不一致的状态会不会导致资损?业务上能不能接受补偿和冲正?

下单减库存这个链路,用户点击下单后,订单状态变“待支付”,库存扣减完全可以在几秒内完成,甚至用户不查看库存数字根本感知不到。这种场景适合最终一致性,用事务消息或本地消息表就够了。但如果是充值扣余额,用户立刻要看到余额变化,你就得考虑TCC或者更实时的事务方案。一致性目标一旦定错,后面全盘皆输。

3.2 路线一:RocketMQ事务消息实现订单与库存最终一致

我做一个简化的订单服务伪代码,用RocketMQ事务消息。

第一步,准备一个事务消息监听器:

public class OrderTransactionListener implements TransactionListener { @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { // 半消息发送成功后,执行本地订单创建 try { Order order = (Order) arg; orderMapper.insert(order); return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // 回查:半消息长时间没有提交/回滚时,根据订单是否存在决定 Long orderId = (Long) msg.getUserProperty("orderId"); return orderMapper.selectById(orderId) != null ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } }

第二步,发送事务消息:

TransactionMQProducer producer = new TransactionMQProducer("order_producer_group"); producer.setTransactionListener(new OrderTransactionListener()); producer.start(); Message msg = new Message("ORDER_STOCK_TOPIC", body); msg.putUserProperty("orderId", String.valueOf(orderId)); TransactionSendResult result = producer.sendMessageInTransaction(msg, order);

第三步,库存服务作为消费者,收到消息后扣减库存,关键逻辑用条件更新防止超卖:

UPDATE stock SET stock = stock - #{count} WHERE product_id = #{productId} AND stock >= #{count};

如果你用Spring,可以把事务消息发送封装成注解,但核心思想不变:半消息和本地事务绑定,下游消费保证幂等。这套方案产线上我实测过,高峰期只要不出现长时间的MQ集群故障,基本不会漏消息。

3.3 路线二:TCC手动实现库存冻结

TCC的落地比事务消息要重,但控制力完全在业务手里。我以“冻结库存”为例,给你一个接口设计参考。

库存表增加两个字段:total_stock(总库存)、frozen_stock(冻结库存),可用库存 = total_stock - frozen_stock。

Try阶段:

UPDATE stock SET frozen_stock = frozen_stock + #{count} WHERE product_id = #{productId} AND (total_stock - frozen_stock) >= #{count};

Confirm阶段:

UPDATE stock SET total_stock = total_stock - #{count}, frozen_stock = frozen_stock - #{count} WHERE product_id = #{productId};

Cancel阶段:

UPDATE stock SET frozen_stock = frozen_stock - #{count} WHERE product_id = #{productId};

Try成功锁定了3件库存,Confirm真正扣库存,Cancel释放冻结库存。注意每个阶段都必须幂等,因为网络重试可能导致同一个Try、Confirm请求被发送多次。我处理幂等的做法是在库存流水表里加唯一键,比如用全局事务ID做唯一约束,重复执行直接返回成功。

3.4 消息不丢不重不乱序:生产环境必备的可靠性措施

方案选完之后,真正考验功力的是可靠性细节。我用三个词总结:不丢、不重、不乱序。

不丢。事务消息模式里,半消息提交后MQ保证消息不会丢失,但消费者这边可能处理失败。所以消费端一定要开手动ACK,逻辑处理成功才提交offset。如果消费失败,允许MQ重试,重试多次还失败就转入死信队列,配合告警人工处理。

不重。MQ的重试机制会导致消息重复投递,库存扣减不能扣两次。最简单的方法是用唯一键去重:给每条消息带上orderId,库存扣减前先查询流水表有没有这条orderId的扣减记录,有就直接忽略。

不乱序。如果同一个订单的多个操作消息都发到同一个Topic,要保证按顺序执行,就得让同一个订单的消息进同一个消费队列。RocketMQ支持消息队列选择器,用orderId做取模,保证相同订单进入同一队列,消费端串行处理。

3.5 幂等设计:最终一致性方案的命门

我在前几节反复提到幂等,因为它太重要了。分布式事务任何一个环节都可能重试,没有幂等,补偿逻辑本身就是制造脏数据。

库存扣减的幂等建议用三步:全局唯一操作流水号,条件更新SQL,流水表记录。伪代码思路如下:

public boolean deductStock(StockDeductRequest request) { // 1. 流水号唯一键防重 if (stockFlowMapper.selectByRequestId(request.getRequestId()) != null) { return true; } // 2. 条件更新,防止超卖 int rows = stockMapper.deductWithCondition( request.getProductId(), request.getCount()); if (rows == 0) { throw new BizException("库存不足"); } // 3. 记录流水 stockFlowMapper.insert(request); return true; }

流水表的作用不只是防止重复,它还是后面对账补偿的数据源。很多团队忽略流水表,只靠条件更新防超卖,等出问题想查“到底哪些订单扣了库存”时,手里没有任何可追溯的记录,这才是真正的灾难。

4. 真刀真枪踩过的坑:问题排查与调优

4.1 消息发送成功但一直没消费

有一次线上反馈,订单创建成功,库存迟迟不扣。排查半天,发现是事务消息的回查机制出了问题。executeLocalTransaction里插入订单数据库耗时较长,提交半消息后本地事务还没返回,MQ等不到提交状态就开始回查。回查接口查的是订单号,但订单此时还没提交,于是返回ROLLBACK,消息被回滚了。

这个坑的关键在于:回查接口不能简单查“主业务表里有没有数据”,而是要查本地事务的最终结果。更稳妥的做法是在本地事务里先写一张transaction_log表,记录事务状态,回查时以这张表的状态为准。另外我还习惯把回查超时时间调大一点,给本地事务足够的执行窗口。

4.2 库存扣成负数:缓存扣减与数据库不一致

另一个高频事故是Redis库存和DB库存不一致。很多团队为了扛高并发,先扣Redis缓存库存,再异步同步到DB。Redis扣成功了,但异步同步失败或顺序颠倒,DB库存就一直不对,超卖问题看起来像是“Redis和DB数据不一致”。

我的排查思路是:先确定哪个是最终数据源。库存的最终数据源必须是数据库,Redis只是加速层。任何Redis扣减都必须记录操作流水,并通过MQ异步把流水同步到DB执行条件扣减。出现不一致时,用流水表重放一遍即可,不要直接改Redis数值“临时修一下”,临时修改往往引发更大的乱子。

4.3 对账补偿:最终一致性最后一道防线

不管方案设计得多完善,我都建议加一道对账补偿的任务。每天凌晨跑一次,比对订单表和库存流水表:订单状态是已创建但库存流水缺失的,触发补扣;库存流水存在但订单状态异常的,触发冲正。

对账任务不需要实时,但对业务兜底价值极大。我见过一个团队平时一致性问题很少,全靠消息重试和事务回查兜底,结果有一次MQ集群版本升级导致消息大面积积压,订单数据乱了几天,最后就是靠对账脚本一条条修复的。对账不是重复劳动,它是分布式一致性方案的保险丝。

4.4 性能与可用性调优

引入分布式事务后,性能瓶颈往往不在事务方案本身,而在锁和日志。TCC里Try阶段如果长时间不释放冻结资源,会造成大量库存被“锁死”,所以一定要给冻结记录加超时自动释放。事务消息方案里,消费端如果串行消费,吞吐会卡在消费速度上,建议根据订单号分片并行消费,同一订单串行、不同订单并行。

可用性方面,我强烈建议做降级开关。正常情况下用RocketMQ事务消息,MQ一旦不可用,立刻切到本地消息表方案,用定时任务投递。这个降级逻辑平时没人注意,大促前必须压测验证。

5. 用开源框架能省多少事:Seata 接入提醒

5.1 AT模式:自动生成反向SQL

如果不追求手写TCC,可以了解下Seata的AT模式。它的核心思路是在全局事务里记录每个分支事务的undo_log,业务SQL执行完后,自动生成反向SQL。全局事务提交时删除undo_log,回滚时用反向SQL恢复数据。

AT模式对业务侵入很小,你只需要在业务方法上加@GlobalTransactional注解,数据源换成Seata代理的数据源。但它有局限:只适合简单的INSERT、UPDATE、DELETE,不适合复杂SQL、存储过程、批量操作。反向SQL在某些场景下生成不准确,比如SQL里带函数、子查询,回滚数据可能对不上。

5.2 Seata接入注意点

如果你决定用Seata,注意几件事。第一,事务分组名和配置中心要统一,不同环境别混。第二,AT模式靠全局锁实现写隔离,并发高时锁等待时间会变长,别把它当无锁方案用。第三,undo_log表必须和业务表在同一个数据库,否则本地事务和undo_log不在同一事务边界,回滚就失效。

另外,Seata更适合内部短链路事务,跨公司跨系统的调用链路上AT模式不现实,因为对方不见得愿意接你的全局事务。跨组织边界还是事务消息或者Saga更靠谱。

5.3 我的选型经验:先别急着上框架

最后说点实际经验。很多团队一听说分布式事务稳定,马上引入Seata或事务消息,结果业务没那么复杂,反而引入一大堆运维成本和性能损耗。我自己的选型顺序是:能不用分布式事务就不用,能异步最终一致就不做强一致;如果必须在核心链路上保证数据不错,优先事务消息;如果业务状态复杂、需要实时控制,再考虑TCC;只有极少数场景才需要Seata的AT模式。

踩过几次坑之后,我的体会是,分布式事务里“一致性”和“可用性”注定要博弈,技术方案只是把你选择的天平变成可执行的机制。真正让订单与库存分布式事务“轻松搞定”的,不是某个万能中间件,而是把业务拆清楚、把幂等做扎实、把对账补起来。这套基本功在,无论未来换什么框架,心里都不慌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询