分布式事务 Seata
项目默认不开启分布式事务。一个业务操作需要通过 RPC 调用多个服务、修改多个数据库,并且要求它们要么都成功、要么都回滚时,可以基于 Apache Seata (opens new window) 的 AT 模式按需接入,实际使用的依赖版本见《3.3 添加依赖》小节。
Seata 提供了 AT、TCC、SAGA、XA 多种事务模式,本文只接入对业务代码侵入最小的 AT 模式。接入方式是在 《多数据源(读写分离)》 的基础上,由 Dynamic Datasource 为指定的数据源包装 Seata 代理,并通过 Feign 请求头传递全局事务的 XID:
- 各服务的
master主库、slave从库保持不变,不参与全局事务。工作流、商城、支付等已有业务的 SQL 和 RPC 调用,不会因为接入 Seata 而自动变成分布式事务。 - 只有添加了
@GlobalTransactional注解的入口方法,以及它直接、通过 Feign 间接调用的、开启了 Seata 代理的数据源(示例是system-server的seata_order、infra-server的seata_account),才组成一个全局事务。 - 每个服务的依赖、配置是独立的,只影响接入的服务。其它服务、
gateway网关无需接入 Seata。
友情提示:
分布式事务会带来额外的 Seata Server 部署、全局锁、回滚日志等成本。优先通过服务拆分让强一致的数据落在同一个服务、同一个库中;跨服务的场景,也可以先评估“本地事务 + 消息队列 + 补偿”的最终一致性方案是否满足需求。
# 1. 方案说明
# 1.1 整体结构
system-server(发起方,TM)
DemoSeataTradeService#placeOrder @GlobalTransactional
├── DemoSeataOrderService#createOrder @DS("seata_order") + @Transactional(分支一)
│ └── yudao_seata_order 库:demo_seata_order 表 + undo_log 表
└── DemoSeataAccountApi#deductBalance Feign 调用,请求头 TX_XID = 全局事务的 XID
│
infra-server(参与方) ▼
Seata 自带的 HTTP 拦截器:将请求头中的 XID 绑定到当前线程
DemoSeataAccountRpcController → DemoSeataAccountService#deductBalance @DS("seata_account") + @Transactional(分支二)
└── yudao_seata_account 库:demo_seata_account 表 + undo_log 表
Seata Server(TC,事务协调者):记录全局事务及其分支,决定二阶段提交或回滚
# 1.2 AT 模式的执行过程
AT 模式对业务代码没有侵入,业务代码仍然是普通的 MyBatis Plus 读写:
① 一阶段:每个分支执行业务 SQL 时,Seata 的 DataSourceProxy 先记录修改前、后的数据镜像,与业务数据在同一个本地事务中写入 undo_log 表,然后立即提交本地事务,并向 Seata Server 注册分支。
② 二阶段提交:全局事务的入口方法正常结束,Seata Server 通知各服务的分支异步删除 undo_log 即可,业务数据在一阶段已经提交。
③ 二阶段回滚:入口方法抛出异常,Seata Server 通知各服务的分支根据 undo_log 生成反向 SQL,将数据恢复到修改前的镜像。
所以,一阶段提交后、二阶段完成前,其它不在全局事务中的查询可以读到“中间状态”的数据,参见《4.4 隔离性》小节。
# 1.3 示例
项目提供了两个独立的演示库,不会修改已有的业务表:
| 服务 | 数据源 | 库 | 表 | 说明 |
|---|---|---|---|---|
system-server | seata_order | yudao_seata_order | demo_seata_order | 订单,主键 id 由调用方传入,作为幂等主键 |
infra-server | seata_account | yudao_seata_account | demo_seata_account | 账户,初始化租户 1、用户 1 的账户,余额 100 分 |
两张表都继承项目的 TenantBaseDO,带有 create_time、update_time(LocalDateTime)和 tenant_id 字段,可以在回滚后核对时间、租户字段是否一起恢复。租户编号通过项目已有的 Feign 拦截器传递给 infra-server。
示例 DemoSeataTradeService#placeOrder 的执行顺序是:先在 system-server 创建订单,再通过 Feign 调用 infra-server 条件扣减余额(balance >= 金额 时才更新):
| 场景 | 结果 |
|---|---|
| 余额足够 | 两个分支都提交:订单存在,余额减少 |
| 余额不足 | infra-server 的分支二抛出异常,system-server 分支一的订单根据 undo_log 删除:订单不存在,余额不变 |
| 两个分支都成功,外层的全局事务随后失败 | 两个服务的分支都根据 undo_log 回滚:订单不存在,余额恢复 |
| 重复的订单编号 | 分支一主键冲突、抛出异常,不会重复扣减余额 |
为什么不直接使用商城、支付的业务做示例?
商城下单、支付等真实流程,涉及库存、优惠券、积分、钱包等多个服务,以及消息队列、定时任务、第三方回调等不受数据库事务控制的操作,需要按业务逐个评估回滚方式。所以,示例使用独立的演示库,接入 Seata 不等于商城、支付等业务自动启用了分布式事务。
# 2. 获取集成材料
集成材料在项目的 script/seata/ (opens new window) 目录。目前只有 master-jdk17 分支提供这些材料,master、master-jdk25 分支请先从 master-jdk17 分支获取,同步到对应分支后,也可以直接从当前分支获取。材料适用于下面三个分支,接入步骤相同,兼容差异如下:
| 分支 | JDK | Spring Boot | 差异 |
|---|---|---|---|
master | 8 | 2.7.18 | 无,Jackson 2 已由 Spring Boot 提供 |
master-jdk17 | 17 | 3.5.15 | 无,Jackson 2 已由 Spring Boot 提供 |
master-jdk25 | 25 | 4.1.2 | Spring Boot 4 默认使用 Jackson 3,必须添加 dependencies.xml 中的 Jackson 2 依赖;使用 Feign 客户端的服务缺少 spring-boot-data-commons 模块时,还需要按条件添加 dependencies-jdk25.xml,见《3.3 添加依赖》小节 |
目录中的文件及用途如下:
| 文件 | 说明 | 目标位置 |
|---|---|---|
sql/mysql/demo_seata.sql | 创建 2 个演示库,各一张示例表 + undo_log 表 | 在 MySQL 中执行 |
dependencies.xml | Maven 依赖片段 | yudao-module-system-server/pom.xml、yudao-module-infra-server/pom.xml 都要添加 |
dependencies-jdk25.xml | 仅 master-jdk25 分支按条件补充的 spring-boot-data-commons 依赖片段 | 满足条件的每个使用 Feign 客户端的服务,示例的 yudao-module-system-server/pom.xml、yudao-module-infra-server/pom.xml 都要检查 |
seata.yaml | 按服务分为两段:数据源、Seata 客户端的配置片段 | 分别合并到两个服务的 application-local.yaml 等配置文件,或 Nacos 配置 |
src/main/java/.../system/seatademo/ | 订单 DO、Mapper、Service,全局事务入口 DemoSeataTradeService,Feign 客户端 DemoSeataAccountApi,传递 XID 的 DemoSeataRpcConfiguration | yudao-module-system-server/src/main/java/ 的同名包 |
src/main/java/.../infra/seatademo/ | 账户 DO、Mapper、Service,RPC 接口 DemoSeataAccountRpcController | yudao-module-infra-server/src/main/java/ 的同名包 |
单体项目 ruoyi-vue-pro 的 script/seata/ 目录,使用同样的 SQL、依赖,以及订单、账户的 DO、Mapper、Service;差异是单体项目在同一个进程中直接调用账户 Service,没有 Feign 相关的类。
# 3. 接入步骤
友情提示:以 `system-server` 发起、`infra-server` 参与为例子。
示例代码的包名分别是 cn.iocoder.yudao.module.system.seatademo、cn.iocoder.yudao.module.infra.seatademo,在对应服务的 yudao.info.base-package 下,才能被 Spring、MyBatis 扫描到。接入其它服务时,需要将包名改到对应服务的 base-package 下。
# 3.1 启动 Seata Server
Seata Server(TC)是独立的进程,与项目使用的 JDK 版本无关。本机演示时,使用官方 Docker 镜像启动即可:
docker run -d --name yudao-seata-server \
-p 127.0.0.1:18091:18091 \
-e SEATA_PORT=18091 \
-e JVM_XMS=256m \
-e JVM_XMX=512m \
apache/seata-server:2.6.0
-p 127.0.0.1:18091:18091:只在本机的127.0.0.1上映射端口,不对局域网、公网开放;system-server、infra-server与 Docker 运行在同一台机器上,通过grouplist直连127.0.0.1:18091。SEATA_PORT:Seata Server 的服务端口,必须与《3.4 添加配置》小节中seata.service.grouplist.default的端口一致。JVM_XMS、JVM_XMX:限制 JVM 内存,本机演示足够。
这里没有设置 SEATA_IP:Seata Server 不接受 127.0.0.1 这类回环地址作为自己的地址,会改用容器内的 IP,日志、XID 中看到 172.17.* 之类的地址属于正常现象,不影响本机演示。服务与 Seata Server 不在同一台机器时,需要使用服务能够访问到的真实地址、端口,参见下面的生产部署说明。
执行 docker logs -f yudao-seata-server 命令,确认日志中没有异常、服务已经启动在 18091 端口。
本机演示 ≠ 生产部署
上面的命令使用镜像默认的 file 存储模式,全局事务的状态保存在容器内的文件中,容器删除或重建后会丢失,也只有单个节点;客户端也是通过 file 直连这个地址。生产环境需要:
- 将存储模式改为
db(需要初始化 Seata Server 自己的表)或raft等可持久化的方式,并部署多个节点保证高可用。 - 将 Seata Server 注册到 Nacos,客户端的
seata.registry、seata.config改为 Nacos,而不是file直连。 - Seata Server 注册、声明的地址,必须是各个服务能够访问到的真实地址、端口,不能依赖
127.0.0.1等回环地址。 - 为 Seata Server 设置访问控制,只允许业务服务所在的内网访问。
具体参见 Seata 官方的 《新人文档》 (opens new window)、《Docker 部署》 (opens new window)、《高可用部署》 (opens new window)、《事务分组》 (opens new window) 文档。
# 3.2 初始化数据库
在 MySQL 中执行 sql/mysql/demo_seata.sql 脚本,创建 yudao_seata_order、yudao_seata_account 两个库,以及:
- 业务表:
demo_seata_order、demo_seata_account,并初始化租户 1、用户 1 的账户,余额 100 分。 undo_log表:AT 模式的回滚日志,每个参与全局事务的库都要有一张,与业务表在同一个库中。表结构来自 Seata 官方的script/client/at/db/mysql.sql(opens new window),Seata Server 不需要这张表。
脚本使用「库名.表名」的写法、CREATE ... IF NOT EXISTS 和 INSERT IGNORE,可以重复执行,不会删除已有的库、表、数据,也不会修改各服务的 master 主库。生产环境中,两个库通常属于不同服务、不同的 MySQL 实例,分别执行对应库的语句即可。
# 3.3 添加依赖
将 dependencies.xml 中的依赖,复制到 yudao-module-system-server、yudao-module-infra-server 两个服务的 pom.xml 中:
<dependency>
<groupId>org.apache.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>2.6.0</version>
<exclusions>
<exclusion>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
① Seata 使用 org.apache.seata 的坐标,并显式指定 2.6.0 版本:项目的 Spring Cloud Alibaba BOM 管理的 Seata 版本是 2.5.0(master-jdk17、master-jdk25)或 io.seata 的 1.6.1(master),不是本文验证的版本。它的 seata-all 自带 io.seata 包的兼容类,Dynamic Datasource 4.6.0 包装数据源时使用的 io.seata.rm.datasource.DataSourceProxy 就由它提供。
② 不要引入 spring-cloud-starter-alibaba-seata、io.seata:* 等坐标,否则会与 seata-all 2.6.0 自带的兼容类重复。XID 的传递,由示例的 Feign 拦截器和 Seata 自带的 HTTP 拦截器完成,参见《4.2 XID 的传递》小节。
③ 排除 Seata 传递的 druid 1.2.25,避免它把项目原有的 Druid 降级。排除后,Seata 解析 SQL 时继续使用项目 Druid starter 提供的版本。
④ Seata 默认使用 Jackson 2 序列化 undo_log 中的数据镜像。master、master-jdk17 分支的 Spring Boot 已经提供,声明后版本不变;master-jdk25 分支的 Spring Boot 4 默认使用 Jackson 3,必须添加这两个依赖,版本由 Spring Boot BOM 管理。它们只给 Seata 内部使用,不会替换项目接口、Redis 等使用的 JSON 序列化。
⑤ 仅 master-jdk25 分支(Spring Boot 4.1.2 + Spring Cloud OpenFeign 5.0.2):使用 Feign 客户端的服务,启动时的 classpath 中有 org.springframework.data.domain.Pageable(例如说 Redis 传递引入的 spring-data-commons),却没有 Spring Boot 4 拆分到 spring-boot-data-commons 模块的 org.springframework.boot.data.autoconfigure.web.DataWebProperties 时,需要将 dependencies-jdk25.xml 中的依赖,添加到该服务的 pom.xml 中,版本由 Spring Boot BOM 管理。示例的两个服务都需要检查:
- 发起方
yudao-module-system-server:示例的DemoSeataAccountApi等 Feign 客户端。 - 参与方
yudao-module-infra-server:虽然示例没有在其中新增 Feign 客户端,但多租户组件的YudaoTenantRpcAutoConfiguration会注册TenantCommonApi等 Feign 客户端,同样受这个条件影响。
依赖如下:
<!-- 仅 master-jdk25 分支,且 classpath 中缺少 spring-boot-data-commons 时添加 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-data-commons</artifactId>
</dependency>
原因是 OpenFeign 5.0.2 中,普通的 Encoder 要求 classpath 中没有 Pageable,支持分页参数的 Encoder 要求 Pageable、DataWebProperties 都有。只有 Pageable 时,两个都不会创建,启动报错参见《5.8 报错 No bean found of type interface feign.codec.Encoder》小节。classpath 中已经有该模块时,不需要重复添加;单体项目及 master、master-jdk17 分支不使用该文件。
本文对这一条件的验证,是在 Java 25 下只包含示例相关依赖的启动环境中进行的:发起方、参与方都补充了该依赖后通过,参与方使用真实的 YudaoTenantRpcAutoConfiguration、TenantCommonApi,不补充时复现了上述报错;并没有验证完整业务服务的构建、启动。所以,以自己服务实际的 classpath 为准判断是否需要添加。
# 3.4 添加配置
seata.yaml 按服务分为两段,分别合并到对应服务的 application-local.yaml 配置文件中。
① system-server(发起方):
spring:
datasource:
dynamic:
seata: true # 开启 Dynamic Datasource 的 Seata 支持,由它为每个数据源包装 Seata 的 DataSourceProxy
seata-mode: AT # 使用 AT 模式
datasource:
master:
seata: false # 只追加这一行:master 主库不参与 AT,不被 Seata 代理
slave:
seata: false # 只追加这一行:slave 从库不参与 AT,不被 Seata 代理;没有配置 slave 时,删除整个 slave 段
seata_order: # 分布式事务示例的订单库,只有 @DS("seata_order") 的 Mapper、Service 使用
url: ${YUDAO_SEATA_ORDER_URL:jdbc:mysql://127.0.0.1:3306/yudao_seata_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true}
username: ${YUDAO_SEATA_DB_USERNAME:root}
password: ${YUDAO_SEATA_DB_PASSWORD:}
seata: true # 参与 AT:被 Seata 代理,本地事务提交时注册分支、写入 undo_log
seata:
enabled: true
application-id: ${spring.application.name}
tx-service-group: yudao_seata_demo_group # 事务分组,与下面的 vgroup-mapping 对应
enable-auto-data-source-proxy: false # 必须关闭:数据源已经由 Dynamic Datasource 代理,避免双重代理
client:
http:
interceptor-enabled: false # 发起方不需要从 HTTP 请求头 TX_XID 接收 XID;当前服务也要作为参与方时,删除本项
service:
vgroup-mapping:
yudao_seata_demo_group: default # 事务分组 -> Seata Server 集群名
grouplist:
default: ${YUDAO_SEATA_SERVER_ADDR:127.0.0.1:18091} # Seata Server 地址,端口与启动 Seata Server 时的 SEATA_PORT 一致
registry:
type: file # 直连上面的 grouplist,仅用于本机演示;服务的注册发现仍然使用 Nacos,不受影响
config:
type: file
② infra-server(参与方):数据源换成 seata_account(环境变量 YUDAO_SEATA_ACCOUNT_URL),seata 的配置与 system-server 相同,只是不配置 seata.client.http.interceptor-enabled,保持默认的 true,由 Seata 自带的 HTTP 拦截器绑定请求头中的 XID。完整内容见 seata.yaml 的第二段。
注意事项:
- 合并,而不是替换:
master、slave原有的url、username、password等配置保持不变,只追加seata: false一行。Dynamic Datasource 的seata默认对每个数据源都是开启的,不追加时,主库、从库也会被 Seata 代理、向 Seata Server 注册。seata: false只追加到该服务当前 profile 已经存在的数据源上:没有配置slave时,不要复制slave段,否则会新建一个没有url的slave数据源,导致启动失败。 seata.enable-auto-data-source-proxy必须为false。否则 Seata 会再代理一次 Dynamic Datasource 的路由数据源,形成双重代理。- 两个服务的
tx-service-group、vgroup-mapping、grouplist必须一致。 - 如果服务的配置放在 Nacos 中,同样合并到 Nacos 中该服务的配置。同一个配置项在 Nacos 与本地配置文件中同时存在时,通常以 Nacos 为准,注意不要用片段覆盖掉 Nacos 中已有的
master数据源。
数据库的账号密码通过环境变量传入,不要把真实密码写入配置文件、提交到 Git:
| 环境变量 | 说明 | 默认值 | 使用的服务 |
|---|---|---|---|
YUDAO_SEATA_ORDER_URL | 订单库的 JDBC URL | jdbc:mysql://127.0.0.1:3306/yudao_seata_order?... | system-server |
YUDAO_SEATA_ACCOUNT_URL | 账户库的 JDBC URL | jdbc:mysql://127.0.0.1:3306/yudao_seata_account?... | infra-server |
YUDAO_SEATA_DB_USERNAME | 演示库的账号 | root | 两个服务 |
YUDAO_SEATA_DB_PASSWORD | 演示库的密码 | 空,按实际设置 | 两个服务 |
YUDAO_SEATA_SERVER_ADDR | Seata Server 的地址 | 127.0.0.1:18091 | 两个服务 |
本地开发时,在 IDEA 启动 SystemServerApplication、InfraServerApplication 的 Run Configuration 中,分别设置 Environment variables 即可。
# 3.5 复制示例代码
① 将 src/main/java 下的 cn.iocoder.yudao.module.system.seatademo 包,复制到 yudao-module-system-server/src/main/java 的同名目录:
DemoSeataOrderDO、DemoSeataOrderMapper:订单表,Mapper 上添加@DS("seata_order")注解。DemoSeataOrderService:类上添加@DS("seata_order")注解,createOrder方法添加@Transactional(rollbackFor = Exception.class),是全局事务的分支一。DemoSeataAccountApi:调用infra-server账户接口的 Feign 客户端,使用独立的contextId,避免与已有的infra-serverFeign 客户端冲突。DemoSeataRpcConfiguration:通过@EnableFeignClients(clients = DemoSeataAccountApi.class)单独声明 Feign 客户端,不修改已有的RpcConfiguration;并注册传递 XID 的 Feign 拦截器。DemoSeataTradeService:placeOrder方法添加@GlobalTransactional注解,是全局事务的入口。
② 将 cn.iocoder.yudao.module.infra.seatademo 包,复制到 yudao-module-infra-server/src/main/java 的同名目录:
DemoSeataAccountDO、DemoSeataAccountMapper:账户表,Mapper 上添加@DS("seata_account")注解,updateBalanceDecr方法按balance >= 金额条件扣减。DemoSeataAccountService:类上添加@DS("seata_account")注解,deductBalance方法添加@Transactional(rollbackFor = Exception.class),是全局事务的分支二;余额不足时抛出业务异常。DemoSeataAccountRpcController:RPC 接口,路径是/rpc-api/infra/seata-demo/account/deduct-balance,在infra-server已有安全配置放行的/rpc-api/infra/**下。
全局事务的入口 placeOrder:
@GlobalTransactional(name = "demo-seata-place-order", rollbackFor = Exception.class)
public void placeOrder(Long orderId, Long userId, Integer amount) {
// 1. 创建订单:分支一,本地事务提交后,订单已经写入 seata_order 库,同时记录 undo_log
demoSeataOrderService.createOrder(orderId, userId, amount);
// 2. 扣减余额:分支二,由 infra-server 在 seata_account 库执行;XID 通过 Feign 请求头传递
// 必须调用 getCheckedData():账户服务的业务异常会被转换成 CommonResult 错误码返回,不检查就不会抛出异常,全局事务会被提交
demoSeataAccountApi.deductBalance(userId, amount).getCheckedData();
}
示例代码只使用 Java 8 语法和构造方法注入,不引用 javax、jakarta 包,三个分支均无需修改。
# 3.6 启动验证
① 在 system-server 的 cn.iocoder.yudao.module.system.seatademo 包下,新增两个临时的类。
DemoSeataVerifyService:模拟“下单成功后,外层的后续步骤失败”。它必须是单独的 @Service 类,同一个类内部的 this.xxx() 调用不会经过 @GlobalTransactional 的拦截:
package cn.iocoder.yudao.module.system.seatademo;
import lombok.RequiredArgsConstructor;
import org.apache.seata.spring.annotation.GlobalTransactional;
import org.springframework.stereotype.Service;
/**
* 分布式事务示例:验证外层全局事务失败时的回滚,验证完成后删除
*
* @author 芋道源码
*/
@Service
@RequiredArgsConstructor
public class DemoSeataVerifyService {
private final DemoSeataTradeService demoSeataTradeService;
@GlobalTransactional(name = "demo-seata-verify", rollbackFor = Exception.class)
public void placeOrderThenFail(Long orderId, Long userId, Integer amount) {
demoSeataTradeService.placeOrder(orderId, userId, amount); // 加入外层的全局事务,两个服务的分支都提交成功
throw new IllegalStateException("模拟下单之后的步骤失败");
}
}
DemoSeataVerifyRunner:在 system-server 启动完成后,在租户编号为 1 的上下文中,依次执行三个场景,并核对是否提交、订单是否存在符合预期;余额在 infra-server 的库中,在步骤 ③ 中核对。网络、数据库或参与方服务暂时不可用等普通的回滚失败,Seata Server 会自动重试,所以核对时有限次轮询,等待数据达到预期,不以执行完成后立即读取的结果为准:
package cn.iocoder.yudao.module.system.seatademo;
import cn.hutool.core.thread.ThreadUtil;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* 分布式事务示例:启动后验证提交、回滚,验证完成后删除
*
* @author 芋道源码
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class DemoSeataVerifyRunner implements ApplicationRunner {
private static final Long USER_ID = 1L;
private final DemoSeataTradeService demoSeataTradeService;
private final DemoSeataVerifyService demoSeataVerifyService;
private final DemoSeataOrderService demoSeataOrderService;
@Override
public void run(ApplicationArguments args) {
TenantUtils.execute(1L, () -> {
long orderId = System.currentTimeMillis(); // 每次启动使用新的订单编号
// 场景一:余额足够,两个分支都提交:订单存在
verify("成功", orderId, () -> demoSeataTradeService.placeOrder(orderId, USER_ID, 10), true);
// 场景二:余额不足,infra-server 的分支二失败,分支一根据 undo_log 回滚:订单不存在
verify("余额不足", orderId + 1, () -> demoSeataTradeService.placeOrder(orderId + 1, USER_ID, 100000000), false);
// 场景三:两个分支都已提交,外层的全局事务失败,两个服务的分支都根据 undo_log 回滚:订单不存在
verify("外层失败", orderId + 2, () -> demoSeataVerifyService.placeOrderThenFail(orderId + 2, USER_ID, 10), false);
});
}
private void verify(String scene, Long orderId, Runnable action, boolean expectCommit) {
boolean committed;
try {
action.run();
committed = true;
log.info("[{}] 全局事务提交", scene);
} catch (Exception ex) {
committed = false;
log.info("[{}] 全局事务回滚:{}", scene, ex.getMessage());
}
// 有限次轮询,等待订单达到预期:最多 10 次,每次间隔 500 毫秒
boolean orderExists = false;
for (int i = 0; i < 10; i++) {
orderExists = demoSeataOrderService.getOrder(orderId) != null;
if (orderExists == expectCommit) {
break;
}
ThreadUtil.sleep(500);
}
if (committed == expectCommit && orderExists == expectCommit) {
log.info("[{}] 符合预期:订单是否存在:{}", scene, orderExists);
} else {
log.error("[{}] 不符合预期:是否提交:{}(预期 {}),订单是否存在:{}(预期 {})",
scene, committed, expectCommit, orderExists, expectCommit);
}
}
}
② 按 Seata Server → infra-server → system-server 的顺序启动,并设置好《3.4 添加配置》小节的环境变量;gateway 等其它服务不需要启动。三个场景都应该输出「符合预期」,出现「不符合预期」时参见《5. 常见问题》排查。system-server 首次执行时,日志示例如下,仅供参考,时间、线程等前缀已省略:
[成功] 全局事务提交
[成功] 符合预期:订单是否存在:true
[余额不足] 全局事务回滚:用户(1) 的账户不存在或余额不足
[余额不足] 符合预期:订单是否存在:false
[外层失败] 全局事务回滚:模拟下单之后的步骤失败
[外层失败] 符合预期:订单是否存在:false
启动日志中,一般还可以看到两个服务的 Seata TM、RM 连接 Seata Server 的日志,具体内容以实际使用的 Seata 版本为准。
③ 余额在 infra-server 的库中,直接连接 MySQL 核对:首次执行(余额从 100 开始)后,余额应为 100 - 10 = 90,只扣减了场景一的 10 分;场景三扣减的 10 分已经回滚。不是 90 时,稍等片刻后再查询一次,仍不一致时查看 Seata Server 的日志。undo_log 在二阶段完成后删除,删除是异步的,稍等片刻后再核对,应为 0 条;仍有记录时,查看 Seata Server 的日志,确认是否有全局事务在重试提交、回滚,或者出现了《5.6》小节中不可重试的脏数据回滚失败:
SELECT id, user_id, amount, tenant_id, create_time FROM yudao_seata_order.demo_seata_order ORDER BY id DESC LIMIT 3;
SELECT id, user_id, balance, tenant_id, update_time FROM yudao_seata_account.demo_seata_account;
SELECT 'order' AS db, COUNT(*) FROM yudao_seata_order.undo_log
UNION ALL SELECT 'account', COUNT(*) FROM yudao_seata_account.undo_log;
验证完成后,记得删除 DemoSeataVerifyService、DemoSeataVerifyRunner 两个类,否则每次启动都会扣减演示账户的余额。需要重新演示时,只修改演示库的数据即可,例如说 UPDATE yudao_seata_account.demo_seata_account SET balance = 100 WHERE id = 1;。
# 4. 使用说明
# 4.1 全局事务的写法
① @GlobalTransactional 只添加在发起方的入口方法上,包名是 org.apache.seata.spring.annotation;参与方的 RPC 接口、Service 不要添加,只使用本地事务。入口方法所在的类,需要通过 @Service 等组件扫描注册。Seata 2.6 不会增强 @Bean 方法定义的 Bean,此时 @GlobalTransactional 不生效、也不会报错。
② 入口方法不要再添加 @Transactional。本地事务会在开始时固定一个数据源的连接,后续的 @DS 切换不会生效,参见 《多数据源(读写分离)》 的事务说明。
③ 每个分支在单独的 Service 上添加 @DS + @Transactional(rollbackFor = Exception.class),先切换数据源、再开启本地事务。
④ 异常要抛出,不要 try catch 后吞掉,否则 Seata 认为执行成功,会提交全局事务。特别是 Feign 调用:项目的 RPC 接口会将参与方的异常转换成 CommonResult 的错误码返回,发起方必须调用 getCheckedData() 或 checkError(),将错误码重新抛出为异常。
⑤ 和 @Transactional 一样,通过 this.xxx() 自调用、或者在其它线程中执行时,注解不会被拦截。
# 4.2 XID 的传递
XID 是全局事务的编号,参与方需要拿到它,才能把本地事务注册为全局事务的分支:
① 发起方:示例的 DemoSeataRpcConfiguration 注册了一个 Feign 拦截器,在当前线程存在 XID(处于全局事务中)时,添加 TX_XID 请求头;不在全局事务中的 Feign 调用不受影响。它对当前服务的所有 Feign 客户端生效。
② 参与方:seata-spring-boot-starter 自带 HTTP 拦截器,在请求开始时将 TX_XID 请求头中的 XID 绑定到当前线程,请求结束时解绑,不需要额外代码。
③ 链路传递:参与方再通过 Feign 调用其它服务时,也需要注册同样的 Feign 拦截器;同时作为发起方、参与方的服务,不要配置 seata.client.http.interceptor-enabled: false。
④ 不要为参与全局事务的 Feign 调用开启重试,避免同一个操作在全局事务中执行多次。
只在可信的内网 RPC 中传递 XID
参与方会信任请求头中的 TX_XID,将当前请求加入对应的全局事务。所以:
- 只在内网的服务之间传递 XID。项目的
/rpc-api/**接口只给内网的服务调用,不要通过网关对外暴露。 - 在
gateway网关中,为外部请求移除TX_XID请求头(例如说使用 Spring Cloud Gateway 的RemoveRequestHeader过滤器),不要让公网的输入加入全局事务。 - 只作为发起方的服务,按《3.4 添加配置》小节关闭 Seata 的 HTTP 拦截器。
# 4.3 不在全局事务中的范围
master、slave等配置了seata: false的数据源:即使在全局事务中执行,也是普通的本地 SQL,全局事务回滚时不会恢复。需要参与时,在该数据源中创建undo_log表,并去掉seata: false。- 没有接入 Seata 的服务:通过 Feign 调用它们时,对方的操作不会回滚。
- 异步线程:XID 保存在当前线程中,
@Async、线程池、CompletableFuture等异步执行的 SQL 或 Feign 调用,不会自动加入全局事务。 @DSTransactional:它是 Dynamic Datasource 自己的多数据源本地事务,与 Seata 的全局事务是两套机制,不要混用在同一个调用链路中。- 《分库分表》 的
sharding数据源:不要为 ShardingSphere 的逻辑连接开启 Seata 代理,本方案没有验证两者的组合。 - 消息队列、Redis、文件、第三方接口等非数据库的操作:不会回滚,需要自行补偿,或者在全局事务提交后再执行。
# 4.4 隔离性
① 写隔离:一阶段提交前,Seata 会获取被修改记录的全局锁。其它全局事务修改同一条记录时,会等待锁释放,等待超时后失败、回滚。所以,热点数据(例如说同一个账户)的并发会下降。
② 读隔离:默认是读未提交。不在全局事务中的查询,可以读到其它全局事务一阶段已提交、但最终会回滚的数据。需要读已提交时,查询语句使用 SELECT ... FOR UPDATE,并在方法上添加 @GlobalTransactional 或 @GlobalLock 注解。
③ 回滚时的数据校验:回滚前,Seata 会比较当前数据与一阶段记录的修改后镜像。回滚失败分为两种:
- 网络中断、数据库或参与方服务暂时不可用等普通失败:Seata Server 会自动重试回滚。
- 其它不在全局事务中的 SQL 修改了这条记录(脏数据):该分支的回滚失败且不可重试。Seata Server 停止自动重试,全局事务以回滚失败结束,等待也不会自动恢复;对应业务库的
undo_log会保留该分支的记录,需要人工核对、修复数据后再清理,参见《5.6 回滚失败,日志出现Has dirty records when undo》小节。
所以,被全局事务修改的表,其它写入也要通过全局事务或 @GlobalLock。
详细说明,参见 Seata 官方的 《AT 模式》 (opens new window) 文档。
# 4.5 接入自己的业务
在示例的基础上,接入自己的业务时:
① 每个参与全局事务的服务,都添加《3.3 添加依赖》小节的依赖、《3.4 添加配置》小节的 Seata 配置。
② 在每个参与全局事务的库中创建 undo_log 表,业务表必须有主键。在 spring.datasource.dynamic.datasource 中,为这些库配置数据源,seata 保持默认的 true;其它数据源配置 seata: false。
③ 发起方注册传递 XID 的 Feign 拦截器。RPC 接口建议和项目其它接口一样,放在被调用方的 -api 模块中;示例为了不修改 yudao-module-infra-api 模块,才将 DemoSeataAccountApi 放在发起方。
④ 调用方需要重试时,像示例的订单编号一样,使用业务唯一键保证幂等。
⑤ @GlobalTransactional 的超时时间默认是 60 秒,可以通过 timeoutMills 属性调整,并大于 Feign 调用的超时时间。全局事务越长,全局锁持有越久,尽量只包含必须一致的数据库操作。
除 MySQL 外的数据库,需要使用 Seata 官方对应数据库的 undo_log 建表 SQL,并自行验证;本文只验证了 MySQL。
# 5. 常见问题
# 5.1 报错 NoClassDefFoundError: com/fasterxml/jackson/...
例如说 com/fasterxml/jackson/core/JsonProcessingException、com/fasterxml/jackson/datatype/jsr310/JavaTimeModule,通常出现在 master-jdk25 分支第一次提交分支事务时。缺少 Seata 序列化 undo_log 使用的 Jackson 2 依赖,按《3.3 添加依赖》小节,在每个接入的服务中同时添加 jackson-databind、jackson-datatype-jsr310 两个依赖。
# 5.2 报错 can not get cluster name in registry config 或 no available service found in cluster
客户端没有找到、或者连接不上 Seata Server:
seata.tx-service-group与seata.service.vgroup-mapping的 key 不一致,或者两个服务的配置不一致。- Seata Server 没有启动,或者
grouplist的端口与启动时的SEATA_PORT不一致。 - Docker 端口只绑定了
127.0.0.1,服务却运行在另一台机器、另一个容器中。
# 5.3 发起方回滚了,参与方的数据却没有回滚
按顺序排查:
- 发起方是否注册了传递 XID 的 Feign 拦截器,即
DemoSeataRpcConfiguration是否在system-server的扫描范围内。 - 参与方是否接入了 Seata,并且没有配置
seata.client.http.interceptor-enabled: false。 - 参与方的数据源是否配置了
seata: false,或者是在异步线程中执行的。 - 参与方的该数据源是否被 Seata 代理:可以查看参与方启动、执行时 Seata RM 相关的日志,具体内容以实际使用的 Seata 版本为准。
# 5.4 参与方报错了,全局事务却提交了
发起方没有检查 Feign 调用的返回结果。项目的 RPC 接口会将参与方的异常转换成 CommonResult 的错误码返回,需要调用 getCheckedData() 或 checkError() 重新抛出,参见《4.1 全局事务的写法》小节。另外,也排查入口方法是否添加了 @Transactional、异常是否被 try catch 吞掉。
# 5.5 启动日志出现 Seata auto-agent detected 警告
seata.enable-auto-data-source-proxy 没有设置为 false,Dynamic Datasource 的路由数据源又被 Seata 代理了一次,按《3.4 添加配置》小节关闭即可。
注意,Dynamic Datasource 4.6.0 的这个检查依赖 Seata 旧版的类名,在 Seata 2.6 中可能不会打印警告,所以必须显式配置 false,不能依赖该警告判断。
# 5.6 回滚失败,日志出现 Has dirty records when undo
一阶段提交后、二阶段回滚前,这条记录被不在全局事务中的 SQL 修改了,Seata 无法安全地恢复数据,参见《4.4 隔离性》小节。
这是不可重试的回滚失败,与网络、数据库或参与方服务暂时不可用等可以自动重试的失败不同:Seata Server 会停止自动重试,全局事务以回滚失败结束,等待不会自动恢复,Seata Server 中也不一定还保留可以操作的全局事务。处理方式:
- 在报错分支所在服务的业务库中,根据日志中的
xid、branchId查询undo_log,该分支的记录会保留,其中有一阶段的修改前、修改后镜像。 - 对照镜像与当前数据,人工核对、修复业务数据。其它分支可能已经回滚成功,需要按业务整体核对。
- 确认修复完成后,再删除这条全局事务对应的
undo_log记录。
# 5.7 Druid 的版本变成了 1.2.25
seata-spring-boot-starter 没有排除 com.alibaba:druid,参见《3.3 添加依赖》小节。可以通过 mvn dependency:tree -Dincludes=com.alibaba:druid 查看实际使用的版本。
# 5.8 报错 No bean found of type interface feign.codec.Encoder
例如说 IllegalStateException: No bean found of type interface feign.codec.Encoder for demoSeataAccountApi(发起方 system-server)、... for system-server(参与方 infra-server 的 TenantCommonApi),出现在 master-jdk25 分支使用 Feign 客户端的服务启动时。
该服务的 classpath 中有 org.springframework.data.domain.Pageable,却缺少 spring-boot-data-commons 模块的 DataWebProperties,OpenFeign 5.0.2 的两个 Encoder 都不会创建。按《3.3 添加依赖》小节的 ⑤,为报错的服务添加 dependencies-jdk25.xml 中的 spring-boot-data-commons 依赖。可以通过 mvn dependency:tree -Dincludes=org.springframework.boot:spring-boot-data-commons 确认是否已经存在。