深入浅出系列-分布式事务Seata TCC模式

深入浅出系列-分布式事务Seata TCC模式

Scroll Down

正文

书接上回, 上一篇文章介绍了 Seat AT 模式,想必大家应该都知道个所以然了吧,忘记了的同学麻烦再回去翻一翻。

简单回顾一下,AT 模式的核心思想是通过二阶段提交,实现分布式事务。而且目前 Seata 框架已经对 AT 模式的二阶段操作集成封装非常高,开发者接入非常方便。

However,今天我们要讲的是另外一种模式:TCC

什么是 TCC?为什么需要 TCC,它比 AT 模式更好吗?

下面就跟着小年一同深入了解。

什么是 TCC

Seata TCC 模式,是蚂蚁金服在 2019 年 3 月为其社区贡献的。

TCC(Try-Confirm-Cancel),可以从全称就能看出来它的三个核心操作:

  • Try:业务资源的检查预留
  • Confirm:执行业务操作和提交
  • Cancel:预留资源的释放

与 AT 模式相同的是,TCC 模式的核心思想也是通过 二阶段提交

一阶段:调用业务服务的 Try 接口,对业务资源预留;

二阶段有两种情况:

  • 若所有业务服务 Try 接口返回成功,调用 Confirm 接口,执行业务操作;
  • 否则,调用 Cancel 接口,释放预留的业务资源

不过,从实现层面上来对比的话,二者是有所区别的。

TCC 模式,开发者最重要的是考虑如何将自己的业务模型拆成两阶段来实现,并且需要开发者自行实现 Try、Confirm、Cancel、这三个服务接口的逻辑代码。

举个例子,依旧是上回用户购买商品下单的业务场景。用户发起支付下单,涉及三个业务服务的链路调用。

  1. 库存服务:扣减库存
  2. 订单服务:创建订单
  3. 账户服务:扣减用户余额

一阶段 Try

在开始阶段,跟 AT 模式大致是一样的。Business 业务入口(TM)在发起分布式事务时,会向 TC(事务管理者)注册并生成一个全局事务的 XID,并且这个 XID 会通过服务的链路调用透传到各个业务服务。

库存、订单、账户三个服务都必须实现 Try、Confirm、Cancel 三个接口的逻辑,并且在服务启动的时候,会向 TC 注册。

用户下单真正发起请求时,Business 会先调用库存服务、订单服务的 Try 接口,订单服务调用账户服务的 Try 接口。

而业务服务 Try 接口的处理,是对各自业务资源的预处理。比如说库存服务,在这里并不是直接扣除商品的库存,而是对这个商品的库存进行冻结!

订单和账户也是一致的逻辑操作,账户服务的话需要冻结用户的当前这商品价格的余额,订单有些稍微区别,比如增加一个创建中的状态。

二阶段 Confirm

如果所有业务服务在 Try 阶段都执行成功,那么说明整个全局事务时可执行可提交的。

TM 会向 TC 发起全局事务的确认提交请求,而后 TC 会回调业务服务的 Confirm 接口,执行业务资源的提交处理。

也就是说,在 Confirm 接口中我们才会真的做业务操作,比如扣减库存、创建订单、扣减账户余额。

二阶段 Cancel

反过来说,只要有任意一个业务服务在一阶段 Try 失败,就意味着全局事务失败的,所有业务服务都需要回滚。

同样 TM 会向 TC 发起全局事务的回滚请求,而后 TC 会回调业务服务的 Cancel 接口,执行业务资源的回滚。

这里就需要将一阶段 Try 接口中对冻结了的商品库存、订单、账户余额都要解冻释放。

当然,聪明的同学可能就会有疑问,如果说二阶段 Confirm 或者 Cancel 执行失败了,又怎么办呢?

如果二阶段的 Confirm 或者 Cancel 执行失败,Seata 默认的机制是会一直重试,如果是因为业务处理异常导致,那改实现重新发布。如果是系统异常等环境自愈。

TCC 异常控制

1. 空回滚

什么是空回滚?

简单说就是,二阶段的 Cancel 比一阶段的 Try 先执行。而这时会发现没有对应的事务XID,也就是没有对应被冻结的资源,但 Cancel 接口需要返回回滚成功。

为什么存在这种情况呢?

通过 RPC 调用分支业务时,在分支业务执行 Try 接口逻辑之前,Seata 框架做了拦截的处理,先向 TC 注册一个分支事务,然后在才去执行 Try 接口的逻辑。如果 RPC 调用逻辑有问题,比如调用方机器宕机、网络异常,都会造成 RPC 调用失败,即未执行 Try 方法。但是分布式事务已经开启了,需要推进到终态,因此,TC 会回调参与者二阶段 Cancel 接口,从而形成空回滚。

怎么解决空回滚呢?

思路很简单,就是需要知道一阶段是否执行,如果执行了,那就是正常回滚;如果没执行,那就是空回滚。

因此,需要一张额外的事务控制表,其中有分布式事务 ID 和分支事务 ID,第一阶段 Try 方法里会插入一条记录,表示一阶段执行了。Cancel 接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。

2. 幂等

幂等,相信大家对这个比较好理解,这也是在接口安全性中比较常见的一个基本准则。

在 TCC 的二阶段 Confirm 和 Cancel 接口由 TC 通过 RPC 接口调用分支事务的,中间过程可能会因为网络故障、参与者宕机的情况,分支事务已经执行了,但 TC 没有收到调用的返回结果,又会重复发起调用。

这种情况如果不做幂等的处理,很有可能会重复处理资源或者释放资源,比如说,重复扣取账户余额,这种严重的资损问题。

怎么解决重复执行的幂等问题呢?

一个简单的思路就是记录每个分支事务的执行状态。在执行前状态,如果已执行,那就不再执行;否则,正常执行。前面在讲空回滚的时候,已经有一张事务控制表了,事务控制表的每条记录关联一个分支事务,那我们完全可以在这张事务控制表上加一个状态字段,用来记录每个分支事务的执行状态。

3. 悬挂

什么是悬挂?

上面讲到空回滚的异常情况,会存在二阶段的 Cancel 比一阶段的 Try 先执行。当一阶段 Try 接口执行资源预处理的时候,其实整个事务已经结束了,因为 Cancel 返回回滚成功的状态,所以此时预留的资源再也没有用处了,这种情况称为悬挂

按小年的理解,悬挂空回滚这两个异常,是网络故障中,大概会伴随一起发生的异常情况。

怎么解决?

依旧还是通过事务控制表,空回滚的处理中,Cancel 接口会先判断一阶段的 Try 是否执行过;同理, Try 接口执行前先判断 Cancel 或者 Confirm 是否已经执行过,如果已执行,则不再进行资源预处理。

简单说,为了解决 TCC 的异常问题,我们额外引入一张数据表记录分支事务的状态,并且在接口处理之前,需要根据状态判断是否执行当前阶段的接口逻辑。

为什么要 TCC

了解 TCC 的工作原理之后,对比于 AT 模式来说,是不是感觉 TCC 模式的更加复杂和麻烦呢?

存在即合理,TCC 模式的出现,自然是有它实际意义的使用场景。

1. 接入成本

AT 模式是属于开箱即用的功能,Seata 框架客户端已经封装集成非常完善,只需要在分布式事务入口加上注解@GlobalTransactional即可,对于新手来说是非常友好。

而 TCC 模式对新手来说,学习成本不仅更高,而且业务的复杂度会更高。

首先你需要将业务拆分成两阶段操作,比如一个账户扣减余额的操作,原本正常的业务处理是直接扣减,但是在 TCC 模式下,你需要将次拆分成两阶段,即先冻结,再扣减。

然后在业务代码层面上,你需要分别实现两阶段中的三个接口逻辑及其对应的业务操作,其次,还得需要额外处理像上面提到的 TCC 异常情况。一顿代码实现下来,可能你会发现代码量是原来AT模式的好几倍,代码入侵性比较高。

2. 性能

在上回讲解 AT 模式也谈到过,AT 模式是通过全局锁来解决不同全局事务之间的读写冲突,而全局锁只有在第二阶段提价或者回滚后才会释放,锁的粒度比较大。分布式事务会加长资源锁的持有时间,会严重影响并发的性能。

因此,这也是TCC 模式的一个比较突出的优点。它通过业务的改造,在第一阶段 Try 结束之后,从底层数据库资源层面的加锁过渡为上层业务层面的加锁,从而释放底层数据库锁资源,将锁的粒度降到最低,以最大限度提高业务并发性能。

简单举个例子,假设“账户 A 上有 100 元,事务 T1 要扣除其中的 30 元,事务 T2 也要扣除 30 元,并发执行”。

不管 T1 还是 T2 哪个先执行一阶段 Try 操作,在这里都是利用数据库资源层面的加锁,检查账户可用余额,然后再进行金额的冻结。可以说这里资金的冻结,实际上是串行的操作。

假设 T1 一阶段 Try 成功了,而 T2 失败了,那么在二阶段中,T2 的回滚其实都不会影响到 T1 的任何结果。也就是说二阶段的操作,它们是互不影响的。

3. 数据库依赖

我们知道 AT 模式,Seata 客户端会对业务 SQL 进行拦截,保存数据的前后镜像。而这一切的操作,是需要 Seata 客户端对业务所使用的数据库的支持和适配。而且因为是这种方式,所以并不能支持一些较为复杂的 SQL。

TCC 模式不需要关心业务 SQL,不需要关心用的是什么 DB 厂商,它将具体的实现交由开发者自己实现,开发者只需要实现TryConfirmCancel方法即可。

代码实现

库存服务(RM)接口定义:

@LocalTCC //1.开启TCC事务
public interface StorageTccAction {
    // 2.标记TCC模式,注解在try接口上,并且声明confirm、cancel接口
    @TwoPhaseBusinessAction(name = "storageTccAction", commitMethod = "commit", rollbackMethod = "rollback")
    boolean prepare(BusinessActionContext businessActionContext,
                                   @BusinessActionContextParameter(paramName = "productId") Long productId, @BusinessActionContextParameter(paramName = "num") Long num);

    boolean commit(BusinessActionContext businessActionContext);

    boolean rollback(BusinessActionContext businessActionContext);

}

这是库存服务的接口定义,定义了三个方法 preparecommitrollback 分别是对应一阶段 Try 、二阶段的 Commit 、Cancel。

  • 在接口上添加注解 @LocalTCC ,标识开启 TCC 事务;
  • 在 prepare 方法上标记 @TwoPhaseBusinessAction 注解,声明 TCC 两个阶段对应需要执行方法;
    • 属性 name :业务服务的事务名称,必须是唯一的。当前服务是库存服务,所以命名为库存服务
    • 属性commitMethod:指定二阶段执行的 Commit接口,这里指的就是当前接口中的 commit 方法。
    • 属性 rollbackMethod:同理,对应二阶段执行的 Cancel 接口
  • 方法中的参数 BusinessActionContext 是一个上下文对象,用来在两个阶段之间传递数据。
  • @BusinessActionContextParameter 注解的参数数据会被存入 BusinessActionContext

再来看看实现类:

@Slf4j
@Component
public class StorageTccActionImpl implements StorageTccAction {
  @Autowired
  private StorageAbility storageAbility;

  @Override
  @Transactional
  public boolean prepare(BusinessActionContext businessActionContext, Long productId, Long num) {
    String xid = businessActionContext.getXid();
    // 1.幂等处理
    if (TccActionHandler.hasPrepareResult(xid)) {
      return true;
    }
    // 2.防止悬挂,已经执行过回滚了就不能再预留资源
    if (TccActionHandler.hasRollbackResult(xid) || TccActionHandler.hasCommitResult(xid)) {
      return false;
    }
    // 3.冻结库存
    boolean result = storageAbility.frozen(productId, num);
    // 4.更新分支事务Try状态,执行成功
    TccActionHandler.prepareSuccess(businessActionContext.getXid());
    return result;
  }

  @Override
  @Transactional
  public boolean commit(BusinessActionContext businessActionContext) {
    String xid = businessActionContext.getXid();
    // 1.幂等处理
    if (TccActionHandler.hasCommitResult(xid)) {
      return true;
    }
    long productId = Long.parseLong(businessActionContext.getActionContext("productId").toString());
    long num = Long.parseLong(businessActionContext.getActionContext("num").toString());
    // 2.提交冻结的库存,也就是真正扣减扣库存
    storageAbility.commitFrozen(productId, num);
    // 3.更新分支事务Try状态,执行成功
    TccActionHandler.commitSuccess(businessActionContext.getXid());
    return true;
  }

  @Override
  @Transactional
  public boolean rollback(BusinessActionContext businessActionContext) {
    String xid = businessActionContext.getXid();
    // 1.幂等处理
    if (TccActionHandler.hasRollbackResult(xid)) {
      return true;
    }
    // 2.没有预留资源结果,回滚不做任何处理;
    if (!TccActionHandler.hasPrepareResult(xid)) {
      // 设置回滚结果,防止空悬挂
      TccActionHandler.rollbackResult(xid);
      return true;
    }
    long productId = Long.parseLong(businessActionContext.getActionContext("productId").toString());
    long num = Long.parseLong(businessActionContext.getActionContext("num").toString());
    // 释放库存,不再冻结
    boolean result = storageAbility.releaseFrozen(productId, num);
    // 设置回滚结果
    TccActionHandler.rollbackResult(xid);
    return result;
  }
}

在三个方法中,分别对库存执行不同的操作。

  • prepare 方法:冻结库存
  • commit 方法:扣减库存
  • rollback 方法:释放库存

这里需要注意的是每个方法都必须声明 @Transactional。订单、用户服务同理这里就不重复举例了。

再看看 Business 服务(分布式事务业务入口)是怎么调用各个业务服务的:

@Service
public class BusinessServiceImpl implements BusinessService{
  @Autowired
  StorageTccAction storageTccAction;
  @Autowired
  OrderTccAction orderTccAction;

  @Override
  @GlobalTransactional
  public String pay(){
    storageTccAction.prepare();
    orderTccAction.prepare();
    ...
  }
}

开启全局事务 @GlobalTransactional

调用业务服务的 prepare() 方法

若所有业务服务的 prepare() 方法都执行成功,那么 事务协调器(TC) 就会自动调用对应的 commit 方法,失败则回调 rollback 方法。

总结

今天主要介绍了 TCC 模式,从模型原理到代码实现,再跟 AT 模式一一对比,应该能更加深刻的理解 TCC 模式。

相比 AT 模式而言,AT 模式主要是一个简单易用性,更多关注的是数据库的数据一致性。

而 TCC 模式更多的是关注业务,提供更加灵活的业务扩展性,并且有着更高的业务并发度,当然要接入 TCC 模式会比 AT 模式 更加复杂,成本更高。

分布式事务本身就是一个非常复杂问题,面对的纷繁复杂的业务模式和架构,需要切身结合实际的业务场景具体分析。

没有最好的,只有最合适的。

参考: