Commit 75b5d305a2ded580fba833ec35d29753814ca9a7

Authored by YunaiV
1 parent 94c6863e

feat(infra): 增加可选的 Seata AT 分布式事务集成材料

script/seata/README.md 0 → 100644
  1 +# 分布式事务(可选接入)
  2 +
  3 +基于 [Apache Seata](https://seata.apache.org/) 2.6.0 AT 模式的可选接入材料。项目默认**不启用**:本目录不是 Maven 模块,不参与默认构建,主工程的依赖、配置、业务表都不受影响。
  4 +
  5 +接入后,`master`、`slave` 数据源保持不变、不参与全局事务,只有示例的 `seata_order`、`seata_account` 两个数据源由 Dynamic Datasource 包装 Seata 代理。示例 `DemoSeataTradeService#placeOrder` 在同一个进程中,先在订单库创建订单、再在账户库扣减余额,两个库的操作要么都成功,要么都回滚。
  6 +
  7 +Seata Server 的启动、配置、验证及使用限制,请阅读项目文档《分布式事务》,本文只列出材料及分支适用范围:
  8 +
  9 +- 单体项目 ruoyi-vue-pro:<https://doc.iocoder.cn/distributed-transaction/>
  10 +- 微服务项目 yudao-cloud:<https://cloud.iocoder.cn/distributed-transaction/>
  11 +
  12 +## 1. 材料清单
  13 +
  14 +| 文件 | 说明 | 目标位置 |
  15 +| --- | --- | --- |
  16 +| `sql/mysql/demo_seata.sql` | 创建 2 个演示库,各一张示例表 + `undo_log` 表 | 在 MySQL 中执行 |
  17 +| `dependencies.xml` | Maven 依赖片段 | `yudao-module-system/pom.xml`(全局事务入口所在的模块,`yudao-server` 通过已有的模块依赖传递获得) |
  18 +| `seata.yaml` | `seata_order`、`seata_account` 数据源,以及 Seata 客户端的配置片段 | 合并到 `yudao-server` 的 `application-{profile}.yaml`;`seata: false` 只追加到已经存在的数据源,没有 `slave` 时不要复制 `slave` 段 |
  19 +| `src/main/java/.../system/seatademo/` | 订单 DO、Mapper、Service,以及全局事务入口 `DemoSeataTradeService` | `yudao-module-system/src/main/java` 的同名包 |
  20 +| `src/main/java/.../infra/seatademo/` | 账户 DO、Mapper、Service | `yudao-module-infra/src/main/java` 的同名包 |
  21 +
  22 +订单放在 `yudao-module-system`、账户放在 `yudao-module-infra`,是因为 `yudao-module-system` 已经依赖 `yudao-module-infra`,可以直接调用账户 Service;与微服务项目 yudao-cloud 中「`system-server` 发起、`infra-server` 参与」的示例保持一致。
  23 +
  24 +## 2. 分支适用范围
  25 +
  26 +目前只有 `master-jdk17` 分支提供本目录的材料;`master`、`master-jdk25` 分支请先从 `master-jdk17` 分支的 `script/seata/` 获取,同步到对应分支后,也可以直接从当前分支获取。
  27 +
  28 +本目录的材料适用于 `master`(JDK 8 + Spring Boot 2.7.18)、`master-jdk17`(Spring Boot 3.5.15)、`master-jdk25`(Spring Boot 4.1.2)三个分支,接入步骤相同,兼容要点如下:
  29 +
  30 +| 要点 | 说明 |
  31 +| --- | --- |
  32 +| Seata 坐标 | 只使用 `org.apache.seata:seata-spring-boot-starter:2.6.0`,它自带 Dynamic Datasource 4.6.0 需要的 `io.seata` 兼容类,不要再引入 `io.seata:*` 坐标 |
  33 +| Druid | 排除 Seata 传递的 druid 1.2.25,继续使用项目 Druid starter 提供的版本 |
  34 +| Jackson 2 | Seata 的 undo_log 序列化需要 `jackson-databind`、`jackson-datatype-jsr310`;`master-jdk25` 的 Spring Boot 4 默认使用 Jackson 3,必须添加,其它分支添加后版本不变 |
  35 +| 示例代码 | 只使用 Java 8 语法,使用构造方法注入,不引用 `javax`、`jakarta` 包,三个分支无需修改 |
  36 +
  37 +`@GlobalTransactional` 注解的包名是 `org.apache.seata.spring.annotation`。全局事务入口必须是组件扫描的 `@Service` 等 Bean,Seata 2.6 不会增强 `@Bean` 方法定义的 Bean。
... ...
script/seata/dependencies.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<!--
  3 + 分布式事务(Apache Seata 2.6.0 AT 模式)的 Maven 依赖片段,不是 Maven 工程,不参与项目构建。
  4 +
  5 + 使用时,将下面的 <dependency> 复制到【包含 @GlobalTransactional 等 Seata 代码】的模块的 pom.xml 的 <dependencies> 中,
  6 + 保证该模块编译时能引用 Seata 的类,运行时也能通过模块依赖传递给最终启动的模块:
  7 + - 单体项目:yudao-module-system/pom.xml。示例的全局事务入口 DemoSeataTradeService 在该模块中,
  8 + yudao-server 已经依赖 yudao-module-system,会传递获得这些依赖;只加在 yudao-server/pom.xml 时,yudao-module-system 编译失败。
  9 + 账户示例所在的 yudao-module-infra 不直接引用 Seata 的类,不需要添加
  10 + - 微服务项目:参与全局事务的每个服务的 pom.xml(它们既是编译模块,也是最终启动的模块),示例是
  11 + yudao-module-system/yudao-module-system-server/pom.xml(发起方)、yudao-module-infra/yudao-module-infra-server/pom.xml(参与方)
  12 +
  13 + 三个分支(master、master-jdk17、master-jdk25)复制同样的内容,不需要按分支选择。
  14 + 除 Seata 外的依赖都不写版本号,继续由项目的 Spring Boot BOM 管理。
  15 +-->
  16 +<dependencies>
  17 +
  18 + <!-- ========== 1. Seata 客户端 ========== -->
  19 + <!--
  20 + 必须使用 org.apache.seata 的坐标,并显式指定版本:
  21 + - 微服务项目的 Spring Cloud Alibaba BOM 管理的版本是 2.5.0(master-jdk17、master-jdk25)或 io.seata 的 1.6.1(master),不是本方案的 2.6.0;
  22 + - 不要再引入 spring-cloud-starter-alibaba-seata、io.seata:* 等旧坐标,否则会与 seata-all 2.6.0 自带的 io.seata 兼容类重复。
  23 + Dynamic Datasource 4.6.0 代理数据源时使用的 io.seata.rm.datasource.DataSourceProxy,由 seata-all 2.6.0 自带的兼容类提供。
  24 + -->
  25 + <dependency>
  26 + <groupId>org.apache.seata</groupId>
  27 + <artifactId>seata-spring-boot-starter</artifactId>
  28 + <version>2.6.0</version>
  29 + <exclusions>
  30 + <!--
  31 + Seata 传递依赖 druid 1.2.25,路径比项目的 Druid starter 更短时,会把项目原有的 Druid 降级。
  32 + 排除后,Seata 的 SQL 解析继续使用项目 Druid starter 提供的 druid(版本由项目的 druid.version 管理)。
  33 + -->
  34 + <exclusion>
  35 + <groupId>com.alibaba</groupId>
  36 + <artifactId>druid</artifactId>
  37 + </exclusion>
  38 + </exclusions>
  39 + </dependency>
  40 +
  41 + <!-- ========== 2. Jackson 2:Seata 内部的 undo_log 序列化使用 ========== -->
  42 + <!--
  43 + Seata 默认使用 Jackson 2(com.fasterxml.jackson)序列化 undo_log 的前后镜像,但没有把它声明为传递依赖。
  44 + - master、master-jdk17 分支:Spring Boot 2.7.18、3.5.15 已经提供,这里的声明不会改变版本;
  45 + - master-jdk25 分支:Spring Boot 4.1.2 默认使用 Jackson 3(tools.jackson),必须显式添加。缺少 jackson-databind 时,
  46 + 第一次提交分支事务才报 NoClassDefFoundError: com/fasterxml/jackson/core/JsonProcessingException;
  47 + 缺少 jackson-datatype-jsr310 时报 NoClassDefFoundError: com/fasterxml/jackson/datatype/jsr310/JavaTimeModule。
  48 + 它们只给 Seata 内部使用,不会替换项目接口、Redis 等使用的 JSON 序列化。
  49 + -->
  50 + <dependency>
  51 + <groupId>com.fasterxml.jackson.core</groupId>
  52 + <artifactId>jackson-databind</artifactId>
  53 + </dependency>
  54 + <dependency>
  55 + <groupId>com.fasterxml.jackson.datatype</groupId>
  56 + <artifactId>jackson-datatype-jsr310</artifactId> <!-- undo_log 中的 LocalDateTime 等时间类型 -->
  57 + </dependency>
  58 +
  59 +</dependencies>
... ...
script/seata/seata.yaml 0 → 100644
  1 +# 分布式事务(Seata AT 模式)的配置片段,不能直接作为配置文件使用。
  2 +# 合并到 yudao-server 的 application-{profile}.yaml 中,例如说 application-local.yaml:
  3 +# - spring.datasource.dynamic 下的配置:逐项合并到已有的配置中,master、slave 原有的 url、username、password 等保持不变,只追加 seata: false
  4 +# - seata: false 只追加到当前 profile【已经存在】的数据源上。没有配置 slave 时,不要复制 slave 段,
  5 +# 否则会新建一个没有 url 的 slave 数据源,导致启动失败
  6 +# - seata 下的配置:新增到配置文件的顶层
  7 +# 数据库账号密码通过环境变量传入,不要把真实密码写入配置文件、提交到 Git。
  8 +spring:
  9 + datasource:
  10 + dynamic:
  11 + seata: true # 开启 Dynamic Datasource 的 Seata 支持,由它为每个数据源包装 Seata 的 DataSourceProxy
  12 + seata-mode: AT # 使用 AT 模式
  13 + datasource:
  14 + master:
  15 + seata: false # 只追加这一行:master 主库不参与 AT,不被 Seata 代理
  16 + slave:
  17 + seata: false # 只追加这一行:slave 从库不参与 AT,不被 Seata 代理;没有配置 slave 时,删除整个 slave 段
  18 + seata_order: # 分布式事务示例的订单库,只有 @DS("seata_order") 的 Mapper、Service 使用
  19 + url: ${YUDAO_SEATA_ORDER_URL:jdbc:mysql://127.0.0.1:3306/yudao_seata_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true}
  20 + username: ${YUDAO_SEATA_DB_USERNAME:root}
  21 + password: ${YUDAO_SEATA_DB_PASSWORD:}
  22 + seata: true # 参与 AT:被 Seata 代理,本地事务提交时注册分支、写入 undo_log
  23 + seata_account: # 分布式事务示例的账户库,只有 @DS("seata_account") 的 Mapper、Service 使用
  24 + url: ${YUDAO_SEATA_ACCOUNT_URL:jdbc:mysql://127.0.0.1:3306/yudao_seata_account?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true}
  25 + username: ${YUDAO_SEATA_DB_USERNAME:root}
  26 + password: ${YUDAO_SEATA_DB_PASSWORD:}
  27 + seata: true # 参与 AT:被 Seata 代理,本地事务提交时注册分支、写入 undo_log
  28 +
  29 +seata:
  30 + enabled: true
  31 + application-id: ${spring.application.name}
  32 + tx-service-group: yudao_seata_demo_group # 事务分组,与下面的 vgroup-mapping 对应
  33 + enable-auto-data-source-proxy: false # 必须关闭:数据源已经由 Dynamic Datasource 代理,避免双重代理
  34 + client:
  35 + http:
  36 + interceptor-enabled: false # 单体项目不需要从 HTTP 请求头 TX_XID 接收 XID;关闭后,外部请求不能通过该请求头加入全局事务
  37 + service:
  38 + vgroup-mapping:
  39 + yudao_seata_demo_group: default # 事务分组 -> Seata Server 集群名
  40 + grouplist:
  41 + default: ${YUDAO_SEATA_SERVER_ADDR:127.0.0.1:18091} # Seata Server 地址,端口与启动 Seata Server 时的 SEATA_PORT 一致
  42 + registry:
  43 + type: file # 直连上面的 grouplist,仅用于本机演示
  44 + config:
  45 + type: file
... ...
script/seata/sql/mysql/demo_seata.sql 0 → 100644
  1 +-- ----------------------------
  2 +-- 分布式事务示例:2 个独立的演示库,各自包含一张业务表 + Seata AT 模式的 undo_log 表
  3 +--
  4 +-- 1. yudao_seata_order:订单库,对应 seata_order 数据源(单体:yudao-server;微服务:system-server 发起方)
  5 +-- yudao_seata_account:账户库,对应 seata_account 数据源(单体:yudao-server;微服务:infra-server 参与方)
  6 +-- 2. 本脚本使用「库名.表名」,可以在同一个 MySQL 实例上直接执行,可重复执行;
  7 +-- 如果两个库在不同的 MySQL 实例上,则分别执行对应库的语句
  8 +-- 3. undo_log 必须和业务表在同一个库中,Seata Server 不需要这张表
  9 +-- 4. 不会修改 master 主库,也不会删除已有的库、表、数据
  10 +-- ----------------------------
  11 +
  12 +CREATE DATABASE IF NOT EXISTS `yudao_seata_order` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  13 +CREATE DATABASE IF NOT EXISTS `yudao_seata_account` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  14 +
  15 +-- ----------------------------
  16 +-- 订单库:yudao_seata_order
  17 +-- ----------------------------
  18 +CREATE TABLE IF NOT EXISTS `yudao_seata_order`.`demo_seata_order` (
  19 + `id` bigint NOT NULL COMMENT '订单编号(由调用方传入,作为幂等主键)',
  20 + `user_id` bigint NOT NULL COMMENT '用户编号',
  21 + `amount` int NOT NULL COMMENT '订单金额,单位:分',
  22 + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
  23 + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  24 + `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
  25 + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  26 + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  27 + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
  28 + PRIMARY KEY (`id`) USING BTREE,
  29 + KEY `idx_user_id` (`user_id`) USING BTREE
  30 +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '分布式事务示例订单表';
  31 +
  32 +-- ----------------------------
  33 +-- 账户库:yudao_seata_account
  34 +-- ----------------------------
  35 +CREATE TABLE IF NOT EXISTS `yudao_seata_account`.`demo_seata_account` (
  36 + `id` bigint NOT NULL COMMENT '账户编号',
  37 + `user_id` bigint NOT NULL COMMENT '用户编号',
  38 + `balance` int NOT NULL DEFAULT 0 COMMENT '余额,单位:分',
  39 + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
  40 + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  41 + `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
  42 + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  43 + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  44 + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
  45 + PRIMARY KEY (`id`) USING BTREE,
  46 + UNIQUE KEY `uk_tenant_user` (`tenant_id`, `user_id`) USING BTREE
  47 +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '分布式事务示例账户表';
  48 +
  49 +-- 演示账户:租户 1、用户 1,初始余额 100 分;已存在时不覆盖(INSERT IGNORE),只影响本演示库
  50 +INSERT IGNORE INTO `yudao_seata_account`.`demo_seata_account` (`id`, `user_id`, `balance`, `tenant_id`) VALUES (1, 1, 100, 1);
  51 +
  52 +-- ----------------------------
  53 +-- undo_log:AT 模式的回滚日志,两个演示库各一张
  54 +--
  55 +-- 表结构来自 Apache Seata 2.6.0 的 script/client/at/db/mysql.sql:
  56 +-- https://github.com/apache/incubator-seata/blob/v2.6.0/script/client/at/db/mysql.sql
  57 +-- Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0
  58 +-- (http://www.apache.org/licenses/LICENSE-2.0)。
  59 +-- 与官方脚本的差异:官方使用单独的 ALTER TABLE 添加 ix_log_created 索引,这里写入 CREATE TABLE,便于重复执行;字段、索引保持一致。
  60 +-- ----------------------------
  61 +CREATE TABLE IF NOT EXISTS `yudao_seata_order`.`undo_log`
  62 +(
  63 + `branch_id` BIGINT NOT NULL COMMENT 'branch transaction id',
  64 + `xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id',
  65 + `context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
  66 + `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
  67 + `log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
  68 + `log_created` DATETIME(6) NOT NULL COMMENT 'create datetime',
  69 + `log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime',
  70 + UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`),
  71 + KEY `ix_log_created` (`log_created`)
  72 +) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
  73 +
  74 +CREATE TABLE IF NOT EXISTS `yudao_seata_account`.`undo_log`
  75 +(
  76 + `branch_id` BIGINT NOT NULL COMMENT 'branch transaction id',
  77 + `xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id',
  78 + `context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
  79 + `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
  80 + `log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
  81 + `log_created` DATETIME(6) NOT NULL COMMENT 'create datetime',
  82 + `log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime',
  83 + UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`),
  84 + KEY `ix_log_created` (`log_created`)
  85 +) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
... ...
script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.infra.seatademo;
  2 +
  3 +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
  4 +import com.baomidou.mybatisplus.annotation.TableId;
  5 +import com.baomidou.mybatisplus.annotation.TableName;
  6 +import lombok.*;
  7 +
  8 +/**
  9 + * 分布式事务示例账户 DO
  10 + *
  11 + * 位于 yudao_seata_account 演示库,对应 seata_account 数据源
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@TableName("demo_seata_account")
  16 +@Data
  17 +@EqualsAndHashCode(callSuper = true)
  18 +@ToString(callSuper = true)
  19 +@Builder
  20 +@NoArgsConstructor
  21 +@AllArgsConstructor
  22 +public class DemoSeataAccountDO extends TenantBaseDO {
  23 +
  24 + /**
  25 + * 账户编号
  26 + */
  27 + @TableId
  28 + private Long id;
  29 + /**
  30 + * 用户编号
  31 + *
  32 + * 同一个租户下唯一
  33 + */
  34 + private Long userId;
  35 + /**
  36 + * 余额,单位:分
  37 + */
  38 + private Integer balance;
  39 +
  40 +}
... ...
script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.infra.seatademo;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  4 +import com.baomidou.dynamic.datasource.annotation.DS;
  5 +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
  6 +import org.apache.ibatis.annotations.Mapper;
  7 +
  8 +/**
  9 + * 分布式事务示例账户 Mapper
  10 + *
  11 + * 在 Mapper 上添加 @DS 注解,保证 demo_seata_account 表的 SQL 都使用 seata_account 数据源
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Mapper
  16 +@DS(DemoSeataAccountMapper.DATA_SOURCE)
  17 +public interface DemoSeataAccountMapper extends BaseMapperX<DemoSeataAccountDO> {
  18 +
  19 + /**
  20 + * 账户库的数据源名,对应 spring.datasource.dynamic.datasource.seata_account 配置项
  21 + */
  22 + String DATA_SOURCE = "seata_account";
  23 +
  24 + default DemoSeataAccountDO selectByUserId(Long userId) {
  25 + return selectOne(DemoSeataAccountDO::getUserId, userId);
  26 + }
  27 +
  28 + /**
  29 + * 扣减余额
  30 + *
  31 + * @param userId 用户编号
  32 + * @param amount 扣减金额,必须大于 0
  33 + * @return 更新条数;余额不足或账户不存在时为 0
  34 + */
  35 + default int updateBalanceDecr(Long userId, Integer amount) {
  36 + LambdaUpdateWrapper<DemoSeataAccountDO> updateWrapper = new LambdaUpdateWrapper<DemoSeataAccountDO>()
  37 + .setSql(" balance = balance - " + amount)
  38 + .eq(DemoSeataAccountDO::getUserId, userId)
  39 + .ge(DemoSeataAccountDO::getBalance, amount); // 条件扣减:余额足够才更新,避免扣成负数
  40 + return update(null, updateWrapper);
  41 + }
  42 +
  43 +}
... ...
script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.infra.seatademo;
  2 +
  3 +import com.baomidou.dynamic.datasource.annotation.DS;
  4 +import lombok.RequiredArgsConstructor;
  5 +import org.springframework.stereotype.Service;
  6 +import org.springframework.transaction.annotation.Transactional;
  7 +import org.springframework.util.Assert;
  8 +
  9 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
  10 +
  11 +/**
  12 + * 分布式事务示例账户 Service
  13 + *
  14 + * 作为全局事务中的一个分支:在 seata_account 数据源上执行本地事务,提交时由 Seata 注册分支、写入 undo_log
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@Service
  19 +@DS(DemoSeataAccountMapper.DATA_SOURCE) // 先切换到 seata_account 数据源,再开启 @Transactional 事务
  20 +@RequiredArgsConstructor // 使用构造方法注入,避免 JDK 8 与 JDK 17+ 分支的 @Resource 包名不同
  21 +public class DemoSeataAccountService {
  22 +
  23 + private final DemoSeataAccountMapper demoSeataAccountMapper;
  24 +
  25 + @Transactional(rollbackFor = Exception.class)
  26 + public void deductBalance(Long userId, Integer amount) {
  27 + Assert.notNull(userId, "用户编号不能为空");
  28 + Assert.isTrue(amount != null && amount > 0, "扣减金额必须大于 0");
  29 + int updateCount = demoSeataAccountMapper.updateBalanceDecr(userId, amount);
  30 + if (updateCount == 0) {
  31 + // 抛出异常,不要吞掉:本地事务回滚,并由全局事务的发起方回滚其它分支
  32 + throw invalidParamException("用户({}) 的账户不存在或余额不足", userId);
  33 + }
  34 + }
  35 +
  36 + public DemoSeataAccountDO getAccount(Long userId) {
  37 + return demoSeataAccountMapper.selectByUserId(userId);
  38 + }
  39 +
  40 +}
... ...
script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.seatademo;
  2 +
  3 +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
  4 +import com.baomidou.mybatisplus.annotation.IdType;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.*;
  8 +
  9 +/**
  10 + * 分布式事务示例订单 DO
  11 + *
  12 + * 位于 yudao_seata_order 演示库,对应 seata_order 数据源
  13 + *
  14 + * @author 芋道源码
  15 + */
  16 +@TableName("demo_seata_order")
  17 +@Data
  18 +@EqualsAndHashCode(callSuper = true)
  19 +@ToString(callSuper = true)
  20 +@Builder
  21 +@NoArgsConstructor
  22 +@AllArgsConstructor
  23 +public class DemoSeataOrderDO extends TenantBaseDO {
  24 +
  25 + /**
  26 + * 订单编号
  27 + *
  28 + * 由调用方传入,作为幂等主键:重复的下单请求会因主键冲突失败,不会重复扣减余额
  29 + */
  30 + @TableId(type = IdType.INPUT)
  31 + private Long id;
  32 + /**
  33 + * 用户编号
  34 + */
  35 + private Long userId;
  36 + /**
  37 + * 订单金额,单位:分
  38 + */
  39 + private Integer amount;
  40 +
  41 +}
... ...
script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.seatademo;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  4 +import com.baomidou.dynamic.datasource.annotation.DS;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +
  7 +/**
  8 + * 分布式事务示例订单 Mapper
  9 + *
  10 + * 在 Mapper 上添加 @DS 注解,保证 demo_seata_order 表的 SQL 都使用 seata_order 数据源
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@Mapper
  15 +@DS(DemoSeataOrderMapper.DATA_SOURCE)
  16 +public interface DemoSeataOrderMapper extends BaseMapperX<DemoSeataOrderDO> {
  17 +
  18 + /**
  19 + * 订单库的数据源名,对应 spring.datasource.dynamic.datasource.seata_order 配置项
  20 + */
  21 + String DATA_SOURCE = "seata_order";
  22 +
  23 +}
... ...
script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.seatademo;
  2 +
  3 +import com.baomidou.dynamic.datasource.annotation.DS;
  4 +import lombok.RequiredArgsConstructor;
  5 +import org.springframework.stereotype.Service;
  6 +import org.springframework.transaction.annotation.Transactional;
  7 +import org.springframework.util.Assert;
  8 +
  9 +/**
  10 + * 分布式事务示例订单 Service
  11 + *
  12 + * 作为全局事务中的一个分支:在 seata_order 数据源上执行本地事务,提交时由 Seata 注册分支、写入 undo_log
  13 + *
  14 + * @author 芋道源码
  15 + */
  16 +@Service
  17 +@DS(DemoSeataOrderMapper.DATA_SOURCE) // 先切换到 seata_order 数据源,再开启 @Transactional 事务
  18 +@RequiredArgsConstructor // 使用构造方法注入,避免 JDK 8 与 JDK 17+ 分支的 @Resource 包名不同
  19 +public class DemoSeataOrderService {
  20 +
  21 + private final DemoSeataOrderMapper demoSeataOrderMapper;
  22 +
  23 + @Transactional(rollbackFor = Exception.class)
  24 + public void createOrder(Long id, Long userId, Integer amount) {
  25 + Assert.notNull(id, "订单编号不能为空");
  26 + Assert.notNull(userId, "用户编号不能为空");
  27 + Assert.isTrue(amount != null && amount > 0, "订单金额必须大于 0");
  28 + DemoSeataOrderDO order = DemoSeataOrderDO.builder().id(id).userId(userId).amount(amount).build();
  29 + demoSeataOrderMapper.insert(order); // 主键冲突时抛出 DuplicateKeyException,所在的全局事务回滚
  30 + }
  31 +
  32 + public DemoSeataOrderDO getOrder(Long id) {
  33 + return demoSeataOrderMapper.selectById(id);
  34 + }
  35 +
  36 +}
... ...
script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataTradeService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.seatademo;
  2 +
  3 +import cn.iocoder.yudao.module.infra.seatademo.DemoSeataAccountService;
  4 +import lombok.RequiredArgsConstructor;
  5 +import org.apache.seata.spring.annotation.GlobalTransactional;
  6 +import org.springframework.stereotype.Service;
  7 +
  8 +/**
  9 + * 分布式事务示例下单 Service,即全局事务的入口(发起方)
  10 + *
  11 + * 单体项目中,订单、账户是同一个进程的两个数据源(seata_order、seata_account),直接调用账户 Service
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Service // 使用 @Service 组件扫描注册:Seata 2.6 不会增强 @Bean 方法定义的 Bean,@GlobalTransactional 会失效
  16 +@RequiredArgsConstructor // 使用构造方法注入,避免 JDK 8 与 JDK 17+ 分支的 @Resource 包名不同
  17 +public class DemoSeataTradeService {
  18 +
  19 + private final DemoSeataOrderService demoSeataOrderService;
  20 + private final DemoSeataAccountService demoSeataAccountService;
  21 +
  22 + /**
  23 + * 下单:创建订单 + 扣减余额,两个数据源的操作要么都成功,要么都回滚
  24 + *
  25 + * 注意,这里只添加 @GlobalTransactional,【不要】添加 @Transactional:
  26 + * 本地事务会在开始时固定一个数据源的连接,导致后续的 @DS 切换失效,两个分支的事务由各自的 Service 开启
  27 + *
  28 + * @param orderId 订单编号,由调用方生成,作为幂等主键
  29 + * @param userId 用户编号
  30 + * @param amount 订单金额,单位:分
  31 + */
  32 + @GlobalTransactional(name = "demo-seata-place-order", rollbackFor = Exception.class)
  33 + public void placeOrder(Long orderId, Long userId, Integer amount) {
  34 + // 1. 创建订单:分支一,本地事务提交后,订单已经写入 seata_order 库,同时记录 undo_log
  35 + demoSeataOrderService.createOrder(orderId, userId, amount);
  36 + // 2. 扣减余额:分支二;余额不足时抛出异常,Seata 根据 undo_log 删除分支一已经提交的订单
  37 + demoSeataAccountService.deductBalance(userId, amount);
  38 + }
  39 +
  40 +}
... ...