从下单到扣库存,订单成功了库存却少了,这个问题在很多微服务项目里都遇到过。单体应用时代一个@Transactional注解就能搞定的事,拆库拆服务之后,数据库自身的本地事务就管不了跨服务的数据一致性了。Seata是目前国内使用最广的分布式事务中间件之一,它提供的四种模式里,XA模式是最接近"数据库原生强一致"的一种方案。这篇文章我会把XA模式从原理到落地讲透,包括传统两阶段提交协议、Seata中的角色分工、服务端部署、Spring Boot接入步骤,以及我在实际项目中踩过的几个典型坑。适合正在做微服务改造、准备引入分布式事务,或者面试前想系统梳理Seata相关知识点的后端工程师阅读。
1. 订单扣库存为何会翻车:分布式事务的源头
1.1 一个订单流程里藏着哪些"看不见的事务边界"
先还原一个再常见不过的下单场景。用户在前端点了一下"立即购买",后端订单服务先插入一条订单记录,然后调用库存服务扣减商品库存,最后调用积分服务给用户加分。看起来就是一次普通的HTTP调用链,但注意,这三个服务各自维护着自己的数据库,订单数据落在order_db,库存数据落在storage_db,积分数据落在score_db。
单体架构下,这些操作可能共用一个数据库,要么全部成功,要么全部失败,一个@Transactional就能覆盖。但服务拆分后,一次业务操作被打散到多个数据库连接上,每个数据库的本地事务只能保证自己那一段的一致性。订单服务提交了自己的事务,但库存服务调用超时失败了,结果就是订单在库里躺着,库存却没有扣,这就是分布式事务要解决的数据不一致问题。
我在一个交易类项目里就遇到过这种情况。用户下单成功后一直收不到发货通知,排查半天发现库存表里根本没有扣减记录,而订单表的状态却是"待发货"。当时没有引入任何分布式事务组件,代码里只用了一个简陋的重试机制,结果重试非但没解决问题,反而因为重复扣减导致库存变成负数。这种"假成功、真失败"的情况,远比接口直接报错要难排查。
1.2 从本地事务到分布式事务:一致性代价的转变
要理解分布式事务,先得理解本地事务为什么失效。本地事务依赖数据库的ACID特性,通过日志锁和回滚机制来保证单库内的数据一致。但跨库之后,ACID里的原子性(Atomicity)和隔离性(Isolation)没有办法由单个数据库实例来保证,必须由一个外部的协调者来统筹所有参与者的提交或回滚。
这就引出了分布式系统里常说的CAP理论:在网络发生分区(P)时,只能从一致性(C)和可用性(A)里二选一。分布式事务的本质,就是在这样的约束下寻找一个可接受的折中方案。有些方案牺牲强一致换取高性能,比如TCC和SAGA;有些方案则优先保证强一致,比如XA模式。
Seata的设计很有意思,它把这个问题抽象成三个角色:TC(Transaction Coordinator)是独立部署的事务协调器,负责维护全局事务和分支事务的状态;TM(Transaction Manager)嵌入在业务应用里,负责开启、提交或回滚全局事务;RM(Resource Manager)同样嵌入在应用里,负责管理分支事务上的资源。所有的分布式事务模型,无论AT、TCC还是XA,都是围绕这三个角色构建的。下一节我会重点拆解XA模式在这套体系里是如何工作的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. XA模式到底做了什么:两阶段提交与Seata的角色分工
2.1 传统两阶段提交(2PC)协议,拆开看也就三步
XA规范最早由X/Open组织提出,是一套分布式事务处理的标准模型,对应的协议就是大家常说的两阶段提交(2PC)。整套模型里有三个核心概念:AP(Application)是发起事务的应用程序,RM(Resource Manager)是管理资源的模块,比如数据库或者消息队列,TM(Transaction Manager)是全局事务的协调者。
两阶段提交的执行过程可以分成三步来理解。
第一阶段是准备(Prepare)阶段。TM向所有参与事务的RM发送prepare请求,每个RM收到请求后,在自己的本地事务中执行操作,但先不提交,而是把事务状态置为"可提交",然后向TM返回"就绪"或者"未就绪"。这个阶段很关键的一点是,资源锁不会被释放,数据已经在RM内部做了变更,只是对外部不可见。
第二阶段是提交(Commit)或回滚(Rollback)阶段。TM收集所有RM在准备阶段的反馈,如果所有RM都返回"就绪",TM就向所有RM发送commit指令,让它们正式提交事务。只要有一个RM返回"未就绪",或者TM在等待过程中超时,TM就会向所有RM发送rollback指令,让它们回滚自己的本地事务。
传统2PC的问题也很明显。首先,参与者在prepare之后会一直持有资源锁,如果协调者出问题,参与者只能一直等,这会造成严重阻塞。其次,TM是单点,一旦TM崩溃,整个分布式事务就挂在半中间。最后,在第二阶段如果某个参与者执行commit失败,那么系统里就可能出现一部分库提交了、另一部分库没提交的情况,这仍然有数据不一致的窗口。所以传统2PC在工程实践里直接被企业使用的案例很少,但它给了Seata一个很好的起点。
2.2 Seata的XA实现:TM、RM与TC如何"握手"
Seata的XA模式本质上是对传统2PC的一次工程化封装,它保留了"基于数据库XA协议实现资源层面的强一致"这个核心思想,同时把协调者从业务代码中独立出来,做成了Seata Server。在Seata规范里,TC就是Seata Server,TM和RM则住在业务应用内部。
开发者的使用体验是什么样的?只需要在发起全局事务的方法上标注@GlobalTransactional注解。方法被调用时,TM会向TC发起请求,申请一个全局事务ID,也就是XID。这个XID会通过微服务框架的上下文传递机制,跟着一次完整的业务调用链走,比如在Spring Cloud的Feign调用里,Seata通过自带的拦截器把XID塞进HTTP请求头,被调方拿到请求头里的XID后,就知道自己的操作属于哪一笔全局事务。
每个被Seata管理的数据源,都会在RM层被包装成一个代理数据源。代理数据源拦截所有SQL操作,业务代码正常执行SQL,但底层的连接管理和事务决策已经被RM接管。SQL执行完成后,RM会向TC注册一个分支事务,把这个数据源上发生的操作挂到全局事务下面。全局事务结束时,TM根据业务方法的返回值决策,调用TC的全局提交或全局回滚接口,TC再把指令分发给所有注册过的RM。
这套机制跟传统2PC最大的区别在于:协调者独立化之后,TC自身具备高可用部署能力,而且全局事务的状态可以被持久化,即使TC在某个瞬间宕机,恢复后也能从存储中找回未完成的事务状态,而不是像传统2PC那样所有参与者一起干等。
2.3 一阶段与二阶段:XA在Seata中的完整执行流程
下面用一个具体的订单插入SQL来推演一遍XA模式在Seata里的完整流程。
一阶段时,业务方法里的orderMapper.insert(order)触发RM代理数据源拦截。RM先向TC注册分支事务,拿到分支事务ID。随后,RM从数据源拿到一个数据库连接,由于当前处于XA模式下,这个连接会被标记为XA事务的一部分。业务SQL正常执行后,RM调用XAResource.prepare()方法,数据库内部执行XA准备操作,事务进入"Prepare"状态。
二阶段有两种路径。如果全局事务成功,TM会向TC发送全局提交指令,TC找到所有分支事务,通知各个RM执行XAResource.commit(),数据库收到后正式提交事务,释放锁资源。如果全局事务失败,TC通知各RM执行XAResource.rollback(),数据库段回滚,数据恢复到事务开始前的状态。
从代码层面看,整个过程对业务是无感知的,业务代码不需要写XA的SQL命令,也不需要手动管理XAResource,这些都被Seata的代理层透明处理了。但数据库层面,XA事务的真实状态确实存在,XA RECOVER命令能看到这些处于PREPARED状态的XA事务。
| 流程 | 传统2PC | Seata XA |
|---|---|---|
| 协调者 | TM(常被内嵌) | TC独立部署,支持高可用与恢复 |
| 参与者状态 | RM本地内存维护 | RM向TC注册,TC持久化状态 |
| 业务侵入 | 需要业务方实现XA规范接口 | 注解声明,数据源代理底层处理 |
| 事务ID传递 | 需业务手动传递 | 中间件自动在RPC链传递XID |
| 最终效果 | 资源直接锁定,强一致 | 同样依赖数据库XA,强一致 |
3. 从零接入Seata XA:服务端搭建与Spring Boot整合
3.1 Seata Server(TC)部署:单机模式跑通
先准备好Seata Server,这一步不复杂。去GitHub的Seata仓库Release页面下载对应的seata-server压缩包,我用的版本是1.5.2,后续客户端版本尽量跟服务端保持一致,避免兼容性问题。
解压之后,重点看conf目录下的两个文件:registry.conf和file.conf。单机测试阶段,不需要引入Nacos或Consul,registry.conf里的type配置用file即可,Seata Server会从本地文件感知配置。file.conf里需要关注store部分,默认是file模式,事务日志会写到本地文件,实测单机测试没问题,生产环境则建议改为db模式,把事务状态存到数据库。
启动命令很简单:
bash复制sh seata-server.sh -p 8091 -h 127.0.0.1 -m file
-p指定对外端口,-h指定注册IP,-m指定存储模式。启动成功后,日志里会出现Server started字样,8091端口在监听。如果启动失败,优先检查Java版本是不是8以上,以及8091端口是否被占用。
3.2 客户端配置:数据源是最大的门槛
客户端的Spring Boot项目引入Seata依赖,版本与服务端保持一致:
xml复制<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.5.2</version>
</dependency>
然后看配置文件,我用的是application.yml:
yaml复制seata:
enabled: true
application-id: order-service
tx-service-group: default_tx_group
registry:
type: file
config:
type: file
data-source-proxy-mode: XA
tx-service-group是事务分组名称,客户端会拿这个分组名去注册中心查找TC地址,单机file模式下只要跟Server的启动配置能对应上即可。data-source-proxy-mode: XA这个配置决定了Seata的数据源代理走XA分支事务逻辑,如果漏掉这个配置,默认是AT模式。
但这里有一个非常大的坑,我要特意强调:XA模式要求底层数据源本身支持XA协议。默认的HikariCP连接池并不支持直接创建XA连接,如果你只是把上述配置加进去,项目启动可能不会报错,但一执行全局事务就会失败。这也是很多人接入XA模式卡住的第一道门槛。正确的做法是使用Druid的DruidXADataSource作为数据源实现:
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
username: root
password: root
如果你的项目里已经用了Druid连接池,那就是个好消息,Druid本身提供了DruidXADataSource,轻松就能切过去。如果还在用HikariCP,需要先把HikariCP从数据源自动配置里排除掉,否则DruidXADataSource无法被Spring Boot识别。
还有一点不同于AT模式:XA模式不需要在数据库里创建undo_log表。AT模式必须为每个业务库建一张undo_log,用来记录数据快照以便回滚,XA模式的数据回滚完全由数据库自身处理,这张表是多余的。如果你是从AT模式切过来的,可以留着表,但XA模式不会写入任何记录。
3.3 写一个订单-库存的事务示例并验证回滚
我构造了一个最典型的订单-库存场景:两个服务,order-service负责写订单表,storage-service负责扣库存表。全局事务由order-service发起,它通过Feign调用storage-service的扣减接口。
先看order-service的入口逻辑:
java复制@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StorageFeignClient storageFeignClient;
@Override
@GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class)
public String createOrder(OrderDTO dto) {
Order order = new Order();
order.setProductId(dto.getProductId());
order.setCount(dto.getCount());
order.setStatus(1);
orderMapper.insert(order);
storageFeignClient.deduct(dto.getProductId(), dto.getCount());
if ("mockFail".equals(dto.getFlag())) {
throw new RuntimeException("模拟积分服务失败");
}
return "success";
}
}
storage-service的扣减接口很普通:
java复制@RestController
public class StorageController {
@Autowired
private StorageMapper storageMapper;
@PostMapping("/deduct")
public String deduct(@RequestParam("productId") Long productId,
@RequestParam("count") Integer count) {
storageMapper.deduct(productId, count);
return "success";
}
}
两个服务数据库里准备一张product_stock表和一张order表,先给库存插入一条初始化数据,比如商品ID为1001,库存为100。然后分两种情况测试。
先测正常流程,调createOrder接口,传productId=1001,count=1,flag为空。请求成功后查库,会发现订单表多了一条数据,库存变成了99。这个结果符合预期。
再测异常回滚,同样传productId=1001,count=1,但flag=mockFail。请求会抛出异常,接口返回错误。这时马上去查数据库,重点来了:订单表不能有新增记录,库存仍然应该是99,不能变成98。如果这两个条件都满足,说明XA模式的全局回滚生效了。
这里我建议你查库存时不要只看最终数量,最好在扣减SQL里加上一个last_update_time字段,回滚成功后这个时间戳应该跟扣减前保持一致。有些场景下库存数量恰好被其他补偿任务修正,光看数量会误判。
4. XA模式的暗坑:我在实践中遇到的三种"假成功"
4.1 数据源不支持XA导致的一阶段静默失败
第一个坑就是前面提到的HikariCP问题。当时我把XA模式的所有配置都加好了,也确认了data-source-proxy-mode: XA,代码跑起来后,正常流程一切正常,订单和库存都能提交。但一旦触发异常回滚,订单表确实是没数据了,可库存表依然被扣减了,回滚完全失效。
排查了很久,最后通过看Seata的日志才发现,RM在执行业务SQL后根本没能进入XA的prepare流程,因为底层连接根本拿不到XAResource。HikariCP的数据源在Seata的代理层尝试调用unwrap(XADataSource.class)时直接抛了异常,但异常被吞掉,分支事务是作为普通本地事务提交的。这就造成了"局部提交、全局无感知"的假成功。
解决方式只有一个,把数据源换成DruidXADataSource。我建议你在配置完数据源后,写一个简单的测试接口,通过DataSource拿到连接后尝试转换成XAConnection,能成功转换再开始正式开发。这个小验证步骤能帮你提前暴露问题,而不是等到回滚失效后再来排查。
4.2 连接池与全局锁:长事务把数据库锁死的经过
XA模式最让人头疼的问题是锁时间被拉长。AT模式在业务SQL执行后会立即提交本地事务,然后通过undo_log和全局锁来保证最终一致,所以本地数据库锁能很快释放。但XA模式为了强一致,事务在prepare之后不能提交,行锁一直握在手里,直到全局事务提交或回滚。
有次我对订单服务做压测,QPS只要到200,库存扣减接口的RT就从20ms飙升到7秒。当时以为是数据库连接池不够,把最大连接数调到500,情况反而更糟。后来抓了数据库的锁等待视图,发现大量线程都在等待同一个product_stock表上的行锁,而这些锁全部来源于未提交的XA事务。
这是XA模式的固有特性,不是配置问题。业务请求跨服务越多、执行时间越长,资源锁的持有时间就越久。为了缓解这种情况,我做了两件事:一是严格限制全局事务内的远程调用次数,只把真正需要强一致的核心写操作放在@GlobalTransactional里,读操作和耗时的非核心操作全部移出事务边界;二是把事务超时时间从默认的60秒下调到10秒,让异常事务尽快回滚释放锁。
4.3 事务超时与重试:二阶段异常后发生了什么
XA模式在二阶段出问题时,情况会比AT模式更复杂。第二阶段TC发出commit指令后,如果某个RM因为网络抖动没有收到,或者收到后执行commit时数据库报了XAER_NTA等错误,这条分支事务的状态就会陷入中间状态。
数据库层面的表现是出现一个PREPARED状态的XA事务,锁不掉,数据变不了,像一个"悬挂事务"。MySQL里可以用XA RECOVER命令查看这些PREPARED事务,但除非手工执行XA ROLLBACK或XA COMMIT,否则它们会一直存在。更尴尬的是,Seata在部分版本里对这些悬挂事务的自动恢复能力有限,TC重启后可能无法精确找回所有分支事务的现场。
我的处理经验是:首先把网络超时和事务超时都调小,让异常尽早暴露;其次在TC的file.conf里开启客户端与服务端的连接重试机制,把client.rm.report-retry-count调大一点;最后还是要靠定时任务兜底,扫描长时间处于PREPARED状态的XA事务,人工确认后执行回滚。这个兜底脚本在XA模式落地初期非常有必要。
4.4 失效场景:跨数据库厂商与跨网络边界
XA模式依赖数据库的XA协议实现,但不同数据库对XA的支持成熟度差别很大。MySQL从5.7开始支持得还可以,但有个特性要注意:MySQL的XA事务在连接断开后不会自动恢复,高可用切换或连接被服务端杀掉时,悬挂事务只能手动处理。Oracle和PostgreSQL的XA支持相对成熟,分布式事务的可靠性更有保障。
另外一个限制是跨网络边界。XA的会话需要保持一个持久的连接链路:RM到数据库、RM到TC、TC到RM。如果这几个节点不在同一个机房,网络抖动导致的连接中断会直接打破XA事务的进行。所以部署上要让参与分布式事务的服务和数据库尽量保持在同一网络环境中,跨地域的强一致事务,XA并不合适。
5. XA和AT怎么选:强一致与高性能的一场博弈
5.1 两者的故障处理与一致性模型差异
Seata四种模式里,AT和XA是最容易被放在一起对比的。AT模式的思路是用undo_log记录数据修改前后的快照,一阶段业务SQL正常提交,二阶段如果全局事务需要回滚,就用undo_log里的反向SQL把数据改回去。这是一种"补偿式"的方案,本质上是最终一致,因为回滚操作和被改数据之间有时间差。
XA模式则完全依赖数据库的原生事务能力。一阶段只prepare不提交,数据只在数据库内部可见,二阶段要么全部提交要么全部回滚,不需要应用层做补偿。因此XA是真正的强一致方案,不存在"数据已经变了再改回来"的中间状态,对应用层代码也更透明。
但代价也很清楚。XA在prepare之后资源锁不释放,数据库承担了所有隔离和并发控制压力。AT模式则因为一阶段本地事务就提交了,数据锁很快释放,并发能力明显更好。不过AT模式需要处理全局锁冲突、脏读和悬挂问题,Seata在AT模式里做了很多额外的协议设计,比如全局锁、undo_log状态机。
| 维度 | AT模式 | XA模式 |
|---|---|---|
| 一致性类型 | 最终一致 | 强一致 |
| 补偿方式 | undo_log反向SQL | 数据库原生回滚 |
| 资源锁持有时间 | 短,业务SQL提交即释放 | 长,等待全局提交/回滚 |
| 对SQL的要求 | 有限制,不支持的语法需要改造 | 无特殊限制 |
| 数据库要求 | 需要建undo_log表 | 必须支持XA协议 |
| 业务代码侵入 | 低但仍需考虑SQL规范 | 极低 |
| 典型性能表现 | 高并发下优势明显 | 并发越高越吃力 |
5.2 选型建议:什么业务该用XA,什么业务用AT
如果业务对数据一致性要求极高,能接受一定程度的吞吐下降,比如账户扣款、资金流水、对账核心链路,XA模式会更稳妥。它不需要你做复杂的补偿逻辑,只要数据库支持XA协议,强一致就有保障。但前提是接口的并发量不能太大,事务执行时间要控制得足够短。
如果业务属于高并发互联网场景,下单秒杀、库存抢购,这个链路追求的是高吞吐和低延迟,XA的锁持有模式会迅速击穿数据库。AT模式通过本地事务提交释放锁,再用全局锁管理并发,会更适合。AT模式下要额外写好undo_log的监控和清理,避免回滚日志膨胀。
TCC和SAGA也不是没有适用场景。TCC适合性能要求更高、但愿意写更多业务代码的场景,它通过Try、Confirm、Cancel三阶段手动控制资源,能实现业务的准实时生效。SAGA适合长事务、多阶段业务流,比如订单创建后还经过多个异步步骤,强一致反而会成为障碍。面试时如果被问到Seata四种模式的选择,这个问题本身就是考核你对一致性和性能权衡的理解程度。
5.3 面试常问的XA相关问题快速梳理
很多面试官会从2PC协议切入,问Seata XA模式跟传统2PC有什么区别。核心回答点是:Seata把协调者TC独立部署,事务状态可以持久化,通过全局事务ID管理跨服务事务,业务方只需要一个@GlobalTransactional注解,不需要自己实现XAResource接口。
还有一道高频题是:XA模式为什么比AT模式慢?这里要从隔离等级和锁讲起,XA一阶段prepare到二阶段提交之间,数据库行锁会一直持有,并发高时锁竞争激烈,数据库整体吞吐下降。AT模式一阶段就提交了业务SQL,锁早释放,最终一致靠后续补偿。
另外面试官有时会追问:Seata XA模式有哪些局限?可以说数据库兼容性问题、长事务锁资源、悬挂事务恢复难度大,以及跨网络跨地域场景下HA保障困难。这些问题没有标准答案,能结合实战讲出具体场景和解决办法,一般就能给面试官留下好印象。
我在项目里的最终选择是:资金核心链路用XA,普通交易链路用AT,长流程异步任务用SAGA。这样既保证了核心数据不出错,又不至于让所有业务都背上XA的性能代价。分布式事务没有银弹,选型之前先想清楚业务对于"一致性"和"性能"的容忍边界,这一点比任何一项技术细节都重要。
