☰
基于Seata AT模式的微服务分布式事务完整落地实践
2026/10/19 14:29:39 网站建设 项目流程

只要做微服务,早晚会遇到分布式事务的坑。单体时代一个本地事务搞定订单和库存,拆分成两个服务、两个库之后,同样一个下单接口,订单写进去了,库存却扣失败了,账就对不上了。我的做法是用 Seata 的 AT 模式,配合 Spring Cloud Alibaba 和 Nacos 做注册发现与配置管理,把这套分布式事务方案完整落地。这篇以我实际搭过的订单服务加库存服务为例,从 Seata Server 部署、微服务客户端整合,到跨库联调验证和踩坑排查,一步一步讲清楚。需要说明的是,文中的配置和版本组合是基于我项目里验证过的常用组合,你也可以按自己的环境微调,但核心链路是通用的。

1. 先搞清楚AT模式在幕后做了什么

1.1 三个角色和一次全局事务的完整旅程

Seata 把一次分布式事务拆成了三个角色:TC(事务协调器,就是独立的 Seata Server)、TM(事务管理器,一般是发起全局事务的业务服务)、RM(资源管理器,也就是真正操作数据库的服务)。

一次下单流程走下来大致是这样:订单服务作为 TM,方法上加了@GlobalTransactional注解,它先向 TC 注册一个全局事务,拿到一个全局唯一的 XID。随后订单服务通过 Feign 调库存服务,XID 会跟着调用链传递过去,库存服务作为 RM 把自己执行的本地事务注册成这个全局事务的分支事务。全部本地事务先各自执行成功,TM 再给 TC 发起全局提交;如果中间任何一步抛出异常,TM 发起全局回滚,TC 通知所有分支事务回滚。

这个链路里最容易被忽略的是 XID 的传递。Spring Cloud Alibaba 的 Seata 集成包给 Feign 加了拦截器,会自动把 XID 塞进请求头,所以普通 Feign 调用不用你管。但如果项目里用了 RestTemplate、异步线程池或者消息队列,XID 不会自动传,你得手动把 XID 放进请求头或消息体里,否则后面的服务根本不知道自己参与了全局事务,出了问题也不会跟着回滚。

1.2 为什么AT模式对业务代码的侵入这么小

AT 模式最吸引我的一点是:业务 SQL 基本不用改。它的思路是,在每个参与事务的业务数据库里建一张undo_log表,本地事务执行前,RM 会先记录数据的"镜像"(beforeImage),执行 SQL 后再记录一份 afterImage,然后本地提交。

如果后面需要回滚,TC 会通知 RM,RM 通过 undo_log 里存的前后镜像,把数据恢复到执行前。全局提交时则把 undo_log 记录删掉就行。

这套机制比 TCC 省事太多了。TCC 要你为每个操作都写 Try、Confirm、Cancel 三个方法,还得考虑幂等和悬挂,工作量明显大。AT 模式本质上是用额外的日志记录换来了低侵入,适合大多数订单、库存、账户这类业务场景。

1.3 AT/TCC/SAGA怎么选

我自己的选择标准是这样的:

  • 业务方法相对同步、链路不算太长、对一致性要求高,优先 AT。
  • 接口必须对性能敏感,AT 模式在数据竞争激烈时会频繁出现全局锁等待,这时候 TCC 反而更可控。
  • 长事务、跨系统强异构场景,比如涉及到外部接口或者异步补偿链路,SAGA 更合适。

Seata 也支持 TCC 和 Saga,但如果你是从零开始整合,我建议先用 AT 模式跑通链路,别一上来就上 TCC。

2. 部署Seata Server前的关键准备

2.1 Nacos上需要提前准备好的配置项

Seata Server 要挂到 Nacos 上做注册,同时它的全局配置也可以托管到 Nacos。我用的是 Seata 1.4.2 版本,配合 Nacos 1.4.x,比较稳。

启动之前,先在 Nacos 控制台新建一个配置,Data ID 我习惯叫seataServer.properties,Group 用SEATA_GROUP。

内容大致如下:

store.mode=db store.db.datasource=druid store.db.dbType=mysql store.db.driverClassName=com.mysql.cj.jdbc.Driver store.db.url=jdbc:mysql://127.0.0.1:3306/seata_server?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai store.db.user=root store.db.password=你的数据库密码 service.vgroupMapping.my_test_tx_group=default service.disableGlobalTransaction=false client.rm.lock.retryInterval=10 client.rm.lock.retryTimes=30 client.rm.lock.retryPolicyBranchRollbackOnConflict=true

这里service.vgroupMapping.my_test_tx_group=default特别关键,它把客户端配置的事务分组映射到 Seata Server 集群里的一个虚拟组default。如果这行配置缺失,客户端启动时大概率会出现找不到服务的问题。很多教程会忽略,我刚开始也在这里卡了半天。

2.2 Seata Server的配置落点与DB模式初始化

Seata Server 有两个核心配置文件,1.4.2 版本是registry.conf和file.conf。前者管注册中心和配置中心的连接方式,后者管事务存储等本地配置。

registry.conf里我把 registry 和 config 都指向 Nacos:

registry { type = "nacos" nacos { application = "seata-server" serverAddr = "127.0.0.1:8848" group = "SEATA_GROUP" namespace = "" username = "nacos" password = "nacos" } } config { type = "nacos" nacos { serverAddr = "127.0.0.1:8848" namespace = "" group = "SEATA_GROUP" dataId = "seataServer.properties" } }

注意,如果config.type设置为 nacos,Seata Server 就会去 Nacos 拉取seataServer.properties里的配置,本地file.conf里的 store 配置就不生效了。我建议直接把配置都托管到 Nacos,这样后续 Server 端调参数不用再改配置文件重启,方便很多。

2.3 启动TC并确认注册成功

Seata Server 的 DB 模式需要三张表:global_table、branch_table、lock_table,它们建在一个独立的库里,我一般叫seata_server。脚本在 Seata 解压目录的script/server/db/mysql.sql里,直接执行即可。

启动步骤:

cd /opt/seata/bin sh seata-server.sh -p 8091 -h 127.0.0.1 -m db

启动后看日志,确认没有报错,然后去 Nacos 控制台的服务列表里找seata-server这个服务,看到实例在线就算成功。

这里有个小经验:Server 的-h参数一定要能跟客户端互通的主机 IP,不要写localhost,否则跨服务器调用时客户端会拿着127.0.0.1去连 TC,连接必然失败。

3. 微服务客户端整合的完整步骤

3.1 依赖引入与版本对齐

先看 Maven 依赖。我的项目是 Spring Cloud Alibaba 的,在pom.xml里引入:

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

这个依赖会传递引入 Seata 的 client 端。要特别留意版本冲突,spring-cloud-starter-alibaba-seata 里内置的 Seata 版本必须与你的 Seata Server 一致,或者至少兼容。否则客户端可能用一套协议,Server 用另一套,连上了也无法正常工作。

我用的版本组合是 Spring Cloud Alibaba 2.2.5.RELEASE + Seata 1.4.2。你可以根据自己项目的 Spring Cloud 版本选择一个经过验证的对应关系,不要盲目升级 Seata 到最新版,稳定优先。

3.2 客户端配置:事务分组与注册中心指向

客户端的application.yml里,需要告诉 Seata 三件事:当前服务叫什么、事务分组是什么、注册中心在哪里。

spring: application: name: order-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 alibaba: seata: tx-service-group: my_test_tx_group seata: enabled: true registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 group: SEATA_GROUP namespace: "" username: nacos password: nacos config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP namespace: "" username: nacos password: nacos

这里最核心的是tx-service-group,它必须和 Seata Server 端配置的service.vgroupMapping.my_test_tx_group前半部分对应。如果服务端映射的是order_tx_group,客户端写my_test_tx_group,那 TM 发起全局事务时就找不到TC,直接报错。

库存服务也要写同样的配置,只是spring.application.name改成自己的服务名。两个服务的事务分组保持一致,它们才能参与同一个全局事务。

3.3 数据源代理与undo_log表落地

AT 模式要记录前后镜像,就必须让 Seata 接管你的数据源。Seata 会通过DataSourceProxy代理我们原有的 DataSource,在执行 SQL 时采集数据变化、写 undo_log。

我的做法是写一个数据源配置类:

@Configuration public class DataSourceProxyConfig { @Bean @Primary @ConfigurationProperties(prefix = "spring.datasource") public DataSource dataSource() { return new DruidDataSource(); } @Bean public DataSourceProxy dataSourceProxy(DataSource dataSource) { return new DataSourceProxy(dataSource); } }

这里踩过一个大坑:如果项目里有多数据源,或者 MyBatis 的SqlSessionFactory绑定到了原始数据源,那 Seata 根本不会拦截到 SQL。必须让 MyBatis、JdbcTemplate 等最终使用的都是DataSourceProxy包装后的数据源。如果项目里有多个数据源 Bean,记得把代理数据源标上@Primary。

另外,undo_log表必须建在每个参与全局事务的业务数据库里。脚本在 Seata 解压目录的script/client/mysql/undo_log.sql。漏建这张表的话,本地事务提交的时候可能没立刻报错,等全局回滚时就会发现没有日志可回滚,或者直接抛异常。建议在做整合检查时,把订单库、库存库的 undo_log 表都查询一遍确认存在。

表结构核心字段包括branch_id、xid、rollback_info和log_status。回滚信息是序列化后的二进制数据,Seata 用它来做数据恢复。

3.4 在入口方法上开启全局事务

事务开启的位置很有讲究。全局事务注解要加在整个分布式事务的入口方法上,也就是能代表一个完整业务闭环的方法。

订单场景的入口在订单服务里:

@Service public class OrderService { private final OrderMapper orderMapper; private final StorageFeignClient storageFeignClient; public OrderService(OrderMapper orderMapper, StorageFeignClient storageFeignClient) { this.orderMapper = orderMapper; this.storageFeignClient = storageFeignClient; } @GlobalTransactional(rollbackFor = Exception.class, timeoutMills = 30000) public void createOrder(OrderDTO orderDTO) { Order order = new Order(); order.setUserId(orderDTO.getUserId()); order.setCommodityCode(orderDTO.getCommodityCode()); order.setCount(orderDTO.getCount()); order.setAmount(orderDTO.getAmount()); orderMapper.insert(order); StorageDTO storageDTO = new StorageDTO(); storageDTO.setCommodityCode(orderDTO.getCommodityCode()); storageDTO.setCount(orderDTO.getCount()); storageFeignClient.deduct(storageDTO); } }

注意,@GlobalTransactional并不替代本地@Transactional。如果入口方法里既有本服务的数据库操作,又有远程调用,最好在方法内部用TransactionTemplate或者加上@Transactional控制本地事务的边界。否则本地 SQL 可能在全局事务还没结束时就已经被提交,一旦后续远程调用失败,本地事务已经落库,回滚只能靠 undo_log,性能会差一些。

4. 模拟跨库下单场景验证全局事务

4.1 场景搭建:订单库与库存库

我验证用的拓扑很简单:

  • 一个 MySQL 实例,建两个库,分别是order_db和storage_db。
  • order-service连order_db,管理订单表。
  • storage-service连storage_db,管理库存表。
  • 两个服务都注册到 Nacos。
  • Seata Server 独立部署,使用seata_server库存事务状态。

这个拓扑基本还原了实际项目里的分库效果。核心验证目标只有一个:库存扣减失败时,订单是否会被自动回滚。

4.2 验证路径A:正常流程全部提交

先跑正常流程,确保链路本身是通的。调用下单接口,如果订单插入成功,库存也扣减成功,再分别查两个库的数据,能看到订单记录和扣减后的库存。

同时去seata_server库查global_table,会看到一条记录,status 为提交完成状态。branch_table里能看到两个分支事务,对应订单服务和库存服务。undo_log表在正常提交后应该被清理,说明二阶段提交成功。

第一次跑通的过程其实比我想的要顺利,前提是配置没出错。如果你在验证时发现订单和库存都成功了,但 global_table 里没有记录,说明@GlobalTransactional根本没生效。最常见原因就是事务分组映射不对,或者注解加错了方法。

4.3 验证路径B:库存扣减失败触发全链路回滚

接下来模拟失败场景。我在库存服务里主动抛一个运行时异常:

@Transactional(rollbackFor = Exception.class) public void deduct(String commodityCode, int count) { Storage storage = storageMapper.selectByCommodityCode(commodityCode); if (storage == null) { throw new RuntimeException("库存记录不存在"); } storageMapper.deduct(commodityCode, count); throw new RuntimeException("模拟扣减失败"); }

注意,库存服务这里用本地@Transactional保证自己的先回滚,然后 Seata 的全局回滚会再对订单服务做一次补偿回滚。整个调用链结束后,查order_db的订单表,发现订单记录已经不存在,说明全局事务回滚生效了。

这里我给一个小建议:验证回滚时,先在日志里确认 XID 有从订单服务传到库存服务。Seata 的日志里会打印类似xid的信息,如果没有传递,两个服务各自为政,库存失败不会影响订单,那问题就出在请求头传递上,需要重点检查 Feign 拦截器或者自定义调用链。

4.4 从undo_log和TC日志看二阶段行为

回滚成功后,再去看库存库的undo_log,会发现回滚分支的日志记录还在,但log_status已经被标记成已回滚状态,订单库里的 undo_log 则被清理掉了。这其实是 AT 模式标准的二阶段表现。

如果回滚过程中有问题,日志里会出现rollback error或者branch rollback failed之类的信息。我后面要讲的踩坑部分,很大比例就是围绕这个环节展开的。

5. 整合过程中高频踩坑的完整排查链路

5.1 客户端一直报can not connect to service

这是我遇到的最多的一个问题。现象是启动订单服务后,一调用带@GlobalTransactional的接口,就抛can not connect to services-server这种异常,或者提示找不到服务。

按这个顺序排查:

  1. 先确认 Seata Server 是否已经注册到 Nacos,注册名是不是seata-server。客户端配置里的registry.nacos.application必须能对应上。
  2. 再看客户端的tx-service-group和 Server 端service.vgroupMapping.xxx是否一致。不一致的话,客户端不知道用哪个虚拟组找 TC。
  3. 最后确认网络。Server 启动时-h参数绑定的 IP,客户端必须能访问到。如果 Server 在另一台机器,-h 127.0.0.1会让客户端往自己的网卡回环地址连,必然失败。

这三步按顺序走,基本能解决大部分连接问题。

5.2 全局事务没生效,回滚了个寂寞

另一个高频问题是:库存抛异常了,订单还是没回滚。这个问题的排查链路我建议这样走:

  • 查订单方法上@GlobalTransactional是否真的加载到了。如果方法被同类内部调用,Spring 的this调用会绕开代理,注解失效。这一点非常阴险,我一开始也怀疑框架有问题,最后发现是自己调用姿势的问题。
  • 看日志里有没有 XID 的创建与传递记录。TM 发起全局事务,日志会显示Begin new global transaction之类的信息。如果根本没这行,说明注解没生效。
  • 检查两个服务的事务分组是否一致。如果订单服务用的是group A,库存服务用的是group B,那它们虽然都能连接 TC,但根本不认为自己在同一个全局事务里。
  • 确认 SQL 操作有没有真正走DataSourceProxy。可以在 undo_log 里查一下,如果一阶段执行后 undo_log 没数据,说明数据源没代理上。

5.3 回滚失败的根因:beforeImage与afterImage校验

回滚失败比较隐蔽,表现是日志里有xid、有分支注册,但回滚时报镜像校验失败。

AT 模式回滚时,会用 undo_log 里的 afterImage 和当前数据库中的实际数据做对比。如果当前数据已经被其他事务改了,和 afterImage 不一致,Seata 不敢强制恢复,只能报警并让该分支进入人工处理状态。

这个问题的根源通常是:全局事务执行期间,另一个请求把同一行数据改了。避免手段是尽量缩短全局事务的执行时间,把远程调用和重IO操作放进事务之前完成,减少持有全局锁和本地锁的时间。也可以在查询/更新数据时考虑在相关事务边界使用SELECT FOR UPDATE或@GlobalLock来隔离。不过这不是银弹,根本上还是要让事务链路短而快。

5.4 版本兼容矩阵与常见坑位

最后说版本。根据我实际验证过的组合,Spring Cloud Alibaba 和 Seata 的常见对应关系可以做个参考:

Spring Cloud Alibaba内置 Seata 版本我测试过的稳定性
2.2.5.RELEASE1.4.2稳定
2021.0.1.01.4.2稳定
2021.0.5.01.5.2基本稳定
2021.0.6.01.6.x需要自行验证

请注意,官方发布页的版本对应才是最终依据,上表只是我个人的验证记录。版本不对齐的时候,最常见的坑是注册成功但全局事务无响应,或者二阶段回调时客户端根本处理不了。升级 Seata 时,建议连同 Seata Server 和所有客户端一起升,不要只升一边。

另外,如果你用了 Druid 连接池,注意DataSourceProxy包装的是 DruidDataSource。如果 Druid 的监控页或部分插件拿的是原始数据源,会出现监控数据正常但 Seata 不生效的情况,所以要把数据源 Bean 的注入关系梳理清楚。我把数据源代理统一放在一个配置类里,明确标注@Primary,之后排查起来非常省事。

最后再分享一个实操习惯。每整合完一个新的 Seata 项目,我会先跑一遍最小场景:两个服务、两张业务表、一次带远程调用的全局事务,故意在第二个服务里抛一个异常,看第一个服务是否回滚。这个冒烟测试通过了,再往复杂业务上加。别急着一步到位,分布式事务这种业务越往后越难排查,先把底座打牢,后面扩展才不慌。

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

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

立即咨询