
最近好几个朋友都在问我同一个问题订单服务和库存服务之间到底怎么保证数据一致有的人直接给库存表加了一把大锁有的人说用消息队列做最终一致还有人上来就提Seata的AT模式。AT模式确实普及率高但也不是万能药。有一个场景我印象特别深——某个系统做库存扣减和订单创建业务上要求绝对不能出现超卖也不能出现订单生成了但库存没扣掉。这种强一致诉求下AT模式因为要依赖undo_log回滚偶发数据补偿不及时的问题反而很难受。后来我把方案切到了Seata的XA模式问题迎刃而解。XA模式不像AT模式那样通过反向SQL补偿而是直接利用数据库底层的事务能力让多个库的本地事务形成一个分布式大事务。网上讲XA模式的资料不少但大多数停留在“两阶段提交”的书本概念上真正把Seata怎么接入XA、代码怎么写、坑在哪里的文章很少。这篇文章就把我实际落地XA模式的完整过程拆开揉碎讲一遍从原理到代码从配置到踩坑希望能帮到正在选型的人。1. 为什么偏偏是XA分布式事务选型的真实场景1.1 从订单与库存的经典场景说起但凡聊分布式事务十有八九绕不开订单和库存。用户下单这个动作表面上看是一个操作背后至少涉及两个独立的服务订单服务负责生成订单记录库存服务负责扣减商品库存。如果这两张表在同一个数据库里那好办一个本地事务就搞定了。但微服务化之后订单库和库存库分属不同的物理库甚至不同的数据库实例本地事务就管不到别人家的数据了。这个时候问题就来了订单写成功了库存扣减失败用户拿到一个已支付但永远发不了货的订单。反过来库存扣了但订单没生成库存就莫名其妙少了。两个操作必须像一个人一样要么全成要么全败。市面上解决这个问题的套路大致分几类。第一类是业务层的补偿比如用本地消息表加定时任务把“扣库存”变成异步消息失败了靠对账去修。这类方案能做到最终一致但中间会有一段不一致的窗口期而且要对账逻辑写得非常细致。第二类是TCC模式也就是Try-Confirm-Cancel把每个操作拆成预留、确认、撤销三段业务侵入性强光是“预留库存”这种语义就得单独设计接口。第三类是AT模式靠框架记录变更前后的数据快照回滚时反向补偿。AT模式对业务代码侵入最小但需要额外的undo_log表而且回滚依赖框架对数据快照的解析脏数据风险偶发。最后一类就是我们今天的主角——XA模式直接采用数据库原生的分布式事务协议强一致性不需要反向补偿。1.2 XA、AT、TCC、SAGA怎么选选型这件事没有绝对的对错只有合不合适。我在实际项目中总结了一个判断标准先问业务能不能接受短暂的不一致。能的话SAGA和消息队列是成本最低的方案。必须实时严格一致那就在AT和XA之间选。AT适合以“单库操作为主、跨库操作为辅”的业务XA适合“两个以上的库必须同时提交”的强一致场景。TCC和SAGA更适合长事务、跨系统甚至跨公司的调用链比如支付清结算、跨机构转账。这类场景里每个参与者可能根本不在同一个技术体系中用XA要求所有数据库都支持同一套XA规范不现实。但在自己掌控全部服务的内部系统里XA是性价比很高的方案。我参与过一个电商后台的库存中心重构库存扣减是纯内部的强一致操作库存服务、订单服务、库存流水服务三个库必须同步成功。当时我们就在AT和XA之间摇摆。AT的方案已经写了一半后来压测时发现高并发下undo_log表的数据量膨胀很快回滚时偶尔因为快照数据被并发修改而产生脏回滚。换到XA之后锁由数据库层面统一管理逻辑上顺了很多。当然XA也有它自己的短板后面我会详细说。2. SEATA中XA模式的架构与核心原理2.1 三组件协作模型Seata对XA模式的实现底层还是Seata那一套三组件模型TC事务协调器、TM事务管理器、RM资源管理器。理解这三个角色是理解整个分布式事务的钥匙。TC是独立部署的服务端负责全局事务的注册、分支事务的注册、全局提交或回滚的决策调度。在Seata中就是seata-server进程它维护了全局事务和所有分支事务的状态。TM是发起全局事务的一方通常是我们业务代码的入口服务。TM向TC申请开启一个全局事务拿到全局事务IDXID业务执行完后由TM决定全局提交或回滚。RM是参与者管理每个分支的资源。XA模式下RM是资源层的数据库连接RM向TC注册分支事务也负责执行分支事务的提交或回滚。XA模式下的调用流程大致是这样的TM向TC发起全局事务拿到XIDXID通过调用链传递到下游服务下游服务的RM在和数据库交互时把当前连接关联到这个XID上并注册分支事务业务全部执行完TM向TC发起全局提交请求TC通知所有RM提交各自的分支事务。关键点在于XA模式中分支事务的提交和回滚是由数据库自己完成的。TM只是发号施令真正的重活是数据库执行XA COMMIT或者XA ROLLBACK。这个设计让XA模式天然比AT模式更“原生”因为数据库本身就对XA协议做了完整支持具备崩溃恢复能力。2.2 两阶段提交在XA模式下的执行细节XA协议基于两阶段提交分Prepare和Commit/Rollback两个阶段。第一阶段RM收到分支事务的注册请求后把业务SQL放在数据库的XA事务中执行执行完先不提交而是调用XA PREPARE。数据库会把事务的修改写入redo日志并锁定涉及的数据资源。第二阶段TC收到所有分支事务都Prepare成功的消息后给所有RM发全局提交指令。RM此时调用XA COMMIT。因为数据已经在Prepare阶段持久化了Commit本身很快不会再有业务SQL执行。如果第一阶段有任何分支Prepare失败TC就向所有RM发节点回滚指令调用XA ROLLBACK把本地事务撤销。这里有个容易被忽略的细节Prepare阶段只保证本地事务能成功提交但还没有真正提交。也就是说从Prepare到Commit之间被修改的数据是处于锁住状态的。其他事务如果要改同一行数据必须等分布式事务全局结束。所以XA模式天然会有“全局锁”的概念锁的时间是整个分布式事务的时长不只是本地SQL的时长。Seata在这个基础上做了一个值得表扬的优化——一阶段提交或叫直接提交。在只涉及单分支事务的全局事务中Seata允许RM在第一阶段直接提交不用等全局二阶段指令。当然这是从整体协调层面的优化XA的标准语义并没有破坏业务可以放心使用。2.3 SEATA对原生XA做了什么增强原生XA的问题是使用门槛太高开发人员光是为了管理XID的传播就要写一堆代码。我最早接触XA时用的是JTA每次都要手动处理UserTransaction的开启和提交还得保证所有参与者拿到的都是同一个XID一不小心就漏了。Seata在XA模式上主要解决了三个问题。第一个是XID的透明传播。Seata靠全局事务ID绑定方式在服务调用链中自动传播XID业务代码里看不到XID的操作。第二个是分支事务的自动管理。Seata的RM会拦截数据源连接把连接、分支事务、全局事务绑定在一起开发者只需要在入口方法上加一个注解。第三个是事务协调的自动化。全局提交还是回滚由Seata的TC统一决策业务代码不需要感知。实际用的时候这种封装带来的差别是巨大的。原来你要手动XA_START、XA_PREPARE、XA_COMMIT串起来现在就是一个注解的事。但代价是理解成本上去了——很多刚开始用Seata XA的人因为不清楚底层发生了什么出了问题无从下手。所以这篇文章的重点不只是教你怎么用更要把底层逻辑讲透。3. SEATA XA模式从零落地完整实操记录3.1 环境准备与依赖引入我用Spring Boot 2.7 MyBatis-Plus Seata 1.7.0作为基础组合数据库用MySQL 8.0。Seata Server端我直接用的Docker部署版本和客户端保持一致否则会出现协议不兼容的问题。创建两个独立的微服务工程一个是order-service一个是storage-service各自连接独立的数据库。order库里有订单表storage库里有库存表。为了模拟真实效果我在两个库里分别建了一张业务表并插入初始数据。-- order库 CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(64) DEFAULT NULL, user_id bigint DEFAULT NULL, product_id bigint DEFAULT NULL, count int DEFAULT NULL, status varchar(32) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO orders VALUES (1, ORDER2024001, 101, 1001, 1, NEW); -- storage库 CREATE TABLE storage ( id bigint NOT NULL AUTO_INCREMENT, product_id bigint DEFAULT NULL, total_count int DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO storage VALUES (1, 1001, 1000, 0);我在storage表里加了residue_count字段表示剩余库存初始为1000。还有version字段预留的乐观锁暂时没用上但这个字段在后面的并发验证中很有用先留着。需要在两个服务的pom.xml中引入Seata依赖dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId version2021.0.5.0/version /dependency如果你不是Spring Cloud项目直接用seata-spring-boot-starter也行dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId version1.7.0/version /dependency这里要注意版本匹配问题我因为版本不一致踩过坑Seata的客户端和服务端版本跨大版本时协议解析经常报错。建议pin死版本别图省事直接用latest。3.2 配置与数据源改造Seata XA模式最核心的配置项在application.yml里seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: group: SEATA_GROUPtx-service-group相当于业务分组名字编排全链路服务归属的组Seata Server通过这个配置找到对应的服务集群。需要在Nacos配置文件里配置service.vgroupMapping把tx-service-group映射到Seata Server集群。XA模式下的数据源不能直接用普通的DataSource需要包一层Seata的XA数据源代理。在Seata 1.7.0中XA数据源代理的配置方式很直接Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource) public DataSource dataSource() { return new DruidDataSource(); } Bean public DataSource dataSourceProxy(DataSource dataSource) { return new DataSourceProxy(dataSource); } Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSourceProxy) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSourceProxy); return factoryBean.getObject(); } }等等上面这种配置在XA模式里其实是不够的。Seata XA模式要求的是XADataSource不是普通的DataSource。我在第一次配置时沿用了AT模式的写法结果启动后虽然不报错但根本没进入XA流程。后来查了源码发现Seata中XA的数据源代理类应该是io.seata.rm.datasource.xa.DataSourceProxyXA它内部要求连接要么是XAConnection要么能unwrap到XAConnection。正确做法是基于Druid的XA连接来构造Configuration public class XADataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource) public DataSource dataSource() { DruidDataSource dataSource new DruidDataSource(); dataSource.setDbType(mysql); dataSource.setUrl(jdbc:mysql://localhost:3306/order_db?useUnicodetruecharacterEncodingutf8); dataSource.setUsername(root); dataSource.setPassword(root123); return dataSource; } Bean public DataSource dataSourceProxy(DataSource dataSource) { return new DataSourceProxyXA(dataSource); } }DataSourceProxyXA是Seata专门为XA模式提供的数据源代理类。它会在获取连接的时候包一层XAConnection之后每次执行业务SQL之前Seata都会通过这个代理连接跟TC交互注册分支事务。这里用Druid是因为Druid本身对MySQL的XA连接支持做得比较完善。如果你常用HikariCP也可以但要注意HikariCP默认的连接池行为和XA的兼容性在部分版本上有兼容问题表现为连接偶尔被回收时XA事务被打断。实测下来Druid在这块更省心。3.3 业务代码编写与注解使用XA模式的业务代码写起来非常简单。订单服务的入口方法加上GlobalTransactional注解内部调用库存服务的接口两边的本地数据库操作正常写就行。订单服务核心逻辑Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private FeignStorageService storageService; GlobalTransactional(name create-order, timeoutMills 30000) public void createOrder(Long productId, Integer count) { // 1. 生成订单 Order order new Order(); order.setOrderNo(UUID.randomUUID().toString().replace(-, )); order.setProductId(productId); order.setCount(count); order.setStatus(NEW); orderMapper.insert(order); // 2. 调用库存服务扣减库存 storageService.deduct(productId, count); } }库存服务这边正常写扣减逻辑不需要注解因为全局事务的入口在订单服务侧Service public class StorageService { Autowired private StorageMapper storageMapper; public void deduct(Long productId, Integer count) { // 扣减库存扣减到负数则报错 int rows storageMapper.deduct(productId, count); if (rows 0) { throw new RuntimeException(库存不足); } } }对应的Mapper SQLUpdate(UPDATE storage SET residue_count residue_count - #{count}, version version 1 WHERE product_id #{productId} AND residue_count #{count}) int deduct(Param(productId) Long productId, Param(count) Integer count);注意SQL里带了AND residue_count #{count}条件这是防止超卖的关键。如果库存不够update影响行数为0业务主动抛异常触发全局回滚。就这么多。代码层面跟AT模式唯一的区别就是数据源代理不能搞错注解还是同一个。Seata的XA做得比较克制尽量不侵入业务代码这是它能被广泛接受的重要原因。3.4 联调验证与结果分析代码写完后我启动两个服务用Postman调用了订单创建接口。正常情况下order表和storage表各自多了一条记录库存从1000变成999。为了验证回滚我把库存改为1然后连续调用两次下单接口。第一次扣减成功库存从1变0第二次因为residue_count count条件不成立SQL影响行数为0抛出“库存不足”异常。此时注意观察数据库第二次订单并没有写入orders表库存也保持为0。这说明全局事务确实把两个分支的回滚都执行了。这就是XA模式和业务补偿方案的本质区别——它不允许中间状态要么都成功要么都失败。我也试过在订单服务insert完成之后、调用库存服务之前手动睡10秒然后杀掉库存服务进程模拟库存服务宕机。XA模式下因为库存服务的分支事务始终没有响应Prepare成功的确认TC会等待全局事务超时然后发起回滚。这里timeoutMills参数很重要建议根据业务耗时合理设置我一般设30秒。4. 常见问题与排查技巧实录4.1 偶发XAER_RMFAIL的根因XA模式上线后我被一个偶发错误困扰了好几天。报错信息是XAER_RMFAIL意思是资源管理器RM操作失败。每次触发都发生在订单服务调用库存服务时但不是每次都出现压测时更频繁。排查过程挺曲折。先从Seata日志里看分支事务状态发现全局事务在第二阶段准备提交时某一个分支部落处于Idle状态没有收到XA_COMMIT指令。继续追发现是我在数据源配置里同时配置了Seata的XA代理和Druid自己的连接池监控两者在连接的生命周期管理上产生冲突。Druid的后台线程会定期检测连接的可用性检测时可能把XA事务中的连接给“回收”了XA事务被打断RMFAIL就冒出来了。解决办法是在Druid配置里关闭连接空闲检测或者调整检测间隔给XA连接留出足够的生命周期spring: datasource: druid: test-while-idle: false test-on-borrow: false test-on-return: false time-between-eviction-runs-millis: 600000另一个常见原因是Seata客户端和服务端版本不一致。我遇到过一次客户端1.6.0、服务端1.7.0的混搭压测时频繁出现分支事务注册超时后来把两边版本统一后问题消失。强烈建议上线前核对客户端和服务端版本特别是做了灰度升级的时候。4.2 全局锁等待与超时控制XA模式最容易被低估的是锁等待问题。我压测时把并发从50调到200结果接口的P99从80ms飙升到3秒以上。分析后发现XA Prepare之后订单表的相关行一直在锁状态直到全局事务提交或回滚才释放。在高并发写入场景下大量请求挤在同一行数据上全局锁等待急剧上升。这不是XA的缺陷而是强一致性方案的固有代价——为了数据绝对一致你必须牺牲并发度。如果业务并发很高建议能批量就批量把多个操作压到一个事务里或者考虑用TCC把锁的时间缩短到Try阶段。不过大部分内部系统的订单库存场景XA的并发表现足够用了。超时参数也需要重点关注。Seata全局事务的默认超时是60秒如果业务链路比较长比如还要调外部接口建议明确设置timeoutMills。我习惯把这个值设成“正常耗时的5倍左右”既不会因为偶发慢请求误回滚也不会让全局锁悬太久影响其他事务。4.3 分支事务悬挂与回滚失效还有一个坑是关于“悬挂事务”的。XA模式下如果某一分支事务因为网络原因迟迟没有收到TC的提交或回滚指令而TC已经判定全局事务超时并回滚了本地事务可能处于“悬空”状态数据库连接释放后事务既不提交也不回滚。在AT模式下Seata有个分支事务状态表来控制悬挂。XA模式下Seata主要靠数据库的XA事务超时机制兜底。为了防患于未然最好在数据库层面设置XA事务超时时间MySQL可以通过xa_rollback手动清理悬挂的XA事务。我在线上遇到过一次库存流水悬挂排查方式是通过MySQL的XA RECOVER命令查询处于ACTIVE状态的XA事务手动配合Seata日志判断归属然后人工清理。4.4 性能损耗的量化评估给打算引入XA的人一个直观的参考数据。我在同样的环境和并发下对AT和XA各做了一次压测核心结果是这样的指标AT模式XA模式单事务平均耗时45ms78msP99耗时180ms310ms200并发下吞吐量4200 TPS2600 TPS全局回滚耗时约150ms约100msXA的写入耗时偏高主要开销在XA Prepare的额外通信和数据库层的持久化上。但回滚耗时XA反而更低因为不用执行反向补偿SQL直接数据库回滚。在一致性要求极高的场景里这几十毫秒的损耗完全值得。还有一点XA模式下不建议把大事务和多事务混在一起。一个全局事务里如果同时操作订单库、库存库、积分库三个库每个库都陷入Prepare状态任何一个库出问题其他库全部被锁住等待回滚。这种情况下整个链路的可用性由最弱的那个节点决定。我一般的方案是跨库操作控制在两个以内链路中避开长事务和慢SQL。写在最后的一点心得Seata的XA模式不是银弹但它非常适合内部服务之间的强一致场景尤其是订单、库存这类不能出半点差错的业务。我最初担心XA性能损失大实际用下来只要事务粒度控制得当性能完全够用。它比AT模式更保险比TCC更省事缺点是需要数据库支持XA规范MySQL、PostgreSQL、Oracle这些主流数据库都支持基本不会有选型障碍。如果你刚开始接触分布式事务建议先用XA模式把原理搞透再去研究AT模式的补偿机制理解会更深刻。我始终觉得靠框架封装能省掉的是写代码的时间省不掉的是理解底层逻辑的时间。XA原理搞懂了再去看Seata其他模式都是水到渠成的事。