Skip to content

解决方案之TCC

1. 什么是TCC事务

TCC是Try、Confirm、Cancel三个词语的缩写,它是一种柔性事务方案,TCC要求每个分支事务实现三个操作:预处理Try、确认Confirm、撤销Cancel。Try操作做业务检查及资源预留,Confirm做业务确认操作,Cancel实现一个与Try相反的操作即回滚操作。TM首先发起所有的分支事务的try操作,任何一个分支事务的try操作执行失败,TM将会发起所有分支事务的Cancel操作,若Try操作全部成功,TM将会发起所有分支事务的Confirm操作,其中Confirm/Cancel操作若执行失败,TM会进行重试。 TCC事务 分支事务失败的情况:
分支事务失败 TCC事务分为两个阶段来执行:

  1. Try阶段是做业务检查(一致性)及资源预留(隔离),此操作仅是一个初步操作,它和后续的Confirm一起才能真正构成一个完整的业务逻辑。
  2. Confirm/Cancel阶段: Confirm是做确认提交,Try操作所有分支事务执行成功后开始执行Confirm。通常情况下,采用TCC则认为Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引入重试机制或人工处理。而在业务执行错误需要回滚的状态下,Cancel操作会执行分支事务的业务取消,预留资源释放。通常情况下,采用TCC则认为Cancel阶段也是一定成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。
    上图中全局事务发起方充当TM事务管理器,TM事务管理器可以实现为独立的服务,TM独立出来是为了成为公用组件,是为了考虑系统结构和软件复用。TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务调用链条,用来记录事务上下文,追踪和记录状态,由于Confirm和Cancel失败需进行重试,因此需要实现为幂等,幂等性是指同一个操作无论请求多少次,其结果都相同。

2. TCC 优势

  1. TCC事务最大的优势是效率高。TCC事务在Try阶段的锁定资源并不是真正意义上的锁定,而是真实提交了本地事务,减少资源锁持有时间,将资源预留到中间态,并不需要阻塞等待,因此效率比XA事务高。
  2. TCC事务不依赖于底层数据资源的事务支持,需要自己实现Try、Confirm、Cancel的代码。

3. TCC的空回滚、幂等、悬挂问题

3.1 空回滚

在没有调用TCC资源Try方法的情况下,调用了二阶段的Cancel方法,Cancel方法需要识别出这是一个空回滚,然后直接返回成功。出现原因是当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行Try阶段,当故障恢复后,分布式事务进行回滚则会调用二阶段的Cancel方法,从而形成空回滚。
解决思路是关键就是要识别出这个空回滚。思路很简单就是需要知道一阶段是否执行,如果执行了,那就是正常回滚;如果没执行,那就是空回滚。前面已经说过TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务调用链条。再额外增加一张分支事务记录表,其中有全局事务 ID 和分支事务 ID,第一阶段 Try方法里会插入一条记录,表示一阶段执行了。Cancel接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。

3.2 幂等

通过前面介绍已经了解到,为了保证TCC二阶段提交重试机制不会引发数据不一致,要求 TCC 的二阶段 Try、Confirm 和 Cancel 接口保证幂等,这样不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致数据不一致等严重问题。
解决思路在上述"分支事务记录"中增加执行状态,每次执行前都查询该状态。

3.3 悬挂

悬挂就是对于一个分布式事务,其二阶段Cancel接口比Try接口先执行。出现原因是在RPC调用分支事务try时,先注册分支事务,再执行RPC调用,如果此时RPC调用的网络发生拥堵,通常RPC调用是有超时时间的,RPC超时以后,TM就会通知RM回滚该分布式事务,可能回滚完成后,RPC请求才到达参与者真正执行,而一个Try方法预留的业务资源,只有该分布式事务才能使用,该分布式事务第一阶段预留的业务资源就再也没有人能够处理了,对于这种情况,我们就称为悬挂,即业务资源预留后没法继续处理。
解决思路是如果二阶段执行完成,那一阶段就不能再继续执行。在执行一阶段事务时判断在该全局事务下,"分支事务记录"表中是否已经有二阶段事务记录,如果有则不执行Try。

3.4 场景举例分析

需求: A 转账 30 元给 B,A和B账户在不同的服务。
方案1:

账户A:                        账户B:                              
try:                         try:
    检查余额是否够30元              增加30元
    扣减30元                      
confirm:                     confirm:
    空                            空
cancel:                      cancel:
    增加30元                       减少30元

方案1的问题分析:

  • 如果账户A的try没有执行在cancel则就多加了30元。
  • 由于try,cancel、confirm都是由单独的线程去调用,且会出现重复调用,所以都需要实现幂等。
  • 账号B在try中增加30元,当try执行完成后可能会其它线程给消费了。
  • 如果账户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:                      账户B:
try:                       try:
    try幂等校验                 空
    try悬挂处理
    检查余额是否够30元
    扣减30元
confirm:                   confirm:
    空                          confirm幂等校验
                                正式增加30元
cancel:                    cancel:
    cancel幂等校验               空
    cancel空回滚处理
    增加可用余额30元

4. Seata实现TCC事务

Seata的TCC模式的整体是两阶段提交的模型。全局事务是由若干分支事务组成的,每个分支事务都具备自己的:一阶段prepare、二阶段commit或rollback,如下图:
Alt text Seata的TCC事务中有三个角色组成:

  • TM(Transaction Manager): 事务管理器(全局事务发起方),管理全局事务, 包括开启全局事务,提交/回滚全局事务
  • RM(Resource Manager): 资源管理器(参与者)管理分支事务,与TC交互以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚
  • TC(Transaction Coordinator): 事务协调者, 管理全局事务和分支事务的状态
    具体流程说明:
  1. TM发起全局事务
  2. TM调用分支事务RM的try方法
  3. RM在Try执行前注册分支事务到TC
  4. RM在Try执行完后,上报事务状态给TC
  5. TM通知TC进行全局事务的提交或回滚
  6. TC调度,执行分支事务RM的Confirm或Cancel

信息

RM需要提前提供"准备"、"提交"和"回滚"3个操作;而TM分2阶段协调所有资源管理器RM,在第一阶段询问所有资源管理器"准备"是否成功,如果所有资源均"准备"成功则在第二阶段执行所有资源的"提交"操作,否则在第二阶段执行所有资源的"回滚"操作,保证所有资源的最终状态是一致的,要么全部提交要么全部回滚。

在Seata1.5.1版本之后,终于解决了TCC模式的幂等、悬挂和空回滚问题。增加了一张表名tcc_fence_log的事务控制表来解决这个问题。在@TwoPhaseBusinessAction注解中提到的属性useTCCFence就是来指定是否开启这个机制。 Seata的优化流程如下图:
实现TCC事务 TC和RM之间的交互由之前的四次变成1次:TC仍然保存全局事务的状态。TM开启全局事务时,RM不再需要向TC发送注册消息,而是把分支事务状态保存在了本地。TM向TC发送提交或回滚消息后,RM异步线程首先查出本地保存的未提交分支事务,然后向TC发送消息获取(本地分支事务)所在的全局事务状态,以决定是提交还是回滚本地事务。

4.1 业务说明

两个账户在三个不同的银行(张三在bank1、李四在bank2),bank1和bank2是两个个微服务。交易过程是,张三给李四转账指定金额。
上述交易步骤,要么一起成功,要么一起失败,必须是一个整体性的事务。
Alt text

4.2 程序实现

用户转账的业务逻辑。整个业务逻辑由2个微服务提供支持:

  1. bank1金融服务
  2. bank2金融服务

使用技术架构: Dubbo + SEATA + Zookeeper + SpringBoot + MySQL

4.3 部署SEATA、Zookeeper

步骤详情可以参照Seata实现2PC事务章节内容--部署Seata、Zookeeper内容。

4.4 数据库初始化

步骤详情在Seata实现2PC事务章节内容--数据库初始化基础上,额外在bank1和bank2两个数据库分别创建tcc_fence_log表。

sql
-- 需要在bank1、bank2数据库执行,增加freeze_money来实现手动回滚资金。
ALTER TABLE account_info ADD COLUMN freeze_money double comment '冻结金额';
-- 需要在bank1、bank2数据库执行,用来支持`@TwoPhaseBusinessAction`注解中的属性`useTCCFence`。
CREATE TABLE IF NOT EXISTS `tcc_fence_log`
(
    `xid`           VARCHAR(128)  NOT NULL COMMENT 'global id',
    `branch_id`     BIGINT        NOT NULL COMMENT 'branch id',
    `action_name`   VARCHAR(64)   NOT NULL COMMENT 'action name',
    `status`        TINYINT       NOT NULL COMMENT 'status(tried:1;committed:2;rollbacked:3;suspended:4)',
    `gmt_create`    DATETIME(3)   NOT NULL COMMENT 'create time',
    `gmt_modified`  DATETIME(3)   NOT NULL COMMENT 'update time',
    PRIMARY KEY (`xid`, `branch_id`),
    KEY `idx_gmt_modified` (`gmt_modified`),
    KEY `idx_status` (`status`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;

信息

Seata的TCC模式由于不依赖数据库回滚事务,由代码实现,所以不依赖XA模式中的undo_log表。

3.5 工程代码

源码附上: https://gitee.com/javaee_home/distritrans_learn

  1. 在distritrans_learn父工程下面创建两个模块:
    dtx-tcc-seata-bank1: 操作张三账户,连接数据库bank1
    dtx-tcc-seata-bank2: 操作李四账户,连接数据库bank2
  2. 配置文件详见Seata实现2PC事务章节内容--项目配置
  3. 编写TCC接口实现类,在Try方法需要上面使用@TwoPhaseBusinessAction注解标识,表示了当前方法使用TCC模式管理事务提交, name属性表示给当前事务注册了一个全局唯一的事务名,另外需要在注解中使用commitMethod属性写明Confirm的方法名,使用rollbackMethod属性写明Cacel的方法名。其次,可以在 TCC 模式下使用 BusinessActionContext 在事务上下文中传递查询参数。如下属性:
  • xid 全局事务id
  • branchId 分支事务id
  • actionName 分支资源id,(resource id)
  • actionContext 业务传递的参数,可以通过@BusinessActionContextParameter来标注需要传递的参数。
    在定义好TCC接口实现类之后,如果是本地事务需要加上@LocalTCC注解,远程RPC服务接口则需要再实现类上面加@LocalTCC,执行流程如下:
  1. @GlobalTransactional开启一个分布式事务。
  2. @TwoPhaseBusinessAction注解标识的方法会被执行进行Try阶段操作。
  3. 在Comfirm阶段时RM会执行commitMethod属性表明的方法。如果返回false, 将会执行Cancel操作,执行rollbackMethod属性表明的方法。

重要提示

Seata2.0之后版本的TCC模式关于绑定属性注解需要加在实现类上,接口上加TCC相关注解的方式将要被废弃。可以参照官网github的issue:https://github.com/apache/incubator-seata/issues/6235

其中dtx-tcc-seata-bank1代码:

java
@SpringBootApplication(scanBasePackages = "com.example.dtxtccbank1",
        exclude = {DataSourceAutoConfiguration.class})
@EnableDubbo(scanBasePackages = "com.example.dtxtccbank1")
@MapperScan("com.example.dtxtccbank1.mapper")
public class DtxSeataTccBank1Application {

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

}
java
@Service
@LocalTCC
public class Bank1MoneyServiceImpl implements Bank1MoneyService {

    private Logger logger = LoggerFactory.getLogger(Bank1MoneyServiceImpl.class);
    @Resource
    AccountInfoMapper accountInfoMapper;

    /**
     * TCC的try方法:张三转账
     *
     * 定义两阶段提交,在try阶段通过@TwoPhaseBusinessAction注解定义了分支事务的 resourceId,commit和 cancel 方法
     *  name = 该tcc的bean名称,全局唯一
     *  commitMethod = commit 为二阶段确认方法
     *  rollbackMethod = rollback 为二阶段取消方法
     *  BusinessActionContextParameter注解 传递参数到二阶段中
     *  useTCCFence seata1.5.1的新特性,用于解决TCC幂等,悬挂,空回滚问题,需增加日志表tcc_fence_log
     */
    @Override
    @Transactional(rollbackFor=Exception.class)
    @TwoPhaseBusinessAction(name = "prepareUpdateAccountBalance", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
    public void prepareUpdateAccountBalance(@BusinessActionContextParameter(paramName = "accountNo") String accountNo,
                                            @BusinessActionContextParameter(paramName = "amount")  double amount) {
        logger.info("******** Bank1 Service Begin ... xid: {}", RootContext.getXID());
        // 1. 查询金额是否足够
        double accountMoney = accountInfoMapper.queryAccountMoney(accountNo);
        if (accountMoney > amount) {
            logger.info("开始冻结用户 {} 余额", accountNo);
            accountInfoMapper.updateAccountBalance(accountNo, amount);
        }else{
            throw  new RuntimeException("用户的账户金额不足");
        }
    }

    /**
     *
     * TCC的confirm方法:释放冻结金额
     * 二阶段确认方法可以另命名,但要保证与commitMethod一致
     * context可以传递try方法的参数
     */
    @Override
    public boolean commit(BusinessActionContext actionContext) {
        // 获取账户
        String accountNo = actionContext.getActionContext("accountNo").toString();
        double amount = Double.parseDouble(actionContext.getActionContext("amount").toString());
        //扣减冻结库存
        int updateOrderRecord = accountInfoMapper.updateAccountFreeze(accountNo, amount);
        logger.info("更新账户转账信息:{} {}", accountNo, updateOrderRecord > 0 ? "成功" : "失败");
        return true;
    }

    /**
     * TCC的cancel方法:账户金额恢复
     * 二阶段取消方法可以另命名,但要保证与rollbackMethod一致
     */
    @Override
    public boolean rollback(BusinessActionContext actionContext) {
        //获取订单id
        String accountNo = actionContext.getActionContext("accountNo").toString();
        double amount = Double.parseDouble(actionContext.getActionContext("amount").toString());
        //更新金额为上一个状态
        int updateOrderRecord = accountInfoMapper.unfreezeAccountStorage(accountNo, amount);
        logger.info("更新账户转账信息:{} {}", accountNo, updateOrderRecord > 0 ? "成功" : "失败");
        return true;
    }
}
java
@Service
public class BusinessServiceImpl implements BussinessService {

    private Logger logger = LoggerFactory.getLogger(BusinessServiceImpl.class);

    @DubboReference(version = "1.0.0", timeout = 80000) // 设置6s可以验证超时回滚
    private Bank2TccMoneyService bank2TccMoneyService;

    @Resource
    private Bank1MoneyService bank1MoneyService;

    @GlobalTransactional(name = "businessAccount", rollbackFor = Exception.class)
    @Override
    public void businessAccount(String accountNo, double amount) {
        logger.info("=============用户转账=================");
        logger.info("当前 XID: {}", RootContext.getXID());

        //张三扣减金额
        bank1MoneyService.prepareUpdateAccountBalance(accountNo, amount);
        //向李四转账
        boolean result = bank2TccMoneyService.preTransfer("李四", amount);
        /*//远程调用失败
        if (remoteRst.equals("fallback")) {
            throw new RuntimeException("bank1 下游服务异常");
        }*/
        //人为制造错误
        if (amount == 3) {
            throw new RuntimeException("bank1 make exception 3");
        }
    }
}
java
public interface Bank1MoneyService {
    void prepareUpdateAccountBalance(String accountNo, double amount);

    boolean commit(BusinessActionContext actionContext);

    boolean rollback(BusinessActionContext actionContext);
}

张三账户服务两阶段处理流程:

  • 一阶段先对账户余额进行冻结:update account_info set money=money-#{money},freeze_money=freeze_money+#{money}
  • 如果后续其他业务的try(一阶段)执行没问题,则二阶段框架会调用 commit 方法,把冻结的金额给释放掉:update account_info set freeze_money=freeze_money-#{money}
  • 如果后续其他业务的try(一阶段)执行异常,则二阶段框架会调用 rollback 方法,把扣减的金额和冻结的一并回滚:update account_info set money=money+#{money},freeze_money=freeze_money-#{money}

其中dtx-tcc-seata-bank2代码:

java
@SpringBootApplication(scanBasePackages = "com.example.dtxtccbank2",
        exclude = {DataSourceAutoConfiguration.class})
@EnableDubbo(scanBasePackages = "com.example.dtxtccbank2")
@MapperScan("com.example.dtxtccbank2.mapper")
public class DtxSeataTccBank2Application {

    public static void main(String[] args) {
        SpringApplication.run(DtxSeataTccBank2Application.class, args);
    }
}
java
@DubboService(version = "1.0.0", interfaceClass = Bank2TccMoneyService.class)
@LocalTCC
public class Bank2MoneyServiceImpl implements Bank2TccMoneyService {

    private Logger logger = LoggerFactory.getLogger(Bank2MoneyServiceImpl.class);
    @Resource
    AccountInfoMapper accountInfoMapper;

    // 李四转账
    @Override
    @Transactional(rollbackFor=Exception.class)
    public boolean preTransfer(BusinessActionContext actionContext,
                               @BusinessActionContextParameter(paramName = "accountName") String accountName,
                               @BusinessActionContextParameter(paramName = "amount") double amount) {
        logger.info("******** Bank1 Service Begin ... xid: {}", RootContext.getXID());
        // 1. 查询金额是否足够
        double accountMoney = accountInfoMapper.queryAccountMoney(accountName);
        if(amount==4){
            throw new RuntimeException("转账李四出错了。。。");
        }
        logger.info("开始冻结用户 {} 余额", accountName);
        accountInfoMapper.updateAccountBalance(accountName, amount);
        return true;
    }

    @Override
    public boolean commit(BusinessActionContext actionContext) {
        // 获取账户
        Object accountNameObj = actionContext.getActionContext("accountName");
        System.out.println("==================commit==========================");
        if(accountNameObj!=null ){
            String accountName = accountNameObj.toString();
            double amount = Double.parseDouble(actionContext.getActionContext("amount").toString());
            //扣减冻结金额
            int updateOrderRecord = accountInfoMapper.updateAccountFreeze(accountName, amount);
            logger.info("更新账户转账信息:{} {}", accountName, updateOrderRecord > 0 ? "成功" : "失败");
            return true;
        }else{
            logger.info("commit过程失败---------- 用户 为空 ");
            return true;
        }
    }

    @Override
    public boolean rollback(BusinessActionContext actionContext) {
        //获取账户
        Object accountNameObj = actionContext.getActionContext("accountName");
        if(accountNameObj!=null ) {
            String accountName = accountNameObj.toString();
            double amount = Double.parseDouble(actionContext.getActionContext("amount").toString());
            //更新金额为上一个状态
            int updateOrderRecord = accountInfoMapper.unfreezeAccountStorage(accountName, amount);
            logger.info("更新账户转账信息:{} {}", accountName, updateOrderRecord > 0 ? "成功" : "失败");
            return true;
        }else{
            return true;
        }
    }
}

其中dtx-seata-common代码:

java
//@LocalTCC  Dubbod服务接口类无需加上@LocalTCC注解
public interface Bank2TccMoneyService {

    boolean preTransfer(String accountName, double amount);

    boolean commit(BusinessActionContext actionContext);

    boolean rollback(BusinessActionContext actionContext);
}

执行结果:
张三转账控制台输出 Alt text 李四收款控制台输出 Alt text

5. 总结

  • XA是资源(数据库)层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。
  • TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁。