JavaSpringCloud AlibabaSeata
来看阿里巴巴 Seata 新版本(1.5.0)是怎么解决 TCC 模式下的幂等、悬挂和空回滚问题的。

1、TCC 回顾

TCC 模式是最经典的分布式事务解决方案,它将分布式事务分为两个阶段来执行,try 阶段对每个分支事务进行预留资源,如果所有分支事务都预留资源成功,则进入 commit 阶段提交全局事务,如果有一个节点预留资源失败则进入 cancel 阶段回滚全局事务。
以传统的订单、库存、账户服务为例,在 try 阶段尝试预留资源,插入订单、扣减库存、扣减金额,这三个服务都是要提交本地事务的,这里可以把资源转入中间表。在 commit 阶段,再把 try 阶段预留的资源转入最终表。而在 cancel 阶段,把 try 阶段预留的资源进行释放,比如把账户金额返回给客户的账户。
注意:try 阶段必须是要提交本地事务的,比如扣减订单金额,必须把钱从客户账户扣掉,如果不扣掉,在 commit 阶段客户账户钱不够了,就会出问题。

1.1 try-commit

try 阶段首先进行预留资源,然后在 commit 阶段扣除资源。如下图:
阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题 - 图1

1.2 try-cancel

try 阶段首先进行预留资源,预留资源时扣减库存失败导致全局事务回滚,在 cancel 阶段释放资源。如下图:
阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题 - 图2

2、TCC 优势

TCC 模式最大的优势是效率高。TCC 模式在 try 阶段的锁定资源并不是真正意义上的锁定,而是真实提交了本地事务,将资源预留到中间态,并不需要阻塞等待,因此效率比其他模式要高。
同时 TCC 模式还可以进行如下优化:

2.1 异步提交

try 阶段成功后,不立即进入 confirm/cancel 阶段,而是认为全局事务已经结束了,启动定时任务来异步执行 confirm/cancel,扣减或释放资源,这样会有很大的性能提升。

2.2 同库模式

TCC 模式中有三个角色:

  • TM:管理全局事务,包括开启全局事务,提交/回滚全局事务;
  • RM:管理分支事务;
  • TC: 管理全局事务和分支事务的状态。

下图来自 Seata 官网:
阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题 - 图3
TM 开启全局事务时,RM 需要向 TC 发送注册消息,TC 保存分支事务的状态。TM 请求提交或回滚时,TC 需要向 RM 发送提交或回滚消息。这样包含两个个分支事务的分布式事务中,TC 和 RM 之间有四次 RPC。
优化后的流程如下图:
阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题 - 图4
TC 保存全局事务的状态。TM 开启全局事务时,RM 不再需要向 TC 发送注册消息,而是把分支事务状态保存在了本地。TM 向 TC 发送提交或回滚消息后,RM 异步线程首先查出本地保存的未提交分支事务,然后向 TC 发送消息获取(本地分支事务所在的)全局事务状态,以决定是提交还是回滚本地事务。
这样优化后,RPC 次数减少了 50%,性能大幅提升。

3、RM 代码示例

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

  1. @LocalTCC
  2. public interface StorageService {
  3. /**
  4. * 扣减库存
  5. * @param xid 全局xid
  6. * @param productId 产品id
  7. * @param count 数量
  8. * @return
  9. */
  10. @TwoPhaseBusinessAction(name = "storageApi", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
  11. boolean decrease(String xid, Long productId, Integer count);
  12. /**
  13. * 提交事务
  14. * @param actionContext
  15. * @return
  16. */
  17. boolean commit(BusinessActionContext actionContext);
  18. /**
  19. * 回滚事务
  20. * @param actionContext
  21. * @return
  22. */
  23. boolean rollback(BusinessActionContext actionContext);
  24. }

通过 @LocalTCC 这个注解,RM 初始化的时候会向 TC 注册一个分支事务。在 try 阶段的方法(decrease方法)上有一个 @TwoPhaseBusinessAction 注解,这里定义了分支事务的 resourceId,commit 方法和 cancel 方法,useTCCFence 这个属性下一节再讲。

4、TCC 存在问题

TCC 模式中存在的三大问题是幂等、悬挂和空回滚。在 Seata1.5.0 版本中,增加了一张事务控制表,表名是 tcc_fence_log 来解决这个问题。而在上一节 @TwoPhaseBusinessAction 注解中提到的属性 useTCCFence 就是来指定是否开启这个机制,这个属性值默认是 false。
tcc_fence_log 建表语句如下(MySQL 语法):

  1. CREATE TABLE IF NOT EXISTS `tcc_fence_log`
  2. (
  3. `xid` VARCHAR(128) NOT NULL COMMENT 'global id',
  4. `branch_id` BIGINT NOT NULL COMMENT 'branch id',
  5. `action_name` VARCHAR(64) NOT NULL COMMENT 'action name',
  6. `status` TINYINT NOT NULL COMMENT 'status(tried:1;committed:2;rollbacked:3;suspended:4)',
  7. `gmt_create` DATETIME(3) NOT NULL COMMENT 'create time',
  8. `gmt_modified` DATETIME(3) NOT NULL COMMENT 'update time',
  9. PRIMARY KEY (`xid`, `branch_id`),
  10. KEY `idx_gmt_modified` (`gmt_modified`),
  11. KEY `idx_status` (`status`)
  12. ) ENGINE = InnoDB
  13. DEFAULT CHARSET = utf8mb4;

4.1 幂等

在 commit/cancel 阶段,因为 TC 没有收到分支事务的响应,需要进行重试,这就要分支事务支持幂等。
看一下新版本是怎么解决的。下面的代码在 TCCResourceManager 类:

  1. @Override
  2. public BranchStatus branchCommit(BranchType branchType, String xid, long branchId, String resourceId,
  3. String applicationData) throws TransactionException {
  4. TCCResource tccResource = (TCCResource)tccResourceCache.get(resourceId);
  5. //省略判断
  6. Object targetTCCBean = tccResource.getTargetBean();
  7. Method commitMethod = tccResource.getCommitMethod();
  8. //省略判断
  9. try {
  10. //BusinessActionContext
  11. BusinessActionContext businessActionContext = getBusinessActionContext(xid, branchId, resourceId,
  12. applicationData);
  13. Object[] args = this.getTwoPhaseCommitArgs(tccResource, businessActionContext);
  14. Object ret;
  15. boolean result;
  16. //注解 useTCCFence 属性是否设置为 true
  17. if (Boolean.TRUE.equals(businessActionContext.getActionContext(Constants.USE_TCC_FENCE))) {
  18. try {
  19. result = TCCFenceHandler.commitFence(commitMethod, targetTCCBean, xid, branchId, args);
  20. } catch (SkipCallbackWrapperException | UndeclaredThrowableException e) {
  21. throw e.getCause();
  22. }
  23. } else {
  24. //省略逻辑
  25. }
  26. LOGGER.info("TCC resource commit result : {}, xid: {}, branchId: {}, resourceId: {}", result, xid, branchId, resourceId);
  27. return result ? BranchStatus.PhaseTwo_Committed : BranchStatus.PhaseTwo_CommitFailed_Retryable;
  28. } catch (Throwable t) {
  29. //省略
  30. return BranchStatus.PhaseTwo_CommitFailed_Retryable;
  31. }
  32. }

上面的代码可以看到,执行分支事务提交方法时,首先判断 useTCCFence 属性是否为 true,如果为 true,则走 TCCFenceHandler 类中的 commitFence 逻辑,否则走普通提交逻辑。
TCCFenceHandler 类中的 commitFence 方法调用了 TCCFenceHandler 类的 commitFence 方法,代码如下:

  1. public static boolean commitFence(Method commitMethod, Object targetTCCBean,
  2. String xid, Long branchId, Object[] args) {
  3. return transactionTemplate.execute(status -> {
  4. try {
  5. Connection conn = DataSourceUtils.getConnection(dataSource);
  6. TCCFenceDO tccFenceDO = TCC_FENCE_DAO.queryTCCFenceDO(conn, xid, branchId);
  7. if (tccFenceDO == null) {
  8. throw new TCCFenceException(String.format("TCC fence record not exists, commit fence method failed. xid= %s, branchId= %s", xid, branchId),
  9. FrameworkErrorCode.RecordAlreadyExists);
  10. }
  11. if (TCCFenceConstant.STATUS_COMMITTED == tccFenceDO.getStatus()) {
  12. LOGGER.info("Branch transaction has already committed before. idempotency rejected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
  13. return true;
  14. }
  15. if (TCCFenceConstant.STATUS_ROLLBACKED == tccFenceDO.getStatus() || TCCFenceConstant.STATUS_SUSPENDED == tccFenceDO.getStatus()) {
  16. if (LOGGER.isWarnEnabled()) {
  17. LOGGER.warn("Branch transaction status is unexpected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
  18. }
  19. return false;
  20. }
  21. return updateStatusAndInvokeTargetMethod(conn, commitMethod, targetTCCBean, xid, branchId, TCCFenceConstant.STATUS_COMMITTED, status, args);
  22. } catch (Throwable t) {
  23. status.setRollbackOnly();
  24. throw new SkipCallbackWrapperException(t);
  25. }
  26. });
  27. }

从代码中可以看到,提交事务时首先会判断 tcc_fence_log 表中是否已经有记录,如果有记录,则判断事务执行状态并返回。这样如果判断到事务的状态已经是 STATUS_COMMITTED,就不会再次提交,保证了幂等。如果 tcc_fence_log 表中没有记录,则插入一条记录,供后面重试时判断。
Rollback 的逻辑跟 commit 类似,逻辑在类 TCCFenceHandler 的 rollbackFence 方法。

4.2 空回滚

如下图,账户服务是两个节点的集群,在 try 阶段账户服务 1 这个节点发生了故障,try 阶段在不考虑重试的情况下,全局事务必须要走向结束状态,这样就需要在账户服务上执行一次 cancel 操作,这样就空跑了一次回滚操作。
阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题 - 图5
Seata 的解决方案是在 try 阶段 往 tcc_fence_log 表插入一条记录,status 字段值是 STATUS_TRIED,在 Rollback 阶段判断记录是否存在,如果不存在,则不执行回滚操作。代码如下:

  1. //TCCFenceHandler 类
  2. public static Object prepareFence(String xid, Long branchId, String actionName, Callback<Object> targetCallback) {
  3. return transactionTemplate.execute(status -> {
  4. try {
  5. Connection conn = DataSourceUtils.getConnection(dataSource);
  6. boolean result = insertTCCFenceLog(conn, xid, branchId, actionName, TCCFenceConstant.STATUS_TRIED);
  7. LOGGER.info("TCC fence prepare result: {}. xid: {}, branchId: {}", result, xid, branchId);
  8. if (result) {
  9. return targetCallback.execute();
  10. } else {
  11. throw new TCCFenceException(String.format("Insert tcc fence record error, prepare fence failed. xid= %s, branchId= %s", xid, branchId),
  12. FrameworkErrorCode.InsertRecordError);
  13. }
  14. } catch (TCCFenceException e) {
  15. //省略
  16. } catch (Throwable t) {
  17. //省略
  18. }
  19. });
  20. }

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

  1. //TCCFenceHandler 类
  2. public static boolean rollbackFence(Method rollbackMethod, Object targetTCCBean,
  3. String xid, Long branchId, Object[] args, String actionName) {
  4. return transactionTemplate.execute(status -> {
  5. try {
  6. Connection conn = DataSourceUtils.getConnection(dataSource);
  7. TCCFenceDO tccFenceDO = TCC_FENCE_DAO.queryTCCFenceDO(conn, xid, branchId);
  8. // non_rollback
  9. if (tccFenceDO == null) {
  10. //不执行回滚逻辑
  11. return true;
  12. } else {
  13. if (TCCFenceConstant.STATUS_ROLLBACKED == tccFenceDO.getStatus() || TCCFenceConstant.STATUS_SUSPENDED == tccFenceDO.getStatus()) {
  14. LOGGER.info("Branch transaction had already rollbacked before, idempotency rejected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
  15. return true;
  16. }
  17. if (TCCFenceConstant.STATUS_COMMITTED == tccFenceDO.getStatus()) {
  18. if (LOGGER.isWarnEnabled()) {
  19. LOGGER.warn("Branch transaction status is unexpected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
  20. }
  21. return false;
  22. }
  23. }
  24. return updateStatusAndInvokeTargetMethod(conn, rollbackMethod, targetTCCBean, xid, branchId, TCCFenceConstant.STATUS_ROLLBACKED, status, args);
  25. } catch (Throwable t) {
  26. status.setRollbackOnly();
  27. throw new SkipCallbackWrapperException(t);
  28. }
  29. });
  30. }

updateStatusAndInvokeTargetMethod 方法执行的 sql 如下:

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

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

4.3 悬挂

悬挂是指因为网络问题,RM 开始没有收到 try 指令,但是执行了 Rollback 后 RM 又收到了 try 指令并且预留资源成功,这时全局事务已经结束,最终导致预留的资源不能释放。如下图:
阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题 - 图6
Seata 解决这个问题的方法是执行 Rollback 方法时先判断 tcc_fence_log 是否存在当前 xid 的记录,如果没有则向 tcc_fence_log 表插入一条记录,状态是 STATUS_SUSPENDED,并且不再执行回滚操作。代码如下:

  1. public static boolean rollbackFence(Method rollbackMethod, Object targetTCCBean,
  2. String xid, Long branchId, Object[] args, String actionName) {
  3. return transactionTemplate.execute(status -> {
  4. try {
  5. Connection conn = DataSourceUtils.getConnection(dataSource);
  6. TCCFenceDO tccFenceDO = TCC_FENCE_DAO.queryTCCFenceDO(conn, xid, branchId);
  7. // non_rollback
  8. if (tccFenceDO == null) {
  9. //插入防悬挂记录
  10. boolean result = insertTCCFenceLog(conn, xid, branchId, actionName, TCCFenceConstant.STATUS_SUSPENDED);
  11. //省略逻辑
  12. return true;
  13. } else {
  14. //省略逻辑
  15. }
  16. return updateStatusAndInvokeTargetMethod(conn, rollbackMethod, targetTCCBean, xid, branchId, TCCFenceConstant.STATUS_ROLLBACKED, status, args);
  17. } catch (Throwable t) {
  18. //省略逻辑
  19. }
  20. });
  21. }

而后面执行 try 阶段方法时首先会向 tcc_fence_log 表插入一条当前 xid 的记录,这样就造成了主键冲突。代码如下:

  1. //TCCFenceHandler 类
  2. public static Object prepareFence(String xid, Long branchId, String actionName, Callback<Object> targetCallback) {
  3. return transactionTemplate.execute(status -> {
  4. try {
  5. Connection conn = DataSourceUtils.getConnection(dataSource);
  6. boolean result = insertTCCFenceLog(conn, xid, branchId, actionName, TCCFenceConstant.STATUS_TRIED);
  7. //省略逻辑
  8. } catch (TCCFenceException e) {
  9. if (e.getErrcode() == FrameworkErrorCode.DuplicateKeyException) {
  10. LOGGER.error("Branch transaction has already rollbacked before,prepare fence failed. xid= {},branchId = {}", xid, branchId);
  11. addToLogCleanQueue(xid, branchId);
  12. }
  13. status.setRollbackOnly();
  14. throw new SkipCallbackWrapperException(e);
  15. } catch (Throwable t) {
  16. //省略
  17. }
  18. });
  19. }

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

5、总结

TCC 模式是分布式事务使用最多的模式,但是幂等、悬挂和空回滚一直是 TCC 模式需要考虑的问题,Seata 框架在 1.5.0 版本完美解决了这些问题。
对 tcc_fence_log 表的操作也需要考虑事务的控制,Seata 使用了代理数据源,使 tcc_fence_log 表操作和 RM 业务操作在同一个本地事务中执行,这样就能保证本地操作和对 tcc_fence_log 的操作同时成功或失败。