整理资料下载:
链接: https://pan.baidu.com/s/16_LrfRPkbAjTMqaFNHz_lg?pwd=e3jy
提取码: e3jy
–来自百度网盘超级会员v6的分享

一、TCC模式

前两种XA和AT都是加锁,加锁就会有性能的损耗,TCC模式不需要加锁

1、TCC模式

(1)、TCC模式简介

TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复

TCC模式通过将事务拆分为Try、Confirm和Cancel三个阶段,使得每个参与者可以控制自己的操作和资源,从而实现了分布式事务的可靠性和一致性,它要求参与者实现相应的接口和逻辑,确保Try和Cancel操作是幂等的,以处理重试和故障恢复情况

需要实现三个方法

  1. Try阶段(资源的检测和预留)

    在这个阶段,参与者尝试预留或锁定资源并执行必要的前置检查

    如果所有参与者的Try操作都成功,表示资源可用并进入下一阶段

    如果有任何一个参与者的Try操作失败,表示资源不可用或发生冲突,事务将中止

  2. Confirm阶段(完成资源操作业务)

    在这个阶段参与者进行最终的确认操作,将资源真正提交或应用到系统中

    如果所有参与者的Confirm操作都成功则事务完成,提交操作得到确认

    如果有任何一个参与者的Confirm操作失败则事务将进入Cancel阶段

  3. Cancel阶段(预留资源释放)

    在这个阶段参与者进行回滚或取消操作,将之前尝试预留或锁定的资源恢复到原始状态

    如果所有参与者的Cancel操作都成功则事务被取消,资源释放

    如果有任何一个参与者的Cancel操作失败,可能需要进行补偿或人工介入来恢复系统一致性

(2)、TCC业务示例

假设账户A原来余额是100,需要余额扣减30元

  • 阶段一(Try)

    检查余额是否充足,如果充足则冻结金额增加30元,可用余额扣除30

    初始余额

    在这里插入图片描述

    余额充足,可以冻结

    在这里插入图片描述

    此时总金额 = 冻结金额 + 可用金额,数量依然是100不变

    事务直接提交无需等待其它事务

  • 阶段二(Confirm)

    假如要提交(Confirm),则冻结金额扣减30

    确认可以提交,不过之前可用金额已经扣减过了,这里只要清除冻结金额就好了

    在这里插入图片描述

    此时总金额 = 冻结金额 + 可用金额 = 0 + 70 = 70元

  • 阶段二(Canncel)

    如果要回滚(Cancel),则冻结金额扣减30,可用余额增加30

    需要回滚那么就要释放冻结金额,恢复可用金额

    在这里插入图片描述

(3)、TCC优缺点

  • 优点

    1. 一阶段完成直接提交事务,释放数据库资源,性能好
    1. 相比AT模型,无需生成快照,无需使用全局锁,性能最强

    2. 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库

  • 缺点

    1. 有代码侵入,需要人为编写try、Confirm和Cancel接口,太麻烦
    1. 软状态,事务是最终一致

    2. 需要考虑Confirm和Cancel的失败情况,做好幂等处理

(4)、TCC的空回滚

当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作

在未执行try操作时先执行了cancel操作,这时cancel不能做回滚,就是空回滚

在这里插入图片描述

执行cancel操作时应当判断try是否已经执行,如果尚未执行则应该空回滚

(5)、TCC的事务悬挂

对于已经空回滚的业务,之前被阻塞的try操作恢复,如果以后继续执行try,就永远不可能confirm或cancel ,事务一直处于中间状态,这就是业务悬挂

执行try操作时,应当判断cancel是否已经执行过了,如果已经执行,应当阻止空回滚后的try操作,避免悬挂

在这里插入图片描述

2、Seata的TCC模式

(1)、Seata的TCC模式简介

Seata的TCC模式是在TCC模式基础上进行了扩展和优化的实现

Seata引入了Seata Server作为事务协调器,集中管理分布式事务的控制逻辑

Seata的TCC模式还提供了分布式事务日志和分布式锁等功能,以增强事务的可靠性和性能

Seata的TCC模式可以更方便地集成到应用中,并提供了更好的事务管理和监控能力

  • Try(资源检查和预留)

    判断是否有可用数据,足够则冻结可用数据

  • Confirm(业务执行和提交)

    完成资源的操作业务:要求try成功则confirm一定要成功

  • Cancel(预留资源的释放)

    预留资源释放,可以理解为try方向操作

    Seata中的TCC模型依然延续之前的事务架构

在这里插入图片描述

(2)、Seata的TCC常用注解

  • @LocalTCC注解

    @LocalTCC 适用于SpringCloud+Feign模式下的TCC

    该注解需要添加到 Try 方法所在的接口上,表示实现该接口的类被 seata 来管理

    seata 根据事务的状态,自动调用我们定义的方法

    1. 如果try()没问题则调用 commit() 方法
    1. 如果try()有问题调用 rollback() 方法

    【示例】

    @LocalTCC
    public interface AccountService {
    
    }
    
  • @TwoPhaseBusinessAction注解

    该注解用在接口的 Try 方法上,声明 TCC 需要执行方法

    其中三个方法最重要

    1. name()

      用于指定当前业务的Try逻辑对应的方法名称

      @TwoPhaseBusinessAction注解中的name属性要与当前方法名一致

      全局唯一

    2. commitMethod()

      二阶段确认方法(默认方法名commit);

      @TwoPhaseBusinessAction注解中的commitMethod属性要与提交方法名一致

    3. rollbackMethod()

      二阶段取消方法(默认方法名rollback);

      @TwoPhaseBusinessAction注解中的rollbackMethod属性要与回滚方法名一致

    import io.seata.rm.tcc.api.BusinessActionContext;
    import io.seata.rm.tcc.api.BusinessActionContextParameter;
    import io.seata.rm.tcc.api.LocalTCC;
    import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
    
    @LocalTCC
    public interface AccountService {
        /**
         * try逻辑
         *
         * @param userId 用户ID
         * @param money  扣减金额
         * @TwoPhaseBusinessAction注解中的name属性要与当前方法名一致,用于指定Try逻辑对应的方法
         * @TwoPhaseBusinessAction注解中的commitMethod属性要与提交方法名一致,用于指定提交逻辑对应的方法
         * @TwoPhaseBusinessAction注解中的rollbackMethod属性要与回滚方法名一致,用于指定回滚逻辑对应的方法
         */
        @TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
        void deduct(@BusinessActionContextParameter(paramName = "userId") Integer userId,
                    @BusinessActionContextParameter(paramName = "productId") Integer productId,
                    @BusinessActionContextParameter(paramName = "count") Integer count,
                    @BusinessActionContextParameter(paramName = "money") Double money);
    
        /**
         * 二阶段confirm确认方法、可以另命名,但要保证与commitMethod一致
         *
         * @param
         * @param context 上下文可以传递try方法里面的参数
         * @return boolean 执行是否成功
         */
        boolean confirm(BusinessActionContext context);
    
        /**
         * 二阶段回滚方法,要保证与rollbackMethod一致
         *
         * @param context
         * @return
         */
        boolean cancel(BusinessActionContext context);
    }
    
  • @BusinessActionContextParameter

    该注解用来修饰 try() 方法的入参,被修饰的入参可以在 commit() 方法和 rollback() 方法中通过 BusinessActionContext 获取

  • BusinessActionContext

    BusinessActionContext 是 seata tcc 的事务上下文,用于存放 tcc 事务的一些数据

    在try阶段被BusinessActionContextParameter注解的参数都可以获取到

3、使用示例

(1)、业务说明

  • try业务

    记录冻结金额和事务状态到account_freeze表

    扣减account表可用金额

  • Confirm业务

    根据xid删除account_freeze表的冻结记录

  • Cancel业务

    修改account_freeze表,冻结金额为0,state为2

    修改account表,恢复可用金额

  • 如何判断是否空回滚

    Cancel业务中,根据xid查询account_freeze,如果为null则说明try还没做,需要空回滚

  • 如何避免业务悬挂

    Try业务中,根据xid查询account_freeze,如果已经存在则证明Cancel已经执行,拒绝执行try业务

(2)、增加数据表

为了实现空回滚、防止业务悬挂以及幂等性要求,必须在数据库记录冻结金额的同时,记录当前事务ID和执行状态,所以增加两张表

xid:是全局事务id

user_id:用来记录用户信息

freeze_money:用来记录用户冻结金额

freeze_num:用来记录产品剩余数量

state:用来记录事务状态

  • 账户冻结表

    SET NAMES utf8mb4;
    SET FOREIGN_KEY_CHECKS = 0;
    
    -- ----------------------------
    -- Table structure for account_freeze
    -- ----------------------------
    DROP TABLE IF EXISTS `account_freeze`;
    CREATE TABLE `account_freeze`  (
                                       `xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
                                       `user_id` int NOT NULL,
                                       `freeze_money` double UNSIGNED NOT NULL DEFAULT 0,
                                       `state` int NOT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
                                       PRIMARY KEY (`xid`) USING BTREE
    ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
    
    SET FOREIGN_KEY_CHECKS = 1;
    
  • 产品冻结表

    SET NAMES utf8mb4;
    SET FOREIGN_KEY_CHECKS = 0;
    
    -- ----------------------------
    -- Table structure for product_freeze
    -- ----------------------------
    DROP TABLE IF EXISTS `product_freeze`;
    CREATE TABLE `product_freeze`  (
                                       `xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
                                       `user_id` int NOT NULL,
                                       `freeze_num` int UNSIGNED NOT NULL DEFAULT 0,
                                       `state` int NOT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
                                       PRIMARY KEY (`xid`) USING BTREE
    ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
    
    SET FOREIGN_KEY_CHECKS = 1;
    

(3)、父工程

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.8</version>
        <relativePath/>
    </parent>

    <groupId>com.seata</groupId>
    <artifactId>SeataTCCDemo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <!--1.打包方式设置为pom-->
    <packaging>pom</packaging>

    <!--2.配置子模块信息-->
    <modules>
        <module>account-service</module>
        <module>product-service</module>
    </modules>

    <!--3.定义版本信息-->
    <properties>
        <java.version>21</java.version>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <spring-cloud.version>2023.0.3</spring-cloud.version>
        <spring-cloud-alibaba.version>2023.0.3.2</spring-cloud-alibaba.version>
    </properties>

    <!--4.这里定义公共的依赖-->
    <dependencies>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.4.0</version>
        </dependency>

        <!--mybatis依赖-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.4</version>
        </dependency>

    </dependencies>

    <!--5.定义springcloud、springcloudalibba依赖管理-->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>3.3.8</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>1.18.36</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <!--6.引入构建工具-->
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

(4)、账户服务

  • pom

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <parent>
            <groupId>com.seata</groupId>
            <artifactId>SeataTCCDemo</artifactId>
            <version>1.0-SNAPSHOT</version>
        </parent>
    
        <groupId>com.account</groupId>
        <artifactId>account-service</artifactId>
    
        <dependencies>
            <!--feign依赖-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-openfeign</artifactId>
            </dependency>
            <!--负载均衡依赖-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-loadbalancer</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
    
            <!-- nacos 服务注册-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
            </dependency>
    
            <!-- nacos 配置中心-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
            </dependency>
    
            <!-- seata -->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
                <!--<exclusions>
                    <exclusion>
                        <groupId>io.seata</groupId>
                        <artifactId>seata-spring-boot-starter</artifactId>
                    </exclusion>
                </exclusions>-->
            </dependency>
            <!--<dependency>
                <groupId>io.seata</groupId>
                <artifactId>seata-spring-boot-starter</artifactId>
                <version>2.0.0</version>
            </dependency>-->
    
            <!--  seata kryo 序列化-->
            <!--<dependency>
                <groupId>io.seata</groupId>
                <artifactId>seata-serializer-kryo</artifactId>
                <version>2.0.0</version>
            </dependency>-->
            <!--mysql驱动-->
            <dependency>
                <groupId>com.mysql</groupId>
                <artifactId>mysql-connector-j</artifactId>
            </dependency>
            <!--mybatis依赖-->
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>3.0.4</version>
            </dependency>
            <!--lombok依赖-->
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
            </dependency>
        </dependencies>
    </project>
    
  • 配置文件

    重点就是seata客户端的配置

    • 配置配置中心获取集群名称

    • 配置注册中心,要与seata服务在一个命名空间以便服务发现

    #配置使用哪个环境,在真实项目中,一般分为多个环境,此处指定使用的是dev环境
    spring:
      profiles:
        active: dev
    ---
    
    ##指定本服务的端口号
    server:
      port: 8081
    
    spring:
      #设置当前应用的名称
      application:
        name: account-service
      #指定此处配置为dev环境
      profiles:
        active: dev
    
      #数据库连接配置
      datasource:
        url: jdbc:mysql://localhost:3306/account?characterEncoding=UTF-8&useSSL=false&useUnicode=true&serverTimezone=UTC
        username: root
        password: root
        driver-class-name: com.mysql.cj.jdbc.Driver
    
      # 设置从nacos加载的配置文件
      # 配置文件从"spring.cloud.nacos.config"模块里配置的nacos服务器的命名空间下读取
      config:
        import:
          #这里表示读取命名空间名称为nacos的、组名为DEV_GROUP的account.yml配置文件
          # 这里配置组名会覆盖掉"spring.cloud.nacos.config"里面设置的组名
          - optional:nacos:account.yml?group=DEV_GROUP
      cloud:
        # nacos相关的配置
        nacos:
          # 设置连接nacos的用户名、密码
          username: nacos
          password: nacos
          # nacos的配置中心设置#(这里连接的是nacos命名空间)
          config:
            # 配置中心地址
            # 注意config模块里面配置的地址与discovery模块里面配置的地址可以不相同,两者互不影响
            server-addr: localhost:8848
            # 配置文件后缀,即配置文件格式,目前只支持"properties"与"yml"两种格式
            file-extension: yaml
            # 配置文件存放的命名空间ID,这里是从命名空间为nacos的命名空间读取配置
            namespace: 231e01ec-912b-4529-9ff4-6bf792b38776
            # 设置默认的组名
            group: DEFAULT_GROUP
          # 配置注册中心(这里连接的是seata命名空间)
          discovery:
            # 配置注册中心地址
            #注意config模块里面配置的地址与discovery模块里面配置的地址可以不相同,二者互不影响
            server-addr: localhost:8848
            # 获取服务的命名空间ID
            namespace: c511f4d4-0205-4316-909a-d0827ade5435
            # 设置服务的分组名为SEATA_GROUP,因为seata的分组名也是SEATA_GROUP,需要保持一致
            group: SEATA_GROUP
    
    # seata客户端配置
    seata:
      # 设置seata使用哪种模式支持分布式事务(可选的值:XA、AT、TCC、SAGA)
      # data-source-proxy-mode: TCC
      # 应用ID(如果不配置则默认使用"spring.application.name"的值)
      # application-id: account-service
      enabled: true
      # 配置事务组名称,名字随便起
      # 根据这个获取TC服务的cluster名称
      tx-service-group: seata_demo
      # seata.service.vgroup-mapping下一定要有一个对"seata.tx-service-group: XXX"这个名字的映射
      # 注意分两种情况
      #情况1
      #   当没有配置"seata.config.nacos"时,需要在本配置文件中配置"seata.service.vgroup-mapping.自定义事务组名: 集群名"
      #   这里的自定义事务组名称就是上面配置的"seata.tx-service-group"的值,集群名称就是"SeataServer"服务器中
      #   的"seata.registry.nacos.cluster"中配置的集群名称
      #   也就是需要添加下面的配置
      service:
        vgroup-mapping:
          # 配置事务组与TC服务cluster的映射关系
          # key:对应"tx-service-group"中定义的事务组名称
          # value:对应SeataServer上的application.yml中seata.registry.nacos.cluster中配置的集群名称
          seata_demo: default
      # 情况2:
      #   当配置了"seata.config.nacos"的配置后,则"seata.service.vgroup-mapping.自定义事务组名: 集群名"的值从nacos中获取
      #   此时需要在对应的命名空间下(即"seata.config.nacos.namespace"中配置的命名空间)创建名称为"service.vgroup-mapping.自定义事务组名"的配置文件
      #   文件的格式为"text"格式,值为"SeataServer"服务器中的"seata.registry.nacos.cluster"中配置的集群名称,比如下面的"default"
    
      # seata连接nacos的配置
      config:
        # 配置seata的配置中心为nacos
        type: nacos
        #(这里连接的是nacos命名空间)
        nacos:
          # 这里设置seata服务需要从哪个nacos服务器上读取配置,可以与"spring.cloud.nacos.config"中配置的nacos地址不同
          server-addr: localhost:8848
          # 设置需要读取的命名空间的ID
          namespace: 231e01ec-912b-4529-9ff4-6bf792b38776
          # 设置配置文件的分组
          group: TEST_GROUP
          # 连接nacos的用户名
          username: nacos
          # 连接nacos的密码
          password: nacos
      # 配置注册项,配置内容与服务端保持一致(这里连接的是seata命名空间)
      registry:
        type: nacos
        nacos:
          # 设置连接的nacos服务器地址
          # 这里必须与SeataServer服务器连接的nacos地址一致,测试服务器中配置的命名空间为seata,这里也连接为seata空间才能实现服务发现
          server-addr: localhost:8848
          # 设置命名空间ID:这里必须与SeataServer服务器配置的命名空间一致
          namespace: c511f4d4-0205-4316-909a-d0827ade5435
          # TC服务在nacos中注册的服务名
          # 默认是seata-server,若没有修改则可以不配置
          application: seata-server
          # 设置服务所在的分组,这里必须与SeataServer服务器配置的分组名一致,默认就是SEATA_GROUP
          group: SEATA_GROUP
          # 连接nacos的用户名
          username: nacos
          # 连接nacos的密码
          password: nacos
    
    ##关闭驼峰
    #mybatis-plus:
    #  configuration:
    #    map-underscore-to-camel-case: true
    #    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
    #mybatis
    mybatis:
      #所有pojo类所在包的路径
      type-aliases-package: com.account.entity
      # xml映射文件所在的路径,一般用模糊匹配来指定最终的xml文件
      mapper-locations: classpath:mapper/*.xml #mapper映射文件
      configuration:
        #采用驼峰形式将数据表中以‘_’分隔的字段映射到java类的某个属性,比如表字段user_name可以映射为类里面的userName属性
        map-underscore-to-camel-case: true #支持驼峰映射
    
  • 控制器类

    添加feign注解开启feign功能

    package com.account;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.cloud.openfeign.EnableFeignClients;
    
    @SpringBootApplication
    @EnableFeignClients
    public class AccountApplication {
        public static void main(String[] args) {
            SpringApplication.run(AccountApplication.class, args);
        }
    
    }
    
  • 控制器类

    定义一个方法,方法调用模拟的testService中的方法

    package com.account.controller;
    
    import com.account.service.TestService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    
    @RestController
    @RequestMapping("/account")
    public class AccountController {
    
        @Autowired
        private TestService testService;
    
        /**
         * 修改用户账户信息
         *
         * @return
         */
        @RequestMapping("/deduct")
        public void deduct(int userId, int productId, int count, double money) {
            testService.deduct(userId, productId, count, money);
        }
    
    }
    
  • 业务类

    • 接口:模拟业务服务

      package com.account.service;
      
      public interface TestService {
          /**
           * 模拟业务Service
           * 1、扣减用户账户余额
           * 2、扣减库存
           *
           * @param userId    用户ID
           * @param productId 产品ID
           * @param count     购买产品数量
           * @param money     消费金额
           */
          void deduct(Integer userId,
                      Integer productId,
                      Integer count,
                      Double money);
      }
      
    • 实现类

      该方法模拟业务中的逻辑

      该方法是分布式事务的入口,需要在该方法上添加"@GlobalTransactional"开启全局事务

      package com.account.service;
      
      import io.seata.spring.annotation.GlobalTransactional;
      import lombok.extern.slf4j.Slf4j;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.stereotype.Service;
      
      @Service
      @Slf4j
      public class TestServiceImpl implements TestService {
      
          @Autowired
          AccountService accountService;
      
          /**
           * 模拟业务Service
           * 1、扣减用户账户余额
           * 2、扣减库存
           * <p>
           * 在方法上添加注解"@GlobalTransactional"用来开启全局事务
           *
           * @param userId    用户ID
           * @param productId 产品ID
           * @param count     购买产品数量
           * @param money     消费金额
           */
          @GlobalTransactional
          public void deduct(Integer userId,
                             Integer productId,
                             Integer count,
                             Double money) {
              // 调用用户账户服务来扣减金额
              accountService.deduct(userId, productId, count, money);
          }
      }
      
  • 业务事务类

    • 接口

      package com.account.service;
      
      import io.seata.rm.tcc.api.BusinessActionContext;
      import io.seata.rm.tcc.api.BusinessActionContextParameter;
      import io.seata.rm.tcc.api.LocalTCC;
      import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
      
      @LocalTCC
      public interface AccountService {
          /**
           * try逻辑
           *
           * @param userId 用户ID
           * @param money  扣减金额
           * @TwoPhaseBusinessAction注解中的name属性要与当前方法名一致,用于指定Try逻辑对应的方法
           * @TwoPhaseBusinessAction注解中的commitMethod属性要与提交方法名一致,用于指定提交逻辑对应的方法
           * @TwoPhaseBusinessAction注解中的rollbackMethod属性要与回滚方法名一致,用于指定回滚逻辑对应的方法
           */
          @TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
          void deduct(@BusinessActionContextParameter(paramName = "userId") Integer userId,
                      @BusinessActionContextParameter(paramName = "productId") Integer productId,
                      @BusinessActionContextParameter(paramName = "count") Integer count,
                      @BusinessActionContextParameter(paramName = "money") Double money);
      
          /**
           * 二阶段confirm确认方法、可以另命名,但要保证与commitMethod一致
           *
           * @param
           * @param context 上下文可以传递try方法里面的参数
           * @return boolean 执行是否成功
           */
          boolean confirm(BusinessActionContext context);
      
          /**
           * 二阶段回滚方法,要保证与rollbackMethod一致
           *
           * @param context
           * @return
           */
          boolean cancel(BusinessActionContext context);
      }
      
    • 实现类

      package com.account.service;
      
      import com.account.client.ProductService;
      import com.account.entity.Account;
      import com.account.entity.AccountFreeze;
      import com.account.mapper.AccountFreezeMapper;
      import com.account.mapper.AccountMapper;
      import io.seata.core.context.RootContext;
      import io.seata.rm.tcc.api.BusinessActionContext;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.beans.factory.annotation.Value;
      import org.springframework.cloud.context.config.annotation.RefreshScope;
      import org.springframework.stereotype.Service;
      import org.springframework.transaction.annotation.Transactional;
      
      import java.util.Date;
      
      @Service
      @RefreshScope
      public class AccountServiceImpl implements AccountService {
      
      
          @Value("${address.name}")
          private String name;
      
      
          @Autowired
          AccountMapper accountMapper;
      
          @Autowired
          AccountFreezeMapper accountFreezeMapper;
      
          @Autowired
          ProductService productService;
      
          /**
           * @param userId
           * @param money
           */
          @Override
          @Transactional
          public void deduct(Integer userId, Integer productId, Integer count, Double money) {
              //1、获取事务id
              String xid = RootContext.getXID();
      
              System.out.println("地址名称:" + name + ";事务ID:" + xid);
      
              //2、判断freeze中是否有冻结记录,如果有则一定是CANCEL执行过,需要要拒绝业务
              AccountFreeze oldFreeze = accountFreezeMapper.selectById(xid);
      
              if (oldFreeze != null) {
                  System.out.println("地址名称:" + name + ";CANCEL执行过,需要拒绝业务:");
      
                  //CANCEL执行过,需要拒绝业务
                  return;
              }
      
              //3、用户账户扣减可用余额
              Account account = new Account();
              account.setUserId(userId);
              account.setMoney(money);
              account.setUpdateDate(new Date());
              accountMapper.deduct(account);
      
              //4、冻结表记录冻结金额、事务状态
              AccountFreeze freeze = new AccountFreeze();
              freeze.setUserId(userId);
              freeze.setFreezeMoney(money);
              freeze.setState(AccountFreeze.State.TRY);
              freeze.setXid(xid);
              accountFreezeMapper.insert(freeze);
              System.out.println("地址名称:" + name + ";冻结完成");
              productService.deduct(productId, userId, count);
          }
      
          /**
           * 提交操作
           *
           * @param context 上下文可以传递try方法里面的参数
           * @return
           */
          @Override
          @Transactional
          public boolean confirm(BusinessActionContext context) {
              // 1、获取事务id
              String xid = context.getXid();
      
              //2、根据id删除冻结记录
              int count = accountFreezeMapper.deleteById(xid);
              System.out.println("地址名称:" + name + ";提交完成");
      
              return count == 1;
          }
      
          /**
           * 回滚操作
           *
           * @param context
           * @return
           */
          @Override
          @Transactional
          public boolean cancel(BusinessActionContext context) {
              // 1、获取事务ID
              String xid = context.getXid();
              //2、查询freeze表中是否有冻结记录
              AccountFreeze freeze = accountFreezeMapper.selectById(xid);
              String userId = context.getActionContext("userId").toString();
              //3、空回滚的判断:判断freeze是否为null,如果为null则证明try没有执行,需要空回滚
              if (freeze == null) {
                  //证明try没执行,需要空回滚
                  double money = 0l;
                  freeze = new AccountFreeze();
                  freeze.setUserId(Integer.valueOf(userId));
                  freeze.setFreezeMoney(money);
                  freeze.setState(AccountFreeze.State.CANCEL);
                  freeze.setXid(xid);
                  accountFreezeMapper.insert(freeze);
                  return true;
              }
      
              //4、幂等判断
              if (freeze.getState() == AccountFreeze.State.CANCEL) {
                  //已经处理过一次CANCEL,无需重复处理
                  return true;
              }
      
              //5、恢复可用余额
              Account account = new Account();
              account.setUserId(Integer.valueOf(freeze.getUserId()));
              account.setMoney(freeze.getFreezeMoney());
              account.setUpdateDate(new Date());
              accountMapper.refund(account);
      
              //6、将冻结金额清零,状态改为CANCEL
              double money = 0;
              freeze.setFreezeMoney(money);
              freeze.setState(AccountFreeze.State.CANCEL);
              int count = accountFreezeMapper.updateById(freeze);
              System.out.println("地址名称:" + name + ";回滚完成");
      
              return count == 1;
          }
      }
      
  • 实体类

    • 账户类

      package com.account.entity;
      
      import lombok.Data;
      
      import java.util.Date;
      
      @Data
      public class Account {
      
          private int id;
      
          private int userId;  //用户ID
      
          private double money;//剩余金额
      
          private Date updateDate;//更新时间
      
          private Date createDate;//创建时间
      }
      
    • 冻结类

      package com.account.entity;
      
      import lombok.Data;
      
      @Data
      public class AccountFreeze {
      
          private String xid;
      
          private Integer userId;
      
          private Double freezeMoney;
      
          private Integer state;
      
          public static abstract class State {
              public final static int TRY = 0;
              public final static int CONFIRM = 1;
              public final static int CANCEL = 2;
      
          }
      
      }
      
  • mapper

    • 账户mapper

      package com.account.mapper;
      
      import com.account.entity.Account;
      import org.apache.ibatis.annotations.Mapper;
      
      @Mapper
      public interface AccountMapper {
      
          //扣减金额
          int deduct(Account account);
      
          //恢复金额
          int refund(Account account);
      }
      
    • 冻结mapper

      package com.account.mapper;
      
      import com.account.entity.AccountFreeze;
      import org.apache.ibatis.annotations.Mapper;
      
      @Mapper
      public interface AccountFreezeMapper {
      
          //新增冻结记录
          int insert(AccountFreeze accountFreeze);
      
          //根据id删除冻结记录
          int deleteById(String xid);
      
          //修改冻结记录
          int updateById(AccountFreeze accountFreeze);
      
          //查询freeze中是否有冻结记录
          AccountFreeze selectById(String xid);
      }
      
  • feign配置类

    package com.account.config;
    
    import feign.Retryer;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    import static java.util.concurrent.TimeUnit.SECONDS;
    
    /**
     * 自定义Feign配置类
     */
    @Configuration
    public class FeignConfig {
        @Bean
        public Retryer feignRetryer() {
            return new Retryer.Default(100, SECONDS.toMillis(1), 5);
        }
    }
    
  • 远程调用类

    package com.account.client;
    
    import org.springframework.cloud.openfeign.FeignClient;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    
    /**
     * 用于访问Product服务的feign
     */
    @FeignClient("product-service")
    public interface ProductService {
    
        @RequestMapping(value = "/product/deduct")
        String deduct(@RequestParam("productId") int productId,@RequestParam("userId") int userId, @RequestParam("productNum") int productNum);
    }
    
  • xml文件

    • 账户xml

      <?xml version="1.0" encoding="UTF-8" ?>
      <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
      <mapper namespace="com.account.mapper.AccountMapper">
      
          <!--扣减金额-->
          <update id="deduct" parameterType="com.account.entity.Account">
              update
                  account
              set money=money - #{money,jdbcType=DOUBLE},
                  update_date=#{updateDate,jdbcType=TIMESTAMP}
              where user_id = #{userId,jdbcType=INTEGER};
          </update>
      
          <!--恢复金额-->
          <update id="refund" parameterType="com.account.entity.Account">
              update
                  account
              set money=money + #{money,jdbcType=DOUBLE},
                  update_date=#{updateDate,jdbcType=TIMESTAMP}
              where user_id = #{userId,jdbcType=INTEGER};
          </update>
      </mapper>
      
    • 账户冻结xml

      <?xml version="1.0" encoding="UTF-8" ?>
      <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
      <mapper namespace="com.account.mapper.AccountFreezeMapper">
      
          <!--根据用户ID查询用户的账户信息-->
          <insert id="insert" parameterType="com.account.entity.AccountFreeze">
              insert into account_freeze(XID, USER_ID, FREEZE_MONEY, STATE)
              values (#{xid,jdbcType=VARCHAR},
                      #{userId,jdbcType=INTEGER},
                      #{freezeMoney,jdbcType=DOUBLE},
                      #{state,jdbcType=INTEGER})
          </insert>
      
          <!--根据id删除冻结记录-->
          <update id="deleteById" parameterType="java.lang.String">
              delete
              from account_freeze
              where xid = #{xid,jdbcType=VARCHAR};
          </update>
      
      
          <!--修改冻结记录-->
          <update id="updateById" parameterType="com.account.entity.AccountFreeze">
              update
                  account_freeze
              set freeze_money= #{freezeMoney,jdbcType=DOUBLE},
                  state= #{state,jdbcType=INTEGER},
              where xid = #{xid,jdbcType=VARCHAR};
          </update>
      
          <!--查询freeze中是否有冻结记录-->
          <select id="selectById" parameterType="java.lang.String" resultType="com.account.entity.AccountFreeze">
              select *
              from account_freeze
              where xid = #{xid,jdbcType=VARCHAR};
          </select>
      </mapper>
      

(5)、产品服务

  • pom

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <groupId>com.seata</groupId>
            <artifactId>SeataTCCDemo</artifactId>
            <version>1.0-SNAPSHOT</version>
        </parent>
    
        <groupId>com.product</groupId>
        <artifactId>product-service</artifactId>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
    
            <!-- nacos 服务注册-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
            </dependency>
    
            <!-- nacos 配置中心-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
            </dependency>
    
            <!-- seata -->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
                <!--<exclusions>
                    <exclusion>
                        <groupId>io.seata</groupId>
                        <artifactId>seata-spring-boot-starter</artifactId>
                    </exclusion>
                </exclusions>-->
            </dependency>
            <!--<dependency>
                <groupId>io.seata</groupId>
                <artifactId>seata-spring-boot-starter</artifactId>
                <version>2.0.0</version>
            </dependency>-->
            <!--mysql驱动-->
            <dependency>
                <groupId>com.mysql</groupId>
                <artifactId>mysql-connector-j</artifactId>
            </dependency>
            <!--mybatis依赖-->
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>3.0.4</version>
            </dependency>
            <!--lombok依赖-->
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
            </dependency>
        </dependencies>
    </project>
    
  • 配置文件

    #配置使用哪个环境,在真实项目中,一般分为多个环境,此处指定使用的是dev环境
    spring:
      profiles:
        active: dev
    ---
    
    ##指定本服务的端口号
    server:
      port: 8082
    
    spring:
      #设置当前应用的名称
      application:
        name: product-service
      #指定此处配置为dev环境
      profiles:
        active: dev
    
      #数据库连接配置
      datasource:
        url: jdbc:mysql://localhost:3306/product?characterEncoding=UTF-8&useSSL=false&useUnicode=true&serverTimezone=UTC
        username: root
        password: root
        driver-class-name: com.mysql.cj.jdbc.Driver
    
      # 设置从nacos加载的配置文件
      # 配置文件从"spring.cloud.nacos.config"模块里配置的nacos服务器的命名空间下读取
      config:
        import:
          #这里表示读取命名空间名称为nacos的、组名为DEV_GROUP的product.yml配置文件
          # 这里配置组名会覆盖掉"spring.cloud.nacos.config"里面设置的组名
          - optional:nacos:product.yml?group=DEV_GROUP
      cloud:
        # nacos相关的配置
        nacos:
          # 设置连接nacos的用户名、密码
          username: nacos
          password: nacos
          # nacos的配置中心设置#(这里连接的是nacos命名空间)
          config:
            # 配置中心地址
            # 注意config模块里面配置的地址与discovery模块里面配置的地址可以不相同,两者互不影响
            server-addr: localhost:8848
            # 配置文件后缀,即配置文件格式,目前只支持"properties"与"yml"两种格式
            file-extension: yaml
            # 配置文件存放的命名空间ID,这里是从命名空间为nacos的命名空间读取配置
            namespace: 231e01ec-912b-4529-9ff4-6bf792b38776
            # 设置默认的组名
            group: DEFAULT_GROUP
          # 配置注册中心(这里连接的是seata命名空间)
          discovery:
            # 配置注册中心地址
            #注意config模块里面配置的地址与discovery模块里面配置的地址可以不相同,二者互不影响
            server-addr: localhost:8848
            # 获取服务的命名空间ID
            namespace: c511f4d4-0205-4316-909a-d0827ade5435
            # 设置服务的分组名为SEATA_GROUP,因为seata的分组名也是SEATA_GROUP,需要保持一致
            group: SEATA_GROUP
    
    # seata客户端配置
    seata:
      # 设置seata使用哪种模式支持分布式事务(可选的值:XA、AT、TCC、SAGA)
      # data-source-proxy-mode: TCC
      # 应用ID(如果不配置则默认使用"spring.application.name"的值)
      # application-id: product-service
      enabled: true
      # 配置事务组名称,名字随便起
      # 根据这个获取TC服务的cluster名称
      tx-service-group: seata_demo
      # seata.service.vgroup-mapping下一定要有一个对"seata.tx-service-group: XXX"这个名字的映射
      # 注意分两种情况
      #情况1
      #   当没有配置"seata.config.nacos"时,需要在本配置文件中配置"seata.service.vgroup-mapping.自定义事务组名: 集群名"
      #   这里的自定义事务组名称就是上面配置的"seata.tx-service-group"的值,集群名称就是"SeataServer"服务器中
      #   的"seata.registry.nacos.cluster"中配置的集群名称
      #   也就是需要添加下面的配置
      service:
        vgroup-mapping:
          # 配置事务组与TC服务cluster的映射关系
          # key:对应"tx-service-group"中定义的事务组名称
          # value:对应SeataServer上的application.yml中seata.registry.nacos.cluster中配置的集群名称
          seata_demo: default
      # 情况2:
      #   当配置了"seata.config.nacos"的配置后,则"seata.service.vgroup-mapping.自定义事务组名: 集群名"的值从nacos中获取
      #   此时需要在对应的命名空间下(即"seata.config.nacos.namespace"中配置的命名空间)创建名称为"service.vgroup-mapping.自定义事务组名"的配置文件
      #   文件的格式为"text"格式,值为"SeataServer"服务器中的"seata.registry.nacos.cluster"中配置的集群名称,比如下面的"default"
    
      # seata连接nacos的配置
      config:
        # 配置seata的配置中心为nacos
        type: nacos
        #(这里连接的是nacos命名空间)
        nacos:
          # 这里设置seata服务需要从哪个nacos服务器上读取配置,可以与"spring.cloud.nacos.config"中配置的nacos地址不同
          server-addr: localhost:8848
          # 设置需要读取的命名空间的ID
          namespace: 231e01ec-912b-4529-9ff4-6bf792b38776
          # 设置配置文件的分组
          group: TEST_GROUP
          # 连接nacos的用户名
          username: nacos
          # 连接nacos的密码
          password: nacos
      # 配置注册项,配置内容与服务端保持一致(这里连接的是seata命名空间)
      registry:
        type: nacos
        nacos:
          # 设置连接的nacos服务器地址
          # 这里必须与SeataServer服务器连接的nacos地址一致,测试服务器中配置的命名空间为seata,这里也连接为seata空间才能实现服务发现
          server-addr: localhost:8848
          # 设置命名空间ID:这里必须与SeataServer服务器配置的命名空间一致
          namespace: c511f4d4-0205-4316-909a-d0827ade5435
          # TC服务在nacos中注册的服务名
          # 默认是seata-server,若没有修改则可以不配置
          application: seata-server
          # 设置服务所在的分组,这里必须与SeataServer服务器配置的分组名一致,默认就是SEATA_GROUP
          group: SEATA_GROUP
          # 连接nacos的用户名
          username: nacos
          # 连接nacos的密码
          password: nacos
    
    #关闭驼峰
    mybatis-plus:
      configuration:
        map-underscore-to-camel-case: true
        log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
    #mybatis
    mybatis:
      #所有pojo类所在包的路径
      type-aliases-package: com.product.entity
      # xml映射文件所在的路径,一般用模糊匹配来指定最终的xml文件
      mapper-locations: classpath:mapper/*.xml #mapper映射文件
      configuration:
        #采用驼峰形式将数据表中以‘_’分隔的字段映射到java类的某个属性,比如表字段user_name可以映射为类里面的userName属性
        map-underscore-to-camel-case: true #支持驼峰映射
    
  • 控制器类

    package com.product.controller;
    
    import com.product.service.ProductService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    @RequestMapping("/product")
    public class ProductController {
    
        @Autowired
        private ProductService productService;
    
        /**
         * 根据产品ID更新产品信息
         *
         * @param productId
         * @param productNum
         * @return
         */
        @RequestMapping("/deduct")
        public void deduct(@RequestParam("productId") int productId, @RequestParam("userId") int userId, @RequestParam("productNum") int productNum) {
            System.out.println("ProductController 开始执行");
            productService.deduct(productId, userId, productNum);
        }
    }
    
  • 实体类

    • 产品实体

      package com.product.entity;
      
      import lombok.Data;
      
      import java.util.Date;
      
      @Data
      public class Product {
          private int id;//产品ID
      
          private int productNum;//产品剩余数量
      
          private String productDesc;//产品描述
      
          private Date createDate;//创建时间
      
      }
      
    • 产品冻结实体

      package com.product.entity;
      
      import lombok.Data;
      
      import java.util.Date;
      
      @Data
      public class ProductFreeze {
          private String xid;//事务ID
      
          private Integer freezeNum;//冻结数量
      
          private Integer userId;//用户ID
      
          private Integer state;//冻结状态
      
          public static abstract class State {
              public final static int TRY = 0;
              public final static int CONFIRM = 1;
              public final static int CANCEL = 2;
      
          }
      
      }
      
  • mapper

    • 产品mapper

      package com.product.mapper;
      
      import com.product.entity.Product;
      import org.apache.ibatis.annotations.Mapper;
      
      @Mapper
      public interface ProductMapper {
          //扣减金额
          int deduct(Product account);
      
          //恢复金额
          int refund(Product account);
      }
      
    • 冻结mapper

      package com.product.mapper;
      
      import com.product.entity.ProductFreeze;
      import org.apache.ibatis.annotations.Mapper;
      
      @Mapper
      public interface ProductFreezeMapper {
      
          //新增冻结记录
          int insert(ProductFreeze accountFreeze);
      
          //根据id删除冻结记录
          int deleteById(String xid);
      
          //修改冻结记录
          int updateById(ProductFreeze accountFreeze);
      
          //查询freeze中是否有冻结记录
          ProductFreeze selectById(String xid);
      }
      
  • service

    • 产品service

      package com.product.service;
      
      import io.seata.rm.tcc.api.BusinessActionContext;
      import io.seata.rm.tcc.api.BusinessActionContextParameter;
      import io.seata.rm.tcc.api.LocalTCC;
      import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
      
      @LocalTCC
      public interface ProductService {
          /**
           * try逻辑
           *
           * @param productId  产品ID
           * @param userId     用户ID
           * @param productNum 扣减库存数量
           * @TwoPhaseBusinessAction注解中的name属性要与当前方法名一致,用于指定Try逻辑对应的方法
           * @TwoPhaseBusinessAction注解中的commitMethod属性要与提交方法名一致,用于指定提交逻辑对应的方法
           * @TwoPhaseBusinessAction注解中的rollbackMethod属性要与回滚方法名一致,用于指定回滚逻辑对应的方法
           */
          @TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
          void deduct(@BusinessActionContextParameter(paramName = "productId") Integer productId,
                      @BusinessActionContextParameter(paramName = "userId") Integer userId,
                      @BusinessActionContextParameter(paramName = "productNum") Integer productNum);
      
          /**
           * 二阶段confirm确认方法、可以另命名,但要保证与commitMethod一致
           *
           * @param
           * @param context 上下文可以传递try方法里面的参数
           * @return boolean 执行是否成功
           */
          boolean confirm(BusinessActionContext context);
      
          /**
           * 二阶段回滚方法,要保证与rollbackMethod一致
           *
           * @param context
           * @return
           */
          boolean cancel(BusinessActionContext context);
      }
      
    • 冻结service

      package com.product.service;
      
      import com.product.entity.Product;
      import com.product.entity.ProductFreeze;
      import com.product.mapper.ProductFreezeMapper;
      import com.product.mapper.ProductMapper;
      import io.seata.core.context.RootContext;
      import io.seata.rm.tcc.api.BusinessActionContext;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.beans.factory.annotation.Value;
      import org.springframework.cloud.context.config.annotation.RefreshScope;
      import org.springframework.stereotype.Service;
      import org.springframework.transaction.annotation.Transactional;
      
      @Service
      @RefreshScope
      public class ProductServiceImpl implements ProductService {
      
          @Value("${product.name}")
          private String productName;
      
          @Autowired
          ProductMapper productMapper;
      
          @Autowired
          ProductFreezeMapper productFreezeMapper;
      
          /**
           * @param productId
           * @param freezeNum
           */
          @Override
          @Transactional
          public void deduct(Integer productId, Integer userId, Integer freezeNum) {
              System.out.println("产品名称:" + productName);
              //1、获取事务id
              String xid = RootContext.getXID();
              System.out.println("产品名称:" + productName + ";事务ID:" + xid);
              //2、判断freeze中是否有冻结记录,如果有则一定是CANCEL执行过,需要要拒绝业务
              ProductFreeze oldFreeze = productFreezeMapper.selectById(xid);
      
              if (oldFreeze != null) {
                  System.out.println("产品名称:" + productName + ";CANCEL执行过,需要拒绝业务" + xid);
                  //CANCEL执行过,需要拒绝业务
                  return;
              }
      
              //3、商品库存扣减数量
              Product account = new Product();
              account.setProductNum(freezeNum);
              account.setId(productId);
              productMapper.deduct(account);
              if(1==1){
                  throw new RuntimeException("模拟异常");
              }
              //4、冻结表记录冻结数量、事务状态
              ProductFreeze freeze = new ProductFreeze();
              freeze.setUserId(userId);
              freeze.setFreezeNum(freezeNum);
              freeze.setState(ProductFreeze.State.TRY);
              freeze.setXid(xid);
              productFreezeMapper.insert(freeze);
              System.out.println("产品名称:" + productName + ";冻结完成");
      
          }
      
          /**
           * 提交操作
           *
           * @param context 上下文可以传递try方法里面的参数
           * @return
           */
          @Override
          @Transactional
          public boolean confirm(BusinessActionContext context) {
              // 1、获取事务id
              String xid = context.getXid();
      
              //2、根据id删除冻结记录
              int count = productFreezeMapper.deleteById(xid);
              System.out.println("产品名称:" + productName + ";提交完成");
              return count == 1;
          }
      
          /**
           * 回滚操作
           *
           * @param context
           * @return
           */
          @Override
          @Transactional
          public boolean cancel(BusinessActionContext context) {
              // 1、获取事务ID
              String xid = context.getXid();
              //2、查询freeze表中是否有冻结记录
              ProductFreeze freeze = productFreezeMapper.selectById(xid);
              String userId = context.getActionContext("userId").toString();
              //3、空回滚的判断:判断freeze是否为null,如果为null则证明try没有执行,需要空回滚
              if (freeze == null) {
                  //证明try没执行,需要空回滚
                  freeze = new ProductFreeze();
                  freeze.setUserId(Integer.valueOf(userId));
                  freeze.setFreezeNum(0);
                  freeze.setState(ProductFreeze.State.CANCEL);
                  freeze.setXid(xid);
                  productFreezeMapper.insert(freeze);
                  return true;
              }
      
              //4、幂等判断
              if (freeze.getState() == ProductFreeze.State.CANCEL) {
                  //已经处理过一次CANCEL,无需重复处理
                  return true;
              }
      
              //5、恢复剩余数量
              Product account = new Product();
              account.setProductNum(freeze.getFreezeNum());
              account.setId(Integer.valueOf(context.getActionContext("productId").toString()));
              productMapper.refund(account);
      
              //6、将冻结金额清零,状态改为CANCEL
              freeze.setFreezeNum(0);
              freeze.setState(ProductFreeze.State.CANCEL);
              int count = productFreezeMapper.updateById(freeze);
              System.out.println("产品名称:" + productName + ";回滚完成");
              return count == 1;
          }
      }
      
  • xml

    • 产品xml

      <?xml version="1.0" encoding="UTF-8" ?>
      <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
      <mapper namespace="com.product.mapper.ProductMapper">
      
          <!--扣减金额-->
          <update id="deduct" parameterType="com.product.entity.Product">
              update
                  product
              set product_num=product_num - #{productNum,jdbcType=INTEGER}
              where id = #{id,jdbcType=INTEGER};
          </update>
      
          <!--恢复金额-->
          <update id="refund" parameterType="com.product.entity.Product">
              update
                  product
              set product_num=product_num + #{productNum,jdbcType=INTEGER}
              where id = #{id,jdbcType=INTEGER};
          </update>
      
      </mapper>
      
    • 冻结xml

      <?xml version="1.0" encoding="UTF-8" ?>
      <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
      <mapper namespace="com.product.mapper.ProductFreezeMapper">
      
          <!--根据用户ID查询用户的账户信息-->
          <insert id="insert" parameterType="com.product.entity.ProductFreeze">
              insert into product_freeze(XID, USER_ID, FREEZE_NUM, STATE)
              values (#{xid,jdbcType=VARCHAR},
                      #{userId,jdbcType=INTEGER},
                      #{freezeNum,jdbcType=INTEGER},
                      #{state,jdbcType=INTEGER})
          </insert>
      
          <!--根据id删除冻结记录-->
          <update id="deleteById" parameterType="java.lang.String">
              delete
              from product_freeze
              where xid = #{xid,jdbcType=VARCHAR};
          </update>
      
      
          <!--修改冻结记录-->
          <update id="updateById" parameterType="com.product.entity.ProductFreeze">
              update
                  product_freeze
              set freezeNum= #{freezeNum,jdbcType=INTEGER},
                  state= #{state,jdbcType=INTEGER},
                  where xid = #{xid,jdbcType=VARCHAR};
          </update>
      
          <!--查询freeze中是否有冻结记录-->
          <select id="selectById" parameterType="java.lang.String" resultType="com.product.entity.ProductFreeze">
              select *
              from product_freeze
              where xid = #{xid,jdbcType=VARCHAR};
          </select>
      </mapper>
      

(6)、数据库表

  • 账户表

    SET NAMES utf8mb4;
    SET FOREIGN_KEY_CHECKS = 0;
    
    -- ----------------------------
    -- Table structure for account
    -- ----------------------------
    DROP TABLE IF EXISTS `account`;
    CREATE TABLE `account`  (
      `id` int NOT NULL AUTO_INCREMENT COMMENT 'id',
      `user_id` int NOT NULL COMMENT '用户ID',
      `money` double NULL DEFAULT NULL COMMENT '账户余额',
      `create_date` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
      `update_date` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
      PRIMARY KEY (`id`, `user_id`) USING BTREE,
      CONSTRAINT `chech_money` CHECK (`money` >= 0)
    ) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = DYNAMIC;
    
    -- ----------------------------
    -- Records of account
    -- ----------------------------
    INSERT INTO `account` VALUES (2, 10, 760, '2025-04-03 12:23:19', '2025-04-03 04:23:19');
    
    SET FOREIGN_KEY_CHECKS = 1;
    
  • 产品表

    SET NAMES utf8mb4;
    SET FOREIGN_KEY_CHECKS = 0;
    
    -- ----------------------------
    -- Table structure for product
    -- ----------------------------
    DROP TABLE IF EXISTS `product`;
    CREATE TABLE `product`  (
      `id` int NOT NULL AUTO_INCREMENT,
      `product_num` int NOT NULL,
      `product_desc` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL,
      `create_date` datetime NOT NULL,
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = DYNAMIC;
    
    -- ----------------------------
    -- Records of product
    -- ----------------------------
    INSERT INTO `product` VALUES (1, 95, '格力空调', '2023-10-17 09:02:32');
    INSERT INTO `product` VALUES (2, 30, '咖啡机', '2023-10-16 09:02:56');
    INSERT INTO `product` VALUES (3, 4, '美的冰箱', '2023-10-04 09:03:28');
    
    SET FOREIGN_KEY_CHECKS = 1;
    
    

(7)、测试

启动项目访问http://localhost:8081/account/deduct?userId=100&productId=1&count=5&money=5

在这里插入图片描述

4、使用步骤

(1)、引入依赖

  • 旧版本中

    所有事务相关的服务都需要添加如下依赖,先排除spring-cloud-starter-alibaba-seata包中的seata-spring-boot-starter依赖,再重新引入合适版本的依赖,因此seata-spring-boot-starter默认的版本可能不兼容

    <!-- seata -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
        <exclusions>
            <exclusion>
                <groupId>io.seata</groupId>
                <artifactId>seata-spring-boot-starter</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>io.seata</groupId>
        <artifactId>seata-spring-boot-starter</artifactId>
    </dependency>
    
  • 新版本中直接引入seata包即可,测试后是兼容的

    <dependencies>
        <!-- seata -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
        </dependency>
    </dependencies>
    

(2)、添加配置

配置事务使用的模式、nacos使用的配置与服务的地址等信息

  • seata.data-source-proxy-mode

    TCC中这一项不需要配置

  • seata.application-id

    应用ID

    如果不配置则默认使用"spring.application.name"的值

  • seata.tx-service-group(非必须)

    配置事务组名称,名字随便起,但是同一个全局事务相关的事务组名称要一致

    比如一个全局事务涉及到三个微服务,那么这三个微服务的事务组名称要一致

    后面根据这个事务组名称获取TC服务器上配置的cluster名称

  • seata.service.vgroup-mapping(非必须)

    seata.service.vgroup-mapping下一定要有一个对"seata.tx-service-group: 事务组名字"这个名字的映射

    分两种情况

    1. 配置了"seata.config.nacos"的配置后

      则"seata.service.vgroup-mapping.自定义事务组名: 集群名"的值从nacos中获取

      此时需要在对应的命名空间下(即"seata.config.nacos.namespace"中配置的命名空间)创建名称为"service.vgroup-mapping.自定义事务组名"的配置文件

      文件的格式为"text"格式,值为"SeataServer"服务器中的"seata.registry.nacos.cluster"中配置的集群名称,比如下面的"default"

      在这里插入图片描述

    2. 没有配置"seata.config.nacos"

      需要在本配置文件中配置"service.vgroup-mapping.自定义事务组名: 集群名"

      这里的自定义事务组名称就是上面配置的"seata.tx-service-group"的值,集群名称是"SeataServer"服务器中的"seata.registry.nacos.cluster"中配置的集群名称

      seata:
        #即添加下面的配置即可
        service:
          vgroup-mapping:
            # 配置事务组与TC服务cluster的映射关系
            # key:对应"tx-service-group"中定义的事务组名称
            # value:对应SeataServer上的application.yml中seata.registry.nacos.cluster中配置的集群名称
            seata_demo: default
      
  • seata.config.type

    设置seata的配置中心为nacos

  • seata.config.nacos.server-addr

    这里设置seata服务需要从哪个nacos服务器上读取配置

    可以与"spring.cloud.nacos.config"中配置的nacos地址不同

  • seata.config.nacos.namespace

    设置需要从哪个命名空间获取配置,这里设置的是命名空间的ID

  • seata.config.nacos.group

    设置从哪个分组下

  • seata.config.nacos.username

    连接nacos的用户名

  • seata.config.nacos.password

    连接nacos的密码

  • seata.registry.type

    设置seata服务注册到哪里

  • seata.registry.nacos.server-addr

    设置连接的nacos服务器地址

    这里必须与SeataServer服务器"seata.registry.nacos.server-addr"设置的命名空间一致

  • seata.registry.nacos.namespace

    设置命名空间ID

    这里必须与SeataServer服务器"seata.registry.nacos.namespace"设置的命名空间一致

  • seata.registry.nacos.application

    SeataServer服务端在nacos中注册的服务名

    默认是seata-server,若没有修改则可以不配置

  • seata.registry.nacos.group

    设置服务所在的分组

    这里必须与SeataServer服务器"seata.registry.nacos.group"设置的分组名一致

    因为只有SeataServer服务端与SeataClient的服务在相同的nacos服务器集群下的相同的命名空间下的相同分组下,才能进行服务的发现与调用

  • seata.registry.nacos.username

    连接nacos的用户名

  • seata.registry.nacos.password

    连接nacos的密码

完整配置

# seata客户端配置
seata:
  # 设置seata使用哪种模式支持分布式事务(可选的值:XA、AT、TCC、SAGA)
  # data-source-proxy-mode: TCC
  # 应用ID(如果不配置则默认使用"spring.application.name"的值)
  # application-id: account-service
  enabled: true
  # 配置事务组名称,名字随便起
  # 根据这个获取TC服务的cluster名称
  tx-service-group: seata_demo
  # seata.service.vgroup-mapping下一定要有一个对"seata.tx-service-group: XXX"这个名字的映射
  # 注意分两种情况
  #情况1
  #   当没有配置"seata.config.nacos"时,需要在本配置文件中配置"seata.service.vgroup-mapping.自定义事务组名: 集群名"
  #   这里的自定义事务组名称就是上面配置的"seata.tx-service-group"的值,集群名称就是"SeataServer"服务器中
  #   的"seata.registry.nacos.cluster"中配置的集群名称
  #   也就是需要添加下面的配置
  service:
    vgroup-mapping:
      # 配置事务组与TC服务cluster的映射关系
      # key:对应"tx-service-group"中定义的事务组名称
      # value:对应SeataServer上的application.yml中seata.registry.nacos.cluster中配置的集群名称
      seata_demo: default
  # 情况2:
  #   当配置了"seata.config.nacos"的配置后,则"seata.service.vgroup-mapping.自定义事务组名: 集群名"的值从nacos中获取
  #   此时需要在对应的命名空间下(即"seata.config.nacos.namespace"中配置的命名空间)创建名称为"service.vgroup-mapping.自定义事务组名"的配置文件
  #   文件的格式为"text"格式,值为"SeataServer"服务器中的"seata.registry.nacos.cluster"中配置的集群名称,比如下面的"default"

  # seata连接nacos的配置
  config:
    # 配置seata的配置中心为nacos
    type: nacos
    #(这里连接的是nacos命名空间)
    nacos:
      # 这里设置seata服务需要从哪个nacos服务器上读取配置,可以与"spring.cloud.nacos.config"中配置的nacos地址不同
      server-addr: localhost:8848
      # 设置需要读取的命名空间的ID
      namespace: 231e01ec-912b-4529-9ff4-6bf792b38776
      # 设置配置文件的分组
      group: TEST_GROUP
      # 连接nacos的用户名
      username: nacos
      # 连接nacos的密码
      password: nacos
  # 配置注册项,配置内容与服务端保持一致(这里连接的是seata命名空间)
  registry:
    type: nacos
    nacos:
      # 设置连接的nacos服务器地址
      # 这里必须与SeataServer服务器连接的nacos地址一致,测试服务器中配置的命名空间为seata,这里也连接为seata空间才能实现服务发现
      server-addr: localhost:8848
      # 设置命名空间ID:这里必须与SeataServer服务器配置的命名空间一致
      namespace: c511f4d4-0205-4316-909a-d0827ade5435
      # TC服务在nacos中注册的服务名
      # 默认是seata-server,若没有修改则可以不配置
      application: seata-server
      # 设置服务所在的分组,这里必须与SeataServer服务器配置的分组名一致,默认就是SEATA_GROUP
      group: SEATA_GROUP
      # 连接nacos的用户名
      username: nacos
      # 连接nacos的密码
      password: nacos

(3)、添加配置文件

在nacos上添加配置文件"service.vgroupMapping.事务组名",配置文件的内容为seata服务注册时设置的集群名

在这里插入图片描述

内容

在这里插入图片描述

(4)、添加注解

在入口方法添加注解"@GlobalTransactional"

(5)、开启TCC事务

在每个分支事务中使用@LocalTCC注解开启并提供对应的业务、提交、回滚方法

二、SAGA模式

1、Saga模式

(1)、Saga模式原理

在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作

分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交

如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态

在这里插入图片描述

Saga也分为两个阶段

  • 一阶段:直接提交本地事务

  • 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚

(2)、执行过程

  1. 执行正向操作:按照事务的逻辑顺序,依次执行正向操作,每个正向操作都会记录事务的执行状态
  2. 如果所有的正向操作都成功执行,则事务提交完成
  3. 如果某个正向操作失败,将会触发相应的补偿操作,补偿操作会撤销或修复正向操作的影响
  4. 执行补偿操作:按照逆序依次执行已经触发的补偿操作,补偿操作应该具备幂等性,以便可以多次执行而不会造成副作用
  5. 如果所有的补偿操作都成功执行,则事务回滚完成
  6. 如果补偿操作也失败,需要人工介入或其他手段来解决事务的一致性问题

Saga模式适用于长时间运行的事务或跨多个服务进行的事务,可以降低分布式事务的复杂性,Saga模式的优点在于能够在发生故障或异常时进行局部回滚,而不需要回滚整个事务,然而,Saga模式也存在一些挑战,如补偿操作的实现和管理、事务执行的顺序控制等

(3)、优缺点

  • 优点

    事务参与者可以基于事件驱动实现异步调用,吞吐高

    一阶段直接提交事务,无锁,性能好

    不用编写TCC中的三个阶段,实现简单

  • 缺点

    软状态持续时间不确定,时效性差

    没有锁,没有事务隔离,会有脏写

2、Seata的Saga模式

Seata的Saga模式通过Seata框架来管理和协调分布式事务,提供了对事务的编排和状态管理的支持

它与Seata的其他特性(如AT模式、TCC模式)结合在一起,构成了Seata全面的分布式事务解决方案

Seata的Saga模式相对于传统的Saga模式,具有以下特点

  • 集成性

    Seata的Saga模式与Seata框架紧密集成,可以与Seata的其他特性一起使用,如分布式事务日志和分布式锁等

  • 强一致性

    Seata的Saga模式提供了强一致性的事务支持,确保事务的执行顺序和一致性

  • 可靠性

    Seata的Saga模式在补偿操作的执行过程中,支持重试和恢复机制,提高了事务的可靠性和恢复能力

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐