1. 先交代项目背景:为什么订单系统会走到Seata这一步
先说我手里这套系统的处境。它是个典型的小型电商中台改造项目:订单服务负责创建订单,库存服务负责扣减库存,账户服务负责扣余额,三个服务各自独立部署、独立数据库,服务间通过 OpenFeign 做同步调用,注册中心和配置中心统一由 Nacos 承担。表面上看,这套微服务拆分在早期是很舒服的,单个服务随便改、随便发布。但业务一旦进入"跨服务写多张表"的阶段,事情就开始变得尴尬。
举个例子,用户下单流程里,订单服务插入一条订单记录,同时通过 OpenFeign 调账户服务扣余额、调库存服务扣库存。如果账户扣款成功,但库存服务恰好宕机或者超时,订单库里就会出现一条"已创建但余额已扣、库存没扣"的脏数据。靠定时任务对账补数据也不是不行,但业务方没法接受"用户钱扣了但订单失败"这种状态。于是分布式事务就变成一个躲不开的需求。
当时在我面前有几条路:消息事务、TCC、Saga、Seata AT 模式。考虑到团队规模、现有技术栈(Spring Cloud Alibaba + Nacos),以及"不想把业务代码改得太重"这个前提,我最终选了 Seata 的 AT 模式,并把 Seata 的注册模型接进 Nacos,服务调用链路保持 OpenFeign 不动。这样用户无感知,代码侵入也小,几乎只在需要开启全局事务的方法上动刀。
这篇博文就是把我从"下载 Seata 包"到"整体跑通回滚链路"的做法完整整理一遍,包括版本选择、Seata Server 部署、业务服务改造、OpenFeign 链路的事务传递原理,以及我踩过的几个比较坑的细节。文章适合正在做 Spring Cloud 微服务拆分、准备引入 Seata 但又不太清楚怎么和 Nacos、OpenFeign 拼装在一起的读者。
2. 版本组合先定死:这决定了后面 90% 的报错是否与你有关
Seata 整合 Nacos 和 OpenFeign,最令人崩溃的从来不是概念,而是版本兼容。我最早随便拿了一个 Seata 1.4.2 接 Nacos 2.2.0,启动没问题,一到发起全局事务就疯狂报"could not find global transaction"或"client not connected"。后来排查到是 Seata 客户端与服务端的注册模型不匹配,加上 Nacos Client 版本互相冲突,白白浪费了一整天。所以,先说版本。
2.1 我最终确定的一套依赖组合
下面是经过实际联调验证过得比较稳的一套组合:
| 组件 | 版本 | 说明 |
|---|---|---|
| Spring Boot | 2.6.13 | 不要太新,也别太旧 |
| Spring Cloud | 2021.0.6 | 与 Boot 2.6 配套 |
| Spring Cloud Alibaba | 2021.0.5.0 | 负责 Nacos 客户端版本管理 |
| Nacos Server | 2.2.1 | 注册+配置中心 |
| Seata Server | 1.6.1 | TC 服务端 |
| Seata Client(seata-spring-boot-starter) | 1.6.1 | 嵌入业务服务 |
| OpenFeign | 随 Spring Cloud 管理 | 使用 declarative HTTP 客户端 |
这套组合的核心逻辑是:Spring Cloud Alibaba 2021.0.5.0 管理的 Nacos Client 版本比较收敛,Seata 1.6.1 对 Nacos 2.x 的适配已经比较成熟。如果你手头有团队统一指定的 Spring Cloud Alibaba 版本,也可以酌情用 Seata 1.5.2 或 1.6.1,前提是服务端与客户端版本必须一致,千万别出现客户端 1.5、服务端 1.7 这种组合。
2.2 版本冲突最容易炸在 Nacos Client 和 seata-all 依赖上
实际开发中你通常不用显式引入 Nacos Client,而是由 Spring Cloud Alibaba 通过spring-cloud-starter-alibaba-nacos-discovery传递依赖。但 Seata 的某些模块也会传依赖 Nacos Client,两边版本不一致就可能在运行时报一些让人摸不着头脑的序列化异常。
建议在 pom 或 build.gradle 里对 Nacos Client 做统一版本锁定,我一般会显式写上:
<dependency> <groupId>com.alibaba.nacos</groupId> <artifactId>nacos-client</artifactId> <version>2.2.1</version> </dependency>同时,不要同时引入seata-all和seata-spring-boot-starter,两个都装不仅容易把配置类加载重复,还会导致部分 Bean 被代理两次。我这边只用seata-spring-boot-starter就够了,它自带常用集成。
2.3 版本兼容之外的另一个隐性问题:Spring Cloud 2022 与 Seata 老版本
如果你已经升到 Spring Boot 3.x 或者 Spring Cloud 2022.x,那 Seata 1.6.1 基本就不用想了。Spring Boot 3 的自动配置机制变了,很多 Seata 老版本的核心组件不再生效,需要选 Seata 2.x。建议如果你手头是全新项目,可以直接上 Seata 2.x 系列;如果是老项目改造,第一优先确认的还是 Spring Cloud Alibaba 的版本,因为它同时锁定了 Nacos Client 和 OpenFeign 的行为。别一上来就埋头配 Seata,整体版本矩阵先拉出来对一遍,这个步骤值得花二十分钟。
3. Seata Server 部署:让 TC 注册到 Nacos 才算真正"整合"
Seata 的服务端(TC,事务协调者)要承担全局事务的开启、提交、回滚决策。它既可以单机启动,也可以集群部署。我在项目里先用单机跑通,再考虑扩展成多实例。这里说的"整合 Nacos",最关键的一点是让 TC 把自己注册到 Nacos,这样各业务服务才能通过 Nacos 找到 TC。
3.1 下载与基本启动姿势
到 Seata 的 GitHub Releases 页面下载seata-server-1.6.1.tar.gz,解压后目录结构里最重要的就是conf/application.yml。1.5 之后的 Seata 服务端已经不太用老的registry.conf,默认配置集中在application.yml,这点和很多网上老教程不一样,别照搬旧内容。
解压后先别急着启动,修改conf/application.yml,让注册中心和配置中心都指向 Nacos:
server: port: 7091 seata: 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 namespace: "" group: SEATA_GROUP >sh nacos-config.sh -h 127.0.0.1 -p 8848 -g SEATA_GROUP -u nacos -w nacos -t ""执行完后,Nacos 配置列表里会多出几十条group=SEATA_GROUP的配置项。只要application.yml中的>service: vgroup-mapping: my_test_tx_group: default default: grouplist: 127.0.0.1:8091
同时在客户端的 application.yml 里写:
seata: tx-service-group: my_test_tx_group service: vgroup-mapping: my_test_tx_group: default两边必须都对上。最典型的问题是客户端写tx-service-group: my_test_tx_group,但服务端vgroup-mapping里根本没有这个 key,TC 就永远发现不了,日志里会反复出现"no available service found"。所以在启动业务服务之前,先在 Nacos 服务列表里确认seata-server实例已经注册成功,这一步排错成本最低。
3.4 服务端启动后的验证清单
启动命令就一条:
sh bin/seata-server.sh -p 8091 -h 127.0.0.1 -m file参数里的-m file表示 Seata 事务日志存储模式。生产环境建议用-m db并提前建好global_table、branch_table、lock_table三张表,我本地测试为了省事先用 file 模式。启动之后做三个验证:
- 看 Nacos 控制台服务列表,能搜到
seata-server这个服务。 - 看服务端日志,确认它连上了 Nacos 并拉取配置成功,没有
config operation failed之类的报错。 - 在 Nacos 配置中心里搜索
SEATA_GROUP,能看到推上去的配置项。
这三步都过了,Seata Server 才算是真正接进了 Nacos,后面客户端接入才有地基。
4. 业务服务改造:pom 依赖、数据源代理与 @GlobalTransactional
服务端就绪后,开始动业务服务。我这边有订单、库存、账户三个服务,为了让链路清晰,改造就从订单服务和账户服务开始,库存服务先放后面。每个需要参与全局事务的服务,改造套路几乎一样,核心就三件事:加依赖、配数据源代理、建 undo_log 表,最后在入口方法上打注解。
4.1 业务服务里的 Maven 依赖
在需要参与分布式事务的每个服务中加入seata-spring-boot-starter:
<dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.6.1</version> </dependency>如果你用了 Feign 做服务间调用,还需要引入 OpenFeign 相关依赖,一般是:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>同时建议引入:
<dependency> <groupId>io.seata</groupId> <artifactId>seata-core</artifactId> </dependency>不过要注意,在 Spring Cloud Alibaba 版本管理下,seata-core的版本可能被 pom 管理覆盖,所以最稳的还是显式声明与 starter 一致的 1.6.1。这里不细讲依赖树里的每一层,只提醒一句:所有依赖的最终版本,以你实际依赖树里的为准,IDE 里 Maven Helper 插件查一下最靠谱。
4.2 配置文件里的 Seata 参数
业务服务的 application.yml 里,除了 Nacos 和 Feign 的常规配置,要加上:
seata: application-id: order-service tx-service-group: my_test_tx_group service: vgroup-mapping: my_test_tx_group: default >@Configuration public class DataSourceConfig { @Bean @Primary public DataSource dataSource(DataSource dataSource) { return new DataSourceProxy(dataSource); } @Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean(); sessionFactory.setDataSource(dataSource); return sessionFactory.getObject(); } }手动配置时最容易犯的错误是,把DataSourceProxy包在了一个已经开启了 Spring 本地事务管理器管理的数据源外面,导致事务边界混乱。一般建议只保留 Seata 这一层代理,并确保 MyBatis 的 SqlSessionFactory 使用代理后的数据源,这块最好通过日志确认。启动时观察输出,看到类似DataSourceProxy被成功创建的日志,才能确认代理生效。
4.4 在每个业务库里建 undo_log 表
AT 模式回滚依赖undo_log表,这张表要建在每个参与事务的业务数据库里。它放在业务库中,是为了在业务事务提交的同时,把前后镜像数据一并落库,保证本地事务和 undo_log 写入的原子性。建表 SQL 是官方的标准脚本:
CREATE TABLE IF NOT EXISTS `undo_log` ( `id` BIGINT(20) NOT NULL AUTO_INCREMENT, `branch_id` BIGINT(20) NOT NULL, `xid` VARCHAR(100) NOT NULL, `context` VARCHAR(128) NOT NULL, `rollback_info` LONGBLOB NOT NULL, `log_status` INT(11) NOT NULL, `log_created` DATETIME NOT NULL, `log_modified` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8;注意,你用了几个业务库,就要在每个业务库里都建这张表,漏掉任何一个,那个服务的分支事务在回滚时就会报"undo_log不存在"。我项目里有订单库、账户库、库存库,每个库都执行了一遍脚本,这一步没有任何捷径。
4.5 在服务入口方法上开启全局事务
数据源代理完成后,在业务入口方法加@GlobalTransactional注解即可。注意,它应该加在作为全局事务发起方的服务方法上,而不是加在每个 Feign 被调服务的方法上。我订单服务里的伪代码如下:
@Service public class OrderService { @Autowired private AccountFeignClient accountFeignClient; @GlobalTransactional public void createOrder(OrderDTO orderDTO) { // 1. 本地插入订单 orderMapper.insert(orderDTO); // 2. 远程调用账户服务扣减余额 accountFeignClient.deductBalance(orderDTO.getUserId(), orderDTO.getAmount()); // 3. 假设这里抛异常,应当触发全局回滚 if (orderDTO.getAmount() > 10000) { throw new RuntimeException("模拟业务异常"); } } }这里有一个关键点:@GlobalTransactional开启全局事务后,Seata 会生成一个全局唯一的 XID,并把它绑定到当前线程上下文。后续通过 OpenFeign 发出的远程请求,会在 HTTP 头里携带这个 XID,从而把账户服务里的本地事务纳入同一个全局事务。这个传递机制我下一节专门讲。
5. OpenFeign 调用链路上,XID 是怎么传下去的
很多人在 Seata 和 OpenFeign 搭配时,只记得在配置里开了 Feign,却不知道 Seata 是如何让下游服务感知到全局事务的。其实就靠一个东西:自定义的 Feign 拦截器,把 XID 放到请求头里。下游服务再从请求头里取出 XID,绑定到自己的事务上下文。
5.1 Seata 内置的 Feign 拦截器机制
在seata-spring-boot-starter中,Seata 已经内置了一个用于 OpenFeign 的拦截器,它的核心逻辑是:在发送请求前,从RootContext.getXID()取出当前全局事务的 XID,如果存在,就把它放进 Feign 请求的 header 中,header 的 key 就是tx-service-group下约定的那个字段(实际是RootContext.KEY_XID)。下游服务在接收到请求时,Seata 的过滤器会从 header 里解析 XID,并重新绑定到本地的 RootContext。
所以在标准的单数据源、单 Feign Client 场景下,你不需要手动写拦截器。但如果你遇到类似"下一跳服务虽然收到了请求,却没有参与全局事务"的诡异问题,大概率是拦截器没生效。
什么情况会导致内置拦截器失效?我遇到过两类:
- 业务服务里自定义了全局的
RequestInterceptor,在 Seata 的内置拦截器之前提前修改了请求头,或者直接构造了新请求体,导致 XID 丢失。 - 使用了异步的 Feign 调用(比如把 Feign 调用包在
CompletableFuture里),线程切换导致 RootContext 中的 XID 没传到子线程。
5.2 手动补一个 XID 传递拦截器
如果项目里自定义 Feign 逻辑较多,最稳妥的做法是自己补一个拦截器,把 XID 传递这件事完全掌握在手里:
@Component public class SeataFeignRequestInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { String xid = RootContext.getXID(); if (StringUtils.isNotBlank(xid)) { template.header(RootContext.KEY_XID, xid); } } }这段代码本身不复杂,但它能保证无论 Seata 内置拦截器是否被其他配置覆盖,只要当前线程中存在 XID,就一定会传给下游。在排查阶段,我还会在 Feign 调度的服务端配合日志,确认 header 里的 XID 确实被接收了。
5.3 下游服务如何加入同一个全局事务
账户服务的扣减接口本身不需要加@GlobalTransactional,它只需要正常开启本地事务(@Transactional),Seata 的全局事务会把它的分支事务管理起来。具体来说:
@Service public class AccountService { @Transactional public void deductBalance(Long userId, BigDecimal amount) { // 本地数据库扣减余额 accountMapper.deductBalance(userId, amount); } }关键点是,账户服务在收到 Feign 请求后,Seata 的全局过滤器会先取出 XID,并把它绑定到当前线程。随后@Transactional打开本地事务时,Seata 会识别到全局事务上下文,把本地事务注册为一个分支事务。之后订单服务的方法如果抛异常,TC 会通知所有分支事务回滚,账户服务的数据也会被恢复。
这里有新手容易误解的一点:全局事务并不等于每个服务都要写@GlobalTransactional,只有真正作为流程入口的发起方需要这个注解,下游服务通过 XID 自动参与。如果我在账户服务也加上@GlobalTransactional,反而可能造成嵌套全局事务的问题。所以,注解的位置和 XID 的传递是两件分开的事,先分清,后面排错才不会乱。
6. 从回滚测试到生产建议:实测中的坑与经验
配置全部完成以后,肯定要验证一把真正的分布式回滚。我的验证流程非常简单,但很有效:在订单服务的createOrder方法里故意加了一个人为抛异常的逻辑,然后调用下单接口,观察订单表和账户表的数据是否都被还原。如果配置正确,这个测试会在极短时间内暴露出整条链路的每一个薄弱点。
6.1 一次完整的回滚测试怎么设计
第一步,先准备干净的测试数据。比如清空订单表和账户表里的目标用户数据,记录当前余额。
第二步,调用下单接口,订单创建成功,同时账户扣款成功。这里可以正常走一遍,确保正向链路没毛病。
第三步,修改代码,在订单服务调用账户服务成功后,抛出一个RuntimeException。重新部署后再次调用下单接口。
第四步,观察三件事:
- 接口是否返回 500,日志里是否出现
Global transaction rollback字样。 - 订单表里是否多出了那条脏数据。
- 账户表里的余额是否还是扣减前的值。
如果订单表和账户表都完好如初,说明全局回滚生效。如果账户表回滚了,但订单表里仍留着订单数据,那问题通常出在订单服务的本地事务没有被 Seata 纳入管理,典型原因就是数据源代理没生效。
6.2 一个我印象最深的坑:undo_log 表没建导致回滚静默失败
有段时间我把注意点全放在配置上,回滚测试却总是不彻底。后来打开数据库一看,账户库里的undo_log表根本不存在,导致运行时报错,但因为日志级别没调高,被 MyBatis 的错误信息淹没了。那一瞬间我才意识到,Seata 的回滚不是一个"看起来合理"的机制,它非常依赖底层的undo_log表是否真实存在、字段是否齐全。
所以后来我的检查顺序固定成:看服务端日志确认 TC 收到了全局回滚请求,看客户端的依赖确认seata-spring-boot-starter存在,看数据库确认undo_log表存在,最后再看具体异常栈。这个顺序能帮我快速把问题定位到"配置层""依赖层""数据库层"三个层面,基本不会跑偏。
6.3 尽量保持 Seata 客户端版本与服务端一致
另外还想强调一下版本问题。曾经有人问我,客户端用 1.6.1,服务端用 1.7.0,能不能跑?理论上是兼容的,但分布式事务这种对一致性要求极高的场景,我不建议你赌这种兼容性。Seata 的客户端和服务端在全局事务 ID 生成、分支事务注册协议、undo_log 读取逻辑上都有演进,跨版本容易出现"客户端注册成功但服务端找不到分支"这类问题。既然部署成本不高,把两边版本锁到一致,是最省心的选择。
6.4 生产环境部署的前置检查项
- 将 Seata 事务日志存储在 DB 模式,建好
global_table、branch_table、lock_table,避免 file 模式重启丢状态。 - 为 Nacos 配置中心开启权限认证,Seata 与 Nacos 的账号密码不要使用默认值。如果不使用 Nacos 权限,就把客户端和服务端的 username、password 保持一致留空,但生产环境强烈建议开启鉴权。
- 事务分组
tx-service-group最好不要所有服务都用一个,可以按业务线拆成多个,隔离故障面。 - 监控 TC 的内存与线程池,AT 模式下分支事务多时,TC 的异步处理压力不小,服务端日志里出现大量重试或超时要及时扩容。
- 回滚测试不要只在测试环境跑,发布前在预发环境做一次包含真实 OpenFeign 调用的演练,特别是那些涉及批量更新、模糊条件的 SQL,AT 模式的镜像对比可能因为 SQL 复杂而影响性能。
6.5 最后分享一个实际操作中的小习惯
我把这套方案搭完以后,后来在项目里定了一条规矩:凡是参与 Seata 全局事务的服务,其核心表都必须有明确的唯一索引或主键,并且 UPDATE 语句要尽量走主键或唯一键。原因是 AT 模式生成 before/after 镜像时需要锁定数据行,SQL 条件越宽泛,锁的范围越大,并发性能越差。另外,每个服务的undo_log表都要纳入数据库备份脚本,它不仅是 Seata 的运行依赖,也是排查历史问题的重要现场。
最后再说一句个人体会:Seata + Nacos + OpenFeign 这套组合,真正的难点根本不是"能不能通",而是"版本、数据源代理、事务分组、XID 传递"这些细节彼此纠缠。先把服务端注册到 Nacos 验证清楚,再改业务代码;每改一层就验证一层,不要等全部写完再一起调,否则你永远分不清是哪一环断了。希望这篇整理能帮你少走几个弯路。