直接切入:先从一个真实场景说起。微服务架构下,订单服务和库存服务分开部署,各自拥有独立数据库。用户下单时,订单库写入一条订单记录,库存库扣减一个库存数量。看起来很简单,但一旦库存扣减成功、订单写入失败,或者订单写入成功、库存扣减超时,数据就对不上了。这类问题就是分布式事务要解决的。而SEATA作为国内使用率极高的分布式事务框架,提供了AT、TCC、Saga、XA四种模式。其中XA模式因为实现简单、对业务代码侵入小,成了很多团队的入门首选,也是面试中最高频被问到的点。
这篇文章我从原理到实操,把SEATA的XA模式讲透。适合刚接触分布式事务的开发者,也适合准备面试、或者正在做技术选型的朋友。我会把XA模式的两阶段提交机制、TM/TC/RM三个角色的协作方式、Spring Boot工程怎么接入、以及我在生产环境里踩过的坑全部拆开讲。读完你不仅能跑通一个XA模式的示例,还能搞清楚为什么它“简单”,以及它到底“牺牲”了什么。
1. 理解分布式事务以前,先看一个具体的业务场景
1.1 从一次下单扣库存说起
假设你有一个电商系统,拆成了订单服务(Order Service)和库存服务(Inventory Service)。订单服务负责插入订单数据,库存服务负责扣减库存。两个服务各自连接不同的数据库,甚至部署在不同机房。此时用户发起一次购买请求,系统要完成两步操作:
- 订单服务在自己的数据库里新增一条订单记录,状态为“待支付”。
- 库存服务在自己的数据库里扣减对应商品库存,如果库存不足则报错。
在单体应用时代,这两步操作可以放在同一个本地事务里,要么都成功,要么都失败。但微服务拆开后,事务边界被打破了——订单服务无法感知库存服务的内部事务,库存服务也无法回滚订单服务已经提交的数据。于是就会出现:订单创建成功,但库存扣减失败,用户看到订单存在,却永远无法发货;或者库存扣减成功,但订单创建失败,商品被白白占住。
更麻烦的是,如果中间还有支付、优惠券、积分等服务,链条越长,数据不一致的概率越高。这个问题的本质是:如何让多个独立数据库上的操作,在业务上保持原子性。这就是分布式事务要解决的核心问题。
1.2 分布式事务的常见解决方案
行业内对分布式事务的探索很多,大致可以分成两类方向。
第一类是强一致性方案,典型代表是XA协议(两阶段提交)。XA是一个数据库层面的标准协议,由X/Open组织提出,几乎所有主流关系型数据库(MySQL、Oracle、PostgreSQL等)都原生支持。它的特点是:多个数据库参与同一个全局事务,事务管理器(Transaction Manager)协调所有参与者,要么全部提交,要么全部回滚,中间不会出现部分成功的情况。缺点是性能损耗大,因为事务过程中数据库资源一直被锁住,并发能力会明显下降。
第二类是最终一致性方案,典型代表是TCC(Try-Confirm-Cancel)、Saga、本地消息表、消息事务等。这类方案牺牲了强一致性,允许在某个时间点出现短暂的不一致,但通过补偿机制最终达到一致。优点是性能好、扩展性强,缺点是需要业务方自己实现大量的补偿逻辑,开发成本高。
SEATA框架把这几种方案全部收编了。AT模式相当于对XA的改良,通过数据快照和全局锁实现类似效果,但没有XA那么重的资源锁;TCC需要业务方实现三个方法;Saga适合长流程;而XA模式最直接,数据库原生支持,代码侵入最小。接下来重点讲XA模式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SEATA中的XA模式:核心角色与原理
2.1 TM、TC、RM 三个角色
SEATA框架定义了三个核心角色,理解这三个角色是掌握XA模式的关键。
- TC(Transaction Coordinator):事务协调器,独立部署的Seata Server。它负责全局事务的注册、分支事务的登记、全局提交或回滚指令的下发。你可以把它理解成“总指挥”。
- TM(Transaction Manager):事务管理器,通常嵌入在发起全局事务的业务服务里。它负责向TC申请开启一个全局事务,然后根据业务执行结果,向TC发起全局提交或全局回滚。
- RM(Resource Manager):资源管理器,负责管理分支事务的资源。在XA模式里,RM就是对数据库XA连接的管理。当业务代码里执行SQL时,RM会向TC注册分支事务,并参与全局事务的提交或回滚。
以订单服务为例:订单服务的业务方法上标注了@GlobalTransactional注解,这个方法就是全局事务的入口。TM在方法开始时向TC申请全局事务ID,然后方法内所有对数据库的操作(包括调用库存服务),都会通过RM把分支事务注册到TC上。最终方法执行成功,TM通知TC提交全局事务;如果方法抛出异常,TM通知TC回滚全局事务。
这个过程中,TC是全局事务的核心,它需要高可用部署。TM和RM是以依赖包的形式嵌入在业务应用里的,不需要单独部署。理解这三个角色后,再看XA的提交流程就清楚了。
2.2 两阶段提交与XA的关系
XA协议本身就是两阶段提交(2PC)的工业实现。SEATA的XA模式,本质上是把SEATA的全局事务治理能力与数据库的XA能力结合起来。
第一阶段(Prepare阶段):全局事务开启后,RM向TC注册分支事务,同时让数据库资源执行SQL,但先不提交,而是进入Prepare状态。数据库此时会记录事务日志,并持有相应的行锁或表锁。这个阶段所有分支事务都要返回“准备好”的状态。
第二阶段(Commit/Rollback阶段):TC收到TM的提交或回滚指令后,向所有RM发出指令。如果所有分支事务都Prepare成功,TC通知所有RM执行Commit,此时各个数据库才真正提交事务;如果任何一个分支事务Prepare失败,或者业务方法抛异常,TC通知所有RM执行Rollback,各个数据库回滚本地操作。
这里有个容易混淆的点:XA模式中,每个分支事务的Prepare和Commit是数据库原生的,不依赖SEATA做数据回滚。对比一下AT模式,AT模式是在业务SQL执行后,SEATA记录修改前和修改后的数据快照,回滚时反向生成补偿SQL来恢复数据。而XA模式是直接调用数据库的Rollback能力,所以它更“原生”,也更安全。
XA模式的真正代价在于锁:在Prepare阶段,数据库资源(比如库存行记录)会被锁住,直到全局事务提交或回滚才会释放。如果全局事务持续时间长,或者并发量高,数据库很容易出现锁等待甚至死锁。这也是为什么很多团队在低并发、短事务场景下用XA,而在高并发场景下宁愿选择AT或TCC。
3. 实操:基于Spring Boot + Seata XA模式实现订单库存一致性
3.1 环境准备与Seata部署
先准备一套可运行的环境。我用的是:
- Spring Boot 2.7.x
- Spring Cloud Alibaba 2021.0.5.0(Seata版本1.5.2)
- MySQL 8.0,两个数据库:
order_db和inventory_db - Nacos作为注册中心和配置中心(Seata也支持文件配置和直连方式)
Seata Server的部署不复杂。下载seata-server-1.5.2的压缩包,解压后修改conf/application.yml,把注册中心和配置中心指向Nacos。如果不想用Nacos,也可以直接用file模式,配置文件里写死。我建议测试阶段用file模式,省事;生产环境一定要用Nacos或其他注册中心,否则Seata Server挂了无法自动发现。
启动Seata Server后,观察日志。看到“Server started successfully”就说明启动成功。然后记下Seata Server的IP和端口,默认是8091。客户端应用需要配置这个地址。
接着创建两个数据库,并准备两张表:
sql复制-- order_db
CREATE TABLE `order_info` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` varchar(64) DEFAULT NULL,
`product_id` varchar(64) DEFAULT NULL,
`status` int DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- inventory_db
CREATE TABLE `inventory` (
`id` bigint NOT NULL AUTO_INCREMENT,
`product_id` varchar(64) DEFAULT NULL,
`stock` int DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
注意:使用XA模式时,不需要像AT模式那样额外创建undo_log表。因为XA模式不依赖反向SQL补偿,而是数据库原生的回滚。这是XA模式的一个优势——少一张表,多一份舒心。
3.2 代码改造与配置
在订单服务(order-service)和库存服务(inventory-service)的pom.xml中添加依赖:
xml复制<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.5.2</version>
</dependency>
两个服务的application.yml中配置Seata相关参数:
yaml复制seata:
enabled: true
application-id: order-service
tx-service-group: my_test_tx_group
enable-auto-data-source-proxy: true
registry:
type: file
config:
type: file
service:
vgroup-mapping:
my_test_tx_group: default
grouplist:
default: 127.0.0.1:8091
这段配置的核心是:告诉客户端Seata Server的地址,以及当前服务属于哪个事务分组。tx-service-group相当于一个逻辑组名,Seata Server根据这个组名找到对应的TC集群。测试环境只有一个Seata Server,所以映射到default即可。
然后是数据库连接池的配置。注意XA模式要求数据源支持XA,我们使用Druid或者HikariCP都行,但推荐Druid,因为它提供了DruidXADataSource,能直接支持XA连接。配置如下:
yaml复制spring:
datasource:
type: com.alibaba.druid.pool.xa.DruidXADataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/order_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
这里的关键点是type和driver-class-name。如果使用默认的HikariCP,需要额外开启hibernate.jdbc.use_streams_for_binary之类的设置,不如直接换成Druid XA数据源省心。当然,SEATA的XA模式也支持普通数据源自动代理,但为了性能和数据源特性,显式配置XA数据源是更稳妥的做法。
业务代码改造的核心只有两步。第一步是在全局事务的入口方法上加上@GlobalTransactional注解,这个注解通常加在订单服务BFF层(比如Controller或Service的实现类)的方法上。第二步是在调用远程库存服务时,确保库存服务也使用同一个全局事务ID。由于Seata集成了OpenFeign或Dubbo的上下文传递,默认会把全局事务ID通过请求头或RPC上下文透传,所以这一步基本上不需要额外代码。
订单服务的核心业务代码大致是这样的:
java复制@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private InventoryClient inventoryClient;
@GlobalTransactional(rollbackFor = Exception.class)
public void createOrder(Long userId, String productId, Integer count) {
// 1. 本地插入订单
OrderInfo order = new OrderInfo();
order.setUserId(userId);
order.setProductId(productId);
order.setStatus(0);
orderMapper.insert(order);
// 2. 远程调用库存服务扣减库存
inventoryClient.deduct(productId, count);
}
}
库存服务里不需要加@GlobalTransactional,只需要正常的本地事务:
java复制@Service
public class InventoryService {
@Autowired
private InventoryMapper inventoryMapper;
@Transactional
public void deduct(String productId, Integer count) {
Inventory inventory = inventoryMapper.selectByProductId(productId);
if (inventory == null || inventory.getStock() < count) {
throw new RuntimeException("库存不足");
}
inventory.setStock(inventory.getStock() - count);
inventoryMapper.updateById(inventory);
}
}
这里有个细节:库存服务的方法上加了@Transactional。在XA模式下,这个本地事务会被Seata纳入全局事务分支。如果没有加@Transactional,数据库操作自动提交,就无法参与XA的Prepare阶段,全局事务也就无法保证一致性。这一点很多人容易忽略。
3.3 验证与异常回滚场景
启动两个服务后,我们构造一个正常的请求和一个异常的请求来做验证。
正常请求:调用订单服务的创建订单接口,传入userId=1, productId="p100", count=1。执行完成后,分别查看order_db.order_info表和inventory_db.inventory表:订单新增了一条记录,库存减少了1。然后查看Seata Server的日志,可以看到全局事务提交成功、分支事务提交成功。
异常请求:我们在库存服务里手动制造一个异常,比如把库存扣为负数。调用接口后,观察结果:库存服务的deduct方法抛出RuntimeException,这个异常会通过RPC传递回订单服务,订单服务的方法也会抛出异常。此时@GlobalTransactional会捕获异常并通知TC回滚全局事务。最终结果应该是:订单表没有新增记录,库存也没有变化。
这里我故意不写异常处理方法,就是为了验证XA模式下的回滚能力。如果一切正常,你会发现在order_db里插入的订单记录,即使订单服务自己的SQL已经执行成功,也会被回滚掉。这就是数据库原生XA回滚的威力——它不需要依赖任何补偿代码,数据库事务本身的原子性保证了这一点。
4. XA模式与其他模式对比,以及选型建议
4.1 AT、TCC、Saga、XA一张表看懂
把SEATA的四种模式放在一起对比,能更清楚地看到XA的定位。我根据自己的使用经验整理了一个表格:
| 模式 | 一致性 | 代码侵入 | 性能 | 适用场景 | 实现难度 |
|---|---|---|---|---|---|
| XA | 强一致 | 低 | 低 | 低并发、短事务、金融/订单核心链路 | 低 |
| AT | 最终一致(但有全局锁,近似强一致) | 低 | 中 | 中低并发,可以接受短暂的数据延迟 | 低 |
| TCC | 最终一致 | 高 | 中高 | 高并发,业务可灵活控制资源 | 高 |
| Saga | 最终一致 | 中 | 高 | 长流程、多服务,允许补偿 | 中 |
表格里的“一致性”一栏,AT模式我写了“最终一致(但有全局锁,近似强一致)”。因为AT模式通过全局锁避免了脏写,在全局事务提交前,其他事务无法修改同一数据,所以从最终结果看,它和XA一样能保持一致性。但AT模式本质是补偿式回滚,如果在执行期间出现未知异常,可能会有短暂的数据不一致窗口。
TCC模式则完全不同。它要求业务方把每个操作拆成Try、Confirm、Cancel三个方法。Try阶段做资源预留,Confirm阶段执行真正的业务,Cancel阶段回滚预留的资源。这种模式把事务的控制权完全交给了业务方,可以做到非常细粒度地控制锁,所以高并发下性能比较好。但代价是每个参与方都要写三套逻辑,开发量翻倍,而且要考虑幂等和空回滚问题。
Saga模式则是把一个长事务拆成多个本地事务,每个本地事务都有自己的补偿事务。如果某个本地事务失败,则依次执行之前所有事务的补偿逻辑。Saga适合流程长、允许中间状态存在的场景,比如订单创建后需要经过多个服务审批,最终才完成。但Saga没有隔离性保障,业务方需要自己处理数据竞争问题。
4.2 什么时候选XA,什么时候避开XA
根据上面的对比,选型其实很明确。
优先选XA的场景有:
- 强一致性要求极高。比如金融转账、订单支付、库存扣减。这类场景不允许出现任何中间状态,数据错了就是事故。
- 全局事务的时长很短。XA的锁是硬伤,但如果每个事务只需要几百毫秒甚至更短,那么锁的持有时间很短,对并发的影响就可控。
- 分支事务数量少,链路不复杂。比如只有订单和库存两个服务,XA完全可以胜任。如果链路像蜘蛛网一样复杂,XA的协调成本会指数上升。
- 团队没有精力维护复杂的补偿代码。XA模式业务侧不用写任何补偿代码,这是它最大的吸引力。
尽量避开XA的场景:
- 高并发、长事务。比如秒杀场景,库存只有100件,但同时有10万请求进来,如果用XA,数据库行锁会堆成一片,系统吞吐量直接崩掉。
- 事务中包含非数据库资源。比如调用外部HTTP接口、操作Redis、发送MQ消息。XA只能管数据库事务,管不了这些外部资源。
- MySQL主从延迟大的场景。XA模式依赖数据库的Prepare/Commit,如果从库数据延迟导致查询结果不一致,会放大问题。
我个人的经验是:能用XA就用XA,用不了再考虑AT。因为XA是数据库级别的保证,最简单、最可靠。AT模式虽然性能好一点,但需要维护undo_log,还要处理全局锁冲突和回滚日志丢失等坑。TCC和Saga则是备选项,等业务复杂度真正超出前两者承受范围时再上。
5. 常见问题排查与避坑经验
5.1 我踩过的几个坑
第一个坑是:Seata XA模式下,事务一直超时但不报错。排查下来发现是数据库连接池没有启用XA支持。HikariCP默认提供的Connection不是XAConnection,导致Seata无法注册分支事务。后来换成Druid的DruidXADataSource后解决。如果你不想换连接池,需要自己包装XAConnection,非常麻烦,所以直接用Druid是最快路径。
第二个坑是:@GlobalTransactional加错了地方。我最初把注解加在订单服务Mapper层的方法上,导致全局事务ID根本没有传递到库存服务。后来把注解上移到Service层入口方法,才正常。注解必须加在“全局事务的边界方法”上,也就是所有分支事务的发起入口。
第三个坑是:库存服务抛出业务异常后,订单服务没有回滚。问题出在OpenFeign的异常传播机制。Feign默认只会抛出FeignException,而不会透传服务端的业务异常类型。如果订单服务没有对FeignException做处理,@GlobalTransactional可能捕获不到可回滚的异常。解决方法是:在订单服务的createOrder方法里,对Feign调用做try-catch,捕获任何异常后重新抛出RuntimeException,确保全局事务回滚。
5.2 问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 全局事务注册失败,报错“txServiceGroup is not found” | tx-service-group配置与Seata Server不匹配 |
检查两端的vgroup-mapping配置 |
| XA事务提交后数据还是不一致 | 某个服务的方法没有加@Transactional |
确保分支事务方法必须开启本地事务 |
| 回滚后无法恢复,undo_log报错 | XA模式下不需要undo_log,但如果你用了AT模式才需要 | 确认你用的是XA还是AT,不要混合配置 |
| 业务异常提示“Global transaction rollback failed” | 可能全局事务ID传递失败 | 检查Feign/Dubbo过滤器是否配置了Seata上下文传递 |
| 高并发下XA锁等待超时 | 事务持锁时间过长 | 缩短全局事务内耗时,或考虑换TCC/AT |
还有一个容易被忽略的点:XA模式下Seata Server和客户端都必须开启MySQL XA支持。MySQL默认是支持的,但如果你用的是老版本驱动,可能需要升级数据库驱动到mysql-connector-java 8.0以上。否则在注册分支事务时会收到“XAER_RMERR”之类的错误。
最后再分享一个小技巧:如果你在调试XA模式,可以把Seata的日志级别调到DEBUG,日志中会出现RM register branch、Branch commit等关键词,配合数据库的SHOW ENGINE INNODB STATUS观察事务状态,能快速定位是哪个分支事务卡住了。我在写这篇文章时,也是靠这两把抓手定位了三次问题,效率提升非常明显。
