分布式事务避坑指南:方案对比、Seata实战与故障排查
2026/9/12 23:37:10 网站建设 项目流程

分布式事务这四个字,在项目里一提起来,基本都是先兴奋后头大。兴奋是因为业务量走到这一步,说明系统开始真正拆微服务了;头大是因为随便翻一圈资料,2PC、TCC、Saga、AT模式、事务消息……每个方案都有自己的拥护者,也有自己的劝退者,选型一旦不对,后面全是坑。我自己接手过不少这类改造项目,包括最典型的订单与库存扣减场景,踩过坑、也填过坑。这篇我先把各类方案的原理和代价讲透,再给出一套可以直接落地的Seata实战路径,最后把上线后最容易出问题的几个故障排查过程完整展开。适合准备引入分布式事务、或者在现有方案上反复出问题的后端开发同学,看完至少能少走两个月的弯路。

1. 先解决一个前置认知:哪些“分布式事务问题”根本不该用分布式事务

很多人一听说系统拆了微服务、接口调用链路上出现分布式事务需求,就立刻开始选框架、建表、加注解。但实际上,相当一部分所谓分布式事务问题,在架构层面就能直接规避掉。先把这个问题想清楚,后面才不会被方案绑架。

1.1 90%的场景是本地事务能解决的

有一种很常见的“伪分布式事务”场景:订单服务和库存服务拆成了两个微服务,但两个服务共享同一个MySQL实例。这时候订单表和库存表都在同一个数据库里,完全可以像单体应用一样,在一个事务里同时写两张表。

有些团队因为微服务拆分惯性,哪怕数据库没拆,也要走Feign调用扣减库存,然后为了数据一致性引入分布式事务框架。这属于典型的方案过度设计。真正的技术判断维度不是服务拆没拆,而是资源有没有被跨物理实例隔离。只要核心的业务数据还在同一个数据库实例,本地事务@Transactional就是最简单、最可靠、性能最好的方案。

再退一步,如果订单服务和库存服务确实连数据库都分开了,也还有一条路:调整业务顺序,把跨服务调用从同步强一致改成异步最终一致。比如下单时只写订单库,把“待扣库存”的消息发到MQ,库存服务消费后扣减,扣失败就重试、告警、转人工。很多时候业务方要的并不是“技术上的强一致”,而是“最终数据对得上”。这个认知如果没对齐,技术方案会越做越重。

我见过太多团队,花了三周接Seata,最后发现核心问题只是商品超卖,而超卖完全可以在SQL层面用update ... where stock >= count解决,连异步消息都不需要。先解决问题,再引入复杂度,这个顺序不能反。

1.2 一致性分级:强一致、最终一致与业务兜底

在确定要不要用分布式事务之前,必须把一条清晰的一致性需求量级写下来。根据业务接受程度,一致性大体分三档:

一致性要求能接受的失败表现常见业务
强一致要么全部成功,要么全部失败,中间状态对外不可见转账扣款、订单生成同时扣库存
最终一致允许短暂不一致,但经过补偿/异步最终收敛订单状态同步、积分累计、通知推送
弱一致数据即使偶尔不一致,由业务规则兜底阅读计数、访问日志、统计报表

分布式事务框架并不是从A档到C档全部通吃。2PC、XA、Seata AT模式偏向强一致和最终一致之间,TCC侧重于业务层面的强一致,Saga和事务消息则天然适配最终一致。

我建议在项目启动时就把每一处跨服务写操作的一致性等级标出来,比如“订单创建:强一致”“订单状态同步:最终一致,允许10秒延时”。只要明确了等级,方案选择就变成一道排除题:最终一致优先走消息队列,强一致才值得引入分布式事务框架。很多团队恰恰是倒过来,所有接口都想强一致,结果每个调用链都挂了全局事务,性能一压测就崩。

2. 六类方案全景对比:2PC、AT、TCC、Saga、本地消息表、事务消息

分布式事务领域没有银弹,每个方案的定位和使用成本差异非常大。我把目前主流的六类方案按它们的核心机制拆开讲。

2.1 强一致路线:2PC/XA 和它解决不了的问题

2PC(两阶段提交)是分布式事务的理论起点,几乎所有大学教材都会讲。第一阶段叫准备阶段(Prepare),协调者问所有参与者“能不能提交”;所有参与者都返回“能”,协调者才进入第二阶段——提交阶段(Commit),让所有参与者真正提交。这个过程保证了所有资源要么一起提交,要么一起回滚。

XA是2PC在数据库层面的标准实现,Oracle、MySQL、PostgreSQL都有对应的XA支持。早年很多银行核心系统走的是这套,因为它是数据库原生能力,强一致从机制上就有保证。但XA的问题也很突出:参与者要持有事务资源直到第二阶段结束,数据库连接和行锁要一直占着,在高并发场景下数据库分分钟被拖死;协调者本身成为单点,如果协调者在第二阶段崩了,参与者会一直停在阻塞状态,数据库锁迟迟不释放。

后来衍生出Prepare阶段不lock数据、或者事务管理器高可用的改良方案,但本质上2PC的“同步阻塞”和“协调者宕机阻塞”两个经典问题并没有被彻底解决。所以现在直接裸用XA的团队非常少,除非业务对强一致要求极高、并发量又在可控范围内。

2.2 最终一致路线:TCC、Saga、消息方案各自的代价

TCC(Try-Confirm-Cancel)是一种侵入性非常强的业务补偿方案。每个参与事务的操作要被拆成三步:Try阶段预留资源、Confirm阶段确认提交、Cancel阶段补偿释放。以库存举例,Try阶段不直接扣库存,而是把库存改成“冻结”状态;Confirm阶段把冻结库存变成已扣减;Cancel阶段把冻结库存恢复。这样即使某个分支失败,也能通过Cancel把预留的资源释放掉。

TCC最大的问题是开发量巨大,而且有三个经典陷阱:空回滚(Try还没执行,但Cancel被调用了,需要能识别出没有预留过)、悬挂(Cancel先到,Try后到,需要拒绝执行迟到的Try)、幂等(Confirm和Cancel可能重复执行)。这些都需要靠一张事务控制表人工维护,团队如果没有足够的架构能力,TCC很容易埋坑。

Saga是另一种业务编排思路。把一个大事务拆成一串本地事务,每个本地事务提交后,如果后续步骤失败,就反向执行之前每一步的补偿动作。比如订单创建成功后,假设物流服务创建单失败了,就回滚订单创建。Saga不需要在资源层面加锁,适合长流程业务,比如旅游订单、审批流、多步骤增值服务。它的核心难点是补偿动作要设计得足够完备,而且补偿本身也可能失败,需要定时任务兜底扫描。

本地消息表是最朴素的消息最终一致方案。在业务库建一张消息表,业务操作和消息写入放在同一个本地事务里;事务提交后,由定时任务把消息表里状态为“待发送”的记录投递到MQ,消费方收到后执行自己的业务。它的问题在于消息表耦合在业务库里,高并发下对数据库压力不小,而且消费方必须做幂等。

事务消息是本地消息表的优化版本,以RocketMQ的half消息机制为代表。生产者先发送一条“半消息”,这条消息消费者暂时不可见;接着执行本地事务,执行成功后commit消息,消息才变成可见;如果本地事务失败,rollback消息直接被丢弃;如果本地事务执行状态未知,MQ会回调发送方的check接口确认。

2.3 一张表看懂六类方案的适用边界

方案一致性类型资源锁定代码侵入开发量典型场景
2PC/XA强一致严重并发极高但ACID要求极高的金融核心
Seata AT最终一致偏强极低常规微服务业务,订单、库存、积分
TCC业务级强一致业务预留高并发资金类、热点库存业务
Saga最终一致长流程业务,旅游、审批、订单全链路
本地消息表最终一致无MQ基础设施的存量系统改造
事务消息最终一致已有RocketMQ、可接受消息异步场景

从表格能看出来,没有哪个方案每个维度都占优。这也是为什么查分布式事务资料时,不同文章会给出截然相反的结论——它们各自假设的业务水位不一样。脱离具体场景谈方案优劣,没有意义。

3. Seata AT模式原理拆解:为什么回滚不需要二阶段长锁

Seata是国产开源分布式事务框架,热度一直很高。它最被人熟知的是AT模式,原因是接入成本低到令人发指:加一个@GlobalTransactional注解,配置一个回滚日志表,业务代码几乎不用改。但低接入成本背后是复杂的机制在兜底,不搞懂原理,出了问题根本无从排查。

3.1 三组件与XID传播链路

Seata里有三个核心角色。TC(Transaction Coordinator)是事务协调器,负责全局事务的开启、提交、回滚,独立部署,负责记录全局事务状态和全局锁;TM(Transaction Manager)是事务管理器,负责在业务入口开启全局事务,并最终发起全局提交或回滚;RM(Resource Manager)是资源管理器,每个微服务里都有一个RM,负责管理各自分支事务的注册、状态上报,以及执行真正的提交或回滚。

一次完整请求的链路是这样的:订单服务的TM向TC请求开启全局事务,TC返回一个全局唯一的XID;XID会被塞进RPC调用链路,无论是Dubbo的attachment还是Feign的Header;下游库存服务的RM通过拦截器获取XID,向TC注册为某个全局事务下的分支事务;所有分支执行完后,TM向TC提交全局事务,TC再通知各个RM执行二阶段提交或回滚。

这里最容易被忽略的是XID的传播。如果下游服务拿不到XID,它只会执行本地事务,全局事务规则对它完全无效,这也是后面我讲到的线上第5章故障的最常见根源。

3.2 undo_log 与前后镜像的补偿机制

AT模式最核心的机制是“一阶段本地提交,二阶段补偿回滚”。在订单服务执行insert into order_tbl时,RM的DataSource代理会拦截SQL,在真正执行前先查询一遍目标数据,生成before image(前镜像);SQL执行完成后,再查一遍生成after image(后镜像);然后把镜像数据和SQL类型写入业务库里的undo_log表,最后才提交本地事务。

到二阶段时,如果全局要提交,RM只需异步删除undo_log记录;如果全局要回滚,RM会读取undo_log,先校验当前数据是不是和after image一致——一致说明中间没有被其他事务篡改,可以安全回滚;校验通过后,根据before image和SQL类型生成反向SQL,把数据改回原来的样子。

这个设计的高明之处在于:一阶段就让各分支本地提交,释放了数据库连接和行锁,不会像2PC那样长时间占着资源;二阶段的回滚是补偿式的,不需要重新执行事务,所以性能和可用性都提升了一大截。代价就是回滚依赖undo_log表的正确性,后续故障排查中看这张表比看任何日志都快。

3.3 全局锁与隔离级别的真实行为

AT模式还设计了一个“全局锁”概念来保证写隔离。分支事务本地提交后、全局事务结束前,TC会记录这个分支修改过的数据主键;其他全局事务如果也要改同一条记录,必须先获取全局锁,获取不到就等待。

但很多人在官网文档里会忽略两个关键事实。第一,全局锁只对主键维度的数据生效,如果你的SQL通过非主键条件批量更新,全局锁不一定能拦住并发,极端情况下会出现回滚失败。第二,AT模式的默认隔离级别是“读未提交”级别,因为在全局事务提交前,分支事务已经本地提交了,数据对其他事务是可见的。如果业务要求读取未提交后的数据都不能串,必须用select ... for update或改用TCC方案。

明白了这两点,就能解释为什么AT模式适合大多数业务、却不适合对并发和隔离要求都极为苛刻的资金类核心系统。它不是做不到,而是需要你付出额外的代价——要么写for update,要么自己做业务锁。

4. 订单与库存扣减:一个跨服务事务的完整落地方案

下面用最经典的订单与库存场景,把Seata AT模式从表结构到核心代码完整走一遍。这个场景也是面试被问烂、但团队里真正落地时依然容易翻车的典型。

4.1 场景设计与数据库表结构

假设有两个微服务:order-service负责创建订单,stock-service负责扣减库存。两个服务使用各自的独立数据库。业务规则是:订单创建成功,库存必须同步扣减成功;任何一个失败,全部回滚。

两个服务各自的库需要建业务表,另外每个库都要建Seata要求的undo_log表:

-- 订单库 order_db CREATE TABLE `order_tbl` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) DEFAULT NULL, `product_id` bigint(20) DEFAULT NULL, `count` int(11) DEFAULT NULL, `status` varchar(32) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存库 stock_db CREATE TABLE `stock_tbl` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `product_id` bigint(20) DEFAULT NULL, `quantity` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_product_id` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

undo_log表每个参与分布式事务的业务库都要建,字段要求官方固定,不能随意改名:

CREATE TABLE `undo_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `branch_id` bigint(20) NOT NULL, `xid` varchar(100) NOT NULL, `context` varchar(128) NOT NULL, `rollback_info` longblob NOT NULL, `log_status` int(11) NOT NULL, `log_created` datetime NOT NULL, `log_modified` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有个初学者经常踩的坑:只给参与“扣减/更新”的服务建undo_log,觉得订单服务只是insert,不需要回滚;但AT模式的所有分支本地提交前都会写undo_log,连插入语句也要记录before image(虽然插入前没有数据行)。所以只要是全局事务链路里的服务,库表都要建全。

4.2 Seata接入与核心代码

Seata的Server端(TC)需要独立部署,我用文件配置方式举例。客户端引入依赖:

<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> <version>2.2.5.RELEASE</version> </dependency>

注意版本要和Spring Cloud Alibaba版本对应,否则会出现XID无法自动传播的问题。客户端application.yml里配置事务分组:

spring: cloud: alibaba: seata: tx-service-group: my_test_tx_group seata: registry: type: file config: type: file service: vgroup-mapping: my_test_tx_group: default

然后最关键的是在订单服务入口方法上加上全局事务注解:

@Service public class OrderService { @GlobalTransactional(name = "create-order", timeoutMills = 30000) public void createOrder(OrderDTO dto) { // 1. 本地库写入订单 orderMapper.insert(dto); // 2. 调用库存服务扣减库存 stockFeignClient.deductStock(dto.getProductId(), dto.getCount()); } }

name只是用来做监控辨认的,timeoutMills是全局事务超时时间,超时后TC会主动回滚,需要根据实际接口耗时来调整,不能拍脑袋写个固定值。比如订单服务在极端情况下要3秒,库存扣减要2秒,再加上Feign重试和网络延迟,30秒通常够用;但如果库存服务做了复杂的预校验,这个值就要重新评估。

库存服务无需加@GlobalTransactional,它只需要正常在自己的本地事务里扣减库存:

@Service public class StockService { @Transactional(rollbackFor = Exception.class) public void deductStock(Long productId, Integer count) { int updated = stockMapper.deduct(productId, count); if (updated == 0) { throw new BizException("库存不足"); } } }

这里要明确一点:全局事务注解只加在事务发起方,不是每个服务都加。如果库存服务也加了@GlobalTransactional,它会重新开启一个新的全局事务,XID会被覆盖,反而破坏了原事务边界。

4.3 防超卖在AT模式下的正确写法

扣库存SQL不能先查再算,必须用一条带条件的更新SQL保证原子性:

@Update("UPDATE stock_tbl SET quantity = quantity - #{count} " + "WHERE product_id = #{productId} AND quantity >= #{count}") int deduct(@Param("productId") Long productId, @Param("count") Integer count);

quantity >= #{count}这个条件保证库存不够时不扣减,返回0就抛业务异常。这一步和AT模式配合后,能兜住两层问题:单服务内部的超卖由SQL条件挡住;跨服务的整体一致性由Seata全局回滚挡。并发场景下,全局锁还会让多个事务串行执行,热点商品的库存更新不至于互相覆盖。

但要注意,AT模式的全局锁是在“分支事务本地提交后”才加的,如果两个请求同时进来,第二个请求会在获取全局锁时等待;一旦等待超过锁超时时间,会抛LockConflictException。这个异常不像普通业务异常,很多人第一次看到以为是数据库死锁,实际是全局锁冲突,后面我会专门展开讲。

5. Seata上线后的三类典型故障:完整排查链路

框架接入只是开始,上线后才能体会到真实的残酷。下面三个故障都是我在项目中真实遇到过的,每一个都直接造成了数据不一致或者生产告警,我把当时完整的排查过程写出来,比直接给答案更有参考价值。

5.1 undo_log缺失导致“回滚无门”

现象:全局事务执行到一半,某个分支服务主动throw new RuntimeException,理论上TC应该通知所有分支回滚。但订单服务回滚动作始终不生效,日志里报类似Could not found undo_log的错误。

排查链路:先看订单服务的日志,能确认二阶段回滚请求已经到达本地RM;接着连上订单服务的数据库,执行:

select * from undo_log order by id desc limit 5;

发现表里一条记录都没有。这时候要意识到,不是回滚动作没有执行,而是本地事务在一阶段提交前就没能写入undo_log。可能的根因是订单库从来没建undo_log表,AT模式拦截器一阶段准备写镜像时发现表不存在,抛出的异常被框架当成了“不能开启分支事务”,但业务SQL本身却正常执行了,最后全局回滚时找不到可补偿的数据。

处理办法是补建undo_log表,并确保所有参与分布式事务的库都建了。排查这类问题时有个小技巧:直接查undo_log在业务高峰期有多少数据、多少状态为未清除,能侧面看出事务流量和回滚频率。

5.2 Feign调用丢失XID导致数据不一致

现象:库存服务扣减成功,但订单服务后续逻辑异常触发全局回滚后,库存数据没有被回滚。订单和库存数据出现不一致。

排查链路:这个最隐蔽。先怀疑库存服务有没有收到二阶段回滚指令,在库存服务侧加日志,发现它根本没有执行回滚。再看库存服务有没有注册到全局事务中,打印RootContext.getXID(),结果为空。问题定位到Feign调用时XID没有传递过去。

原因:虽然引入了spring-cloud-starter-alibaba-seata,但我用的自定义Feign配置覆盖了默认的RequestInterceptor,导致Seata的XID传递拦截器失效。解决办法是自己手动加一个拦截器:

@Configuration public class FeignConfig { @Bean public RequestInterceptor seataRequestInterceptor() { return template -> { String xid = RootContext.getXID(); if (xid != null) { template.header(RootContext.KEY_XID, xid); } }; } }

这个故障的核心教训是:只要下游服务出现“单独执行成功、但没有参与整体回滚”的情况,第一反应就应该是查XID链路,而不是去查业务逻辑。XID传播链路断裂是Seata使用中最常见的数据不一致来源,优先级排在最前面。

5.3 热点库存触发全局锁超时

现象:大促期间,某个爆款商品的库存扣减接口大量报LockConflictException,堆栈里有Seata的GlobalLockConflictException,数据库本身没有死锁日志。

排查链路:先看TC端监控,发现大量的全局锁等待超时;再结合业务特征,确认所有请求都在争抢同一款商品的同一行库存记录。AT模式为了保证写隔离,在分支事务提交后会持有全局锁直到全局事务结束。爆款单品意味着同一时刻几十个请求都在抢这一行数据,排在后面的全局事务只能等待,等待超过TC配置的锁重试时间就会抛异常。

处理方案有几个方向。第一,调整客户端锁重试参数:

seata: client: lock: retry-interval: 10 retry-times: 30

retry-interval是每次重试的间隔毫秒数,retry-times是最大重试次数,调大后能容忍更长的全局锁等待。第二,如果热点商品并发实在太高,AT模式的全局锁会成为吞吐瓶颈,需要考虑对热点商品做库存拆分,把一行库存拆成多行,或者改用TCC模式,把库存扣减前置为“冻结”动作,降低单行记录的锁竞争。

这类故障最重要的启示是:分布式事务框架也有自己的“热点数局限”,不是无限扩展的。接入前必须对核心数据的并发特征做评估,大促前的压测里要专门构造热点SKU的并发场景。

6. 选型决策框架:从一致性等级到团队运营成本

讲完了原理、实战和故障,最后给一套完整的决策方法。我见过很多团队在选型会议上争论太久,本质是没有统一的判断维度。

6.1 五个决策维度及判断方法

做分布式事务方案选型,我会按以下五个维度依次打分:

  1. 一致性等级要求:强一致优先2PC/TCC,最终一致优先Saga/事务消息,能最终一致就不要强行追求强一致。
  2. 并发量级:日请求量在百万以内的常规CRUD,Seata AT完全够;千万以上且有热点数据,优先考虑事务消息或TCC拆解锁粒度。
  3. 代码侵入成本:把现有代码改造量估算出来。AT模式几乎不碰业务代码,TCC每个方法都要拆三段,Saga要插桩补偿逻辑,事务消息要改发送方和消费方。
  4. 基础设施依赖:有没有现成的MQ、有没有Nacos环境、有没有人员熟悉Seata Server的运维。没有基础设施支撑时,本地消息表反而是更务实的方案。
  5. 故障恢复能力:分布式事务一定会出现极端不一致,团队有没有完善的监控告警、对账任务、人工补单工具。这个常常被忽略,却是决定方案能否存活的底线。

把这五个维度全部列出来后,多数项目的结论会非常清晰。一个典型的常规电商业务,如果团队不大、又要求开发速度快,Seata AT模式是性价比最高的;如果业务链路特别长、包含很多外部系统调用,Saga加状态机更合适;如果流量集中在秒杀场景,TCC配合库存冻结才是正道。

6.2 我的个人建议:能规避就规避,能异步就异步

做了这么多年分布式事务相关的改造,我个人的体会是:不要为了用分布式事务而制造分布式事务。很多团队拆了微服务之后,不自觉地就把原本一个事务里能完成的逻辑拆成了跨服务调用,然后拼命引入各种框架去找补,这个是本末倒置。

真实业务里,我建议先做三步排查:第一步看能不能把跨服务写操作合并成同一数据库的本地事务,或者通过批量接口一次完成;第二步看能不能改成“先落库、后发消息、最终补偿”的异步模式,把一致性要求降级;第三步才考虑上分布式事务框架。这个顺序走下来,至少能砍掉一半的“分布式事务需求”。

而一旦真的需要Seata这类框架,我的经验是把监控和告警体系放在和接入同等重要的位置。TC端要关注全局事务提交成功率、回滚率、锁等待时长;业务侧要定期对账,尤其是订单和库存这种核心数据,每天跑一次存量比对任务,发现不一致立刻介入。框架只负责大概率一致,剩下的极端情况,最终还是要靠人的机制去兜底。

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

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

立即咨询