基于Hmily实现TCC分布式事务解决方案

前言

在上一篇中,我们大致讲述了TCC事务的来源以及执行原理,并使用seata提供的解决方案完成了一个简单案例的整合与代码演示,本篇我们将采用Hmily的方式实现TCC事务的解决方案与演示

业务描述

基于Hmily实现TCC分布式事务解决方案_第1张图片

有一个银行转账的场景,用户A需要向用户B转1块钱,如果大家使用的是同一个数据库,就不存在分布式事务的问题,现实中大家都各自使用自己的库,就产生了分布式事务

可以理解为,两个账户分别在不同的银行(用户A在bank1、用户B在bank2),bank1、bank2是两个微服务。交易过程是,用户A给 用户B转账指定金额

对于上述交易步骤,要么一起成功,要么一起失败,必须是一个整体性事务

环境准备

  • 数据库:MySQL-5.7.25
  • 微服务:spring-boot-2.1.3
  • Hmily:hmily-springcloud.2.0.6-RELEASE

微服务及数据库的关系 :

  • transfer/transfer-bank1 银行1,操作用户A账户, 连接数据库bank1
  • transfer/transfer-bank1 银行2,操作用户B账户,连接数据库bank2
  • 服务注册中心:dtx/discover-server

1、创建数据库

创建hmily数据库,用于存储hmily框架记录的数据

CREATE DATABASE `hmily` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';

创建bank1库,并导入以下表结构和数据(包含张三账户)

CREATE DATABASE `bank1` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';
DROP TABLE IF EXISTS `account_info`; CREATE TABLE `account_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `account_name` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '户 主姓名', `account_no` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '银行 卡号', `account_password` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '帐户密码', `account_balance` double NULL DEFAULT NULL COMMENT '帐户余额', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;

创建bank2库,并导入以下表结构和数据(包含用户B账户),和上述表结构保持一致,建表语句不再罗列

CREATE DATABASE `bank2` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';

2、创建Hmily数据表

这个和seata在AT模式使用中类似,每个库需要加入一张undo_log的表,框架通过这个表可以进行数据回滚,同样,hmily要能够完成分布式环境下事务解决方案,也需要依赖一些操作过程中的数据日志表,即每个数据库都创建try、confirm、cancel三张日志表,将下面的建表语句分别在bank1和bank2库中执行一下即可

CREATE TABLE `local_try_log` ( `tx_no` varchar(64) NOT NULL COMMENT '事务id', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`tx_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 CREATE TABLE `local_confirm_log` ( `tx_no` varchar(64) NOT NULL COMMENT '事务id', `create_time` datetime DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8 CREATE TABLE `local_cancel_log` ( `tx_no` varchar(64) NOT NULL COMMENT '事务id', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`tx_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8

说明:Hmily的引入不需要向seata那样单独开启一个seata-server服务,而是以jar包依赖的形式被各个使用的模块依赖即可,关于Hmily的原理,我们在上一篇做过简单介绍,主要如下:

  1. Hmily利用AOP对参与分布式事务的本地方法与远程方法进行拦截处理,通过多方拦截,事务参与者能透明的 调用到另一方的Try、Confirm、Cancel方法;传递事务上下文;并记录事务日志,酌情进行补偿,重试等。
  2. Hmily不需要事务协调服务,但需要提供一个数据库(mysql/mongodb/zookeeper/redis/file)来进行日志存 储。Hmily实现的TCC服务与普通的服务一样,只需要暴露一个接口,也就是它的Try业务。
  3. Confirm/Cancel业务 逻辑,只是因为全局事务提交/回滚的需要才提供的,因此Confirm/Cancel业务只需要被Hmily TCC事务框架 发现即可,不需要被调用它的其他业务服务所感知。

业务逻辑分析

在正式开始编码之前,我们需要了解使用hmily处理分布式事务过程中,可能产生的问题以及注意点,大体来说,主要有以下3点

  • 空回滚
  • 幂等
  • 悬挂

其实以上3点,就是我们在使用过程中,从代码的业务逻辑处理上面要面临的和需要解决的3个注意点,我们知道TCC对应着程序执行的3个阶段,try,confirm,cancel,对应一个具体的操作,比如上述从用户A转账1块钱到用户B的账户中,程序中对应的3个阶段的业务逻辑是如何执行的呢?

对于用户A来说,转账的操作发生在try阶段,从数据库层面讲,就是在try阶段完成从用户A扣款,而commit阶段,只要try没有发生异常,commit不需要做任何操作,最后调用远程rpc方法的时候,如果成功直接返回,如果失败,执行cancel操作

而对于用户B来说,try阶段不需要做任何事,因为用户B的操作是被动的,如果在commit阶段执行成功,直接返回,但是如果在commit阶段执行失败,就返回,本事务回滚

上面是正常的一个分布式事务的执行过程,但是在出现异常的时候,就可能引发上述3种问题了

  • 空回滚:
    在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法,Cancel 方法需要识别出这是一个空回 滚,然后直接返回成功。 出现原因是当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行Try阶 段,当故障恢复后,分布式事务进行回滚则会调用二阶段的Cancel方法,从而形成空回滚。 解决思路是关键就是要识别出这个空回滚。思路很简单就是需要知道一阶段是否执行,如果执行了,那就是正常回 滚;如果没执行,那就是空回滚。前面已经说过TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分 布式事务调用链条。再额外增加一张分支事务记录表,其中有全局事务 ID 和分支事务 ID,第一阶段 Try 方法里会 插入一条记录,表示一阶段执行了。Cancel 接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存 在,则是空回滚。
  • 幂等:
    通过前面介绍已经了解到,为了保证TCC二阶段提交重试机制不会引发数据不一致,要求 TCC 的二阶段 Try、 Confirm 和 Cancel 接口保证幂等,这样不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致数据 不一致等严重问题。 解决思路在上述“分支事务记录”中增加执行状态,每次执行前都查询该状态,这个和前一篇在使用seata操作库存表使用了一个预扣库存的思路相似
  • 悬挂:
    悬挂就是对于一个分布式事务,其二阶段 Cancel 接口比 Try 接口先执行。 出现原因是在 RPC 调用分支事务try时,先注册分支事务,再执行RPC调用,如果此时 RPC 调用的网络发生拥堵, 通常 RPC 调用是有超时时间的,RPC 超时以后,TM就会通知RM回滚该分布式事务,可能回滚完成后,RPC 请求 才到达参与者真正执行,而一个 Try 方法预留的业务资源,只有该分布式事务才能使用,该分布式事务第一阶段预 留的业务资源就再也没有人能够处理了,对于这种情况,我们就称为悬挂,即业务资源预留后没法继续处理。 解决思路是如果二阶段执行完成,那一阶段就不能再继续执行。在执行一阶段事务时判断在该全局事务下,“分支 事务记录”表中是否已经有二阶段事务记录,如果有则不执行Try。

还以上面用户A给用户B转账为例

方案1:

账户A

try:
		检查余额是否够30元 扣减30元
		 
confirm:
	 	空 

cancel:
		增加30元

账户B

try:
		增加30元
		 
confirm:
	 	空 

cancel:
		减少30元

方案1说明:

  1. 账户A,这里的余额就是所谓的业务资源,按照前面提到的原则,在第一阶段需要检查并预留业务资源,因此, 我们在扣钱 TCC 资源的 Try 接口里先检查 A 账户余额是否足够,如果足够则扣除 30 元。 Confirm 接口表示正式 提交,由于业务资源已经在 Try 接口里扣除掉了,那么在第二阶段的 Confirm 接口里可以什么都不用做。Cancel 接口的执行表示整个事务回滚,账户A回滚则需要把 Try 接口里扣除掉的 30 元还给账户
  2. 账号B,在第一阶段 Try 接口里实现给账户B加钱,Cancel 接口的执行表示整个事务回滚,账户B回滚则需要把 Try 接口里加的 30 元再减去

问题分析

  1. 如果账户A的try没有执行在cancel则就多加了30元
  2. 由于try,cancel、confirm都是由单独的线程去调用,且会出现重复调用,所以都需要实现幂等
  3. 账号B在try中增加30元,当try执行完成后可能会其它线程给消费了
  4. 如果账户B的try没有执行在cancel则就多减了30元

解决思路:

  1. 账户A的cancel方法需要判断try方法是否执行,正常执行try后方可执行cancel。
  2. try,cancel、confirm方法实现幂等
  3. 账号B在try方法中不允许更新账户金额,在confirm中更新账户金额
  4. 账户B的cancel方法需要判断try方法是否执行,正常执行try后方可执行cancel

优化方案:

账户A

try:
		try幂等校验 
		try悬挂处理 
		检查余额是否够30元 
		扣减30元
		 
confirm:
	 	空 

cancel:
		cancel幂等校验 
		cancel空回滚处理 
		增加可用余额30元

账户B

try:
		空
		 
confirm:
	 	confirm幂等校验 
	 	正式增加30元

cancel:
		空

基本上,以上就是我们在程序中代码逻辑的思路,下面开始代码的事项过程

代码实现

项目整体结构如图所示:
基于Hmily实现TCC分布式事务解决方案_第2张图片

父工程pom依赖

 
        UTF-8
        UTF-8
        1.8
    

    
        
            
                org.springframework.boot
                spring-boot-dependencies
                2.1.3.RELEASE
                pom
                import
            

            
                org.springframework.cloud
                spring-cloud-dependencies
                Greenwich.RELEASE
                pom
                import
            
            
                io.springfox
                springfox-swagger2
                2.9.2
            
            
                io.springfox
                springfox-swagger-ui
                2.9.2
            

            
                org.projectlombok
                lombok
                1.18.0
            

            
                javax.servlet
                javax.servlet-api
                3.1.0
                provided
            

            
                javax.interceptor
                javax.interceptor-api
                1.2
            

            
                mysql
                mysql-connector-java
                5.1.47
            

            
                org.mybatis.spring.boot
                mybatis-spring-boot-starter
                2.0.0
            

            
                com.alibaba
                druid-spring-boot-starter
                1.1.16
            

            
                org.apache.rocketmq
                rocketmq-spring-boot-starter
                2.0.2
            

            
                commons-lang
                commons-lang
                2.6
            

        
    

    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
            
        
    

搭建eureka-server

1、pom依赖


        
        
            org.springframework.cloud
            spring-cloud-starter-netflix-eureka-server
        

        
            org.springframework.boot
            spring-boot-starter-web
        

        
            org.springframework.boot
            spring-boot-starter-actuator
        

        
            org.projectlombok
            lombok
            true
        

        
            org.springframework.boot
            spring-boot-starter-test
            test
        

    

2、配置文件application.yml


server:
  port: 7001

spring:
  application:
    name: cloud-eureka-server

eureka:
  instance:
    # eureka服务端的实例名称
    # 单机 hostname: localhost
    hostname: localhost
    # Eureka客户端向服务端发送心跳的时间间隔,单位为秒(默认是30秒)
    lease-renewal-interval-in-seconds: 1
    # Eureka服务端在收到最后一次心跳后等待时间上限 ,单位为秒(默认是90秒),超时剔除服务
    lease-expiration-duration-in-seconds: 2

  server:
    # 禁用自我保护,保证不可用服务被及时删除
    enable-self-preservation: true
    eviction-interval-timer-in-ms: 2000

  client:
    # false表示不向注册中心注册自己
    register-with-eureka: false
    # false表示自己端就是注册中心,我的职责就是维护服务实例,并不需要检索服务
    fetch-registry: false
    service-url:
      # 设置与Eureka Server交互的地址查询服务和注册服务都需要依赖这个地址
      defaultZone: http://localhost:7001/eureka/

3、启动类

@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApp {

    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApp.class,args);
    }

}

启动之后,浏览器输入:http://localhost:7001即可

bank1服务搭建

1、pom依赖



        
            org.dromara
            hmily-springcloud
            2.0.6-RELEASE
            
                
                    zookeeper
                    org.apache.zookeeper
                
                
                    disruptor
                    com.lmax
                
            
        

        
        
            com.lmax
            disruptor
            3.4.2
        

        
            org.springframework.cloud
            spring-cloud-starter-openfeign
        

        
            org.springframework.cloud
            spring-cloud-starter-netflix-hystrix
        

        
            org.springframework.cloud
            spring-cloud-starter-netflix-eureka-client
        

        
            com.netflix.hystrix
            hystrix-javanica
        

        
            org.springframework.retry
            spring-retry
        

        
            org.springframework.boot
            spring-boot-starter-web
        

        
            org.springframework.boot
            spring-boot-starter-actuator
        

        
            org.mybatis.spring.boot
            mybatis-spring-boot-starter
        

        
            com.alibaba
            druid-spring-boot-starter
        

        
            mysql
            mysql-connector-java
        

        
            org.projectlombok
            lombok
        

    

2、配置文件application.yml


spring:
  application:
    name: tcc-bank1

  main:
    allow-bean-definition-overriding: true

  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: GMT+8

server:
  servlet:
    context-path: /bank1

eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://localhost:7001/eureka

management:
  endpoints:
    web:
      exposure:
        include: refresh,health,info,env

feign:
  hystrix:
    enabled: true
  compression:
    request:
      enabled: true # 配置请求GZIP压缩
      mime-types: ["text/xml","application/xml","application/json"] # 配置压缩支持的MIME TYPE
      min-request-size: 2048 # 配置压缩数据大小的下限
    response:
      enabled: true # 配置响应GZIP压缩

hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 1000  # 设置熔断超时时间  default 1000
        timeout:
          enabled: true # 打开超时熔断功能 default true


ribbon:
  ConnectTimeout: 600 # 设置连接超时时间 default 2000
  ReadTimeout: 6000    # 设置读取超时时间  default 5000
  OkToRetryOnAllOperations: true # 对所有操作请求都进行重试  default false
  MaxAutoRetriesNextServer: 2    # 切换实例的重试次数  default 1
  MaxAutoRetries: 1     # 对当前实例的重试次数 default 0

  datasource:
    ds0:
      url: jdbc:mysql://106.15.37.145:3306/bank1?useUnicode=true
      username: root
      password: root
      type: com.alibaba.druid.pool.DruidDataSource
      driver-class-name: com.mysql.jdbc.Driver
      initialSize: 5
      minIdle: 5
      maxActive: 20
      maxWait: 60000
      timeBetweenEvictionRunsMillis: 60000
      minEvictableIdleTimeMillis: 300000
      validationQuery: SELECT user()
      testWhileIdle: true
      testOnBorrow: false
      testOnReturn: false
      poolPreparedStatements: true
      connection-properties: druid.stat.mergeSql:true;druid.stat.slowSqlMillis:5000

org:
  dromara:
    hmily :
      serializer : kryo
      recoverDelayTime : 30
      retryMax : 30
      scheduledDelay : 30
      scheduledThreadMax :  10
      repositorySupport : db
      started: true
      hmilyDbConfig :
        driverClassName  : com.mysql.jdbc.Driver
        url :  jdbc:mysql://106.15.37.145:3306/hmily?useUnicode=true
        username : root
        password : root

logging:
  level:
    root: info
    org.springframework.web: info
    org.apache.ibatis: info
    org.dromara.hmily.bonuspoint: debug
    org.dromara.hmily.lottery: debug
    org.dromara.hmily: debug
    io.netty: info
    cn.itcast.wanxintx.seatademo.bank2: debug

使用hmily框架,需要使用代理事务的数据源替代,因此需要增加一个数据源的配置类

@Configuration
@EnableAspectJAutoProxy(proxyTargetClass=true)
public class DatabaseConfiguration {


    private final ApplicationContext applicationContext;


    @Autowired
    private Environment env;

    public DatabaseConfiguration(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }


    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.ds0")
    public DruidDataSource ds0() {
        DruidDataSource druidDataSource = new DruidDataSource();
        return druidDataSource;
    }



    @Bean
    public HmilyTransactionBootstrap hmilyTransactionBootstrap(HmilyInitService hmilyInitService){
        HmilyTransactionBootstrap hmilyTransactionBootstrap = new HmilyTransactionBootstrap(hmilyInitService);
        hmilyTransactionBootstrap.setSerializer(env.getProperty("org.dromara.hmily.serializer"));
        hmilyTransactionBootstrap.setRecoverDelayTime(Integer.parseInt(env.getProperty("org.dromara.hmily.recoverDelayTime")));
        hmilyTransactionBootstrap.setRetryMax(Integer.parseInt(env.getProperty("org.dromara.hmily.retryMax")));
        hmilyTransactionBootstrap.setScheduledDelay(Integer.parseInt(env.getProperty("org.dromara.hmily.scheduledDelay")));
        hmilyTransactionBootstrap.setScheduledThreadMax(Integer.parseInt(env.getProperty("org.dromara.hmily.scheduledThreadMax")));
        hmilyTransactionBootstrap.setRepositorySupport(env.getProperty("org.dromara.hmily.repositorySupport"));
        hmilyTransactionBootstrap.setStarted(Boolean.parseBoolean(env.getProperty("org.dromara.hmily.started")));
        HmilyDbConfig hmilyDbConfig = new HmilyDbConfig();
        hmilyDbConfig.setDriverClassName(env.getProperty("org.dromara.hmily.hmilyDbConfig.driverClassName"));
        hmilyDbConfig.setUrl(env.getProperty("org.dromara.hmily.hmilyDbConfig.url"));
        hmilyDbConfig.setUsername(env.getProperty("org.dromara.hmily.hmilyDbConfig.username"));
        hmilyDbConfig.setPassword(env.getProperty("org.dromara.hmily.hmilyDbConfig.password"));
        hmilyTransactionBootstrap.setHmilyDbConfig(hmilyDbConfig);
        return hmilyTransactionBootstrap;
    }
}

如下为演示工程的整个结构
基于Hmily实现TCC分布式事务解决方案_第3张图片

下面我们就主要的模块和代码进行说明

  1. AccountInfoDao,bank1操作数据库的接口
  2. Bank2Client,openfign远程调用bank2方法的本地客户端
  3. Bank2ClientFallback,feign调用过程中异常处理类
  4. AccountInfoServiceImpl,主要的服务实现类,转账的逻辑在该类完成

非关键性代码直接贴出,可通过注释查看

AccountInfoDao

@Mapper
@Component
public interface AccountInfoDao {
    @Update("update account_info set account_balance=account_balance - #{amount} where account_balance>=#{amount} and account_no=#{accountNo} ")
    int subtractAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);

    @Update("update account_info set account_balance=account_balance + #{amount} where account_no=#{accountNo} ")
    int addAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);

    /**
     * 增加某分支事务try执行记录
     * @param localTradeNo 本地事务编号
     * @return
     */
    @Insert("insert into local_try_log values(#{txNo},now());")
    int addTry(String localTradeNo);

    @Insert("insert into local_confirm_log values(#{txNo},now());")
    int addConfirm(String localTradeNo);

    @Insert("insert into local_cancel_log values(#{txNo},now());")
    int addCancel(String localTradeNo);

    /**
     * 查询分支事务try是否已执行
     * @param localTradeNo 本地事务编号
     * @return
     */
    @Select("select count(1) from local_try_log where tx_no = #{txNo} ")
    int isExistTry(String localTradeNo);
    /**
     * 查询分支事务confirm是否已执行
     * @param localTradeNo 本地事务编号
     * @return
     */
    @Select("select count(1) from local_confirm_log where tx_no = #{txNo} ")
    int isExistConfirm(String localTradeNo);

    /**
     * 查询分支事务cancel是否已执行
     * @param localTradeNo 本地事务编号
     * @return
     */
    @Select("select count(1) from local_cancel_log where tx_no = #{txNo} ")
    int isExistCancel(String localTradeNo);

}

Bank2Client

@FeignClient(value="TCC-DEMO-BANK2",fallback=Bank2ClientFallback.class)
public interface Bank2Client {

    //远程调用用户B微服务
    @GetMapping("/bank2/transfer")
    @Hmily
    public  Boolean transfer(@RequestParam("amount") Double amount);
}

Bank2ClientFallback

@Component
public class Bank2ClientFallback implements Bank2Client {

    public Boolean transfer(Double amount) {
        return false;
    }
}

AccountInfoServiceImpl

@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {

    @Autowired
    AccountInfoDao accountInfoDao;

    @Autowired
    Bank2Client bank2Client;

    // 账户扣款,就是tcc的try方法

    /**
     * try幂等校验
     * try悬挂处理
     * 检查余额是够扣减金额
     * 扣减金额
     * @param accountNo
     * @param amount
     */
    @Transactional
    //只要标记@Hmily就是try方法,在注解中指定confirm、cancel两个方法的名字
    @Hmily(confirmMethod = "commit", cancelMethod = "rollback")
    public void updateAccountBalance(String accountNo, Double amount) {
        //获取全局事务id
        String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
        log.info("bank1 try begin 开始执行...xid:{}", transId);
        //幂等判断 判断local_try_log表中是否有try日志记录,如果有则不再执行
        if (accountInfoDao.isExistTry(transId) > 0) {
            log.info("bank1 try 已经执行,无需重复执行,xid:{}", transId);
            return;
        }
        //try悬挂处理,如果cancel、confirm有一个已经执行了,try不再执行
        if (accountInfoDao.isExistConfirm(transId) > 0 || accountInfoDao.isExistCancel(transId) > 0) {
            log.info("bank1 try悬挂处理  cancel或confirm已经执行,不允许执行try,xid:{}", transId);
            return;
        }
        //扣减金额
        if (accountInfoDao.subtractAccountBalance(accountNo, amount) <= 0) {
            //扣减失败
            throw new RuntimeException("bank1 try 扣减金额失败,xid:{}" + transId);
        }
        //插入try执行记录,用于幂等判断
        accountInfoDao.addTry(transId);
        //远程调用李四,转账
        if (!bank2Client.transfer(amount)) {
            throw new RuntimeException("bank1 远程调用李四微服务失败,xid:{}" + transId);
        }
        if (amount == 2) {
            throw new RuntimeException("人为制造异常,xid:{}" + transId);
        }
        log.info("bank1 try end 结束执行...xid:{}", transId);
    }

    //confirm方法
    @Transactional
    public void commit(String accountNo, Double amount) {
        //获取全局事务id
        String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
        log.info("bank1 confirm begin 开始执行...xid:{},accountNo:{},amount:{}", transId, accountNo, amount);
    }

    /**
     * cancel方法
     * cancel幂等校验
     * cancel空回滚处理
     * 增加可用余额
     *
     * @param accountNo
     * @param amount
     */
    @Transactional
    public void rollback(String accountNo, Double amount) {
        //获取全局事务id
        String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
        log.info("bank1 cancel begin 开始执行...xid:{}", transId);
        //	cancel幂等校验
        if (accountInfoDao.isExistCancel(transId) > 0) {
            log.info("bank1 cancel 已经执行,无需重复执行,xid:{}", transId);
            return;
        }
        //cancel空回滚处理,如果try没有执行,cancel不允许执行
        if (accountInfoDao.isExistTry(transId) <= 0) {
            log.info("bank1 空回滚处理,try没有执行,不允许cancel执行,xid:{}", transId);
            return;
        }
        //	增加可用余额
        accountInfoDao.addAccountBalance(accountNo, amount);
        //插入一条cancel的执行记录
        accountInfoDao.addCancel(transId);
        log.info("bank1 cancel end 结束执行...xid:{}", transId);
    }

}

bank1提供一个对外调用的controller,方便后面进行测试

@RestController
public class Bank1Controller {
    @Autowired
    AccountInfoService accountInfoService;

    @GetMapping("/transfer")
    public Boolean transfer(@RequestParam("amount") Double amount) {
        this.accountInfoService.updateAccountBalance("1", amount);
        return true;
    }

}

启动类

@SpringBootApplication(exclude = MongoAutoConfiguration.class)
@EnableDiscoveryClient
@EnableAspectJAutoProxy
@ComponentScan({"com.congge","org.dromara.hmily"})
@EnableFeignClients(basePackages = {"com.congge.feign"})
public class Bank1App {

    public static void main(String[] args) {
        SpringApplication.run(Bank1App.class,args);
    }

}

然后启动bank1服务,在eureka中可以看到已经注册进去了一个服务
在这里插入图片描述

bank2工程代码

由于基本上和bank1的代码大部分都类似,下面只贴出关键性的服务事项层的代码

基于Hmily实现TCC分布式事务解决方案_第4张图片

1、application.yml中,修改数据库连接信息即可,以及端口号

2、配置类参考上述bank1基本类似

AccountInfoDao

@Component
@Mapper
public interface AccountInfoDao {

    @Update("update account_info set account_balance=account_balance + #{amount} where  account_no=#{accountNo} ")
    int addAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);


    /**
     * 增加某分支事务try执行记录
     * @param localTradeNo 本地事务编号
     * @return
     */
    @Insert("insert into local_try_log values(#{txNo},now());")
    int addTry(String localTradeNo);

    @Insert("insert into local_confirm_log values(#{txNo},now());")
    int addConfirm(String localTradeNo);

    @Insert("insert into local_cancel_log values(#{txNo},now());")
    int addCancel(String localTradeNo);

    /**
     * 查询分支事务try是否已执行
     * @param localTradeNo 本地事务编号
     * @return
     */
    @Select("select count(1) from local_try_log where tx_no = #{txNo} ")
    int isExistTry(String localTradeNo);
    /**
     * 查询分支事务confirm是否已执行
     * @param localTradeNo 本地事务编号
     * @return
     */
    @Select("select count(1) from local_confirm_log where tx_no = #{txNo} ")
    int isExistConfirm(String localTradeNo);

    /**
     * 查询分支事务cancel是否已执行
     * @param localTradeNo 本地事务编号
     * @return
     */
    @Select("select count(1) from local_cancel_log where tx_no = #{txNo} ")
    int isExistCancel(String localTradeNo);

}

AccountInfoServiceImpl服务实现类

@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {

    @Autowired
    AccountInfoDao accountInfoDao;

    @Hmily(confirmMethod="confirmMethod", cancelMethod="cancelMethod")
    public void updateAccountBalance(String accountNo, Double amount) {
        //获取全局事务id
        String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
        log.info("bank2 try begin 开始执行...xid:{}",transId);
    }

    /**
     * confirm方法
     * 	confirm幂等校验
     * 	正式增加金额
     * @param accountNo
     * @param amount
     */
    @Transactional
    public void confirmMethod(String accountNo, Double amount){
        //获取全局事务id
        String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
        log.info("bank2 confirm begin 开始执行...xid:{}",transId);
        if(accountInfoDao.isExistConfirm(transId)>0){
            log.info("bank2 confirm 已经执行,无需重复执行...xid:{}",transId);
            return ;
        }
        //增加金额
        accountInfoDao.addAccountBalance(accountNo,amount);
        //增加一条confirm日志,用于幂等
        accountInfoDao.addConfirm(transId);
        log.info("bank2 confirm end 结束执行...xid:{}",transId);
    }

    /**
     * @param accountNo
     * @param amount
     */
    public void cancelMethod(String accountNo, Double amount){
        //获取全局事务id
        String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
        log.info("bank2 cancel begin 开始执行...xid:{}",transId);
    }
}

暴露出去的接口Bank2Controller

@RestController
public class Bank2Controller {
    @Autowired
    AccountInfoService accountInfoService;

    @GetMapping("/transfer")
    public Boolean transfer(@RequestParam("amount") Double amount) {
        this.accountInfoService.updateAccountBalance("2", amount);
        return true;
    }

}

启动类

@SpringBootApplication(exclude = MongoAutoConfiguration.class)
@EnableDiscoveryClient
@EnableAspectJAutoProxy
@ComponentScan({"com.congge","org.dromara.hmily"})
public class Bank2App {

    public static void main(String[] args) {
        SpringApplication.run(Bank2App.class,args);
    }

}

最后启动bank2服务

测试

首先在数据库中,我们将用户A的账户初始化一条数据,用户B的数据为0
基于Hmily实现TCC分布式事务解决方案_第5张图片
基于Hmily实现TCC分布式事务解决方案_第6张图片

1、正常测试

浏览器输入:http://localhost:7002/bank1/transfer?amount=1 ,即在bank1中进行调用

执行成功,然后我们去控制台查看输出日志帮助我们更好的理解执行过程
基于Hmily实现TCC分布式事务解决方案_第7张图片
基于Hmily实现TCC分布式事务解决方案_第8张图片
基于Hmily实现TCC分布式事务解决方案_第9张图片
同时可以看到,在程序启动的时候,初始化到hmily库下面的2张表,数据为空,即在执行成功的情况下数据最后被清理了
基于Hmily实现TCC分布式事务解决方案_第10张图片

2、异常测试

1、用户B事务失败,用户A事务回滚成功

浏览器输入:http://localhost:7002/bank1/transfer?amount=10000,很明显,用户A 账户上面并没有这么多的余额,通过bank1的输入日志可以看到
在这里插入图片描述
同时用户A和B的账户并没有发生异常

2、用户A事务失败,用户B分支事务回滚成功

我们在代码里面人为制造一个异常,该异常产生在RPC调用之后
基于Hmily实现TCC分布式事务解决方案_第11张图片
浏览器输入:http://localhost:7002/bank1/transfer?amount=2
基于Hmily实现TCC分布式事务解决方案_第12张图片

分别观察bank1和bank2的控制台输出日志
在这里插入图片描述
基于Hmily实现TCC分布式事务解决方案_第13张图片

然后我们去数据库观察一下记录,验证一下结果,可以发现用户A的账户记录依然是999,B的账户还是1,说明bank2的事务回滚成功了
基于Hmily实现TCC分布式事务解决方案_第14张图片

还有更多的异常,有兴趣的同学可以继续验证,比如超时异常等,本篇篇幅较长,需要耐心观看!本篇到此结束最后感谢观看!

你可能感兴趣的:(分布式事务)