TCC真没这么简单,一文讲透|分布式事务系列(三)

时间:2022-03-14 01:11:29

来源:后端开发技术


本文从两个场景说起,详细描述了TCC的详细过程,以及对比2PC有什么区别,适用什么样的场景。

在面试前复习 TCC 的时候你是不是这样做的:百度TCC关键词,随便找了篇文章,查询到他有try、confirm、Cancel 三个阶段,业务侵入度高,和两阶段差不多。复习完毕。

如果你是这样去理解和复习的,只能说对 TCC 的理解太不到位了,真的有必要耐心看完这篇文章。

TCC

有些人可能觉得 TCC 和两阶段提交非常相似,无法区分。首先强调,TCC是一种最终一致性方案,他并不是强一致性。如果你不了解两阶段提交,请先看这篇。

TCC真没这么简单,一文讲透|分布式事务系列(三)

分布式事务,强一致性方案有哪些?|分布式事务系列(二)


这篇文章我们来探讨下 TCC 到底是什么,他用来解决什么问题?在继续阅读之前,我先抛出两个问题。

一个面试中的问题

在某大厂面试的时候,面试官问了我一个问题:由于我介绍的是一个支付项目,业务系统调用支付系统扣款成功后采用本地事务状态表(最终一致性)的方案通知业务系统。由于我们的系统QPS并不高,所以我自认为这样的方案是没问题的。

但是面试官问我,如果扣款成功后订单系统数据库写入不可用怎么办?虽然用户支付成功,但是即使有短暂几分钟的写入不可用也会导致客诉暴涨,你怎么解决这个问题?

这让我意识到,如果在高并发场景下这个方案是有问题的。本地事务状态表的缺点就是及时性不够,当然这个可以通过再本地事务结束后及时发起对业务的通知解决。但是有个问题无法解决,本地事务状态表都是基于本地业务执行成功后,下游依赖业务也会成功。如果下游系统发生不可用问题,我们的本地事务状态表中状态将阻塞在这里,出现上述支付扣款后一直无法通知到订单的情况。

电商超卖问题

除了上述面试问题,还有一个电商中很常见的超卖问题。

还是以电商场景下的余额支付、扣库存为例。如果我们采用可靠消息队列或者本地事务表的方案,用户购买了一瓶可乐,余额扣款成功后发送消息到库存系统,库存系统对可乐的库存减1。如果是只有一个用户买并且库存充足的情况下这是没有问题的,但是如果此时有3个用户在购买可乐,但是库存仅剩 1 瓶,支付前使用接口检查剩余库存的时候是通过的。但是等到支付成功,扣减库存的消息同步到库存服务的时候系统就会出现超卖 2 瓶的情况。

TCC真没这么简单,一文讲透|分布式事务系列(三)

之所以出现上述问题,是因为创建订单在业务上是用户之间隔离的,业务上不会有资源的竞争,但是库存数据是一种共享资源,多个用户同时购买同样的商品就会出现并发问题,所以需要不同的线程之间事务隔离

实现隔离性可以使用我们之前提到的强一致性方案 两阶段提交,但是在电商场景高并发下,两阶段提交持有资源时间过长并不合适。强一致性性能太低,消息队列方案由无法资源隔离,怎么办呢?TCC 方案就是为这种场景而生的。

什么是 TCC

TCC 是“Try-Confirm-Cancel”三个单词的缩写,是由数据库专家 Pat Helland 在 2007 年撰写的论文《Life beyond Distributed Transactions: An Apostate’s Opinion》中提出。一提到 TCC 我们都知道它是一种业务侵入较强的分布式事务方案,要求业务处理过程必须拆分为“try ”和“ confirm/cancel”两个阶段,并且需要分别提供这两个阶段所涉及三个步骤的接口。

  • Try :尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
  • Confirm :确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理(也就是说业务上理论一定可以执行成功)。由于分布式环境下的不可靠性,Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
  • Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。由于分布式环境下的不可靠性,Cancel 阶段可能会重复执行,也需要满足幂等性。

具体时序图如下:

TCC真没这么简单,一文讲透|分布式事务系列(三)

这三个阶段的特点如下:

流程 特点
Try 预留业务资源(不锁定资源,独立事务)
Confirm 确认提交资源(需要重试,最终一致性)
Cancel 释放资源(需要重试,最终一致性)

如何解决前文两个问题

订单状态不更新问题

第一个问题,如何解决扣款成功订单状态迟迟不更新:

  1. 如果采用TCC方案,首先本地创建事务,生成事务 ID,记录在活动日志中,此时状态为Try。
  2. 订单在扣款和通知订单状态变更会在Try阶段进行检查,首先在用户的余额系统锁定订单金额,然后通知订单状态变为支付待确认。
  3. 如果订单库此时写入不可用,那么Try阶段订单服务会失败,活动日志状态变更为 Cancel。
  4. 进入Cancel阶段后,余额服务将锁定的金额释放,通知订单支付不成功,如果任意环节失败,可以用最终一致性方案不断尝试。

这样,上述问题通过余额回退的方式得到化解。

超卖问题

第二个问题,如何解决电商超卖的问题:

  1. 用户发起支付请求,购买一瓶可乐。

  2. 创建事务,生成事务 ID,记录在活动日志中,进入 Try 阶段:

    余额服务尝试锁定金额,库存服务尝试锁定库存资源。两者有任意失败或者接口超时活动日志的状态记录为 Cancel,将进入 Cancel阶段;全部成功则进入 Confirm 阶段,活动日志的状态记录为 Confirm。

  3. Confirm 阶段,说明Try全部成功:余额服务执行扣减金额,库存执行扣减库存。

    Confirm 阶段如果全部完成,事务正常结束。如果任何一个环节出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Confirm 操作,实现最终一致。

  4. Cancel 阶段,说明Try 阶段有服务超时或者执行失败:撤销用户被锁定的金额,解锁被占用的库存。

    Cancel 阶段如果全部完成,事务以执行回滚结束。如果在 Cancel 时任意服务超时或者失败,都将根据活动日志中的记录,重复执行该服务的 Cancel 操作,实现最终一致性。

假设可乐只剩 1 瓶,因为我们在第 Try 阶段首先执行余额锁定和库存扣减,如果用户A扣款成功,并且锁定库存成功,此时可乐的可购买库存为0。当用户B并发购买,即使余额锁定成功,但是检查库存时发现已经库存不足,将通知余额解锁金额,超卖问题由此解决。

TCC真没这么简单,一文讲透|分布式事务系列(三)

TCC与2PC对比

正因为 TCC 也分为了 Try 和 Confirm/Cancel 两个阶段,所以很多人对此和两阶段提交产生了混淆,这里用两个表格列出他们的异同。

相同:

TCC 两阶段提交
可分为 Try 和 Confirm/Cancel 两个阶段 可以分为投票阶段和提交阶段 两个阶段
在Try阶段占用资源 在第一阶段占用资源

不同:

TCC 两阶段提交
实现了最终一致性 刚性事务,实现了强一致性
服务的代码逻辑层面实现,需要对三种操作分别编码,有业务侵入,开发成本更高 底层数据库支持两阶段协议,无业务侵入
第一阶段,Try 阶段会直接提交事务,只会短暂持有资源锁,性能较高 第一阶段会持续锁定资源并持有锁,事务不提交,造成服务阻塞,性能低
第二阶段的 Confirm 和 Cancel 如果出现失败,会利用最终一致性方案不断重试 第二阶段如果有失败,会造成系统之间的数据不一致
TCC 的每个步骤在数据库层面都是一个独立事务 两阶段的两个步骤和起来是一个完整的事务

适用场景

说了这么多,我们应该在什么场景下使用 TCC 呢?我做了些总结。

  1. 当常规最终一致性方案无法满足,需要更强的一致性
  2. 当业务对实时性要求高。
  3. 当业务涉及到资源争夺,需要更高的隔离性
  4. 当业务在满足一致性和隔离性的时候,还需要更好的性能表现

当你的业务遇到这些问题的时候,那就可以考虑TCC了,比如涉及到资金数据,银行业务,金融业务,涉及到交易、支付、账务都可以考虑。

但是上述优点都是有代价的,TCC 对于业务的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。难度也比较大,需要根据不同的失败原因实现不同的回滚策略,有更高的开发成本和更换事务实现方案的替换成本。我们可以基于某些分布式事务中间件(譬如阿里开源的Seata)去完成,尽量减轻一些编码工作量。