如果你手头也有一个“下单成功但库存没扣”、“库存扣了但订单失败”这种跨库数据不一致的问题,那你已经站在分布式事务的门槛上了。这篇文章聊的 Seata XA 模式,是我在一个电商后端项目里实际落地过的方案,用在两个 MySQL 库之间做订单和库存的强一致写入,整体效果稳定。XA 模式最吸引人的地方在于:业务代码几乎不需要关心分布式事务的细节,它直接依赖数据库底层的 XA 协议,让多个库像一个库一样原子提交。这篇文章会从底层原理拆起,给出一份可以直接复用的配置和代码,然后重点分享我在实际使用中踩过的悬挂事务、连接池耗尽、XID 丢失这些坑,希望能帮你少走弯路。
1. 一个下单接口,两个数据库:XA 模式解决的是什么层面的问题
1.1 为什么“扣库存”和“下订单”不能分两次提交
先还原一下典型的业务场景。订单服务和库存服务拆库拆服务已经是很常见的架构了,订单库order_db里写订单,库存库storage_db里扣库存。两个库各自有本地事务,单看一个库,数据没问题,但跨库就不一样了。用户下单那一刻,你是先插入订单还是先扣库存?无论哪个先执行,只要另一个失败,业务上就出问题了。
你可能会想:那先扣库存,失败就回滚订单不就行了?问题是,这两个操作在物理上属于两个独立的数据源,各自的commit是即时的,一旦先执行的那个提交了,后执行的失败了,前面的操作已经从数据库层面落盘,本地事务管不到了。这不是代码 bug,而是单机事务模型在分布式环境下的天然局限。
最简单也最常见的“土办法”是手工补偿:扣库存失败后,再发一条消息把订单取消掉。这确实能解决一部分问题,但它属于最终一致,而且补偿逻辑是业务代码自己写的,一旦补偿过程本身失败,或者消息队列抖动,数据照样不一致。最终一致不是不好,而是要看场景。电商下单这种用户感知强、链路短的场景,我更倾向于让数据库层面保证强一致,而不是靠业务代码去兜底。
1.2 Seata XA 在分布式事务方案里的定位,以及它和 AT、TCC 的分工
Seata 一共提供四种事务模式:AT、TCC、SAGA、XA。很多刚接触 Seata 的人容易把 AT 当成唯一选择,毕竟它用起来最简单、性能也相对好。但 AT 模式本质上是 Seata 自研的一种“补偿式”方案,它会在 SQL 执行前记录前后镜像,二阶段通过反向 SQL 来恢复数据。这个方案很巧妙,但它有一个前提:Seata 必须能解析你的 SQL,如果你的 SQL 写得比较特殊,或者数据库方言支持不完善,AT 的补偿就可能出问题。
XA 模式走的是另一条路。它不是 Seata 发明的,而是 X/Open DTP 模型中定义的分布式事务处理标准,Oracle、MySQL、PostgreSQL 这些主流数据库都实现了 XA 协议。Seata 所做的,是把数据库原生的 XA 能力编排进自己的全局事务框架,让多个跨库的 XA 事务被同一个全局事务ID管理起来。相比之下,XA 模式在一致性上更“硬核”,因为提交和回滚的最终裁决者是数据库本身,而不是 Seata 的补偿逻辑。
我的判断标准很简单:如果业务对强一致要求高,比如支付、下单、转账,并且事务链路短、并发冲突不大,XA 模式是首选;如果业务链路长、并发高、要求最终一致就好,那 AT、TCC、SAGA 各有各的适用场景。XA 不是万能的,但对于“两个库、一次请求、要绝对一致”这种场景,它是我最愿意用的方案。
2. 拆开 XA 模式的底层骨架:二阶段提交,以及 TM/TC/RM 的博弈
2.1 TC、TM、RM 三个角色,对照一次下单请求串一遍
Seata 整个分布式事务框架里,有三个角色必须彻底理解,面试也爱问:TM、TC、RM。
TM(Transaction Manager)是事务管理器,负责全局事务的开启、提交和回滚,业务代码里那个@GlobalTransactional注解,就是 TM 的入口。TC(Transaction Coordinator)是事务协调者,也就是独立部署的 Seata Server,它维护全局事务的状态,给所有参与者下达提交或回滚的指令。RM(Resource Manager)是资源管理器,负责管理分支事务,在 Seata 里,RM 对应的是被代理的数据源,它会向 TC 注册分支,并执行实际的数据库操作。
对照一次下单请求来看,整个流程是这样的:
- 业务方法入口上有
@GlobalTransactional,TM 向 TC 申请开启全局事务,TC 生成一个全局事务 ID,也就是 XID,返回给 TM。 - 业务代码开始执行,第一次访问订单库时,订单库对应的 RM 向 TC 注册分支事务,并执行
XA START把当前数据库连接纳入 XA 事务。 - 订单库执行插入 SQL,执行
XA END,再执行XA PREPARE,此刻订单库的事务已经进入 prepare 状态,数据锁没有释放。 - 同样地,库存库执行扣减 SQL,也走一遍
XA START、SQL、XA END、XA PREPARE流程。 - 所有分支都 prepare 完成,TM 向 TC 发起全局提交指令,TC 通知所有 RM 执行
XA COMMIT。 - 如果任何一个分支 prepare 失败,TM 向 TC 发起全局回滚,TC 通知所有 RM 执行
XA ROLLBACK。
这里最关键的一点是:XA 的一阶段结束于 PREPARE,而不是 COMMIT。数据库在 PREPARE 阶段已经确定了自己“可以提交”,但锁不会释放,一直要等到二阶段收到全局提交或者回滚指令才真正落定。这也是 XA 模式性能和并发上限不如 AT 模式的根本原因。
2.2 Seata XA 和标准数据库 XA 的关系,以及数据库底层的真实动作
很多人把 Seata XA 理解成 Seata 自己实现了一套 XA,这是不对的。Seata XA 里的 XA 动作,最终都是数据库自己完成的,Seata 的 RM 只是封装或者说代理了数据库的 XA 能力。
你完全可以用 MySQL 客户端手动模拟一遍 XA 的执行过程,这样理解会更深刻:
XA START 'global-001'; -- 这里执行你的业务SQL,比如 INSERT INTO t_order (order_no, commodity_code, count, amount) VALUES ('SN20240101', 'C001', 1, 99.90); XA END 'global-001'; XA PREPARE 'global-001'; -- 到这里,当前连接上的事务已经被数据库置于 prepare 状态,但数据还没提交 XA COMMIT 'global-001'; -- 或者 XA ROLLBACK 'global-001';注意这个'global-001',它就是 Seata 里说的 XID 的数据库形态。Seata 的 RP 在执行业务 SQL 之前,会先拿到一条物理连接,然后像上面一样执行XA START,把你的 SQL 包在中间,XA END之后执行XA PREPARE,最后等待 TC 的指令做XA COMMIT或者XA ROLLBACK。
所以你在代码里看到的DataSourceProxyXA,它代理的不是业务逻辑,而是“让这个数据源拿到的连接在执行 SQL 前自动完成 XA 事务的开启和 prepare”。这就是为什么 XA 模式下业务代码几乎不用改,只需要把数据源换掉。
2.3 XA 为什么能做到“强一致”,但它锁住了什么
强一致的核心在于 PREPARE 这个动作。在一个分布式系统里,多个数据库各自执行本地事务,最怕的就是“一部分提交了,一部分回滚了”。XA 用两阶段提交解决这个问题:第一阶段先让所有参与者表态,我是否可以提交;第二阶段根据所有人的表态,统一决定是提交还是回滚。
这种设计的巧妙之处在于,任何一个数据库只要进入了 PREPARE 状态,它就保证“只要收到 COMMIT 指令,就一定能提交成功”,不会再因为本地问题反悔。因为真正可能出错的地方,比如唯一键冲突、约束失败、磁盘空间不足,都已经在第一阶段暴露了。所以 TC 在第二阶段只需要做简单的指令转发,不会出现“说好了提交,结果提交失败”的尴尬情况。
代价就是把锁拉长了。在 MySQL 的 InnoDB 里,一个事务从 PREPARE 到最终全局 COMMIT/ROLLBACK,期间占用的行锁、间隙锁都不会释放。也就是说,两个 XA 事务如果操作同一行数据,后一个事务的等待时间不是本地 SQL 执行时间,而是前一个全局事务从启动到结束的整个生命周期。
我在实际项目里的体会是:XA 非常适合事务短、分支少、行冲突小的业务。比如一个下单操作,两个库各执行一条 SQL,正常情况下从 PREPARE 到 COMMIT 也就是几十毫秒的事,锁的代价可以接受。但如果你把外部 HTTP 调用、短信通知这类耗时的非数据库操作也塞进全局事务,锁的持有时间会被拉长到秒级甚至分钟级,那数据库基本就被锁死了。这也是面试里经常追问的点:为什么 XA 强一致但是吞吐量上不去?答案就在 PREPARE 到 COMMIT 之间的锁窗口。
3. 手把手集成 Seata XA:订单库+库存库双写落地的完整过程
3.1 前置准备:Seata Server、数据库表、XA 支持检查
先说明一下,下面这套是基于 Spring Boot 2.7.x + Seata 1.7.1 的常见组合,也是目前社区里用得比较多的一套版本搭配。Seata 2.x 在配置上有些变化,但整体思路一样。
你需要的环境:
- 两个 MySQL 库,推荐 8.0。本文示例是
order_db和storage_db。 - Seata Server,我用的是 1.7.1,去 GitHub Releases 下载
seata-server-1.7.1.tar.gz解压即可。 - 一个 Spring Boot 工程,准备集成多个数据源。
启动 Seata Server 很简单,默认的注册中心和配置中心都是 file 模式,直接执行:
sh seata-server.sh -p 8091 -m file -h 127.0.0.1-p指定服务端口,-m指定会话存储方式,开发环境用 file 就够了,生产环境建议用 db 模式,否则 Seata Server 重启之后全局事务记录会丢。
建表语句是后面要用到的:
-- order_db CREATE TABLE `t_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '订单号', `commodity_code` varchar(32) NOT NULL COMMENT '商品编码', `count` int NOT NULL COMMENT '数量', `amount` decimal(10,2) NOT NULL COMMENT '金额', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- storage_db CREATE TABLE `t_storage` ( `id` bigint NOT NULL AUTO_INCREMENT, `commodity_code` varchar(32) NOT NULL COMMENT '商品编码', `count` int NOT NULL COMMENT '剩余库存', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;检查数据库是否支持 XA,最简单的办法是直接执行:
XA START 'test'; XA END 'test'; XA ROLLBACK 'test';能正常执行不报错,说明你的 MySQL 版本和驱动支持 XA。这一步建议在集成 Seata 之前先做,可以排除掉数据库本身的兼容性问题。
3.2 依赖与配置:一个容易漏配置的地方
Maven 依赖里需要引入 Seata 的 Spring Boot Starter。如果你用的是 Spring Cloud Alibaba,可以引入spring-cloud-starter-alibaba-seata,它会自动带进来 Seata 的相关依赖。如果不用 Spring Cloud,直接引入seata-spring-boot-starter也行。我这里以直接引入 seata-spring-boot-starter 为例:
<dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.7.1</version> </dependency>然后在application.yml里加入 Seata 的基础配置:
seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group registry: type: file config: type: file service: vgroup-mapping: my_test_tx_group: default grouplist: default: 127.0.0.1:8091这几个配置项的含义,分别说一下:
application-id:当前服务的应用名,Seata 用它识别是哪个服务发起的全局事务。tx-service-group:事务服务分组,可以理解为给当前服务定义的一个逻辑分组名称。vgroup-mapping.my_test_tx_group: default:把逻辑分组映射到 Seata Server 集群的某个集群名。grouplist.default: 127.0.0.1:8091:指定集群下 Seata Server 的实际地址,就是上面启动的 8091 端口。
这里最常见的坑是:只配了tx-service-group,忘了配 vgroup 映射,或者映射关系配错,结果服务启动时报no available service错误,连不上 TC。配置文件路径在不同版本里略有差异,1.7.1 这套配置是最常见的。
3.3 数据源代理与业务代码:XA 模式的重点在 DataSource,不在 SQL
XA 模式集成中,最关键的一步是数据源代理。普通的数据源交给 MyBatis 管理,无法接入 Seata,必须用DataSourceProxyXA包一层。
先定义两个底层的物理数据源,然后分别创建对应的 XA 代理 Bean:
@Configuration public class DataSourceXAConfig { @Bean @ConfigurationProperties(prefix = "spring.datasource.order") public DataSource orderDataSource() { return new DruidDataSource(); } @Bean public DataSource orderDataSourceXA() { return new DataSourceProxyXA(orderDataSource()); } @Bean @ConfigurationProperties(prefix = "spring.datasource.storage") public DataSource storageDataSource() { return new DruidDataSource(); } @Bean public DataSource storageDataSourceXA() { return new DataSourceProxyXA(storageDataSource()); } }对应的配置文件:
spring: datasource: order: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/order_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: root storage: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/storage_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: root注意,这里我没有给任何一个数据源加@Primary,原因后面第 4 章会详细讲。多个数据源的情况下,最重要的是让 SqlSessionFactory 明确绑定到代理后的 XA 数据源上,否则你写的 Mapper 实际用的是原生数据源,XA 全局事务就不会生效。
业务代码方面,XA 模式的侵入性非常小,只需要在方法上加一个注解:
@Service public class OrderBusinessService { @GlobalTransactional(name = "create-order-and-deduct-storage", rollbackFor = Exception.class) public Order createOrderAndDeductStorage(OrderDTO dto) { // 1. 订单库插入订单 Order order = new Order(); order.setOrderNo(dto.getOrderNo()); order.setCommodityCode(dto.getCommodityCode()); order.setCount(dto.getCount()); order.setAmount(dto.getAmount()); orderMapper.insert(order); // 2. 库存库扣减库存 int rows = storageMapper.reduceStorage(dto.getCommodityCode(), dto.getCount()); if (rows == 0) { throw new RuntimeException("库存不足,扣减失败"); } return order; } }@GlobalTransactional注解就是 TM 的入口,它会让当前线程绑定一个 XID,同时让所有 RM 知道当前存在全局事务。里面就是普通的 Mapper 调用,没有任何分布式事务的代码。
看到这里你会发现,XA 模式和 AT 模式在业务代码里几乎长得一模一样,区别全在数据源代理上。XA 用的是DataSourceProxyXA,AT 用的是DataSourceProxy,两者底层执行的事务协议完全不同。
3.4 验证事务:造一次失败,看回滚日志
集成完先不要急着上线,花十分钟做一个验证:在扣减库存之后主动抛一个异常,看看两个库的数据是不是都回滚了。
@GlobalTransactional(name = "create-order-and-deduct-storage", rollbackFor = Exception.class) public Order createOrderAndDeductStorage(OrderDTO dto) { orderMapper.insert(order); storageMapper.reduceStorage(dto.getCommodityCode(), dto.getCount()); throw new RuntimeException("模拟异常,触发回滚"); }正常情况下的结果是:order_db里没有新订单,storage_db里库存也没有扣。从日志层面看,Seata 会打印分支注册、全局事务回滚的回执信息,大致能看到:
Begin new global transaction: xid=127.0.0.1:8091:123456789, name=create-order-and-deduct-storage Registering branch: xid=127.0.0.1:8091:123456789, branchId=1 Handling new global transaction: xid=127.0.0.1:8091:123456789, status=Rollbacking Branch transaction report: xid=127.0.0.1:8091:123456789, branchId=1, status=Rollbacked看到status=Rollbacked且两个库都没有数据,说明 XA 模式已经生效。这时候再去数据库执行XA RECOVER;,应该看不到任何残留的 XA 事务,说明连接没有被悬挂。
我自己在测试时习惯把logging.level.io.seata=debug打开,确认每个分支的执行状态。XA 模式一旦有问题,往往不是代码逻辑问题,而是某个环境细节没配上,调试日志能帮你快速定位是哪一步断了。
4. XA 模式踩坑实录:悬挂事务、连接耗尽与 XID 丢失的排查链路
4.1 数据库悬挂事务:应用挂了一次,库表被锁死
这是一个真实的生产事故。某天晚上 DBA 发来告警,说order_db的t_order表 UPDATE 全部卡住,CPU 不高,但所有写操作都过不去。
第一反应不是去看应用日志,而是先看数据库侧。SHOW FULL PROCESSLIST一看,有一大堆连接堆积在同一个事务上,状态是Updating或者Sleep,它们都在等一把锁。再执行XA RECOVER;,果然发现有一条处于 ACTIVE 状态的 XA 事务,格式类似:
XA RECOVER; +----------+--------------+--------------+----------------+ | formatID | gtrid_length | bqual_length | data | +----------+--------------+--------------+----------------+ | 1 | 88 | 0 | 127.0.0.1:8091:xxx | +----------+--------------+--------------+----------------+这条记录的 data 字段就是 Seata 的 XID。为什么会有悬挂事务?原因是应用在执行完XA PREPARE之后、还没收到全局 COMMIT 指令之前,JVM 发生了 OOM,直接被 kill 掉了。进程没了,物理连接被操作系统回收,但数据库侧的事务并没有收到XA COMMIT或XA ROLLBACK,于是这条 XA 事务就永远停留在 prepare 状态,手里的锁也不会释放。
处理方法分两步。紧急处理:根据XA RECOVER输出的 XID,手动执行:
XA ROLLBACK '127.0.0.1:8091:xxx';或者如果业务上确认可以提交,就执行XA COMMIT。我通常会先确认这个事务对应的业务是否已经不可追踪,然后直接回滚,锁立刻释放。
长期处理是我后来在架构上做的几件事:第一,全局事务的方法里绝不放大段耗时逻辑,尽量保证 PREPARE 到 COMMIT 的窗口在几十毫秒以内;第二,给 Seata Server 配置合理的全局事务超时,@GlobalTransactional注解里的timeoutMills属性可以设置,默认 60000 毫秒对我这个场景太长了,我调到了 10000;第三,DBA 侧做了巡检脚本,定期执行XA RECOVER,发现悬挂事务立即告警。悬挂事务是 XA 模式最需要防范的故障,因为它一发生就是整个表卡死,影响面非常大。
4.2 连接池被榨干:XA“占用连接”的代价
第二个坑发生在压测阶段。并发一上来,应用开始大量报获取连接超时,日志里出现:
Cannot get a connection, pool error Timeout waiting for connection当时我第一反应是连接池配置太小,把最大连接数调大了还是有这个问题,而且调大之后数据库连接数也紧张起来。后来看 Druid 的监控面板,才发现活跃连接数长期处于高位,而且很多连接是同一个线程持有的,好几秒都不释放。
想明白原理之后,这个问题就清晰了。XA 事务在二阶段结束之前,每个参与分支的 RM 都会占用一条物理数据库连接。也就是说,一个全局事务如果涉及两个库,它至少要持有两条物理连接,时间覆盖整个全局事务的生命周期。如果你的连接池最大连接数是 20,又有 15 个并发请求同时在执行全局事务,连接池可能直接被打满,后面新的请求连连接都拿不到。
而且这里还有一个隐藏的恶性循环:连接池被打满之后,Seata Server 给 RM 发送回滚指令,RM 要执行XA ROLLBACK也需要从连接池获取连接,如果连接池里的连接全被全局事务占用了,回滚动作也执行不了,变成“想回滚都没资源回滚”的局面。
我的调整方案是:
- 把每个数据源的最大连接数从 20 提到 50,最小空闲连接数从 5 提到 10。
- 给
@GlobalTransactional方法瘦身,把不涉及数据库的操作全部移到事务方法外面。 - 给 Druid 配置合理的
maxWait和validationQuery,确保连接池等待有上限,不会无限阻塞。 - 生产环境连接池大小要结合“全局事务并发数”和“每个事务占用的连接数”一起估算,而不是只按普通查询的并发量算。
这里也要提醒一句:不要为了缓解连接池压力给 Druid 开启removeAbandoned。这个配置会把“看起来很久没动的连接”强制回收,但在 XA 模式下,一个连接可能在 PREPARE 之后等待全局决策,这个等待是正常的,强制回收物理连接会直接导致数据库侧的 XA 事务悬挂,比连接池耗尽更可怕。
4.3 XID 没传过去:自研 RPC 环境下,全局事务悄悄失效
第三个坑比较隐蔽。现象是:两个服务都接入了 Seata,A 服务调用 B 服务完成下单,A 服务里加了@GlobalTransactional,结果测试时发现 A 服务的订单库回滚了,但 B 服务的库存库没回滚。
排查这种问题,第一件事是看 B 服务的日志,确认它有没有识别到 XID。Seata 有一个专门的无侵入设计:在线程上下文里维护一个RootContext.XID,RM 在执行 SQL 的时候会检查这个 XID 是否存在,存在才走全局事务分支。XID 在服务之间传递,靠的是 RPC 框架的隐式传参。Spring Cloud 生态里,Seata 会通过请求头的TX_XID字段自动透传,如果是基于 Feign/RestTemplate 的调用,通常不用你操心。
但我们的生产环境里有一部分服务用的是自研的 RPC 框架,不走 Spring Cloud 的标准链路,XID 就没法自动传过去。B 服务收到请求的时候,线程上下文里没有 XID,于是它把库存库的扣减当成普通本地事务直接提交了,全局事务根本管不到它。
排查链路很简单:在 A 服务发起 RPC 调用的地方打印RootContext.getXID(),在 B 服务入口打印RootContext.getXID()。如果前者有值,后者为 null,基本可以确认是透传问题。
修复方式是写一个通用的过滤器或者拦截器,在服务出口把当前 XID 塞到请求头,在服务入口取出来绑定到根上下文。伪代码大概是这样的:
// 出口过滤器,RPC 发送前执行 String xid = RootContext.getXID(); rpcRequest.setHeader("TX_XID", xid); // 入口拦截器,RPC 接收后执行 String xid = rpcRequest.getHeader("TX_XID"); if (StringUtils.hasText(xid)) { RootContext.bind(xid); }另外还有一种情况:即便在一个服务内部,如果你在@GlobalTransactional方法里手动开了线程池去执行某个数据库操作,子线程里也没有 XID,需要把 XID 手动传到子线程再绑定。这个不只在 XA 模式,所有 Seata 模式都一样,但 XA 模式因为对强一致要求高,XID 丢失的后果被放大了,所以排查的时候要格外谨慎。
4.4 数据库版本与驱动不兼容:MySQL 老版本的 XA 大坑
第四个坑属于选型问题,但踩的人非常多。MySQL 对 XA 的支持,并不是所有版本都一样完善。
- MySQL 5.6 及之前版本对 XA 的支持不够稳定,不建议在生产环境直接跑 Seata XA。
- MySQL 5.7 支持 XA,但如果并发事务多,历史上出现过一些 prepare 阶段一致性问题,而且还存在多个 XA 事务并发时的性能瓶颈。
- MySQL 8.0 对 XA 的支持相对成熟,是我目前在 XA 模式下的首选。
另外驱动也有讲究。低版本的 MySQL JDBC 驱动可能不支持 XA 连接,至少要使用mysql-connector-java5.1.40 以上版本,但连接串里很可能需要额外参数。我的经验是直接用 MySQL 8.0 对应的com.mysql.cj.jdbc.Driver,配合mysql-connector-j8.0.x 驱动,XA 行为最稳定。
Oracle 和 PostgreSQL 的 XA 支持比 MySQL 更完善,Oracle 要用OracleXADataSource来创建 XA 连接,而不是普通的DriverManagerDataSource。SQL Server 的 XA 支持依赖 Windows 的 MSDTC 服务,配置复杂度更高,如果一块评估这三种数据库,Oracle 接 Seata XA 的阻力最小,MySQL 8.0 次之,SQL Server 最折腾。
验证数据库到底支持不支持 XA,别光看文档,直接到目标环境执行一遍XA START、XA END、XA ROLLBACK,这是最靠谱的。我见过有项目用的还是 MySQL 5.5,集成的时候直接报语法错误,跟 Seata 本身完全无关,纯属数据库版本不支持。
5. 架构决策参考:XA 和 AT 怎么选,以及哪些场景别用 XA
5.1 XA vs AT 的对照表与选择逻辑
很多人会在 AT 和 XA 之间纠结,我用一张表把它们的核心差异列清楚:
| 对比项 | XA 模式 | AT 模式 |
|---|---|---|
| 一致性 | 数据库层面强一致 | Seata 层最终一致 |
| 业务侵入性 | 数据源代理,SQL 无侵入 | 数据源代理 + 需要 undo_log 表 |
| 锁持有时间 | prepare 到 commit 之间,锁不释放 | 本地事务提交即释放,但有全局锁 |
| 回滚方式 | 数据库原生 XA ROLLBACK | 通过 undo_log 逆操作 |
| 对 SQL 的依赖 | 不关心 SQL 内容,只要事务型数据库 | 需要 SQL 可解析,才能生成补偿 SQL |
| 并发性能 | 偏低,锁窗口长 | 偏高,本地提交早 |
| 对数据库要求 | 必须支持 XA 协议 | 主流关系型数据库基本都可以 |
| 对 DBA 友好度 | 高,原生协议,不需要额外表 | 低,要监控 undo_log 和全局锁 |
| 推荐场景 | 短事务、强一致、低冲突 | 长事务、高并发、最终一致可接受 |
选型的核心逻辑就三条。第一,业务能不能接受最终一致?不能接受,且链路短,选 XA。第二,并发冲突是不是很大?比如两个全局事务经常操作同一行数据,XA 的锁等待会拖垮整个系统,这种场景宁可放弃强一致也不要硬上 XA。第三,数据库是不是你能掌控的?如果 DBA 对 XA 不熟悉,或者数据库版本太老,AT 可能是更务实的方案。
这里补充一个面试里经常问到的点:为什么 AT 模式比 XA 模式性能好?因为 AT 模式一阶段直接提交本地事务,锁在本地事务提交时就释放了,虽然它用全局锁表保护了中间状态,但全局锁的粒度比数据库行锁细,灵活性更高。XA 模式必须把锁一直保持到全局事务结束,所以并发能力天然弱一个档次。
5.2 三个别用 XA 的真实场景
第一个场景:全局事务方法里有外部系统调用。比如下单流程里要调内部的会员服务、短信服务,这些外部调用的耗时完全不可控,一旦网络抖动几十秒,数据库锁就被拉长几十秒。正确做法是把外部调用移出@GlobalTransactional方法,或者考虑用 SAGA 模式,逐个分支提交本地事务,失败时通过反向操作补偿。
第二个场景:两个库之间的行冲突概率很高。比如一个秒杀场景,所有用户都去抢同一个商品的库存,扣库存的行锁竞争极其激烈。XA 模式下,所有全局事务都必须等前一个事务的库存扣减完全 commit 之后才能继续,这个等待时间被直接放大到全局事务的完整生命周期,结果就是系统吞吐量断崖式下跌。这种场景我建议放弃强一致,用 Redis 之类的组件做库存扣减,再异步同步到数据库,用最终一致来换取并发能力。
第三个场景:数据库版本太老。如果你还在用 MySQL 5.6 或者 5.7 的早期版本,老老实实升到 8.0 再考虑 XA。版本问题不像代码问题那样可以绕过去,它是数据库层面的能力缺失,硬上就是给自己埋雷。
5.3 关于 Seata 整体落地的几条长期维护经验
最后分享几条我维护这套系统几年下来的实际经验,不一定都是 XA 独有的,但对 Seata 在线上稳定运行很重要。
第一个经验:监控一定要做全。Seata Server 的会话数量、分支数量、全局事务状态,这些指标对判断系统健康度非常关键。数据库侧要重点看Com_xa_start、Com_xa_prepare、Com_xa_commit、Com_xa_rollback这些计数器的增量,如果Com_xa_rollback突然变多,说明业务上开始出现大量回滚,需要及时关注。
第二个经验:日志和 XID 要贯穿全链路。每个全局事务的 XID 必须打到业务日志里,排查问题的时候,你要能从一个异常堆栈一路追到 TC 的事务记录,确认这个分支最终是提交还是回滚。很多分布式事务问题难排查,不是因为技术复杂,而是因为日志里没有 XID,根本找不到线索。
第三个经验:别试图用一个模式解决所有分布式事务问题。我见过有的团队接入 Seata 之后,所有跨库操作统一用 XA,结果复杂业务链路里出现各种超时;也见过团队因为 AT 模式性能好,连转账这种强一致场景也用 AT,结果出现极端情况下的补偿 SQL 拼接问题。更合理的做法是:按业务场景逐个评估,短链路强一致用 XA,长链路最终一致用 SAGA 或消息队列,多服务编排用 TCC,把模式的边界划分清楚,而不是一刀切。
这些坑没有一个是从官方文档里可以直接查到的,都是在生产环境的凌晨和压测报告里一个个逼出来的。分布式事务本身就没有银弹,XA 模式能给你的是数据库层面的强一致承诺,但代价是锁、连接和超时这些资源的全盘权衡。希望你读完这篇之后,能对自己的业务场景做出清晰判断,少踩几个我踩过的坑。