☰
Spring Cloud Alibaba+Seata+Nacos分布式事务搭建实践
2026/10/15 9:27:39 网站建设 项目流程

第一次要把下单、扣库存、扣余额拆到三个库里时,我还觉得问题不大,本地@Transactional一把锁搞定。后来才意识到,@Transactional只能保证一个数据源内的一致性,三个库就像三张桌子,你没法用一只手同时按住三张桌子上的纸。Spring Cloud Alibaba 2.2.0 + Seata 1.2 + Nacos 1.2.1 这组搭配,就是我最终选定的解法。如果你正在搜“Spring Cloud Alibaba 2.2.0 Seata 1.2 Nacos 1.2.1 搭建分布式事务”,大概率已经踩到或即将踩到版本坑、配置坑,以及“注解我加了但事务没生效”的坑。这篇把从 Server 部署到客户端接入、再到线上排障的经验一次写清,适合要落地 Seata AT 模式的团队参考。

顺便纠正一个措辞:这里说的是“事务”(Transaction),不是“事物”,但我知道很多人搜的时候都会打成“事物”。这不影响搜索引擎召回,倒是能看出大家都被分布式事务坑得不轻。

1. 选型与版本约束:为什么是 Seata 1.2 而不是别的方案

1.1 分布式事务为什么不能靠本地事务硬扛

服务拆分之前,订单、库存、账户都在同一个数据库里,一个事务就能保证要么全部成功、要么全部回滚。服务拆开之后,下单要调三个服务,每个服务写各自的库,这时候任何一个服务成功、另一个服务失败,账就平不了。

本地事务解决不了跨库问题,本质原因是本地事务的边界被限制在单一数据库连接里。你不可能在一个@Transactional方法里让两个不同数据源的事务共享同一个提交或回滚决策,除非引入一个独立的协调者。这个协调者就是分布式事务框架做的事。

1.2 主流方案对比

我最初纠结过几种方案:本地消息表、事务消息、TCC、直接上 2PC/XA。它们的取舍差别很大。

方案侵入性一致性适用场景
本地消息表中最终一致异步、能接受延迟
事务消息中最终一致依赖消息中间件
TCC高强一致资金类、需要预留资源
2PC/XA低强一致数据库支持有限,性能损耗明显
Seata AT低最终一致 + 全局锁大多数中小型分布式事务

Seata 的 AT 模式对我最有吸引力的一点是:业务代码几乎不用改,只需加一个@GlobalTransactional注解,Seata 在底层拦截 SQL、记录快照、自动补偿。前提是 SQL 能被解析成标准 INSERT/UPDATE/DELETE,如果你的业务大量使用存储过程、复杂嵌套 SQL,就要慎重评估。

1.3 版本兼容性硬约束

版本组合是这套实践里最容易被忽略的环节。

组件推荐版本备注
Spring Boot2.2.5.RELEASESCA 2.2.0 基于 2.2.x 验证
Spring CloudHoxton.SR1+2020 之后配置方式不同
Spring Cloud Alibaba2.2.0.RELEASE标题固定写法
Seata1.2.0必须手动覆盖默认版本
Nacos1.2.1同时承担注册中心与配置中心
MySQL5.7 / 8.08.0 注意驱动与时区参数

这里有个大坑:Spring Cloud Alibaba 2.2.0.RELEASE 的 BOM 会把 Seata 相关组件钉在 1.0.0,和 Server 端的 1.2 不一致。客户端注册分支时会出现版本不兼容的异常,或者明明配置都对了,事务就是不生效。所以后面 Maven 部分我会专门给出强制覆盖版本的做法。

2. Seata Server 1.2 在 Nacos 1.2.1 上的部署细节

2.1 下载与启动前要分清两个配置文件

先下载seata-server-1.2.0.tar.gz,解压后conf目录下有两个文件是核心:registry.conf和file.conf。

registry.conf管的是“Seata Server 注册到哪里、配置从哪里读”,file.conf管的是“事务存储方式、事务分组、锁超时等运行参数”。很多人只改了registry.conf,却忘记file.conf里的事务分组要和客户端一致,导致客户端报找不到服务。

2.2 registry.conf 改成 Nacos

用 Nacos 1.2.1 同时做注册中心和配置中心时,registry.conf这样改:

registry { type = "nacos" nacos { serverAddr = "127.0.0.1:8848" namespace = "" cluster = "default" } } config { type = "nacos" nacos { serverAddr = "127.0.0.1:8848" namespace = "" group = "SEATA_GROUP" } }

注意namespace留空代表使用 Nacos 的public命名空间,不要随手填一个不存在的 ID。cluster默认是default,这个值必须和客户端里 Seata 的 cluster 一致,否则服务注册了也发现不了。

2.3 存储模式:先用 file 跑通,再切 db

Seata 1.2 Server 支持file和db两种存储模式。单机快速验证用file最省事,但生产环境我会建议用db,原因是 file 模式下全局事务记录存在本地文件里,集群部署时各节点状态不共享。

如果切到 db 模式,需要先建一个seata库,然后执行 Seata 压缩包自带的db_store.sql。里面有三张核心表:global_table存全局事务,branch_table存分支事务,lock_table存全局锁。MySQL 8 用户还要注意把驱动换成com.mysql.cj.jdbc.Driver,连接串加上serverTimezone=Asia/Shanghai,否则启动必报时区错误。

2.4 事务分组写入 Nacos 配置中心

Seata 有一个“事务分组”的概念。客户端配置tx-service-group=my_test_tx_group,Server 端必须有一条映射关系告诉 Seata:这个分组对应哪个集群,通常是service.vgroupMapping.my_test_tx_group=default。

我用的是把配置导入 Nacos 的方式。解压包conf目录下有nacos-config.txt和nacos-config.sh,脚本会把nacos-config.txt里的配置逐条写入 Nacos,默认 group 是SEATA_GROUP。在导入之前,确认nacos-config.txt里有这一行:

service.vgroupMapping.my_test_tx_group=default

如果初始文件里没有,就手动追加,或者在 Nacos 控制台直接新建一条 DataId 同名的配置。这一步漏掉,客户端会在注册分支时直接报No available service 'default' found。

2.5 启动并验证注册结果

配置改好后,启动命令很简单:

sh seata-server.sh -h 127.0.0.1 -p 8091

看到日志输出Server started只算成功了一半。还要去 Nacos 控制台的“服务管理-服务列表”确认能看到seata-server这个服务,且集群名为default。如果服务列表里没有,优先检查 registry.conf 里的serverAddr、namespace和 cluster。注册中心通了,才算真正跑起来。

3. 客户端接入:Spring Cloud Alibaba 2.2.0 应用里的每一步改动

3.1 Maven 依赖与版本覆盖

客户端接入第一步,是让三个业务服务都引入 Nacos 和 Seata 相关依赖。我建议在dependencyManagement里同时管理 Spring Cloud Alibaba 和 Seata 版本:

<dependencyManagement> <dependencies> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>2.2.0.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.2.0</version> </dependency> </dependencies> </dependencyManagement>

实际依赖这样写:

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

spring-cloud-starter-alibaba-seata会传递引入 Seata 1.0.0,我在依赖管理里强制覆盖成 1.2.0。改完后运行:

mvn dependency:tree -Dincludes=io.seata:*

如果还看到 1.0.0,说明覆盖没生效,要检查依赖管理位置是否正确,或者在spring-cloud-starter-alibaba-seata的依赖里显式排除旧版本。

3.2 bootstrap.yml 配置

Nacos Config 需要在应用程序早期加载,所以放在bootstrap.yml里。每个服务大同小异,以order-service为例:

spring: application: name: order-service cloud: nacos: config: server-addr: 127.0.0.1:8848 file-extension: yaml group: DEFAULT_GROUP discovery: server-addr: 127.0.0.1:8848 namespace: public alibaba: seata: tx-service-group: my_test_tx_group

服务名决定了 Nacos 配置中心的 DataId,比如order-service.yaml。spring.cloud.alibaba.seata.tx-service-group是 Seata 客户端的事务分组名,必须和 Server 端 vgroupMapping 的前半段一致。

3.3 手工数据源代理:最容易被忽略的一步

Seata AT 模式要拦截 SQL、生成 undo_log,前提是数据源必须经过DataSourceProxy包装。只写业务代码但不代理数据源,事务会静默失效,这是最常见的“注解没用”原因。

我会在每个参与分布式事务的服务里手动声明一个DataSourceProxy:

@Configuration public class DataSourceConfig { @Bean @Primary public DataSourceProxy dataSourceProxy(DataSource dataSource) { return new DataSourceProxy(dataSource); } }

为什么手动做而不是完全依赖自动装配?因为 Seata 的自动代理在多数据源、动态数据源场景下经常失效,而且一旦失效没有任何启动报错。手动声明之后,容器里已经有DataSourceProxy,自动配置会主动让路,不会重复代理。

3.4 每个业务库都要建 undo_log 表

AT 模式的回滚依赖 undo_log 表。这张表不是建在 Seata Server 的库里,而是建在每一个参与分布式事务的业务库里,也就是order库、storage库、account库都要建。

CREATE TABLE `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,缺表的分支会直接回滚失败。

3.5 客户端 registry.conf 与 file.conf

Seata 1.2 的客户端 starter 默认会读 classpath 下的registry.conf和file.conf。最简单的做法是从 Seata Server 解压包 conf 目录复制一份,把注册和配置来源都改成 Nacos,cluster 保持default。

registry { type = "nacos" nacos { serverAddr = "127.0.0.1:8848" namespace = "" cluster = "default" } } config { type = "nacos" nacos { serverAddr = "127.0.0.1:8848" namespace = "" group = "SEATA_GROUP" } }

这里要注意 group 和 Server 端一致,都是SEATA_GROUP。如果客户端读的 group 不对,拉不到配置,事务分组映射就失效,表现依然是找不到服务。

4. AT 模式是怎么协调三个库的:全局锁和 undo_log 的配合

4.1 第一阶段:本地提交,但把后路留好

当一个请求进入order-service的@GlobalTransactional方法时,Seata 会先向 Server 申请一个全局事务,拿到 XID,然后把它绑定到当前线程。接下来每个服务执行本地 SQL,看起来和普通事务一样:

UPDATE t_storage SET count = count - 1 WHERE id = 1;

但 Seata 的DataSourceProxy在底层做了两件事:把修改前的主键值、字段值记为“前镜像”,把修改后的值记为“后镜像”,并且以二进制形式写入undo_log。写完 undo_log 后再提交本地事务。

我一开始觉得这个设计很奇怪:明明分支还没全部执行完,本地事务怎么就提交了?原因在于 Seata 需要先释放数据库连接资源,避免长事务占用连接。如果后续分支失败,走的是补偿回滚,而不是像本地事务那样直接回滚。

4.2 第二阶段:提交是清理,回滚是补偿

全局事务结束时,Server 会向每个分支发指令。全部成功,就通知各分支删除undo_log中对应的记录,这是清理动作。一旦有分支失败,就通知所有分支根据undo_log生成反向 SQL。

比如扣库存的 UPDATE 前镜像 count 是 1,后镜像 count 是 0,回滚时 Seata 就会执行:

UPDATE t_storage SET count = 1 WHERE id = 1;

这个补偿动作在业务代码里完全透明,你看到的结果就是库存又回来了。补偿成功的前提是回滚期间这行数据没有被其他事务改写,否则会报镜像校验失败,需要人工介入。

4.3 全局锁:防止两个全局事务互相踩踏

第一阶段本地事务提交后,数据实际上已经可见,但 Seata 会在lock_table里记录这行数据被某个全局事务的某个分支锁定。另一个事务要修改同一行,必须先等锁。

很多时候看到Global lock wait timeout,就是两个并发请求同时改同一行库存,后到的分支在等待全局锁,等太久了就超时。这个报错其实是正常保护机制在工作,说明全局锁逻辑已经生效,问题出现在业务并发设计上。

4.4 数据可见性与隔离级别

由于一阶段的本地事务已经提交,其他系统直接查库可能读到“全局事务还没最终提交”的中间数据。Seata AT 模式默认全局隔离级别接近读未提交,要做到读已提交,需要使用SELECT FOR UPDATE或者在业务层加锁来规避。

我在实际项目里的处理是:对外提供的查询接口尽量走缓存或异步刷新,不直接依赖正在变更的库表;确有必要读实时数据的,就明确告知会有短暂不一致。绝大多数业务场景都能接受这个权衡。

5. 可复现示例:跨库下单扣库存扣余额的完整验证

5.1 服务与库的划分

为了验证整套链路,我把模拟项目拆成三个服务,对应三个独立库。

服务数据库核心表
order-serviceds_ordert_order
storage-serviceds_storaget_storage
account-serviceds_accountt_account

建表语句保持精简:

CREATE TABLE `t_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) DEFAULT NULL, `product_id` bigint(20) DEFAULT NULL, `amount` decimal(10,2) DEFAULT NULL, `status` tinyint(4) DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `t_storage` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `product_id` bigint(20) DEFAULT NULL, `count` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `t_account` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) DEFAULT NULL, `balance` decimal(10,2) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

每个库各建一张undo_log表,初始数据准备:一件商品库存 10,一个用户余额 100.00。

5.2 调用链与注解位置

@GlobalTransactional必须放在事务发起方的最外层实现方法上,我一般放在 order-service 的 ServiceImpl 方法上:

@Override @GlobalTransactional(name = "create-order", timeoutMills = 30000) public void createOrder(OrderDTO dto) { orderMapper.insert(dto.toEntity()); storageFeign.deduct(dto.getProductId(), dto.getCount()); accountFeign.debit(dto.getUserId(), dto.getAmount()); }

storage-service 和 account-service 的内部方法只加本地事务注解:

@Override @Transactional(rollbackFor = Exception.class) public void deduct(Long productId, Integer count) { storageMapper.deduct(productId, count); }

Feign 调用会自动传递 XID,这依赖 Spring Cloud Alibaba 的 Seata 拦截器:发送方把 XID 放进请求头,接收方从请求头取出来绑定到新线程。如果你不是用 Feign 而是用裸RestTemplate,要自己处理这个传递,否则第二个分支会找不到全局事务。

5.3 正常流程验证

调用下单接口后,分别到三个库确认数据。正常预期是三张表都发生变化:

SELECT * FROM ds_order.t_order; SELECT * FROM ds_storage.t_storage WHERE product_id = 1; SELECT * FROM ds_account.t_account WHERE user_id = 1;

能看到订单表多一行、库存从 10 变成 9、余额从 100 变成 80。同时undo_log里出现过记录,但提交后会被清理,所以最终查出来可能为空。

5.4 异常回滚验证

在 account-service 的扣款方法里,在 UPDATE 执行之后手动抛一个运行时异常:

@Override @Transactional(rollbackFor = Exception.class) public void debit(Long userId, BigDecimal amount) { accountMapper.debit(userId, amount); throw new RuntimeException("mock balance not enough"); }

这时候整个链路会全部回滚。调用方捕获到异常后,再去查三个库,预期结果:订单表没有新增记录、库存恢复为 10、余额恢复为 100。这个结果能出来,就说明分布式事务真正生效了。

5.5 从日志确认链路

除了看数据库,也要学会看日志。Seata Server 日志里会出现全局事务的提交或回滚记录,客户端日志里能看到分支注册和分支事务的提交/回滚。把日志和数据库结果结合起来,才能确定是某个分支没执行,还是执行了但回滚失败。

6. 高频报错与排障思路:从日志反推配置问题

6.1 No available service 'default' found

这个报错几乎每个第一次搭 Seata 的人都见过,原因是客户端根据事务分组去 Nacos 找后端服务时,找的是default集群,但没找到。

排查链路我建议固定成一套:

  • 确认 Nacos 服务列表里存在seata-server,没有就回到 Server 的 registry.conf。
  • 确认 Server 的 cluster 是default,如果改过集群名,所有配置要同步改。
  • 确认service.vgroupMapping.my_test_tx_group=default这条配置在 Nacos 里存在,且 group 是客户端 config 指向的SEATA_GROUP。
  • 确认客户端registry.conf的 serverAddr 和 namespace 与 Server 一致。

这四步按顺序走完,基本能解决九成“找不到服务”的问题。

6.2 Global lock wait timeout

报错长这样:Global lock wait timeout, xid=..., branchTx=...。原因通常是并发更新同一行数据,或者上一个大事务迟迟没结束。

我先看lock_table:

SELECT * FROM seata.lock_table ORDER BY gmt_create DESC;

如果锁表里残留大量记录,说明有事务一直没结束。再看对应的全局事务是不是已经超时,必要时把超时时间调大一些,但从根上解决还是要缩短全局事务的执行时间,把耗时的外部调用移出去。

6.3 undo_log 相关 SQL 异常

报错可能有两种:Table 'undo_log' doesn't exist和Batch update returned unexpected row count。

前者简单,就是某个业务库漏建了undo_log表。后者严重一些,说明回滚时 Seata 根据前镜像去恢复数据,发现当前数据已经变了。最常见原因是回滚前有其他事务改了同一行数据。遇到这种情况,要人工核对数据并把账补平,不能假装重放一次就能解决。

6.4 nacos-client 版本冲突

Seata 1.2 传递依赖的 Nacos 客户端版本通常不是 1.2.1,而 Spring Cloud Alibaba 2.2.0 希望用 1.2.1。两个版本同时存在时,可能抛出NacosException: endpoint is blank或者ClassNotFoundException: com.alibaba.nacos.api.config.ConfigService。

处理方式是在 Maven 依赖管理里统一 Nacos 客户端版本:

<dependency> <groupId>com.alibaba.nacos</groupId> <artifactId>nacos-client</artifactId> <version>1.2.1</version> </dependency>

统一之后重新打包,确认依赖树里只剩一个 nacos-client。

6.5 事务完全没生效时的排查顺序

最气人的场景是:代码都写对了,@GlobalTransactional也加了,但异常发生后数据没回滚。我现在遇到这种问题,会按固定顺序排查:

  • 看请求日志里有没有 XID 出现,并且是否从 order-service 传到了 storage-service、account-service。
  • 看业务库undo_log最近有没有写入记录。如果完全没写,说明分支事务根本没进入 Seata,问题大概率出在数据源没有经过DataSourceProxy,或者客户端没连上 Server。
  • 看 Seata Server 日志里有没有全局事务记录。没有记录,说明全局事务根本没创建。
  • 最后回头看注解位置,@GlobalTransactional是否加在了 Feign 接口上而没加在实现类上。

我现在的习惯是先在每个参与库的undo_log查最近 10 分钟记录,没记录就回头查代理和注册,有记录再看 Server 日志。按这个顺序做,绝大多数“事务不生效”的问题都能在十分钟内定位到根因,而不是被一堆配置项绕晕。

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

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

立即咨询