作者:朱晋君

大家好,我是君哥。

今天来聊一聊阿里巴巴 Seata 新版别(1.5.1)是怎样处理 TCC 形式下的幂等、悬挂和空回滚问题的。

TCC 回忆

TCC 形式是最经典的分布式业务处理方案,它将分布式业务分为两个阶段来履行,try 阶段对每个分支业务进行预留资源,假如一切分支业务都预留资源成功,则进入 commit 阶段提交大局业务,假如有一个节点预留资源失利则进入 cancel 阶段回滚大局业务。

以传统的订单、库存、账户服务为例,在 try 阶段测验预留资源,刺进订单、扣减库存、扣减金额,这三个服务都是要提交本地业务的,这儿能够把资源转入中心表。在 commit 阶段,再把 try 阶段预留的资源转入终究表。而在 cancel 阶段,把 try 阶段预留的资源进行释放,比方把账户金额回来给客户的账户。

留意:try 阶段有必要是要提交本地业务的,比方扣减订单金额,有必要把钱从客户账户扣掉,假如不扣掉,在 commit 阶段客户账户钱不够了,就会出问题。

try-commit

try 阶段首要进行预留资源,然后在 commit 阶段扣除资源。如下图:

阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题

try-cancel

try 阶段首要进行预留资源,预留资源时扣减库存失利导致大局业务回滚,在 cancel 阶段释放资源。如下图:

阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题

TCC优势

TCC 形式最大的优势是功率高。TCC 形式在 try 阶段的确定资源并不是真正意义上的确定,而是实在提交了本地业务,将资源预留到中心态,并不需求阻塞等待,因此功率比其他形式要高。

一起 TCC 形式还能够进行如下优化:

异步提交

try 阶段成功后,不当即进入 confirm/cancel 阶段,而是以为大局业务现已完毕了,发动定时任务来异步履行 confirm/cancel,扣减或释放资源,这样会有很大的性能提高。

同库形式

TCC 形式中有三个人物:

  • TM:办理大局业务,包含敞开大局业务,提交/回滚大局业务;
  • RM:办理分支业务;
  • TC: 办理大局业务和分支业务的状况。

下图来自 Seata 官网:

阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题

TM 敞开大局业务时,RM 需求向 TC 发送注册音讯,TC 保存分支业务的状况。TM 请求提交或回滚时,TC 需求向 RM 发送提交或回滚音讯。这样包含两个个分支业务的分布式业务中,TC 和 RM 之间有四次 RPC。

优化后的流程如下图:

阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题

TC 保存大局业务的状况。TM 敞开大局业务时,RM 不再需求向 TC 发送注册音讯,而是把分支业务状况保存在了本地。TM 向 TC 发送提交或回滚音讯后,RM 异步线程首要查出本地保存的未提交分支业务,然后向 TC 发送音讯获取(本地分支业务所在的)大局业务状况,以决定是提交仍是回滚本地业务。

这样优化后,RPC 次数减少了 50%,性能大幅提高。

RM 代码示例

以库存服务为例,RM 库存服务接口代码如下:

@LocalTCC
public interface StorageService {
    /**
     * 扣减库存
     * @param xid 大局xid
     * @param productId 产品id
     * @param count 数量
     * @return
     */
    @TwoPhaseBusinessAction(name = "storageApi", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
    boolean decrease(String xid, Long productId, Integer count);
    /**
     * 提交业务
     * @param actionContext
     * @return
     */
    boolean commit(BusinessActionContext actionContext);
    /**
     * 回滚业务
     * @param actionContext
     * @return
     */
    boolean rollback(BusinessActionContext actionContext);
}

经过 @LocalTCC 这个注解,RM 初始化的时分会向 TC 注册一个分支业务。在 try 阶段的办法(decrease办法)上有一个 @TwoPhaseBusinessAction 注解,这儿界说了分支业务的 resourceId,commit 办法和 cancel 办法,useTCCFence 这个特点下一节再讲。

TCC 存在问题

TCC 形式中存在的三大问题是幂等、悬挂和空回滚。在 Seata1.5.1 版别中,增加了一张业务控制表,表名是 tcc_fence_log 来处理这个问题。而在上一节 @TwoPhaseBusinessAction 注解中提到的特点 useTCCFence 便是来指定是否敞开这个机制,这个特点值默许是 false。

tcc_fence_log 建表语句如下(MySQL 语法):

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;

幂等

在 commit/cancel 阶段,由于 TC 没有收到分支业务的呼应,需求进行重试,这就要分支业务支撑幂等。

我们看一下新版别是怎样处理的。下面的代码在 TCCResourceManager 类:

@Override
public BranchStatus branchCommit(BranchType branchType, String xid, long branchId, String resourceId,
         String applicationData) throws TransactionException {
 TCCResource tccResource = (TCCResource)tccResourceCache.get(resourceId);
 //省掉判别
 Object targetTCCBean = tccResource.getTargetBean();
 Method commitMethod = tccResource.getCommitMethod();
 //省掉判别
 try {
  //BusinessActionContext
  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 {
   //省掉逻辑
  }
  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) {
  //省掉
  return BranchStatus.PhaseTwo_CommitFailed_Retryable;
 }
}

上面的代码能够看到,履行分支业务提交办法时,首要判别 useTCCFence 特点是否为 true,假如为 true,则走 TCCFenceHandler 类中的 commitFence 逻辑,否则走普通提交逻辑。

TCCFenceHandler 类中的 commitFence 办法调用了 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);
   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);
   }
   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;
   }
   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;
   }
   return updateStatusAndInvokeTargetMethod(conn, commitMethod, targetTCCBean, xid, branchId, TCCFenceConstant.STATUS_COMMITTED, status, args);
  } catch (Throwable t) {
   status.setRollbackOnly();
   throw new SkipCallbackWrapperException(t);
  }
 });
}

从代码中能够看到,提交业务时首要会判别 tcc_fence_log 表中是否现已有记载,假如有记载,则判别业务履行状况并回来。这样假如判别到业务的状况现已是 STATUS_COMMITTED,就不会再次提交,确保了幂等。假如 tcc_fence_log 表中没有记载,则刺进一条记载,供后边重试时判别。

Rollback 的逻辑跟 commit 相似,逻辑在类 TCCFenceHandler 的 rollbackFence 办法。

空回滚

如下图,账户服务是两个节点的集群,在 try 阶段账户服务 1 这个节点发生了故障,try 阶段在不考虑重试的情况下,大局业务有必要要走向完毕状况,这样就需求在账户服务上履行一次 cancel 操作,这样就空跑了一次回滚操作。

阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题

Seata 的处理方案是在 try 阶段 往 tcc_fence_log 表刺进一条记载,status 字段值是 STATUS_TRIED,在 Rollback 阶段判别记载是否存在,假如不存在,则不履行回滚操作。代码如下:

//TCCFenceHandler 类
public static Object prepareFence(String xid, Long branchId, String actionName, Callback<Object> targetCallback) {
 return transactionTemplate.execute(status -> {
  try {
   Connection conn = DataSourceUtils.getConnection(dataSource);
   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) {
   //省掉
  } catch (Throwable t) {
   //省掉
  }
 });
}

在 Rollback 阶段的处理逻辑如下:

//TCCFenceHandler 类
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
   if (tccFenceDO == null) {
    //不履行回滚逻辑
    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);
  }
 });
}

updateStatusAndInvokeTargetMethod 办法履行的 sql 如下:

update tcc_fence_log set status = ?, gmt_modified = ?
    where xid = ? and  branch_id = ? and status = ? ;

可见便是把 tcc_fence_log 表记载的 status 字段值从 STATUS_TRIED 改为 STATUS_ROLLBACKED,假如更新成功,就履行回滚逻辑。

悬挂

悬挂是指由于网络问题,RM 开端没有收到 try 指令,可是履行了 Rollback 后 RM 又收到了 try 指令而且预留资源成功,这时大局业务现已完毕,终究导致预留的资源不能释放。如下图:

阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题

Seata 处理这个问题的办法是履行 Rollback 办法时先判别 tcc_fence_log 是否存在当前 xid 的记载,假如没有则向 tcc_fence_log 表刺进一条记载,状况是 STATUS_SUSPENDED,而且不再履行回滚操作。代码如下:

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
   if (tccFenceDO == null) {
       //刺进防悬挂记载
    boolean result = insertTCCFenceLog(conn, xid, branchId, actionName, TCCFenceConstant.STATUS_SUSPENDED);
    //省掉逻辑
    return true;
   } else {
    //省掉逻辑
   }
   return updateStatusAndInvokeTargetMethod(conn, rollbackMethod, targetTCCBean, xid, branchId, TCCFenceConstant.STATUS_ROLLBACKED, status, args);
  } catch (Throwable t) {
   //省掉逻辑
  }
 });
}

而后边履行 try 阶段办法时首要会向 tcc_fence_log 表刺进一条当前 xid 的记载,这样就造成了主键冲突。代码如下:

//TCCFenceHandler 类
public static Object prepareFence(String xid, Long branchId, String actionName, Callback<Object> targetCallback) {
 return transactionTemplate.execute(status -> {
  try {
   Connection conn = DataSourceUtils.getConnection(dataSource);
   boolean result = insertTCCFenceLog(conn, xid, branchId, actionName, TCCFenceConstant.STATUS_TRIED);
   //省掉逻辑
  } 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) {
   //省掉
  }
 });
}

留意:queryTCCFenceDO 办法 sql 中使用了 for update,这样就不必担心 Rollback 办法中获取不到 tcc_fence_log 表记载而无法判别 try 阶段本地业务的履行成果了。

总结

TCC 形式是分布式业务使用最多的形式,可是幂等、悬挂和空回滚一直是 TCC 形式需求考虑的问题,Seata 框架在 1.5.1 版别完美处理了这些问题。

对 tcc_fence_log 表的操作也需求考虑业务的控制,Seata 使用了代理数据源,使 tcc_fence_log 表操作和 RM 业务操作在同一个本地业务中履行,这样就能确保本地操作和对 tcc_fence_log 的操作一起成功或失利。