• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

分布式事务 - Seata - TCC模式

武飞扬头像
罗小爬EX
帮助1

之前介绍过分布式事务之Seata AT模式,这篇文章接着介绍如何使用Seata TCC模式。

一、什么是TCC

TCC 是分布式事务中的二阶段提交协议,它的全称为 Try-Confirm-Cancel,即资源预留(Try)、确认操作(Confirm)、取消操作(Cancel),他们的具体含义如下:

  • Try:对业务资源的检查并预留。
    • 若分支事务的Try操作出现异常,则直接全局事务回滚,其他分支事务的Cancel被触发;
    • 若所有分支事务的Try操作均成功,则直接提交全局事务,所有分支事务的Confirm被触发;
  • Confirm:对业务处理进行提交,即 commit 操作。
    • 若Try 成功,需要保证Confirm也一定成功;
    • 若Confirm操作自身出现异常,则Confirm操作会被多次重复调用;
  • Cancel:对业务处理进行取消,即回滚操作。
    • 该步骤仅需对 Try 预留的资源进行释放;
    • 若Cancel操作自身出现异常,则Cancel操作会被多次重复调用;

二、AT & TCC区别 及 适用场景

一个分布式的全局事务,整体是两阶段提交Try-[Comfirm/Cancel] 的模型。在Seata中,AT模式与TCC模式事实上都是两阶段提交的具体实现

他们的区别在于:
AT 模式基于支持本地 ACID 事务 的 关系型数据库(目前支持Mysql、Oracle与PostgreSQL):

  • 一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志undo_log记录。
    • 由Seata框架自动生成undo_log,无需开发者手动编程实现
    • 开发者仅定义核心业务逻辑,即实现一个@Transactional方法
  • 二阶段 commit 行为:马上成功结束,自动异步批量清理回滚日志。
  • 二阶段 rollback 行为:通过回滚日志,自动生成补偿操作,完成数据回滚。

TCC 模式不依赖于底层数据资源的事务支持,且Try-Confirm-Cancel三者完全由开发者自行开发定义:

  • 一阶段 prepare 行为:调用 自定义 的 Try逻辑。
  • 二阶段 commit 行为:调用 自定义的 Confirm 逻辑。
  • 二阶段 rollback 行为:调用 自定义的 Cancel 逻辑。

AT和TCC模式在一阶段都会提交本地事务,二阶段都是异步执行,
相较于传统XA阻塞型事务

  • 性能更高
  • 无法做到强一致性,而是最终一致性,需要处理并且能够接受中间状态(软状态)

所谓 TCC 模式,是指支持把 自定义的 分支事务纳入到全局事务的管理中。
简单点概括,SEATA的TCC模式就是手工的AT模式,它允许你自定义两阶段的处理逻辑不依赖AT模式的undo_log

TCC 是一种侵入式的分布式事务解决方案,Try-Confirm-Cancel三个操作都需要业务系统自行实现,对业务系统有着非常大的入侵性,设计相对复杂,但优点是 TCC 完全不依赖数据库,能够实现跨数据库、跨应用资源管理,通过侵入式的编码方式来完成对不同数据源的访问,并将其纳入到分布式事务管理中,更好地解决了在各种复杂业务场景下的分布式事务问题。

如果服务中仅涉及到DB(Mysql、Oracle、PostgreSQL)持久化,Seata的AT模式基本上就足够了,且AT模式除了使用@GlobalTransactional注解外几乎不侵入代码(非侵入式)。

但是当服务中涉及如下情况,则可考虑使用TCC模式

  • 不支持事务的数据库与中间件(如redis)等的操作
  • AT模式暂未支持的数据库(目前AT支持Mysql、Oracle与PostgreSQL)
  • 跨公司服务(第三方服务)的调用(无法共享Seata Server)
  • 跨语言的应用调用
  • 有手动控制整个二阶段提交过程的需求

:TCC模式对业务侵入较大,本文的选型建议是在仅考虑AT、TCC模式下给出,
实际使用时可亦可考虑其他更轻量、侵入低的分布式事务实现方式,如可靠消息、SAGA等模式。

三、代码集成示例

3.1 升级Seata 1.5.2

且Seata1.5版本后解决了TCC模式下的幂等、空回滚、悬挂的问题,
若需支持此特性,还需在各自服务的业务数据库中额外导入表tcc-fence-log:

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;

3.2 示例场景说明

结合Seata官网下单流程示例,具体讲解Java客户端如何集成Seata TCC模式
学新通

示例模拟了一个下单流程,即由业务应用Business依次调用(服务间采用Openfeign Http调用)

  1. Storage -> 扣库存
  2. Order -> Account扣减用户余额
  3. Order -> 新建订单

完整的示例代码可参见:https://gitee.com/luoex/distributed-transaction-demo/tree/master/seata-tcc
相关的Nacos配置、Sql定义可参见:https://gitee.com/luoex/distributed-transaction-demo/tree/master/config/seata

3.3 TCC核心接口定义

TCC核心就是Try-Confrim-Cancel三段逻辑的实现,在Seata中可通过定义一个接口及对应的方法来标记这三段逻辑,之后再具体实现该接口。同时由于我们使用的是 OpenFeign(基于Http协议),因此需在接口声明处使用@LocalTCC注解。TCC模式相关注解说明如下:

  • @LocalTCC 适用于SpringCloud Feign模式下的TCC
  • @TwoPhaseBusinessAction 注解try方法,其中name为当前tcc方法的bean名称,写方法名便可(全局唯一),commitMethod指向提交方法,rollbackMethod指向事务回滚方法。指定好三个方法之后,seata会根据全局事务的成功或失败,去帮我们自动调用提交方法或者回滚方法。
  • @BusinessActionContextParameter 注解可以将参数传递到二阶段(commitMethod/rollbackMethod)的方法。

注:以上TCC相关注解仅在接口定义中进行标记即可,具体的接口实现类中无需再重复标记。

以库存服务为例,作为分支事务其TCC接口定义如下:

import com.luo.dt.common.model.result.RespResult;
import com.luo.dt.seata.tcc.model.dto.DeductStorageDto;
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;

/**
 * 库存信息 服务类<br/>
 * 注:在Tcc接口上标注@LocalTcc注解
 *
 * @author luohq
 * @since 2021-11-02
 */
@LocalTCC
public interface IStorageService {

    /**
     * 减库存
     *
     * @param deductStorageDto 扣库存参数
     * @return 响应结果
     */
    @TwoPhaseBusinessAction(
            //该tcc的bean名称,写方法名便可,全局唯一
            name = "deduct",
            //二阶段确认方法
            commitMethod = "commitDeduct",
            //二阶段取消方法
            rollbackMethod = "cancelDeduct",
            //启用tcc防护(避免幂等、空回滚、悬挂)
            useTCCFence = true
    )
    RespResult deduct(@BusinessActionContextParameter("deductStorageDto") DeductStorageDto deductStorageDto);

    /**
     * 确认方法,与@TwoPhaseBusinessAction.commitMethod对应
     * 注:context可以传递try方法的参数
     *
     * @param context 上下文
     * @return 是否成功
     */
    Boolean commitDeduct(BusinessActionContext context);

    /**
     * 回滚方法,与@TwoPhaseBusinessAction.rollbackMethod对应
     * 注:context可以传递try方法的参数
     *
     * @param context 上下文
     * @return 是否成功
     */
    Boolean cancelDeduct(BusinessActionContext context);

}

除了以上单独为TCC定义的接口(仅包含TCC三个方法),也可以在我们普通的服务接口上(包含多个业务方法)通过TCC相关注解仅对相关方法进行标记即可,如我实际示例代码中集成了Mybatis-Plus的IService接口,该接口定义了Mybatis-Plus框架内置的许多方法,我们仅需对TCC相关的业务方法进行标记即可。

interface IStorageService extends IService<Storage> { ... }

3.4 TCC相关阶段规划

TCC模式下各服务的Try-Confirm-Cancel实现内容如下:
学新通

注:
Order服务的Cancel阶段正常应该实现Try创建订单的补偿操作,即删除之前Try方法中保存的DB订单信息,
但由于当前Order服务位于整个服务调用链路的最后一位,执行到Order服务的Try阶段,即也意味着前面的Storage、Account服务的Try阶段都成功了,
不存在Order服务的Try阶段执行成功了还需进行全局回滚的情况,所以也就没必要实现Order服务的Cancel方法(其实就是懒😓)。
若需实现此Cancel方法,后文具体代码示例中有给出相关说明。

首先Business服务通过 @GlobalTransactional 标记的方法开启全局事务,然后依次调用Storage服务、Order服务 --> Account服务。
服务仅通过RPC协议(本例采用HTTP协议)暴露Try方法即可,且服务间的调用采用OpenFeign(Http协议)。

该编程模型需要注意的点如下:

  • TCC模式中的分支事务的Try方法组成了服务间的调用链,也就是说Try方法是通过RPC同步调用的,同步返回结果,而Confirm、Cancel都是异步调用的,无法同步返回结果。若上游服务需要下游服务的返回结果,如Business服务需要同步获取Order服务的订单创建信息,则订单创建信息需在Order服务的Try方法中返回;

  • 仅当所有分支事务的Try操作均成功,才会提交全局事务,触发所有分支事务的Confirm方法;

    • 若Try 成功,需要保证Confirm也一定成功
    • 若Confirm操作自身出现异常,则Confirm操作会被多次重复调用;
  • 若任一分支事务的Try操作出现异常,则回滚全局事务,触发 其他分支(不包括当前Try操作异常的分支) 事务的Cancel方法;

    • Cancel方法仅需对 Try 预留的资源进行释放
    • 若Cancel操作自身出现异常,则Cancel操作会被多次重复调用;
  • Try、Confirm、Cancel方法需保证自身的本地事务实现,如通过@Transactional注解修饰

    • 例如Try方法通过@Transactional注解保证本地事务实现,在Try方法出现异常时本地事务自动回滚(由于采用Tcc Fence,避免了空回滚问题则不会调用Cancel进行回滚,由Try方法自身保证本地事务)
    • 在Try方法正常结束时(本地事务已提交),若其他分支事务Try方法出现异常,则Seata全局回滚事务,调用Cancel方法进行回滚,Cancel需对Try操作预留的资源进行回滚。参考上图中的Account服务,即便用户扣款成功了,但是之后的Order服务创建订单失败了,导致全局事务回滚,那么Account服务需对之前扣减的款项进行手动回滚(增加之前扣减的金额)。
  • 业务逻辑放在Try 或 Confirm?

    • 如果上游服务依赖下游服务的返回结果,则下游服务需在try中处理返回结果
    • try中放置前置约束(如调用第三方服务、资源预留等)
      • 如果前置约束不通过,则无法继续进行后续的业务处理了,事务全局回滚后通过Cancel来对Try进行补偿,比如扣减订单金额,必须在Try阶段把钱从客户账户扣掉,如果不扣掉,在Confirm阶段客户账户钱不够了,就会出问题。
      • 仅当前置约束通过了,后续的业务逻才能确保成功,此时后续的业务逻辑可通过Confirm完成。
    • 需要考虑软状态(中间状态)。以转账为例,需要先(Try)扣款,否则用户在Confirm执行前花光了银行卡里的钱就没法再扣款了;同理不能先(Try)增款,否则在Cancel执行前将新增的金额花掉了就没法进行回滚了,总结起来就是Try扣款,Confirm增款。

3.5 TCC核心代码

Business服务开启全局事务代码如下:

/**
 * 业务服务 - 接口类
 *
 * @author luohq
 * @date 2022-12-03
 */
public interface BusinessService {

    /**
     * 创建订单
     *
     * @param businessDto 业务参数
     * @return 响应结果
     */
    RespResult<Order> handleBusinessAt(BusinessDto businessDto);

}

---------------------------------------------------------------------

/**
 * 业务服务 - 实现类
 *
 * @author luo
 * @date 2022-12-03
 */
@Service
@Slf4j
public class BusinessServiceImpl implements BusinessService {

    @Resource
    private StorageFeignClient storageFeignClient;

    @Resource
    private OrderFeignClient orderFeignClient;

    /**
     * 下单操作 - TCC全局事务通过@GlobalTransctional注解发起
     *
     * @param businessDto 业务参数
     * @return 响应结果
     */
    @Override
    @GlobalTransactional(timeoutMills = 60000 * 2)
    public RespResult<Order> handleBusinessAt(BusinessDto businessDto) {
        log.info("开始TCC全局事务,XID={}", RootContext.getXID());
        
        /** 扣减库存 */
        DeductStorageDto deductStorageDto = new DeductStorageDto(businessDto.getCommodityCode(), businessDto.getCount());
        log.info("RPC扣减库存,参数:{}", deductStorageDto);
        RespResult storageResult = this.storageFeignClient.deduct(deductStorageDto);
        log.info("RPC扣减库存,结果:{}", storageResult);
        if (!RespResult.isSuccess(storageResult)) {
            throw new MsgRuntimeException("RPC扣减库存 - 返回失败结果!");
        }

        /** 创建订单 */
        CreateOrderDto createOrderDto = new CreateOrderDto(businessDto.getUserId(), businessDto.getCommodityCode(), businessDto.getCount());
        log.info("RPC创建订单,参数:{}", createOrderDto);
        RespResult<Order> orderResult = this.orderFeignClient.createOrder(createOrderDto);
        log.info("RPC创建订单,结果:{}", orderResult);
        if (!RespResult.isSuccess(orderResult)) {
            throw new MsgRuntimeException("RPC创建订单 - 返回失败结果!");
        }
        return orderResult;
    }

}

注:
若全局事务发起者除了发起服务RPC调用,也需要实现自身对应的分支事务TCC处理逻辑,
则可单独定义并实现TCC接口,然后在@GlobalTransactional方法中调用该TCC接口即可,
且在Seata中支持TCC模式和AT模式的混合使用。


Storage服务TCC事务核心实现代码如下:

/**
 * 库存信息 服务类<br/>
 * 注:在Tcc接口上标注@LocalTcc注解
 *
 * @author luohq
 * @since 2022-12-03
 */
@LocalTCC
public interface IStorageService extends IService<Storage> {

    /**
     * 减库存
     *
     * @param deductStorageDto 扣库存参数
     * @return 响应结果
     */
    @TwoPhaseBusinessAction(
            //该tcc的bean名称,写方法名便可,全局唯一
            name = "deduct",
            //二阶段确认方法
            commitMethod = "commitDeduct",
            //二阶段取消方法
            rollbackMethod = "cancelDeduct",
            //启用tcc防护(避免幂等、空回滚、悬挂)
            useTCCFence = true
    )
    RespResult deduct(@BusinessActionContextParameter("deductStorageDto") DeductStorageDto deductStorageDto);

    /**
     * 确认方法,与@TwoPhaseBusinessAction.commitMethod对应
     * 注:context可以传递try方法的参数
     *
     * @param context 上下文
     * @return 是否成功
     */
    Boolean commitDeduct(BusinessActionContext context);

    /**
     * 回滚方法,与@TwoPhaseBusinessAction.rollbackMethod对应
     * 注:context可以传递try方法的参数
     *
     * @param context 上下文
     * @return 是否成功
     */
    Boolean cancelDeduct(BusinessActionContext context);

}

----------------------------------------------------------------


/**
 * 库存信息 服务实现类
 *
 * @author luohq
 * @since 2022-12-03
 */
@Service
@Slf4j
public class StorageServiceImpl extends ServiceImpl<StorageMapper, Storage> implements IStorageService {


    private final String CACHE_STORAGE_KEY_FORMAT = "storage:%s";
    @Resource
    private RedisTemplate redisTemplate;

    /**
     * 分支事务仅使用普通@Transactional注解即可
     */
    @Override
    public RespResult deduct(DeductStorageDto deductStorageDto) {
        log.info("开始TCC分支事务,XID={}", RootContext.getXID());
        log.info("扣减商品库存,参数: {}", deductStorageDto);
        /** 模拟回滚异常 */
        if ("product-2".equals(deductStorageDto.getCommodityCode())) {
            throw new MsgRuntimeException("异常:模拟业务异常:Storage branch exception");
        }

        /** 扣减缓存中的商品库存 */
        String cacheKey = String.format(CACHE_STORAGE_KEY_FORMAT, deductStorageDto.getCommodityCode());
        Long cacheStorageCount = this.redisTemplate.opsForValue().decrement(cacheKey, deductStorageDto.getCount());
        log.info("扣减后的商品库存: {}={}", cacheKey, cacheStorageCount);
        if (cacheStorageCount < 0) {
            throw new MsgRuntimeException("扣减商品缓存库存失败!");
        }
        return RespResult.success();
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean commitDeduct(BusinessActionContext context) {
        //获取事务上下文传递的参数
        DeductStorageDto deductStorageDto = context.getActionContext("deductStorageDto", DeductStorageDto.class);
        log.info("TCC提交成功, XID={}, deductStorageDto={}", context.getXid(), deductStorageDto);
        /** 扣减DB中的商品库存 */
        Integer retCount = this.baseMapper.deductStorage(deductStorageDto.getCommodityCode(), deductStorageDto.getCount());
        log.info("修改商品库存,结果: {}", retCount);
        //修改商品库存失败,则直接回滚
        if (0 >= retCount) {
            throw new MsgRuntimeException("修改商品库存失败!");
        }
        return true;
    }

    @Override
    public Boolean cancelDeduct(BusinessActionContext context) {
        //获取事务上下文传递的参数
        DeductStorageDto deductStorageDto = context.getActionContext("deductStorageDto", DeductStorageDto.class);
        log.warn("TCC回滚业务, XID={}, deductStorageDto={}", context.getXid(), deductStorageDto);
        /** 还原缓存中的商品库存 */
        String cacheKey = String.format(CACHE_STORAGE_KEY_FORMAT, deductStorageDto.getCommodityCode());
        Long cacheStorageCount = this.redisTemplate.opsForValue().increment(cacheKey, deductStorageDto.getCount());
        log.info("还原后的商品库存: {}={}", cacheKey, cacheStorageCount);
        return true;
    }
}

Order服务TCC事务核心实现代码如下:

/**
 * 订单信息 服务类<br/>
 * 注:在Tcc接口上标注@LocalTcc注解
 *
 * @author luohq
 * @date 2022-12-12
 */
@LocalTCC
public interface IOrderService extends IService<Order> {

    /**
     * 创建订单(用户扣款、创建订单)
     *
     * @param createOrderDto 创建订单参数
     * @return 响应结果
     */
    @TwoPhaseBusinessAction(
            //该tcc的bean名称,写方法名便可,全局唯一
            name = "create",
            //二阶段确认方法
            commitMethod = "commitCreate",
            //二阶段取消方法
            rollbackMethod = "cancelCreate",
            //启用tcc防护(避免幂等、空回滚、悬挂)
            useTCCFence = true
    )
    RespResult create(@BusinessActionContextParameter("createOrderDto") CreateOrderDto createOrderDto);

    /**
     * 确认方法,与@TwoPhaseBusinessAction.commitMethod对应
     * 注:context可以传递try方法的参数
     *
     * @param context 上下文
     * @return 是否成功
     */
    Boolean commitCreate(BusinessActionContext context);

    /**
     * 回滚方法,与@TwoPhaseBusinessAction.rollbackMethod对应
     * 注:context可以传递try方法的参数
     *
     * @param context 上下文
     * @return 是否成功
     */
    Boolean cancelCreate(BusinessActionContext context);
}

---------------------------------------------------------------

/**
 * 订单信息 服务实现类
 *
 * @author luohq
 * @date 2022-12-12
 */
@Service
@Slf4j
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements IOrderService {


    @Resource
    private AccountFeignClient accountFeignClient;


    /**
     * 分支事务仅使用普通@Transactional注解即可
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public RespResult<Order> create(CreateOrderDto createOrderDto) {
        log.info("开始TCC分支事务,XID={}", RootContext.getXID());

        //计算订单金额(假设商品单价5元)
        BigDecimal orderMoney = new BigDecimal(createOrderDto.getCount()).multiply(new BigDecimal(5));
        /** 用户扣款 */
        RespResult respResult = accountFeignClient.debit(new DebitDto(createOrderDto.getUserId(), orderMoney));
        log.info("RPC用户扣减余额服务,结果:{}", respResult);
        if (!RespResult.isSuccess(respResult)) {
            throw new MsgRuntimeException("RPC用户扣减余额服务失败!");
        }

        /** 创建订单 */
        Order order = new Order();
        order.setUserId(createOrderDto.getUserId());
        order.setCommodityCode(createOrderDto.getCommodityCode());
        order.setCount(createOrderDto.getCount());
        order.setMoney(orderMoney);
        log.info("保存订单信息,参数:{}", order);
        Boolean result = this.save(order);
        log.info("保存订单信息,结果:{}", result);
        if (!Boolean.TRUE.equals(result)) {
            throw new MsgRuntimeException("保存新订单信息失败!");
        }

        if ("product-3".equals(createOrderDto.getCommodityCode())) {
            throw new MsgRuntimeException("异常:模拟业务异常:Order branch exception");
        }
        return RespResult.successData(order);
    }

    @Override
    public Boolean commitCreate(BusinessActionContext context) {
        log.info("TCC提交成功, XID={}, createOrderDto={}", context.getXid(), context.getActionContext("createOrderDto"));
        return true;
    }

    @Override
    public Boolean cancelCreate(BusinessActionContext context) {
        log.warn("TCC回滚业务, XID={}, createOrderDto={}", context.getXid(), context.getActionContext("createOrderDto"));
        //此处正常应该实现create创建订单的补偿方法,即删除之前create方法中保存的DB订单信息,
        //但由于当前Order服务位于整个服务调用链路的最后一位,执行到Order服务的Try阶段,即也意味着前面的Storage、Account服务的Try阶段都成功了,
        //不存在Order服务的Try阶段执行成功了还需进行全局回滚的情况,所以也就没必要实现Order服务的Cancel方法(其实就是懒-_-|||)。

        //若需实现create创建订单的补偿方法,可在DB中新创建个表,如order_tx_relation(order_id, tx_id)
        //然后在create方法保存order成功后,同时将 (新生成的order_id, 全局事务XID) 的绑定关系保存到order_tx_relation表,
        //在此Cancel方法中通过XID查询到对应的order_id,然后删除此order_id对应的订单信息。
        return true;
    }
}

注1:
Order服务的Cancel阶段正常应该实现Try创建订单的补偿操作,即删除之前Try方法中保存的DB订单信息,
但由于当前Order服务位于整个服务调用链路的最后一位,执行到Order服务的Try阶段,即也意味着前面的Storage、Account服务的Try阶段都成功了,
不存在Order服务的Try阶段执行成功了还需进行全局回滚的情况,所以也就没必要实现Order服务的Cancel方法(其实就是懒😓)。

注2:
若需实现create创建订单的补偿方法,可在DB中新创建个表,如order_tx_relation(order_id, tx_id)
然后在create方法保存order成功后,同时将 (新生成的order_id, 全局事务XID) 的绑定关系保存到order_tx_relation表,
在此Cancel方法中通过XID查询到对应的order_id,然后删除此order_id对应的订单信息。


Account服务TCC事务核心实现代码如下:

**
 * 用户信息 服务类<br/>
 * 注:在Tcc接口上标注@LocalTcc注解
 *
 * @author luohq
 * @date 2022-12-12
 */
@LocalTCC
public interface IAccountService extends IService<Account> {

    /**
     * 用户扣款
     *
     * @param debitDto 扣款参数
     * @return 返回结果
     */
    @TwoPhaseBusinessAction(
            //该tcc的bean名称,写方法名便可,全局唯一
            name = "debit",
            //二阶段确认方法
            commitMethod = "commitDebit",
            //二阶段取消方法
            rollbackMethod = "cancelDebit",
            //启用tcc防护(避免幂等、空回滚、悬挂)
            useTCCFence = true
    )
    RespResult debit(@BusinessActionContextParameter("debitDto") DebitDto debitDto);

    /**
     * 确认方法,与@TwoPhaseBusinessAction.commitMethod对应
     * 注:context可以传递try方法的参数
     *
     * @param context 上下文
     * @return 是否成功
     */
    Boolean commitDebit(BusinessActionContext context);

    /**
     * 回滚方法,与@TwoPhaseBusinessAction.rollbackMethod对应
     * 注:context可以传递try方法的参数
     *
     * @param context 上下文
     * @return 是否成功
     */
    Boolean cancelDebit(BusinessActionContext context);
}

-----------------------------------------------------------------------------------------

/**
 * 用户信息 服务实现类
 *
 * @author luohq
 * @date 2022-12-12
 */
@Service
@Slf4j
public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> implements IAccountService {


    /**
     * 分支事务仅使用普通@Transactional注解即可
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public RespResult debit(DebitDto debitDto) {
        log.info("开始TCC分支事务,XID={}", RootContext.getXID());
        log.info("用户扣款,参数:{}", debitDto);
        int retCount = this.baseMapper.debit(debitDto.getUserId(), debitDto.getMoney());
        log.info("用户扣款,结果:{}", retCount);
        if (0 >= retCount) {
            throw new MsgRuntimeException("用户扣款失败!");
        }
        return RespResult.success();
    }

    @Override
    public Boolean commitDebit(BusinessActionContext context) {
        log.info("TCC提交成功, XID={}, debitDto={}", context.getXid(), context.getActionContext("debitDto"));
        return true;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean cancelDebit(BusinessActionContext context) {
        DebitDto debitDto = context.getActionContext("debitDto", DebitDto.class);
        log.warn("TCC回滚业务, XID={}, debitDto={}", context.getXid(), debitDto);
        log.info("用户补款,参数:{}", debitDto);
        int retCount = this.baseMapper.debit(debitDto.getUserId(), debitDto.getMoney().multiply(new BigDecimal(-1)));
        log.info("用户补款,结果:{}", retCount);
        return true;
    }
}

四、TCC三大问题(幂等、空回滚、悬挂)

TCC 模式中存在的三大问题:幂等、空回滚、悬挂。

幂等(Confirm/Cancel方法可能被多次调用)
在 Confirm/Cancel 阶段,因为 TC 没有收到分支事务的响应,需要进行重试,这就要分支事务Try/Cancel方法支持幂等。

空回滚(Try方法没被执行,却触发了Cancel方法被执行)
在 Try 阶段,分支事务所在节点发生了故障,Try 阶段在不考虑重试的情况下,全局事务必须要走向结束状态(全局事务回滚),这个时候其实是没有执行 Try方法,当故障节点恢复后,分布式事务进行回滚则会调用二阶段的 Cancel 方法,从而形成空回滚。

悬挂(Cancel方法优先于Try方法执行)
悬挂是指因为网络问题,RM 开始没有收到Try指令,但是执行了Cancel方法后 RM 又收到了 Try 指令并且预留资源成功,这时全局事务已经结束,最终导致预留的资源不能释放。

在 Seata1.5.1 版本中,增加了一张事务控制表tcc_fence_log,该表就是来解决这个问题。而在之前 3.3 TCC核心接口定义一章中 @TwoPhaseBusinessAction 注解中的属性 useTCCFence 就是来指定是否开启这个机制,useTCCFence属性值默认是 false(不开启)。若需开启TCC防护则需指定useTCCFence=true,同时在分支事务所在服务的DB中导入如下tcc_fence_log表

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;

TCC Fence相关的详细代码可参见:

io.seata.rm.tcc.TCCResourceManager
io.seata.rm.tcc.TCCFenceHandler

大体实现思路如下:

注: 分支事务生命周期内xid、branch_id是不变且全局唯一的,其中的action_name即对应@TwoPhaseBusinessAction.name属性。

Try阶段

  • 插入tcc_fence_log(xid, branch_id, action_name, status_tried:1)
  • xid、branch_id主键唯一性保证Try阶段幂等性、避免悬挂
  • 插入成功则执行Try方法

Confirm阶段

  • 根据xid, branch_id查询tcc_fence_log记录
  • 若记录存在且状态为status_tried:1,则修改状态为status_commited:2并执行Confirm方法
  • 若记录状态已经为status_commited:2说明已经执行过Confirm方法,直接返回成功,不再重复调用Confirm方法,保证了Confirm阶段的幂等性

Cancel阶段

  • 根据xid, branch_id查询tcc_fence_log记录
  • 若记录不存在(Cancel方法先于Try方法被执行),则插入tcc_fence_log(xid, branch_id, action_name, status_suspended:4) ,同时无需调用Cancel方法直接返回,避免了Cancel阶段空回滚
    • 后续若再执行Try方法插入tcc_fence_log时会报DuplicateKeyException,避免发生悬挂
  • 若记录存在且状态为status_tried:1,则执行Cancel方法且修改状态为status_rollbacked:3
  • 若记录存在且状态已经为status_rollbacked:3 或 status_suspended:4说明已经执行过Cancel方法,直接返回成功,不再重复调用Cancel方法,证了Cancel阶段的幂等性

参考:
http://seata.io/zh-cn/blog/integrate-seata-tcc-mode-with-spring-cloud.html
http://seata.io/zh-cn/blog/seata-tcc-fence.html
http://seata.io/zh-cn/blog/seata-tcc.html

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhfieakc
系列文章
更多 icon
同类精品
更多 icon
继续加载