Seata TCC模式实战

前言

最近状态有点不好,所以创作动力不足,发觉日常生活一定要做减法,对少量的事保持持续专注的投入,养成良好的习惯。
今天补充下,Seata TCC模式实战。


一、TCC设计原则

从 TCC 模型的框架可以发现,TCC 模型的核心在于 TCC 接口的设计。用户在接入 TCC 时,大部分工作都集中在如何实现 TCC 服务上。

设计一套 TCC 接口最重要的是什么?主要有两点,
第一点,需要将操作分成两阶段完成。TCC(Try-Confirm-Cancel)分布式事务模型相对于 XA 等传统模型,其特征在于它不依赖 RM 对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。

TCC 模型认为对于业务系统中一个特定的业务逻辑 ,其对外提供服务时,必须接受一些不确定性,即对业务逻辑初步操作的调用仅是一个临时性操作,调用它的主业务服务保留了后续的取消权。如果主业务服务认为全局事务应该回滚,它会要求取消之前的临时性操作,这就对应从业务服务的取消操作。而当主业务服务认为全局事务应该提交时,它会放弃之前临时性操作的取消权,这对应从业务服务的确认操作。每一个初步操作,最终都会被确认或取消。

因此,针对一个具体的业务服务,TCC 分布式事务模型需要业务系统提供三段业务逻辑

1、初步操作 Try:完成所有业务检查,预留必须的业务资源。
2、确认操作 Confirm:真正执行的业务逻辑,不做任何业务检查,只使用Try 阶段预留的业务资源。因此,只要 Try 操作成功,Confirm 必须能成功另外,Confirm操作需满足幂等性,保证一笔分布式事务能且只能成功一次。
注意:这里说的confirm方法必须能成功是指满足业务执行的条件,比如有足够的预留资源扣减,方法本身不能说保证一定成功。因为所有的网络操作都具有不确定性。
3、取消操作 Cancel:释放 Try 阶段预留的业务资源。同样的,Cancel操作也需要满足幂等性。

第二点,就是要根据自身的业务模型控制并发,这个对应 ACID 中的隔离性。后面会详细讲到。

二、TCC执行逻辑图

在这里插入图片描述

三、TCC模式和AT模式对比

根据两阶段行为模式的不同,我们将分支事务划分为 Automatic (Branch) Transaction Mode 和 TCC (Branch) Transaction Mode.
AT 模式(参考链接 TBD)基于 支持本地 ACID 事务 的 关系型数据库:
一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录。
二阶段 commit 行为:马上成功结束,自动 异步批量清理回滚日志。
二阶段 rollback 行为:通过回滚日志,自动 生成补偿操作,完成数据回滚。

相应的,TCC 模式,不依赖于底层数据资源的事务支持
一阶段 prepare 行为:调用 自定义 的 prepare 逻辑。
二阶段 commit 行为:调用 自定义 的 commit 逻辑。
二阶段 rollback 行为:调用 自定义 的 rollback 逻辑。
所谓 TCC 模式,是指支持把自定义的分支事务纳入到全局事务的管理中。

综合比较:
1、AT需要支持本地 ACID 事务的关系型数据库,TCC不依赖底层数据资源对事务的支持。
2、AT模式使用了全局锁和本地锁,能保证强一致性。TCC只能保证最终一致性。
3、AT模式由于大量使用了锁,吞吐量较低。
4、实现复杂度上,AT模式只需要简单的添加全局事务注解,基本可以实现零侵入,而TCC模式需要自己实现prepare、commit、rollback方法,并考虑空回滚、幂等、悬挂等问题,实现成本较高。

四、TCC三大异常说明

最常见的主要是这三种异常,分别是空回滚、幂等、悬挂。

1、空回滚

首先是空回滚。什么是空回滚?空回滚就是对于一个分布式事务,在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法,Cancel 方法需要识别出这是一个空回滚,然后直接返回成功。

什么样的情形会造成空回滚呢?可以看图中的第 2 步,前面讲过,注册分支事务是在调用 RPC 时,Seata 框架的切面会拦截到该次调用请求,先向 TC 注册一个分支事务,然后才去执行 RPC 调用逻辑。如果 RPC 调用逻辑有问题,比如调用方机器宕机、网络异常,都会造成 RPC 调用失败,即未执行 Try 方法。但是分布式事务已经开启了,需要推进到终态,因此,TC 会回调参与者二阶段 Cancel 接口,从而形成空回滚。
在这里插入图片描述
那会不会有空提交呢?理论上来说不会的,如果调用方宕机,那分布式事务默认是回滚的。如果是网络异常,那 RPC 调用失败,发起方应该通知 TC 回滚分布式事务,这里可以看出为什么是理论上的,就是说发起方可以在 RPC 调用失败的情况下依然通知 TC 提交,这时就会发生空提交,这种情况要么是编码问题,要么开发同学明确知道需要这样做。

那怎么解决空回滚呢?前面提到,Cancel 要识别出空回滚,直接返回成功。那关键就是要识别出这个空回滚。**思路很简单就是需要知道一阶段是否执行,如果执行了,那就是正常回滚;**如果没执行,那就是空回滚。因此,需要一张额外的事务控制表,其中有分布式事务 ID 和分支事务 ID,第一阶段 Try 方法里会插入一条记录,表示一阶段执行了。Cancel 接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。

2、幂等

接下来是幂等。幂等就是对于同一个分布式事务的同一个分支事务,重复去调用该分支事务的第二阶段接口,因此,要求 TCC 的二阶段 Confirm 和 Cancel 接口保证幂等,不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致资损等严重问题。

什么样的情形会造成重复提交或回滚?从图中可以看到,提交或回滚是一次 TC 到参与者的网络调用。因此,网络故障、参与者宕机等都有可能造成参与者 TCC 资源实际执行了二阶段防范,但是 TC 没有收到返回结果的情况,这时,TC 就会重复调用,直至调用成功,整个分布式事务结束。
在这里插入图片描述
怎么解决重复执行的幂等问题呢?一个简单的思路就是记录每个分支事务的执行状态。在执行前状态,如果已执行,那就不再执行;否则,正常执行。前面在讲空回滚的时候,已经有一张事务控制表了,事务控制表的每条记录关联一个分支事务,那我们完全可以在这张事务控制表上加一个状态字段,用来记录每个分支事务的执行状态。
在这里插入图片描述
如图所示,该状态字段有三个值,分别是初始化、已提交、已回滚。Try 方法插入时,是初始化状态。二阶段 Confirm 和 Cancel 方法执行后修改为已提交或已回滚状态。当重复调用二阶段接口时,先获取该事务控制表对应记录,检查状态,如果已执行,则直接返回成功;否则正常执行。

3、悬挂

最后是防悬挂。悬挂就是对于一个分布式事务,其二阶段 Cancel 接口比 Try 接口先执行。因为允许空回滚的原因,Cancel 接口认为 Try 接口没执行,空回滚直接返回成功,对于 Seata 框架来说,认为分布式事务的二阶段接口已经执行成功,整个分布式事务就结束了。但是这之后 Try 方法才真正开始执行,预留业务资源,前面提到事务并发控制的业务加锁,对于一个 Try 方法预留的业务资源,只有该分布式事务才能使用,然而 Seata 框架认为该分布式事务已经结束,也就是说,当出现这种情况时,该分布式事务第一阶段预留的业务资源就再也没有人能够处理了,对于这种情况,我们就称为悬挂,即业务资源预留后没法继续处理。

简单来说,悬挂就是 Cancel 接口比 Try 接口先执行,Cancel 接口进行了空回滚,Try 接口才执行进行资源预留,而预留的资源又没有对应的Cancel 接口去进行消费,所以导致这部分预留资源没法处理。

什么样的情况会造成悬挂呢?按照前面所讲,在 RPC 调用时,先注册分支事务,再执行 RPC 调用,如果此时 RPC 调用的网络发生拥堵,通常 RPC 调用是有超时时间的,RPC 超时以后,发起方就会通知 TC 回滚该分布式事务,可能回滚完成后,RPC 请求才到达参与者,真正执行,从而造成悬挂。

怎么实现才能做到防悬挂呢?根据悬挂出现的条件先来分析下,悬挂是指二阶段 Cancel 执行完后,一阶段才执行。也就是说,为了避免悬挂,如果二阶段执行完成,那一阶段就不能再继续执行。因此,当一阶段执行时,需要先检查二阶段是否已经执行完成,如果已经执行,则一阶段不再执行;否则可以正常执行。那怎么检查二阶段是否已经执行呢?大家是否想到了刚才解决空回滚和幂等时用到的事务控制表,可以在二阶段执行时插入一条事务控制记录,状态为已回滚,这样当一阶段执行时,先读取该记录,如果记录存在,就认为二阶段已经执行;否则二阶段没执行。

五、案例实战

1、业务说明

在这里插入图片描述
业务说明:
1、用户向Order服务发起下订单的请求;
2、Order服务收到请求后,开始创建订单;
3、Order服务向Storage库存服务发起请求,减去商品库存;
4、Order服务向Account账户服务发起请求,减少账户余额;
5、全部执行成功,则成功创建订单。

2、项目结构

在这里插入图片描述
说明:
1、account工程是账户服务,用户管理账户余额。
2、db-init工程用户初始化项目表结构和数据。
3、easy-id-generator工程用来生成全局唯一的订单id,用来控制创建订单方法的幂等性。
4、eureka-server工程是采用eureka作为注册中心。
5、order工程是订单服务,用来新建订单。
6、order-parent是整合微服务项目的公共父依赖,类似spring-boot-starter-parent。
7、storage工程是库存服务,用来管理商品库存。

3、maven依赖

spring cloud和spring boot的版本对应关系:
在这里插入图片描述

spring cloud、spring boot、spring cloud Alibaba(spring-cloud-starter-alibaba-seata)三者的版本对应关系。
在这里插入图片描述
和seata之间的版本对应关系:官方地址
在这里插入图片描述
注意:
spring-cloud-starter-alibaba-seata归属与Spring Cloud Alibaba体系,两者版本保持一致。

版本说明:
spring-cloud-starter-alibaba-seata 2.1.0内嵌seata-all 0.7.1,2.1.1内嵌seata-all 0.9.0,2.2.0内嵌seata-spring-boot-starter 1.0.0, 2.2.1内嵌seata-spring-boot-starter 1.1.0。

其中seata-spring-boot-starter的核心是包含一个对应的seata-all依赖,两者的版本保持一致。

引用的时候主要需要保证spring-cloud-starter-alibaba-seata和当前项目的spring cloud的版本保持一致,
seata的版本可以通过exclusion排除默认依赖后,升级成较新的依赖。这样就做到了spring cloud版本和seate版本的解耦。

spring-cloud-starter-alibaba-seata推荐依赖配置方式

  <dependency>
            <groupId>io.seata</groupId>
            <artifactId>seata-spring-boot-starter</artifactId>
            <version>最新版</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
            <version>2.2.1.RELEASE</version>
            <exclusions>
                <exclusion>
                    <groupId>io.seata</groupId>
                    <artifactId>seata-spring-boot-starter</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

说明:
spring-cloud-starter-alibaba-seata的版本选择与项目依赖的spring cloud相匹配的版本。通过exclusion排除默认依赖的seata,然后引入自己想要的seata版本,实现spring cloud的版本和seata的依赖版本解耦。
注意spring-cloud-starter-alibaba-seata 2.1.0内嵌seata-all 0.7.1,2.1.1内嵌seata-all 0.9.0,2.2.0内嵌seata-spring-boot-starter 1.0.0, 2.2.1内嵌seata-spring-boot-starter 1.1.0。

所以如果是spring-cloud-starter-alibaba-seata 2.1.x的版本,依赖的是seata-all ,所以推荐如下配置:

        <dependency>
          <groupId>com.alibaba.cloud</groupId>
          <artifactId>spring-cloud-alibaba-seata</artifactId>
          <version>${spring-cloud-alibaba-seata.version}</version>
          <exclusions>
            <exclusion>
              <artifactId>seata-all</artifactId>
              <groupId>io.seata</groupId>
            </exclusion>
          </exclusions>
        </dependency>
        
        <dependency>
          <groupId>io.seata</groupId>
          <artifactId>seata-all</artifactId>
          <version>${seata.version}</version>
        </dependency>

order-parent父级项目的核心依赖:

 <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.6.RELEASE</version>
      <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>order-parent</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>
    <name>order-parent</name>


    <properties>
        <mybatis-plus.version>3.3.2</mybatis-plus.version>
        <druid-spring-boot-starter.version>1.1.23</druid-spring-boot-starter.version>
        <seata.version>1.3.0</seata.version>
        <spring-cloud-alibaba-seata.version>2.0.0.RELEASE</spring-cloud-alibaba-seata.version>
        <spring-cloud.version>Hoxton.SR6</spring-cloud.version>
        <skipTests>true</skipTests>
    </properties>

     <dependencies>
         <!-- 打开 seata 依赖 -->
        <dependency>
          <groupId>com.alibaba.cloud</groupId>
          <artifactId>spring-cloud-alibaba-seata</artifactId>
          <version>${spring-cloud-alibaba-seata.version}</version>
          <exclusions>
            <exclusion>
              <artifactId>seata-all</artifactId>
              <groupId>io.seata</groupId>
            </exclusion>
          </exclusions>
        </dependency>
        <dependency>
          <groupId>io.seata</groupId>
          <artifactId>seata-all</artifactId>
          <version>${seata.version}</version>
        </dependency>
           ……
  </dependencies>

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

在这里插入图片描述

4、属性配置

(1)、conf文件方式

针对spring-cloud-starter-alibaba-seata 2.1.x的版本,由于依赖的是seata-all,没有整合更丰富的seata配置,所以一般采用的是属性文件 + conf配置文件的方式进行配置:
在这里插入图片描述
属性文件配置如下:

  spring.cloud.alibaba.seata.tx-service-group=order_tx_group

说明:
通过设置spring.cloud.alibaba.seata.tx-service-group属性设置事务服务的分组名称。

file.conf和registry.conf文件都可以在seata按照文件下找到。其中file.conf用来配置网络、服务端server以及客户端的相关属性。
registry.conf用来配置seata server注册的注册中心,以及属性文件保存的配置中心。

file.conf核心配置:

service {
  #transaction service group mapping
  # order_tx_group 与 yml 中的 “tx-service-group: order_tx_group” 配置一致
  # “seata-server” 与 TC 服务器的注册名一致
  # order_tx_group名称需要和属性文件中配置的spring.cloud.alibaba.seata.tx-service-group的值相匹配
  #  vgroupMapping属性是为了配置
  vgroupMapping.order_tx_group = "seata-server"
  #only support when registry.type=file, please don't set multiple addresses
  # seata-server的名称需要和上面的vgroupMapping.order_tx_group的值相匹配。
  seata-server.grouplist = "127.0.0.1:8091"
  #degrade, current not support
  enableDegrade = false
  #disable seata
  disableGlobalTransaction = false
}

注意:
1、vgroupMapping.order_tx_group中的order_tx_group需要和属性文件中配置的spring.cloud.alibaba.seata.tx-service-group的值相匹配
2、 seata-server.grouplist中的seata-server.需要和vgroupMapping.order_tx_group的值匹配
核心的目的就是指定seata-server的服务分组名称,以及seata-server服务对应的服务器ip地址。

registry.conf核心配置:
这里由于测试,且我的seata-server服务采用的默认file方式部署的,所以这里的registry.conf也都是采用的默认的,没有做相关修改。实际使用中可以根据情况和需求,修改成自己对应的配置中心和注册中心。

registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "file"

  file {
    name = "file.conf"
  }
}

config {
  # file、nacos 、apollo、zk、consul、etcd3、springCloudConfig
  type = "file"
  file {
    name = "file.conf"
  }
}

(2)、属性文件配置方式

针对spring-cloud-starter-alibaba-seata 2.2.x以后的版本,由于依赖的是seata-spring-boot-starter,整合了更丰富的seata配置,所以可以省去conf文件,将seata相关数据都在属性文件中进行配置。
在这里插入图片描述
application.yml属性配置:

seata:
  enabled: true
  tx-service-group: order_tx_group
  service:
    vgroup-mapping:
     order_tx_group: seata-server
    grouplist:
      seata-server: 127.0.0.1:8091

注意:
1、注意属性文件配置中map类型属性配置的对应关系。
2、注意几个属性之间的关联关系。
在这里插入图片描述

(3)、补充说明

事务分组说明

  1. 事务分组是什么?
    事务分组是seata的资源逻辑,类似于服务实例。在file.conf中的my_test_tx_group就是一个事务分组。
  2. 通过事务分组如何找到后端集群?
    首先程序中配置了事务分组(GlobalTransactionScanner 构造方法的txServiceGroup参数),程序会通过用户配置的配置中心去寻找service.vgroup_mapping.事务分组配置项,取得配置项的值就是TC集群的名称。拿到集群名称程序通过一定的前后缀+集群名称去构造服务名,各配置中心的服务名实现不同。拿到服务名去相应的注册中心去拉取相应服务名的服务列表,获得后端真实的TC服务列表。
  3. 为什么这么设计,不直接取服务名?
    这里多了一层获取事务分组到映射集群的配置。这样设计后,事务分组可以作为资源的逻辑隔离单位,当发生故障时可以快速failover。

关于grouplist问题说明下

  1. 什么时候会用到file.conf中的default.grouplist?
    当registry.type=file时会用到,其他时候不读。
  2. default.grouplist的值列表是否可以配置多个?
    可以配置多个,配置多个意味着集群,但当store.mode=file时,会报错。原因是在file存储模式下未提供本地文件的同步,所以需要使用store.mode=db,通过db来共享TC集群间数据
  3. 是否推荐使用default.grouplist?
    不推荐,如问题1,当registry.type=file时会用到,也就是说这里用的不是真正的注册中心,不具体服务的健康检查机制当tc不可用时无法自动剔除列表,推荐使用nacos 、eureka、redis、zk、consul、etcd3、sofa。registry.type=file或config.type=file 设计的初衷是让用户再不依赖第三方注册中心或配置中心的前提下,通过直连的方式,快速验证seata服务。

5、TCC事务代码

(1)、创建订单核心代码

  @GlobalTransactional
    @Override
    public void create(Order order) {
        // 从全局唯一id发号器获得id
        Long orderId = easyIdGeneratorClient.nextId("order_business");
        order.setId(orderId);
        String xid = RootContext.getXID();
        log.info("New Transaction Begins: " + xid);
        // orderMapper.create(order);

        // 这里修改成调用 TCC 第一节端方法
        orderTccAction.prepareCreateOrder(
                null,
                order.getId(),
                order.getUserId(),
                order.getProductId(),
                order.getCount(),
                order.getMoney());

        // 修改库存
        storageClient.decrease(order.getProductId(), order.getCount());

        // 修改账户余额
        accountClient.decrease(order.getUserId(), order.getMoney());

    }

说明:
1、通过添加 @GlobalTransactional注解,开启seata全局分布式事务。
2、通过生成全局的订单id控制事务的幂等性。

(2)、OrderTccAction

@LocalTCC
public interface OrderTccAction {
    /*
    第一阶段的方法
    通过注解指定第二阶段的两个方法名

    BusinessActionContext 上下文对象,用来在两个阶段之间传递数据
    @BusinessActionContextParameter 注解的参数数据会被存入 BusinessActionContext
     */
    @TwoPhaseBusinessAction(name = "orderTccAction", commitMethod = "commit", rollbackMethod = "rollback")
    boolean prepareCreateOrder(BusinessActionContext businessActionContext,
                      @BusinessActionContextParameter(paramName = "orderId") Long orderId,
                      @BusinessActionContextParameter(paramName = "userId") Long userId,
                      @BusinessActionContextParameter(paramName = "productId") Long productId,
                      @BusinessActionContextParameter(paramName = "count") Integer count,
                      @BusinessActionContextParameter(paramName = "money") BigDecimal money);

    // 第二阶段 - 提交
    boolean commit(BusinessActionContext businessActionContext);

    // 第二阶段 - 回滚
    boolean rollback(BusinessActionContext businessActionContext);

}

说明:
1、通过在接口上添加@LocalTCC注解,声明本地TCC事务控制接口
2、通过@TwoPhaseBusinessAction注解,声明Try、Confirm、Cancel三个阶段对应的具体方法。

(3)、OrderTccActionImpl

@Component
@Slf4j
public class OrderTccActionImpl implements OrderTccAction {
    @Autowired
    private OrderMapper orderMapper;

    @Transactional(rollbackFor = Exception.class)
    @Override
    public boolean prepareCreateOrder(BusinessActionContext businessActionContext, Long orderId, Long userId, Long productId, Integer count, BigDecimal money) {
        log.info("创建 order 第一阶段,预留资源 - "+businessActionContext.getXid());

        //因为orderId是唯一的,不能重复执行,满足幂等性, 创建状态为0(创建中)的订单
        Order order = new Order(orderId, userId, productId, count, money, 0);
        orderMapper.create(order);

        //模拟异常
        /*   if (Math.random() < 0.9999) {
            throw new RuntimeException("模拟try阶段出现 异常");
        }*/
        //事务成功,保存一个标识,供第二阶段进行判断
        ResultHolder.setResult(getClass(), businessActionContext.getXid(), "p");
        return true;
    }

    @Transactional(rollbackFor = Exception.class)
    @Override
    public boolean commit(BusinessActionContext businessActionContext) {
        log.info("创建 order 第二阶段提交,修改订单状态1 - "+businessActionContext.getXid());

        // 防止幂等性,如果commit阶段重复执行则直接返回
        if (ResultHolder.getResult(getClass(), businessActionContext.getXid()) == null) {
            return true;
        }

        //Long orderId = (Long) businessActionContext.getActionContext("orderId");
        long orderId = Long.parseLong(businessActionContext.getActionContext("orderId").toString());
        //确认提交,将订单状态修改为1(创建完成)
        orderMapper.updateStatus(orderId, 1);

        //提交成功是删除标识
        ResultHolder.removeResult(getClass(), businessActionContext.getXid());
        return true;
    }

    @Transactional(rollbackFor = Exception.class)
    @Override
    public boolean rollback(BusinessActionContext businessActionContext) {
        log.info("创建 order 第二阶段回滚,删除订单 - "+businessActionContext.getXid());

        //第一阶段没有完成的情况下,不必执行回滚(空回滚处理)
        //因为第一阶段有本地事务,事务失败时已经进行了回滚。
        //如果这里第一阶段成功,而其他全局事务参与者失败,这里会执行回滚
        //幂等性控制:如果重复执行回滚则直接返回
        if (ResultHolder.getResult(getClass(), businessActionContext.getXid()) == null) {
            return true;
        }

        //创建识别,执行Cancel操作,删除临时订单
        //Long orderId = (Long) businessActionContext.getActionContext("orderId");
        long orderId = Long.parseLong(businessActionContext.getActionContext("orderId").toString());
        orderMapper.deleteById(orderId);

        //回滚结束时,删除标识
        ResultHolder.removeResult(getClass(), businessActionContext.getXid());
        return true;
    }
}

说明:
注意TCC具体的方法实现中,对幂等、空回滚、悬挂等问题的解决。
核心逻辑解说:

  1. prepareCreateOrder方法对应try阶段,会根据唯一订单号创建临时状态订单,并在当前类下注入事务id
  2. commit对应confirm阶段,首先查看当前类是否和事务ID(businessActionContext.getXid())有关联,没有关联就直接返回true,不进行真实的提交逻辑。如果关联了事务id,则修改订单状态为已完成,并移除关联的事务ID。
  3. rollback方法对应Cancel阶段,也是首先判断当前类下是否与businessActionContext.getXid()相关联,没有关联就直接返回true防止出现空回滚。如果有关联,则执行回滚操作,根据订单id删除临时状态的订单记录。
  4. 注意TCC的3个实现方法都添加了@Transactional注解开启了事务控制,保证本地分支事务的ACID特性。

(4)、订单表说明

CREATE TABLE `order` (
  `id` bigint(11) NOT NULL,
  `user_id` bigint(11) DEFAULT NULL COMMENT '用户id',
  `product_id` bigint(11) DEFAULT NULL COMMENT '产品id',
  `count` int(11) DEFAULT NULL COMMENT '数量',
  `money` decimal(11,0) DEFAULT NULL COMMENT '金额',
  `status` int(1) DEFAULT NULL COMMENT '订单状态:0:创建中;1:已完结',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

说明:
在TCC模式下,由于需要资源预留,一般都会在事务参与的表中添加一个资源预留字段。
在订单表中的呈现是新增了订单状态字段,记录创建中状态的订单。

(5)、order服务项目结构

在这里插入图片描述
说明:
1、远程调用都采用feign调用,统一存放在feign目录下
2、tcc相关的接口,统一放在tcc目录下
3、由于order服务采用的是spring-cloud-starter-alibaba-seata 2.2.1.RELEASE版本,seata-spring-boot-starter为1.4.1版本,order中采用的是属性配置项设置的seata相关属性,省略了file.conf和registry.conf文件。

(6)、account服务核心代码

@Component
@Slf4j
public class AccountTccActionImpl implements AccountTccAction {
    @Autowired
    private AccountMapper accountMapper;

    @Transactional(rollbackFor = Exception.class)
    @Override
    public boolean prepareDecreaseAccount(BusinessActionContext businessActionContext, Long userId, BigDecimal money) {
        log.info("减少账户金额,第一阶段锁定金额,userId="+userId+", money="+money);

        //剩余可用金额
        Account account = accountMapper.selectById(userId);
        if (account.getResidue().compareTo(money) < 0) {
            throw new RuntimeException("账户金额不足");
        }

        /*
         * 冻结可用金额
        余额-money
        冻结+money
         */
        accountMapper.updateFrozen(userId, account.getResidue().subtract(money), account.getFrozen().add(money));

        //模拟异常
        if (Math.random() < 0.3) {
            throw new RuntimeException("模拟异常");
        }

        //保存标识
        ResultHolder.setResult(getClass(), businessActionContext.getXid(), "p");
        return true;
    }

    /**
     * Confirm 方法一定要在 Try 方法之后执行。因此,Confirm 方法只需要关注重复提交的问题。
     * @param businessActionContext
     * @return
     */
    @Transactional(rollbackFor = Exception.class)
    @Override
    public boolean commit(BusinessActionContext businessActionContext) {
        long userId = Long.parseLong(businessActionContext.getActionContext("userId").toString());
        BigDecimal money =  new BigDecimal(businessActionContext.getActionContext("money").toString());
        log.info("减少账户金额,第二阶段,提交,userId="+userId+", money="+money);

        //防止重复提交,确认try方法已经执行
        if (ResultHolder.getResult(getClass(), businessActionContext.getXid()) == null) {
            return true;
        }

        accountMapper.updateFrozenToUsed(userId, money);

        //删除标识
        ResultHolder.removeResult(getClass(), businessActionContext.getXid());
        return true;
    }

    @Transactional(rollbackFor = Exception.class)
    @Override
    public boolean rollback(BusinessActionContext businessActionContext) {
        long userId = Long.parseLong(businessActionContext.getActionContext("userId").toString());
        BigDecimal money =  new BigDecimal(businessActionContext.getActionContext("money").toString());

        //防止重复提交,确保try方法已经执行,防止空回滚
        if (ResultHolder.getResult(getClass(), businessActionContext.getXid()) == null) {
            return true;
        }

        log.info("减少账户金额,第二阶段,回滚,userId="+userId+", money="+money);

        accountMapper.updateFrozenToResidue(userId, money);

        //删除标识
        ResultHolder.removeResult(getClass(), businessActionContext.getXid());
        return true;
    }

}

说明:
账户金额扣减TCC逻辑的3个阶段说明

  1. prepareDecreaseAccount方法对应Try阶段,主要是执行资源预留,将要扣减的金额先保留到冻结金额中。
  2. commit方法对应Confirm阶段,事务提交过程,主要负责将冻结的金额真正扣除。
  3. rollback方法对应Cancel阶段,事务回滚,主要负责将冻结的金额解冻返回到账户的余额中。

(7)、account表

CREATE TABLE `account` (
  `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `user_id` bigint(11) DEFAULT NULL COMMENT '用户id',
  `total` decimal(10,0) DEFAULT NULL COMMENT '总额度',
  `used` decimal(10,0) DEFAULT NULL COMMENT '已用余额',
  `residue` decimal(10,0) DEFAULT '0' COMMENT '剩余可用额度',
  `frozen` decimal(10,0) DEFAULT '0' COMMENT 'TCC事务锁定的金额',
  PRIMARY KEY (`id`),
  UNIQUE KEY `user_id` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

说明:
主要是新增了frozen字段,用来保存暂时冻结的金额。

6、测试

  1. 先启动db-init服务,初始化数据库
  2. 再启动eureka-server服务,作为注册中心
  3. 然后启动其他服务。
    在这里插入图片描述
    发起创建订单请求:http://localhost:8083/create?userId=1&productId=1&count=10&money=100
    在这里插入图片描述
    在这里插入图片描述

7、异常模拟

分别在TCC的各个阶段添加模拟的异常,看程序的执行情况。

    //模拟try阶段异常
    if (Math.random() < 0.9999) {
        throw new RuntimeException("模拟try阶段出现异常");
    }

    if (Math.random() < 0.9999) {
        throw new RuntimeException("模拟commit阶段异常");
    }
 
    if (Math.random() < 0.9999) {
        throw new RuntimeException("模拟cancel阶段异常");
    }

说明:

  1. 事务的执行顺序是:创建订单——》修改库存——》减账户余额,
    创建订单的try阶段出现异常,会触发创建订单TCC操作中的Cancel操作进行空回滚;
    修改库存的try阶段出现异常,会触发创建订单TCC操作中的Cancel操作以及修改库存TCC操作中的Cancel操作进行空回滚;
    减账户余额的try阶段出现异常,会触发创建订单TCC操作中的Cancel操作以及扣减库存的TCC操作中的Cancel操作,减账户余额的TCC操作中的Cancel操作进行空回滚。
  2. Confirm操作出现异常后,会不停的重试,直到执行成功。
  3. Cancel操作出现异常后,也会不停重试,直到执行成功。
    (针对这里Confirm、Cancel的操作建议加入重试次数,失败一定操作后停止,记录相关记录,后面人工介入处理)。

8、seata tcc模式实战源码

GitHub地址

总结

1、理解TCC模式的底层逻辑:核心是将一个完整的事务分成了2个阶段,一阶段是所有事务参与者RM都注册到TC事务协调器,并发起分支事务请求进行资源预留,然后主动向TC汇报执行结果。二阶段TC事务协调器会根据收到的所有RM一阶段分支事务的执行结果来判断让RM继续执行提交操作还是回滚操作。
2、知道TCC模式和AT模式差别以及相互之间的优势。
3、实现TCC需要自己实现prepare、commit、rollback方法,并考虑空回滚、幂等、悬挂等问题。
4、知道怎么使用Seata框架实现 TCC事务。
5、需要在相关表中添加字段,用来保存预留资源。

很多人对分布式事务都心存畏惧,一是工作中接触的机会少,二是网上可以参考的实际可用的案例真的太少,大多数小伙伴都只是背了下各种分布式事务的相关实现方案的理论,而没有实际实现经验。

推荐大家都自己动手实战一番,做到心中有数,遇事不慌,而不是空谈理论。

  • 14
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

斗者_2013

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值