Commit 75b5d305a2ded580fba833ec35d29753814ca9a7
1 parent
94c6863e
feat(infra): 增加可选的 Seata AT 分布式事务集成材料
Showing
11 changed files
with
489 additions
and
0 deletions
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 | +} |