下单支付的时候,订单服务往订单表里插一条记录很顺利,库存服务那边扣减库存却失败了,这时候你会怎么办?直接抛异常回滚?问题是订单库和库存库根本不在同一个数据库里,本地事务管不着两个库。订单已经显示支付成功,库存却还在那里,后面发货的时候才发现没货,用户投诉、客服背锅、系统被刷屏,这个让无数后端负责人头秃的问题,就是分布式事务要解决的。
市面上聊分布式事务的文章很多,不是堆概念就是贴源码。我这篇不讲虚的,就从“订单与库存”这个最典型、也最容易踩坑的场景出发,把分布式事务涉及的原理、方案、选型、实操和排查一条龙捋清楚。不管你是刚毕业的Java开发,还是带队的架构师,只要线上系统拆分过微服务,这篇文章都有参考价值。
1. 先搞明白:分布式事务到底在解决什么问题
1.1 一个订单引发的连锁反应
先看一个非常现实的下单流程。用户点击“立即购买”,前端调用订单服务创建订单,订单服务除了往订单表写数据,还要调用库存服务扣减库存,调用支付服务发起扣款,可能还要调用积分服务给用户加积分。早期单体应用时代,这些表都在同一个数据库里,一个@Transactional注解就能搞定,哪个环节失败就整体回滚,数据天然一致。
但系统拆分之后情况变了。订单、库存、支付、积分各自成为独立的微服务,甚至独立部署在不同的物理机器上,数据也分散到各自的数据库。这个时候,订单服务调用库存服务,本质上是跨进程、跨数据库的远程操作。库存扣减成功了,但订单服务在回调过程中突然宕机,订单状态没更新,用户却看到钱被扣了。库存服务扣减失败,订单服务已经提交了事务,订单生成了,库存却不够。这种状态不一致就是分布式事务要解决的核心问题。
所以,分布式事务的定义可以概括为:在多个独立的资源管理器(数据库、消息队列、另一个微服务)之间,保证一系列操作要么全部成功,要么全部失败,最终让所有参与者达到一致状态。这里的“一致”不是指强一致,而是根据业务需求,可能是强一致,也可能是最终一致。
1.2 一致性、可用性和网络分区的拉锯战
很多人一上来就背CAP理论,但没想过它和分布式事务的关系。CAP说:在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)三者不可兼得。分布式系统必须接受分区容错性,也就是网络可能中断、机器可能宕机这个事实,所以剩下的选择就是在C和A之间做取舍。
这个取舍直接决定了分布式事务的方案选型。如果追求强一致性,比如用户支付时扣减库存必须和订单状态同步完成,那就得牺牲一部分可用性和性能,典型方案是两阶段提交(2PC)。如果追求高可用和高并发,允许数据存在短暂的不一致窗口,但最终通过补偿、对账机制收敛到一致,那就选择最终一致性方案,比如事务消息、SAGA、本地消息表。
实际业务里,订单和库存这种场景,绝大多数团队选的是最终一致性。原因很简单:用户下单这个动作,本身可以不把“订单创建”和“库存扣减”绑成一个原子操作。只要确保库存最终不会被超卖(也就是扣减具有原子性),订单最终能正常处理,短暂的不一致完全可以接受。想明白这一点,分布式事务就没那么可怕了。
提示:不要一听到“分布式事务”就想着引入重量级框架。先问自己:业务真的需要强一致吗?很多场景只需要保证核心资源的并发控制和最终的幂等对账。
2. 分布式事务的几种主流解法
2.1 2PC:教科书式的齐步走
两阶段提交是最经典的强一致方案。它引入一个协调者( coordinator),把提交过程拆成两个阶段。
第一阶段是准备阶段(Prepare)。协调者给所有参与者发送准备请求,参与者执行本地事务但不提交,把资源锁住,然后返回“yes”表示可以提交,或者返回“no”表示执行失败。
第二阶段是提交阶段(Commit)。协调者收到所有参与者的响应后,如果都是“yes”,就广播提交指令,所有参与者正式提交事务;如果任何一个参与者返回“no”,或者协调者等待超时,就广播回滚指令,所有参与者回滚。
听起来很完美,但2PC有几个致命短板。第一,它是同步阻塞的,参与者准备阶段会把数据库行锁、表锁锁住,直到第二阶段结束,高并发场景下性能很差,锁等待会让系统吞吐量直线下降。第二,协调者单点问题,如果协调者在第二阶段崩溃,参与者就会一直阻塞在锁定状态,这就是“脑裂”风险。第三,参与者可能出现部分提交、部分回滚的极端情况,比如提交指令广播后网络中断,有的参与者提交了,有的没有,需要人为介入。
所以2PC在互联网大厂的核心链路里用得并不多,更多用在金融机构、跨机房强一致场景,而且通常配合XA协议或者Seata的AT模式。AT模式本质上是个改进版的2PC,通过undo_log自动生成前后镜像,业务代码不需要写补偿逻辑,但同样有锁竞争问题,不适合超高并发。
2.2 TCC:把一个操作拆成三个动作
TCC(Try-Confirm-Cancel)是业务层面的分布式事务方案,把每个业务操作拆成三个阶段。
Try阶段:尝试执行,锁定资源但不真正完成业务操作。比如下单扣库存,Try阶段只是把库存从100冻结为10,这10件商品不让别人再买,但还没有实际扣减。
Confirm阶段:确认执行,把Try阶段锁定的资源真正扣减。Try成功且所有参与者的Try都通过,就会执行Confirm,把冻结的10件库存变成已扣减。
Cancel阶段:取消执行,释放Try阶段锁定的资源。任何一个参与者的Try失败,或者Confirm执行失败,就会执行Cancel,把冻结的库存释放掉,回到初始状态。
TCC的优点是把事务控制从数据库资源层提升到业务层,锁粒度更小,性能比2PC好不少。但缺点也很明显:每个参与者都需要实现Try、Confirm、Cancel三套逻辑,开发工作量非常大,而且还需要处理空回滚、悬挂、幂等这些疑难杂症。我见过不少团队被TCC的悬挂问题折腾得不轻——Try还未执行,Cancel却先到了,业务上非常难处理。
2.3 SAGA:长事务的降维打击
SAGA的核心思想是:把一个长事务拆成一组有序的本地事务,每个本地事务执行完都提交,如果后续某个事务失败,就反向执行之前所有事务的补偿操作。
举个例子:一个旅行预订流程包含订机票、订酒店、租车三个步骤。飞机票预订成功后,酒店预订失败,SAGA就会自动取消机票订单作为补偿。每个步骤都是一个独立的本地事务,不需要长时间锁资源,性能好,适合业务流程长、链路多的场景。
SAGA有两种编排方式。一种是事件编排(Choreography):没有中心协调者,各个服务通过消息事件串联,A服务执行完发事件,B服务收到事件后执行,如果失败发补偿事件。实现简单,但业务流程埋在各处,运维排查困难,也很难处理循环依赖。另一种是命令编排(Orchestration):用一个中心化Saga调度器记录流程状态,调用各个参与者,失败时按顺序触发补偿。可控性强,适合复杂业务流程,但协调器需要高可用设计。
SAGA本身不保证隔离性,也就是说中间状态可能被其他线程看到。比如机票已经订了但酒店还没订,此时用户查询订单会看到“机票已出票、酒店未预订”的中间状态。所以SAGA适合无所谓中间状态的场景,不适合并发要求严格的资金类业务。
2.4 从ACID挪到BASE:最终一致性才是主场
对于订单、库存、支付这类互联网业务,真正用得最多的其实是最终一致性方案。思想更朴素:事务不能即刻完成,就让它异步完成,中间允许一段不一致的时间,但通过消息、重试、对账,最终做到一致。
最终一致性的落地手段主要有三个。第一个是本地消息表:在业务事务里,把业务数据和要发出去的消息在同一个本地事务里写入数据库,然后定时扫描消息表,把消息投递到MQ。第二个是事务消息:以RocketMQ为例,发送半消息,本地事务执行成功后再提交消息,消费者才能看见,本质是本地消息表的升级版,把消息表和业务库的操作交给MQ自动进行。第三个是定时对账与补偿:通过定时任务扫描订单表和库存流水,发现不一致就触发补偿逻辑,这是兜底手段,必须有。
很多架构师把最终一致性方案作为订单库存场景的第一选择,与其纠结每个环节是不是强一致,不如保证最终不超卖、不超发、订单不丢,用对账保住底线。这个选择不是技术妥协,而是对业务的深度理解。
3. 落到订单与库存场景,怎么选型
3.1 场景拆解:订单中心和库存中心
先抽象一下订单与库存这个典型场景。用户下单后,订单服务在订单库写一条订单记录,状态先是“待支付”或“已创建”;库存服务在库存库扣减库存,并写一条库存流水。这两个操作属于不同数据库,甚至不同机房。
冲突点在于:如果订单创建了,但库存扣减失败,不能给用户一个已支付的订单,却因为库存不足发不了货。如果库存扣减了,但订单创建失败,库存莫名少了,用户没下单却少了库存,也是灾难。
实际业务里还要考虑支付环节。支付成功回调、订单状态流转、库存扣减,三者如何协同。所以“订单与库存分布式事务”本质是一个多参与者状态一致问题。
3.2 先排除哪些方案
在选定最终方案之前,先做一个排除法,能避免后期踩坑。
2PC基本可以直接排除。它适合强一致、低并发、参与者数量少的场景,比如跨行转账。订单库存系统是典型的互联网高并发场景,2PC的同步阻塞会直接把数据库锁死,双十一这种流量下根本撑不住。而且订单库存流程中,用户支付、取消、超时未支付等状态变化非常频繁,2PC的刚性模型很难优雅处理。
纯TCC可以保留,但要做好评估。库存服务很适合做TCC的Try冻结库存、Confirm扣减、Cancel释放,但订单服务要处理的状态更多:订单创建、支付回调、超时取消、退款,每个状态都要设计confirm/cancel的业务逻辑,开发量会翻倍。如果团队不大,我建议不要一上来就全链路TCC,优先保证核心资源库存的原子性。
SAGA也不适合作为唯一方案。虽然它适合长链路,但订单-库存链路只有两个节点,且库存要求不能超卖,SAGA的隔离性问题可能导致两个并发用户同时看到有库存但实际可能不足,出现超卖。SAGA一般作为大型业务流程的编排层,在订单-库存这个粒度上用事务消息更直接。
3.3 最终一致性方案的两种主流实现
订单库存场景里,最实用的两条路是事务消息和本地消息表。
事务消息(RocketMQ)的流程是:
- 订单服务发送“订单创建”半消息,此时消息在MQ中不可见。
- 订单服务执行本地事务,插入订单记录,状态为“待发货”或“已支付”。
- 本地事务成功后执行
sendMessageInTransaction的 commit,让消息可见;失败则 rollback 半消息。 - 库存服务消费这条消息,执行库存扣减,扣减成功则插入库存流水并ACL确认,失败则重试。
- 如果MQ长时间没有收到commit/rollback,会回调生产端的检查接口,自动确认本地事务状态,保证最终一致性。
本地消息表的原理类似,但实现更土一点:订单本地事务同时写订单表和消息表,提交后定时任务把消息发到MQ,发送成功的标记状态。如果有消息一直发送失败,就用第二条定时任务扫描补发。
两者选哪个?如果消息中间件已经是RocketMQ,直接用事务消息最省事,代码少、可靠性高,事务状态由MQ回查兜底。如果团队自研消息队列或者用的是RabbitMQ(不支持原生事务消息),本地消息表不失为一个稳妥方案,毕竟多一张消息表、多一个定时任务,比依赖外部组件事务能力更可控。
注意:事务消息和本地消息表方案的核心区别是“半消息能否真正隐藏”。RocketMQ的事务消息天然支持;不支持的MQ,你需要用“消息内容+不可见字段”模拟,这会增加复杂度。
3.4 选型还要看这四个因素
第一,流量规模。每秒钟订单创建量低于1000,2PC也许能忍;超过1万,想都别想,老老实实用最终一致性。
第二,一致性窗口要求。用户下单后,你能接受几秒内库存和订单不一致吗?如果能,消息方案够了;如果不能,那就不是技术问题,是产品需求问题。很多团队把“强一致”当成默认需求,实际上业务方根本感知不到这一秒半秒的差异。
第三,团队成本。2PC和TCC都要写大量补偿逻辑和状态机,业务代码侵入性强。事务消息方案,业务代码只需要在本地事务里改动,消费端做好幂等,整体成本低很多。
第四,对账兜底能力。最终一致性方案必须配一套对账系统。只要对账逻辑写得好,即便消息丢失、重复、乱序,最终也能把数据拉回来。这也是我最看重的部分。
4. 实操:用事务消息实现订单与库存分布式事务
4.1 整体架构设计
以RocketMQ事务消息为例,搭建一个最小可用的订单-库存事务系统。
核心组件有四个:订单服务、库存服务、消息队列(RocketMQ)、定时对账任务。数据库方面,订单库有order表和order_status_log表,库存库有product表和inventory_records表。
流程:
- 用户请求下单,订单服务生成订单ID,先发送半消息。
- 订单服务执行本地事务,写入订单表,状态为
CREATED或PAID,并把订单快照写入消息表中作为业务记录。 - 事务消息commit后,库存服务消费消息,执行扣减库存操作,写入库存流水。
- 库存扣减成功后,订单服务可能有回调逻辑来更新订单状态,或者通过订单状态异步推进。
- 定时对账任务每天扫描,对比订单状态和库存流水,把差数补平。
4.2 生产端:事务消息的核心代码
RocketMQ事务消息生产端有两个关键:TransactionListener实现本地事务执行,以及消息回查接口。
public class OrderTransactionListener implements TransactionListener { @Autowired private OrderService orderService; @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { String orderId = (String) arg; try { // 这一步是在本地事务里插入订单,同时标记该订单已发送事务消息 orderService.createOrder(orderId); // 本地事务成功,提交消息,让消费者可以看到 return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { // 本地事务失败,回滚消息,消费者不会看到 return LocalTransactionState.ROLLBACK_MESSAGE; } } @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { String orderId = msg.getKeys(); // MQ回查时,检查本地事务是否成功 if (orderService.isOrderExist(orderId)) { return LocalTransactionState.COMMIT_MESSAGE; } return LocalTransactionState.ROLLBACK_MESSAGE; } }注意,这里的createOrder必须是一个真实本地事务。如果本地事务执行成功,但还没返回时进程宕机了,MQ的回查机制会通过checkLocalTransaction发现订单存在,于是提交消息。如果本地事务失败,回查发现订单不存在,就rollback掉半消息。这个机制很巧妙,也是事务消息能取代本地消息表的原因。
发送半消息的代码如下:
TransactionMQProducer producer = provider.getProducer(); Message msg = new Message("ORDER_INVENTORY_TOPIC", "WAIT_INVENTORY", orderId.getBytes()); TransactionSendResult result = producer.sendMessageInTransaction(msg, orderId);sendMessageInTransaction的第二个参数会传给executeLocalTransaction的arg,这样可以把它当成本地事务入参。
4.3 消费端:扣减库存的幂等与防重
库存服务消费消息时,最大的坑是消息重复。MQ为了可靠性,消费成功后确认超时或者网络抖动,会重投消息,如果不做幂等,库存就会被重复扣减。
幂等方案主要靠业务主键。每条库存流水都应该有唯一的流水号,这个流水号可以直接用订单ID + 商品ID + 操作类型拼接。消费端接收消息后,先查库存流水表里是否存在这个流水号,存在说明已经处理过,直接返回;不存在才执行扣减。
public void onMessage(MessageExt messageExt) { InventoryMessage msg = JSON.parseObject(messageExt.getBody(), InventoryMessage.class); String recordId = msg.getRecordId(); // 唯一流水号 // 幂等判断 if (inventoryRecordMapper.exists(recordId)) { log.info("重复消息:{}", recordId); return; } // 执行扣减,插入流水 inventoryService.deductInventory(msg.getProductId(), msg.getQuantity(), recordId); }这里还要注意并发扣减问题。同一个商品在高并发下,多个订单同时扣减库存,必须保证库存不够时能正确拒绝。最简单可靠的做法是在数据库层做原子扣减:
UPDATE product SET stock = stock - #{quantity} WHERE product_id = #{productId} AND stock >= #{quantity}这个SQL自带条件判断和行锁,stock >= quantity 保证不会超卖,影响行数为0表示库存不足。很多团队用乐观锁version做扣减,其实在这种场景下,stock >= quantity本身就够了,不需要额外的version。
4.4 回查失败与消息滞留怎么办
事务消息虽然在大多数情况下能自动提交,但极端情况依然存在。比如订单服务本地事务执行成功后,RocketMQ的检查回调因为网络分区一直联系不上订单服务,消息就会一直滞留。消费者收不到消息,库存一直没有扣减,订单却已经生成了。
这个问题没有银弹,必须靠定时对账兜底。
我的做法是加一个transaction_message_checker定时任务,每5分钟扫描一次事务消息,把存活时间超过5分钟且状态仍是WAIT_INVENTORY的订单捞出来,主动向订单服务确认订单是否存在:
- 订单存在:手动通知库存服务扣减(走本地补偿或重发消息)。
- 订单不存在:说明消息已被rollback,清理掉脏数据。
- 订单状态是已取消:释放库存,或者直接忽略。
这个定时任务实际上是把MQ回查机制又做了一层备份,相当于双保险。
除了对账,还有一个常见优化:给消息加固定的延迟等级,库存扣减失败时不立即重试,而是退到重试队列,延迟10秒、30秒、60秒渐进重试。这样不会因为库存服务瞬时抖动导致消息风暴。
5. 常见问题与排查技巧实录
5.1 消息重复消费导致库存变负数
现象:库存明明设置了stock >= quantity条件,但最终库存还是变成负数。
排查方向:先看是不是有多个消费者消费同一个消息。如果消费组配置不对,同一消息被两组消费者分别消费,或者消费者实例挂了导致Rebalance,重复投递,都会触发并发扣减。再确认幂等判断是否真的生效,特别是当流水号生成规则有误时。
我遇到过最隐蔽的一次:记录号用了orderId + productId,但没有加操作类型,结果“下单扣减”和“退款回补”共用同一个流水号,后一条被幂等拦截,库存只扣不减,最后库存越查越对不上。改成orderId + productId + operationType之后就正常了。
5.2 订单已支付但库存未扣减
这是最常见的不一致。先查RocketMQ的事务消息状态:
bin/mqadmin queryMsgByKey -n 127.0.0.1:9876 -t ORDER_INVENTORY_TOPIC -k ORDER20250115001如果消息状态是COMMIT_MESSAGE,但消费者一直没消费,看消费组重试队列里有没有堆积,消费日志有没有报错。如果消息状态是UNKNOWN,说明回查一直没成功,检查订单服务有没有从注册中心掉线。
实在查不出来,直接跑对账SQL:
-- 找出已支付订单但没有对应库存流水的记录 SELECT o.order_id FROM order_db.t_order o LEFT JOIN inventory_db.t_inventory_record r ON o.order_id = r.order_id AND r.op_type = 'DEDUCT' WHERE o.status = 'PAID' AND r.id IS NULL;对有问题的订单,走补偿接口补扣库存,或者标记为异常订单进入人工处理队列。所有用最终一致性方案的系统,最后都要靠这条SQL兜底。
5.3 事务消息半消息积压
现象:消息topic里大量消息长期UNKNOWN状态,消费者无法消费。
排查步骤:先看订单服务的checkLocalTransaction有没有抛异常,是不是每次查询订单库都超时。再检查TransactionMQProducer的group是否配置正确。RocketMQ事务消息回查是独立的线程池,如果业务代码里把线程池耗尽了,回查就会失败。
还有一种情况是消息本身过大,超过MQ限制,发送半消息时就写到了“内部Topic”,消费者当然看不到。可以在Broker配置里把maxMessageSize调大,但更建议先检查业务消息体,把大字段抽到OSS或数据库存ID。
5.4 超时参数怎么设
分布式事务涉及的每个环节都要设超时,不然一个服务卡住,整个链路拖垮。
- 订单服务调用库存服务的RPC超时:建议500ms,重试1次。
- 事务消息半消息等待响应超时:默认3秒,别设太长,不然用户体验差。
- 本地事务执行超时:按业务最大耗时设,一般1~2秒。
- 库存扣减消息消费超时:消息重试间隔建议10秒起步,最多重试16次。
超时设太短会导致大量假失败误判;设太长会让故障恢复变慢。我的经验是,先压测出正常耗时P99,再乘以3~5倍作为超时值,比拍脑袋靠谱得多。
经验:分布式事务工程里,真正花时间的不是代码,而是对账逻辑。设计好的系统,应该让大部分请求通过消息去完成一致,只有极少数的异常走向对账和人工处理。如果对账任务每天要处理几千条不一致,说明方案本身设计有问题,别靠对账硬撑。
6. 写在最后的几点实在话
搞了好几年分布式事务,我的核心感受是:别把“最终一致性”当成低技术的退路,它其实是互联网业务在并发和一致性之间做出的理性选择。订单-库存这种场景,根本不需要让用户等待所有节点一起提交,用户只关心“我付了钱,订单别丢,别发不了货”。系统内部有点延迟,完全可以通过异步消息和定时对账解决。
另外,不管选哪个方案,幂等、降级、对账这三件事必须从第一天就做。尤其是对账,它平时没啥存在感,但真的出问题的时候,那是唯一能救你的东西。还有个小技巧:把每次补偿和人工修正都记录下来,线上出问题才能回溯。别怕麻烦,分布式事务的坑,绝大多数不是技术深度不够,而是细节没做到位。