最近在一个微服务拆分改造项目里,我撞上了分布式事务这道坎。用户下单这个操作,牵扯订单服务、库存服务、余额服务,任何一个成功而另一个失败,账就对不上。考虑团队当时已经在用 Nacos 1.2.0 做注册中心和配置中心,顺势把 Seata 1.2.0 也接进来,让注册发现、配置管理、分布式事务三件事全部走同一套基础设施,是成本最低的路。折腾了几天,从服务端启动到客户端事务生效,中间踩了无数个配置坑,这篇就把 Nacos 1.2.0 整合 Seata 1.2.0 的完整过程和爬坑实录写出来,给正在搞 Seata 的同行做个参照。
1. 整合前必须想清楚的事
1.1 为什么要用 Seata,而不是自己写事务
微服务架构下,每个服务各管一个数据库,本地事务天然无法跨库。你不可能靠删库重跑来弥补数据不一致。Seata 做的事并不神秘:它把一次业务操作拆成两阶段提交,第一阶段各分支服务先执行业务 SQL 并且记录回滚日志,第二阶段由 Seata Server 统一决定提交或回滚。业务代码几乎无侵入,只需要在入口方法上加一个 @GlobalTransactional 注解,这对已经写完的接口来说改动量很小。
1.2.0 版本是 Seata 比较稳定的一代,文档齐全,社区讨论多,问题排查资料也容易搜到。Nacos 1.2.0 则是一个经典版本,很多公司的 Spring Cloud Alibaba 体系都停在它上面。两个版本配套使用的例子很多,网上踩坑记录也多,真出问题不会孤立无援。
1.2 先分清 Seata 里的三个角色
很多人在配置阶段就把 TM、RM、TC 搞混了,后面排查问题会非常痛苦。
- TC(Transaction Coordinator):事务协调者,也就是 Seata Server,负责全局事务的开启、提交和回滚。
- TM(Transaction Manager):事务管理器,业务服务里加了 @GlobalTransactional 注解的就是 TM,它告诉 TC 我开了个全局事务。
- RM(Resource Manager):资源管理器,业务服务里被代理的数据源就是 RM,它执行本地事务并且向 TC 注册分支事务。
整个调用链上的每个业务服务,既是 TM 的调用方,也可能同时是 RM。Seata Server 本身不参与业务,它只负责接收 TM 的开始、RM 的分支注册和最后的决议通知。理解这个关系之后,你就知道配置时哪些东西要给服务端、哪些要给客户端。
1.3 版本兼容性和依赖风险
在动手之前,我建议先把你当前项目的 Spring Boot、Spring Cloud、Spring Cloud Alibaba 版本列出来。Seata 1.2.0 的 seata-spring-boot-starter 官方适配 Boot 2.2.x 到 2.3.x 这个区间,如果你项目在 Boot 2.4 以上,自动装配很可能出问题。我见过有人在 Boot 2.6 里硬上 Seata 1.2.0,结果配置项绑定不上,服务起不来。
另外 Seata 1.2.0 依赖库比较老,netty、kotlin、protobuf、guava 都和现在主流版本有偏差。引入之前,建议先跑一下 mvn dependency:tree 看清楚依赖冲突,不要等问题出现了再去补排除。
2. 服务端配置实操
2.1 先把 Nacos 1.2.0 准备到位
如果你的 Nacos 还是默认安装、默认配置,那基本可以直接长这个样子启动:
cd nacos/bin sh startup.sh -m standaloneNacos 1.2.0 默认会把配置数据存在内嵌的 Derby 里,单机演示没问题,但团队开发时重启一次配置就丢,非常坑。所以我强烈建议把 Nacos 的存储切到 MySQL,改 conf/application.properties:
spring.datasource.platform=mysql db.num=1 db.url.0=jdbc:mysql://127.0.0.1:3306/nacos_config?characterEncoding=utf8&connectTimeout=3000&socketTimeout=3000&autoReconnect=true&useUnicode=true db.user=root db.password=root改完之后,先把 conf 目录下自带的 nacos-mysql.sql 导入到 nacos_config 这个库里,然后再启动 Nacos。这一步很多人忽略,结果 Nacos 控制台打开配置列表是空的,还以为刷出来了,实际数据写到了别处。
2.2 Seata Server 的 registry.conf 怎么填
Seata Server 解压之后,conf 目录下有两个核心文件:registry.conf 和 file.conf。先说 registry.conf,它管的是 Seata Server 自己如何注册到 Nacos、以及从 Nacos 拉配置。完整内容填成:
registry { type = "nacos" nacos { application = "seata-server" serverAddr = "localhost:8848" namespace = "" cluster = "default" username = "" password = "" } } config { type = "nacos" nacos { serverAddr = "localhost:8848" namespace = "" group = "SEATA_GROUP" username = "" password = "" } }注意几个关键点:
- application 这个值默认是 seata-server,客户端发现服务时会找服务名为 seata-server 的实例,别乱改。
- cluster 默认是 default,客户端事务分组映射最终也会指向 default,两者必须对上。
- namespace 留空代表使用 Nacos 的 public 命名空间。如果你项目用了自定义命名空间,这里必须填和客户端一致的 namespace ID,否则两边各认各的,服务能被 Nacos 看到但客户端就是连不上。
2.3 事务存储改成 db 模式
Seata Server 默认用 file 模式保存全局事务会话,简单但不利于生产。既然有 Nacos,建议直接用 db 模式,在 file.conf 中把 store 配置改成:
store { mode = "db" db { datasource = "druid" dbType = "mysql" driverClassName = "com.mysql.cj.jdbc.Driver" url = "jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai" user = "root" password = "root" minConn = 5 maxConn = 30 globalTable = "global_table" branchTable = "branch_table" lockTable = "lock_table" queryLimit = 100 maxWait = 5000 } }同时你得先建好 seata 库,并执行 Seata 发行包 conf 目录里的 db_store.sql 脚本,它会创建 global_table、branch_table、lock_table 三张表。我建议自己把这三张表手动建一遍,不要依赖脚本报错之后才看,毕竟 MySQL 版本和 sql_mode 差异可能导致脚本执行一半就中断。
提示:db 模式下,Seata Server 一旦在 Nacos 配置中心读到了 seata.properties,会优先使用配置中心里的 store 配置,本地的 file.conf 反而成为兜底。
2.4 在 Nacos 上发布 seata.properties
Seata 1.2.0 合并了配置项,会把所有相关配置都放到同一个 dataId 里。建议在 Nacos 控制台的 public 命名空间下,创建一个 dataId 为 seata.properties、分组为 SEATA_GROUP 的配置。内容参考:
service.vgroupMapping.my_test_tx_group=default 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?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai store.db.user=root store.db.password=root store.db.minConn=5 store.db.maxConn=20 store.db.globalTable=global_table store.db.branchTable=branch_table store.db.lockTable=lock_table store.db.queryLimit=100 store.db.maxWait=5000这里就出现第一个坑:本地 file.conf 里事务组映射用的是service.vgroup_mapping.my_test_tx_group,带下划线;Nacos 配置中心用的是service.vgroupMapping.my_test_tx_group,驼峰。大小写和下划线不一致是 Seata 1.2.0 最容易踩的雷。我建议以配置中心这份为准,本地 file.conf 保持原样作为兜底,但不要两边配置不同值。
2.5 启动 Seata Server 并验证注册状态
配置都改好之后,直接执行启动脚本:
cd seata/bin sh seata-server.sh -p 8091 -m db-m db 参数最好显式带一下,避免某些版本按 file 模式启动。启动完成后去 Nacos 控制台的服务列表里看,应该能看到一个名为 seata-server 的服务,下面有一个集群名为 DEFAULT 的实例。能看见这一步,服务端就算通了。
如果看不到实例,优先看 seata-server 的日志,常见原因包括 Nacos 地址写错、namespace 填了不存在的 ID、registry.conf 配置没生效。我有一次就是 namespace 填了自定义空间 ID,Nacos 服务列表里切到该空间能看到 seata-server,但客户端在 public 空间找,自然找不到。
3. 客户端接入配置
3.1 依赖引入与冲突排除
客户端业务服务需要引入 Seata 的 Spring Boot Starter。以 Maven 为例:
<dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.2.0</version> <exclusions> <exclusion> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> </exclusion> <exclusion> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> </exclusion> </exclusions> </dependency>为什么排除 netty-all?因为 Spring Cloud 全家桶里各个组件用的 netty 版本经常不一致,Seata 1.2.0 依赖的 netty 版本比较老,很容易把项目原有的 netty 版本覆盖掉,导致运行时出现 ChannelOption、HashedWheelTimer 这类 NoClassDefFoundError。排除之后,用项目现有的 netty 版本即可。kotlin-stdlib 也是类似情况,Seata 内部用了 kotlin 运行时的功能,如果项目主版本太低,就会 NoSuchMethodError。
3.2 不需要客户端再放 registry.conf
Seata 1.2.0 的 Spring Boot Starter 支持直接在 application.yml 里写客户端配置,比在业务服务里塞一个 registry.conf 更干净。我推荐这种方式,避免多个微服务各自复制文件、改漏一个就出了问题。参考配置如下:
spring: application: name: order-service cloud: nacos: discovery: server-addr: localhost:8848 seata: enabled: true application-id: ${spring.application.name} tx-service-group: my_test_tx_group enable-auto-data-source-proxy: true registry: type: nacos nacos: server-addr: localhost:8848 namespace: "" cluster: default config: type: nacos nacos: server-addr: localhost:8848 namespace: "" group: SEATA_GROUP service: vgroup-mapping: my_test_tx_group: default disable-global-transaction: false这里 tx-service-group 和 service.vgroup-mapping 里的 key 必须一致。它的作用机制是:业务服务这个 TM 告诉 TC,我属于 my_test_tx_group 这个事务分组;TC 根据 vgroupMapping 找到对应的后端集群名 default;然后去 Nacos 注册中心里找 cluster 为 default 的 seata-server 实例。这个链条一环不匹配,就会报找不到服务。
3.3 业务代码接入事务
客户端配置完之后,业务代码改动非常小。在需要进行全局事务的入口方法上加上注解:
@GlobalTransactional public void createOrder(OrderDTO orderDTO) { orderMapper.insert(orderDTO); stockClient.deductStock(orderDTO.getProductId()); balanceClient.deductBalance(orderDTO.getUserId()); }注意这个入口方法的调用方必须是另一个 Bean 或者外部接口,不能是同一个类内部方法调用。因为 @GlobalTransactional 是基于 Spring AOP 代理的,类内部 this 调用不会经过代理,事务自然不会生效。我在实际项目中遇到过一次,把 createOrder 放在了 OrderService 里,由 OrderController 调用没问题;后来重构的时候在同一个类里新增了一个方法,内部去调 createOrder,事务就完全没生效,排查了很久才反应过来。
3.4 客户端需要准备 undo_log 表
AT 模式的回滚依赖业务数据库里的 undo_log 表。每个参与全局事务的业务库都要建,不能只建在某个库里。建表 SQL 建议用 Seata 发行包里的 db_undo_log.sql,这里也贴一个标准版本:
CREATE TABLE IF NOT EXISTS `undo_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `branch_id` BIGINT NOT NULL, `xid` VARCHAR(100) NOT NULL, `context` VARCHAR(128) NOT NULL, `rollback_info` LONGBLOB NOT NULL, `log_status` INT 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 = utf8mb4;如果业务库没有 undo_log 表,事务执行阶段是正常的,但一旦需要回滚,Seata 会尝试往 undo_log 表写入回滚记录,直接报 Table 'xxx.undo_log' doesn't exist。这个错误在测试环境回滚场景下才会暴露,很隐蔽。
4. 爬坑过程实录
4.1 服务端能注册,客户端连不上
我把 Seata Server 启动后,Nacos 服务列表里已经能看到 seata-server 实例了,但业务服务一启动就报can not connect to services-server。
排查过程:
- 先确认业务服务所在的机器能不能 telnet 到 Seata Server 的 8091 端口,网络不通就别往后查了。
- 再确认 Seata Server 的 registry.conf 里 namespace 和 cluster 是否和客户端一致。这是高频坑,服务端注册到了 namespace A,客户端配置里 namespace 留空,两边各看各的。
- 最后看 Seata Server 日志,确认客户端请求有没有到达服务端,如果连日志都没有,肯定是网络或注册发现问题。
注意:Seata Server 的 registry.conf 里 nacos.application 不要随便改成业务名,客户端从 Nacos 拿到的服务名必须是 seata-server,改了名字客户端就找不到了。
4.2 提示 No available service in cluster default
这个报错在客户端日志里出现时,说明客户端已经找到了注册中心,但没找到符合条件的 Seata Server 实例。
常见原因两种:
- Seata Server 注册时 cluster 值不是 default。检查服务端 registry.conf,和客户端 service.vgroup-mapping 里映射的值是否一致。
- Seata Server 注册到了别的 namespace,客户端却用 public 空间去查。这种问题在 Nacos 控制台切到对应 namespace 一眼就能看出来。
我在测试环境里遇到过一次,因为服务端 registry.conf 里 cluster 被之前的同事改成了 "SEATA_GROUP",而客户端 vgroupMapping 指向 default,结果两边互相找不到。记住,cluster 是集群名,和配置中心的分组 SEATA_GROUP 完全是两个东西,不要混在一起。
4.3 配置中心读不到 seata.properties
客户端配置 config.type 指向 Nacos 后,它会去拿 dataId 为 seata.properties、group 为 SEATA_GROUP 的配置。如果你只改了 application.yml 里的 seata.config.nacos.group,但 Nacos 上其实没创建这个配置,客户端会退回到本地 file.conf 读取。这时候最容易出现的情况是:本地 file.conf 里的 store 配置和服务端不一致,客户端以为自己在 file 模式下工作。
建议的做法是:在 Nacos 控制台明确创建 seata.properties,然后重启 Seata Server 和业务服务,再看 registry 模式是否真正切换成 Nacos。如果不想用配置中心,也可以把 config.type 改成 file,让业务服务只在本地 file.conf 读取,哪种方式都不推荐混着来。
4.4 本地 file.conf 和配置中心命名差异
Seata 1.2.0 这个版本在配置项命名上有点历史遗留:本地 file.conf 的配置 key 是下划线风格:
service.vgroup_mapping.my_test_tx_group = "default"而 Nacos 配置中心里,同一个含义的 key 是驼峰:
service.vgroupMapping.my_test_tx_group=default如果你在本地 file.conf 和配置中心都配置了事务分组,但值不一样,客户端会以配置中心为准,本地配置被覆盖。反过来说,如果配置中心没配置,客户端读本地 file.conf 的 vgroup_mapping。我在一个项目里看到有人把本地 file.conf 里改成驼峰,启动时直接报非法配置项,这个别折腾,文件里该用什么格式就用什么格式。
4.5 依赖冲突导致服务启动失败
Seata 1.2.0 引入后,我的某个服务启动时直接抛出了NoSuchMethodError: kotlin.collections.ArraysKt.copyInto。查下来是 Seata 内部依赖 kotlin-stdlib 版本比较高,而项目主 pom 里锁了一个老版本。把 kotlin-stdlib 排除掉,让 Spring Boot 依赖管理统一版本之后就好了。
还有一个常见的是 netty 冲突,报错类型五花八门。最直接的办法是在引入 Seata 时先排除 netty-all 和 kotlin-stdlib,让项目主导版本。如果项目里用了同版本的 netty,业务代码不受影响;如果使用的是 Spring Cloud Gateway 这类重度依赖 netty 的组件,尤其要注意。
4.6 事务名称过长或参数类型问题
在数据库里如果看到事务回滚失败,或者 Seata 日志里出现Could not found any open transaction,不一定配置的问题,还可能是表字段和数据类型不匹配。比如 MySQL 8 的 JDBC 驱动要求 url 里带 serverTimezone,否则时间字段解析会报错,连带 undo_log 的写入失败。我踩过最恶心的一次是业务表主键用了 varchar 但存的是数字字符串,Seata 的全局锁解析主键时类型转换出错。遇到这类问题,先看 Seata 所在服务端的完整异常堆栈,别盯着客户端日志猜。
4.7 多数据源场景下的事务边界
如果业务服务里配置了多个数据源,Seata 默认只会代理主数据源,其他数据源参与的操作不会被纳入全局事务。项目里有一个服务同时连主库、订单库、报表库,结果报表库的操作回滚不了。
解决办法是用 Seata 手动包装数据源:
@Bean @Primary public DataSource seataDataSource(DataSource dataSource) { return new DataSourceProxy(dataSource); }对于不需要纳入全局事务的数据源,保持原样即可。这么一改,需要事务的库走 Seata 代理,不需要的库不参与,边界清晰很多。
4.8 全局锁等待超时
高并发测试的时候,全局事务之间会抢同一行数据的锁,如果前一个事务还没提交,后一个事务会一直重试拿锁。默认锁重试间隔和次数在压测时不够,会出现Lock wait timeout的异常。我在 application.yml 里调大了参数:
seata: client: rm: lock: retry-interval: 10 retry-times: 30retry-interval 单位是毫秒,调大到 10 毫秒,重试 30 次,基本能满足测试环境的并发要求。生产环境要根据实际响应耗时来压测调整,不要盲目加太大,否则请求排队会拖垮接口。
4.9 Nacos 重启后 Seata 连接不上
Nacos 1.2.0 本身是单机能力有限的,如果 Nacos 重启期间 Seata Server 和业务服务都还活着,它们和 Nacos 的长连接会断开。Nacos 恢复后,Seata Server 大概率能自己重新注册,但客户端不一定能及时感知。最省心的处理办法:Nacos 恢复后,按顺序重启 Seata Server,再重启业务服务。不要在生产环境随意重启 Nacos,至少要把这个操作排进灰度发布流程里。
5. 常见问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 服务端不能注册到 Nacos | registry.conf 地址、namespace 错误 | 核对 serverAddr、namespace,看 seata-server 日志 |
| 客户端报 can not connect | 网络不通或服务端未启动 | 检查 8091 端口连通性 |
| 客户端报 No available service | cluster 或 namespace 不一致 | 对齐服务端 cluster 和客户端 vgroupMapping |
| 配置中心读不到配置 | dataId/group 写错或未创建 | 在 Nacos 上创建 seata.properties |
| 事务没回滚 | @GlobalTransactional 没生效 | 检查注解位置、同类内部调用、数据源代理 |
| 回滚时报 undo_log 表不存在 | 业务库没建 undo_log | 每个业务库执行 undo_log 建表脚本 |
| 启动报 NoSuchMethodError | kotlin/netty 等依赖冲突 | 排除冲突依赖,统一版本 |
| 全局锁超时 | 并发下重试参数不足 | 调大 lock.retry-interval 和 retry-times |
| MySQL 时间字段报错 | JDBC url 缺 serverTimezone | 增加 serverTimezone=Asia/Shanghai |
6. 最后的实操心得
这套 Nacos 1.2.0 和 Seata 1.2.0 的组合,到现在为止我维护过好几套环境,整体稳定性没让我失望,但配置环节确实容易反复踩坑。个人体会是:配置这个东西,每改一个变量就要重启验证一次,不要一次改一堆,不然出了问题根本不知道是哪一项影响的。我习惯的顺序是 Nacos 先起来,Nacos 控制台能看到配置;再起 Seata Server,Nacos 能看到 seata-server 实例;最后起业务服务,日志里出现 Global Transaction 相关的注册信息,再开始测分布式事务。
另外,如果你是第一次接 Seata,别急着直接改造真实业务。搭一个最小闭环:订单服务调库存服务,库存服务里故意抛异常,验证全局回滚是否生效。这个 Demo 环境我保留了很久,每次换个新环境、升个版本,都先用它跑一遍,确认没问题再放业务进去。分布式事务这种功能,上线后出问题的代价远大于前期准备的时间,值得多花半小时把链路完全打通。