Seata之TCC模式

125 阅读6分钟

前言

TCC模式在《Seata TCC、Saga、XA模式初识》中有提及,笔者认为AT模式不是真正两阶段提交,因为第一阶段已经提交了,第二阶段只是决定要不要回滚,而TCC模式属于应用层面的二阶段提交,XA模式是数据库层面的二阶段提交。

优势

相比XA模式而言TCC模式效率更高,并没有像数据库那样在没有commit前锁定数据(比如mysql的innodb的行锁),只是预留到中间态,不会产生阻塞,因此效率会更高。

缺点

TCC存在三种状态try、confirm、cancel,状态需要同步给TC,因为是RPC所以必定存在 “不信任”“不信任” 就会有超时,超时是无奈之举,存在不确定,所以就会引发不幂等、悬挂和空回滚。

不幂等

在commit/cancel阶段,因为TC没有收到分支事务的响应,需要进行重试,如果没有保证幂等就会重复执行。

悬挂

悬挂是指网络原因,RM开始没有收到try指令,但是执行了Rollback后RM又收到了try指令并且预留资源成功,这时全局事务已经结束,最终导致预留的资源不能释放。

笔者开始没怎么细想觉得问题不大,因为假设是余额先执行cancle回滚(减冻结余额)后面try又执行了把冻结余额又加回去了,这样好像啥也没发生一样,影响并不大。

后面细想了下, 假设冻结库存,如果先执行cancle回滚把冻结库存减一(减一之后对应可卖的加一),如果并发比较高,还没等try(冻结库存加一)执行,如果控制不得当就会出现超卖情况。这样try回去的冻结库存就不能释放了。

image.png

空回滚

是指try因为故障没有执行(不考虑重试的情况下),全局事务必须要走向结束状态执行了cancel,把没有锁定的资源释放掉,会导致释放别的事务的锁定资源,如下(账户的其中一个节点出现故障):

image.png

解决

前面说到了不幂等、悬挂、空回滚的原因是因为动作(prepare、commit、rollback)行为做完了之后没有保存,比如commit之后如果做了记录,那么重试的时候发现前面已经有了committed记录那么就不再重试。

对于业务代码你只需要在在数据库中新增一张表:

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;

在使用@TwoPhaseBusinessAction注解的时候加上useTCCFence = true属性即可:

package com.study.seata.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;

import java.util.Map;

@LocalTCC
public interface SeataService {
    @TwoPhaseBusinessAction(name = "seataService", commitMethod = "commit", rollbackMethod = "rollback",useTCCFence = true)
    void testSeata(@BusinessActionContextParameter(paramName = "params") Map<String, String> params);

    void commit(BusinessActionContext context);

    void rollback(BusinessActionContext context);
}

可以结合《Seata 部署&使用AT+TCC模式》看。

幂等

下面看看commit的源码是怎么处理的: TCCResourceManager#branchCommit(应用端的分支提交)

@Override
public BranchStatus branchCommit(BranchType branchType, String xid, long branchId, String resourceId,
                                 String applicationData) throws TransactionException {
    TCCResource tccResource = (TCCResource)tccResourceCache.get(resourceId);
    if (tccResource == null) {
        throw new ShouldNeverHappenException(String.format("TCC resource is not exist, resourceId: %s", resourceId));
    }
    Object targetTCCBean = tccResource.getTargetBean();
    Method commitMethod = tccResource.getCommitMethod();
    if (targetTCCBean == null || commitMethod == null) {
        throw new ShouldNeverHappenException(String.format("TCC resource is not available, resourceId: %s", resourceId));
    }
    try {
        //BusinessActionContext,如[xid:198.18.0.1:8091:6953882274557321469,branch_Id:6953882274557321471,action_name:seataService,is_delay_report:null,is_updated:null,action_context:{"action-start-time":1667651422889,"useTCCFence":true,"sys::prepare":"testSeata","sys::rollback":"rollback","sys::commit":"commit","host-name":"198.18.0.1","params":{"test":"test"},"actionName":"seataService"}]
        BusinessActionContext businessActionContext = getBusinessActionContext(xid, branchId, resourceId,
            applicationData);
        Object[] args = this.getTwoPhaseCommitArgs(tccResource, businessActionContext);
        Object ret;
        boolean result;
        // 是不是使用了`useTCCFence = true`来控制幂等、空回滚、悬挂等
        if (Boolean.TRUE.equals(businessActionContext.getActionContext(Constants.USE_TCC_FENCE))) {
            try {
                // 这里进来是重点
                result = TCCFenceHandler.commitFence(commitMethod, targetTCCBean, xid, branchId, args);
            } catch (SkipCallbackWrapperException | UndeclaredThrowableException e) {
                throw e.getCause();
            }
        } else {
            ret = commitMethod.invoke(targetTCCBean, args);
            if (ret != null) {
                if (ret instanceof TwoPhaseResult) {
                    result = ((TwoPhaseResult)ret).isSuccess();
                } else {
                    result = (boolean)ret;
                }
            } else {
                result = true;
            }
        }
        LOGGER.info("TCC resource commit result : {}, xid: {}, branchId: {}, resourceId: {}", result, xid, branchId, resourceId);
        // 根据执行结果返回是已提交还是提交失败可以重试
        return result ? BranchStatus.PhaseTwo_Committed : BranchStatus.PhaseTwo_CommitFailed_Retryable;
    } catch (Throwable t) {
        String msg = String.format("commit TCC resource error, resourceId: %s, xid: %s.", resourceId, xid);
        LOGGER.error(msg, t);
        return BranchStatus.PhaseTwo_CommitFailed_Retryable;
    }
}

TCCFenceHandler#commitFence:

public static boolean commitFence(Method commitMethod, Object targetTCCBean,
                                  String xid, Long branchId, Object[] args) {
    return transactionTemplate.execute(status -> {
        try {
            Connection conn = DataSourceUtils.getConnection(dataSource);
            // 这里查询了tcc_fence_log表
            TCCFenceDO tccFenceDO = TCC_FENCE_DAO.queryTCCFenceDO(conn, xid, branchId);
            if (tccFenceDO == null) {
                throw new TCCFenceException(String.format("TCC fence record not exists, commit fence method failed. xid= %s, branchId= %s", xid, branchId),
                        FrameworkErrorCode.RecordAlreadyExists);
            }
            // 如果已经提交就直接返回true
            if (TCCFenceConstant.STATUS_COMMITTED == tccFenceDO.getStatus()) {
                LOGGER.info("Branch transaction has already committed before. idempotency rejected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
                return true;
            }
            // 如果是已经回滚或者暂停就返回false
            if (TCCFenceConstant.STATUS_ROLLBACKED == tccFenceDO.getStatus() || TCCFenceConstant.STATUS_SUSPENDED == tccFenceDO.getStatus()) {
                if (LOGGER.isWarnEnabled()) {
                    LOGGER.warn("Branch transaction status is unexpected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
                }
                return false;
            }
            // 如果都不是就说明是tried状态需要更新状态并执行commit方法
            return updateStatusAndInvokeTargetMethod(conn, commitMethod, targetTCCBean, xid, branchId, TCCFenceConstant.STATUS_COMMITTED, status, args);
        } catch (Throwable t) {
            status.setRollbackOnly();
            throw new SkipCallbackWrapperException(t);
        }
    });
}

可以发现其实并不复杂,就是根据tcc_fence_log的记录进行幂等,幂等的关键也是留存了动作的状态。

悬挂

悬挂很容易模拟,比如在《Seata 部署&使用AT+TCC模式》例子中笔者使用了使用了本地事务,int i = 1 / 0;执行后会抛出异常,插入tcc_fence_log是和本地事务在同一个事务中所以抛出异常后RM没有收到try指令,因此就会变成悬挂

TCCFenceHandler#prepareFence

public static Object prepareFence(String xid, Long branchId, String actionName, Callback<Object> targetCallback) {
    return transactionTemplate.execute(status -> {
        try {
            Connection conn = DataSourceUtils.getConnection(dataSource);
            // 插入tcc_fence_log
            boolean result = insertTCCFenceLog(conn, xid, branchId, actionName, TCCFenceConstant.STATUS_TRIED);
            LOGGER.info("TCC fence prepare result: {}. xid: {}, branchId: {}", result, xid, branchId);
            if (result) {
                // 插入成功后执行
                return targetCallback.execute();
            } else {
                throw new TCCFenceException(String.format("Insert tcc fence record error, prepare fence failed. xid= %s, branchId= %s", xid, branchId),
                        FrameworkErrorCode.InsertRecordError);
            }
        } catch (TCCFenceException e) {
            if (e.getErrcode() == FrameworkErrorCode.DuplicateKeyException) {
                LOGGER.error("Branch transaction has already rollbacked before,prepare fence failed. xid= {},branchId = {}", xid, branchId);
                addToLogCleanQueue(xid, branchId);
            }
            status.setRollbackOnly();
            throw new SkipCallbackWrapperException(e);
        } catch (Throwable t) {
            status.setRollbackOnly();
            throw new SkipCallbackWrapperException(t);
        }
    });
}

TCCFenceHandler#rollbackFence

public static boolean rollbackFence(Method rollbackMethod, Object targetTCCBean,
                                    String xid, Long branchId, Object[] args, String actionName) {
    return transactionTemplate.execute(status -> {
        try {
            Connection conn = DataSourceUtils.getConnection(dataSource);
            // 查询记录
            TCCFenceDO tccFenceDO = TCC_FENCE_DAO.queryTCCFenceDO(conn, xid, branchId);
            // non_rollback,发现没有记录就插入一条状态为suspended的记录
            if (tccFenceDO == null) {
                boolean result = insertTCCFenceLog(conn, xid, branchId, actionName, TCCFenceConstant.STATUS_SUSPENDED);
                LOGGER.info("Insert tcc fence record result: {}. xid: {}, branchId: {}", result, xid, branchId);
                if (!result) {
                    throw new TCCFenceException(String.format("Insert tcc fence record error, rollback fence method failed. xid= %s, branchId= %s", xid, branchId),
                            FrameworkErrorCode.InsertRecordError);
                }
                // 插入后直接返回,没有执行回滚
                return true;
            } else {
                if (TCCFenceConstant.STATUS_ROLLBACKED == tccFenceDO.getStatus() || TCCFenceConstant.STATUS_SUSPENDED == tccFenceDO.getStatus()) {
                    LOGGER.info("Branch transaction had already rollbacked before, idempotency rejected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
                    return true;
                }
                if (TCCFenceConstant.STATUS_COMMITTED == tccFenceDO.getStatus()) {
                    if (LOGGER.isWarnEnabled()) {
                        LOGGER.warn("Branch transaction status is unexpected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
                    }
                    return false;
                }
            }
            return updateStatusAndInvokeTargetMethod(conn, rollbackMethod, targetTCCBean, xid, branchId, TCCFenceConstant.STATUS_ROLLBACKED, status, args);
        } catch (Throwable t) {
            status.setRollbackOnly();
            throw new SkipCallbackWrapperException(t);
        }
    });
}

可以很清晰的看出是通过插入一条suspended状态的记录之后直接返回没有执行回滚 ,后面如果try又执行了会插入tcc_fence_log,但是此时主键会冲突,这样保证了回滚和try都不执行。

笔者在《Seata 部署&使用AT+TCC模式》中的例子不太恰当,其实应该开启redis事务不应该依赖tcc的cancle进行回滚,其他一些不能加入本地事务的回滚可以放在这里处理,比如上传的图片可以放这里删除。

注意:queryTCCFenceDO 方法 sql 中使用了 for update,这样不用担心Rollback方法中获取不到tcc_fence_log表记录无法判断try阶段本地事务的执行结果了。

插入也会有插入意向锁,for update为当前读,所以会阻塞。

空回滚

见上,和悬挂类似,只不过一个是后面会执行try一个是后面不会。

参考

今天来聊一聊阿里巴巴 Seata 新版本(1.5.1)是怎么解决 TCC 模式下的幂等、悬挂和空回滚问题的

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 10 天,点击查看活动详情