目录:在阅读本文之前,请先看下目录,了解下本文主要讲了哪些内容。

@[toc]
本文已经近 12000 字,太长的文章可能让人失去耐心,建议分 2 次看,考虑到这一点,有些地方的讲解假设读者有一定的基础,未做特别详细的讲解。对分布式事务感兴趣的同学,欢迎添加作者微信(w1186355422,请注明一下来源,谢谢),一起讨论交流,由于水平有限,文章内容如有什么问题,欢迎各位大佬批评指导,感谢支持。

1. 实际场景遇到的问题

1.1 单体架构事务场景

事务场景中经典的转账案例,相信大家都看过,这里不再赘述,这里再抽象一个我实际工作中遇到的场景:

两年前,2017 年的时候,我所在的公司有多个项目都是单体架构,所有的模块的都在一起,不同的功能逻辑写在不同的包下,连接的是同一个 MySQL 数据库,项目打包后扔在一个 Tomcat 下跑。

这里我简单抽象下这个系统的实际场景作用:

  • 用户:IT 云清(简称云清)
  • 公司:CSDN 有限公司(简称 CSDN)

由于云清在 CSDN 是博客专家,积累了一定的信誉,所以,CSDN 给云清了 10 万的授信额度,这 10 万是云清的借款额度上限,现在云清没钱了,要从 CSDN 借钱,这个借款操作,可以抽象概括为以下几个步骤:

  1. 云清发起借款,调用借款模块的借款接口
  2. 借款同时,在授信表里,减少云清的授信额度
  3. 借款同时,在资金表里,增加云清的账户余额
  4. 借款同时,在日志表里,增加一条流水记录

实际场景中,此借款的步骤远比上述操作复杂,包括审核流程、还款计划等,这里为了简化模型,只抽取上述几个操作。

成功情况:

上面的场景中,在一切都 OK 的情况下,这个借款操作,等于就是在一个接口的实现方法中,操作了不同的表,然后添加 Spring 的 @Transaction 本地事务。在操作每个表都成功时,如下图所示,整个操作成功,数据一致性得到了保障。

这一套流程走完,云清的授信减少了,云清的账户余额增加了,这一笔操作的流水也记录了,如图一:

在这里插入图片描述

失败情况:

如果其中一个表操作失败,比如借款额度大于剩余授信了,比如拿不到数据库连接了,或者数据库挂了,导致对资金表的操作失败了,会怎么样呢?

在这里插入图片描述

由于有 Spring 本地事务的控制,当其中一张表操作失败时,整个操作都会回滚。这样,整个借款就会失败,由于资金表操作失败,减少授信额度和增加流水记录,这几个步骤都会回滚。这样,最终的数据是正确的,一致的,并没有出现问题。

在单体服务中,一个请求的整个周期,从请求到响应结果,都是在一台服务器上,一个 JVM 中,本地事务就可以保证一组数据操作的一致性。

1.2 微服务架构事务场景

由于微服务越来越火,很多企业很多项目,都逐渐转向微服务架构,我现在的项目中,一个项目拆分成了 7 个微服务,我们这里还是只抽取部分微服务讲解,每个微服务部署在独立的服务器,有自己独立的数据库,如图二:

在这里插入图片描述

这个借款操作,可以抽象概括为以下几个步骤:

  1. 用户发起借款,调用借款服务的借款接口
  2. 借款同时,调用授信服务 减少授信额度
  3. 借款同时,调用资金服务 增加账户余额
  4. 借款同时,调用日志服务 增加流水记录
  5. ……

实际场景中,此借款的步骤远比上述操作复杂,包括审核流程、还款计划等,这里为了简化模型,只抽取上述几个操作。

由于每个服务都是单独部署的,在理想状态下,上述的操作,可以顺利得以执行,每个服务中的每个操作都没有问题,那么最终,数据还是一致的。可是在分布式环境下,由于网络的不可靠、服务器的不可靠、多服务间的跨服务调用延迟、多服务各自数据库的问题、中间件问题等,会出现各种各样的问题。

我们假设,云清发起借款时,资金服务的数据库挂掉了,那么就变成了如下场景,如图三:

在这里插入图片描述

这个链路成为了如下的样子:

  1. 用户发起借款,调用借款服务的借款接口
  2. 借款同时,调用授信服务 减少授信额度
  3. 借款同时,调用资金服务 增加账户余额 x
  4. 借款同时,调用日志服务 增加流水记录

这时,由于是在多个服务中,本地的 Transaction 已经无法应对这个情况了,现在系列操作导致了上述的情况,云清的授信额度减少了,流水也记录了,但是云清账户上却没有收到钱,那 IT 云清内心肯定是 mmp 的,是要把客服电话打爆掉的。

这种问题,如何应对呢?这就涉及到了这篇文章的主题:分布式事务

2. 分布式事务

根据上述的实际情况,我们可以总结出分布式事务的概念:分布式事务,是指会涉及到操作多个数据库的事务(可以对比本地事务操作一库多表),解决分布式事务问题的目的就是为了保证分布式系统中的数据一致性。

通俗一点来讲,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,调用的不同的数据库,分布式事务需要保证这些小操作要么全部成功,要么全部失败。

由于在分布式系统中,各个节点在物理上是相互独立的,通过网络协调和沟通,由于存在 Spring 事务机制,各个节点在本地的事务是可以得到保证的,但是,各独立节点互相之间,是无法准确知道其他节点的事务执行情况的。所以也就不知道本次事务到底应该 Commit 还是 Rollback。所以,咋整呢?

作为老司机,我们可以想到一个方法:

引入一个“协调者”的组件来统一调度所有分布式节点的执行,这个协调者,作为第三方,他获取所有节点的执行情况,然后判断决定出一个统一的结果:全部提交或全部回滚 。

这里对部分刚接触的同学解释下 2 个概念:

  • 本地事务:通常把一个数据库内部的事务处理,如对多个表的操作,作为本地事务看待。
  • 全局事务:是指分布式事务处理环境中,多个数据库可能需要共同完成一个工作,这个工作即是一个全局事务。例如,一个事务中可能更新几个不同的数据库,对数据库的操作发生在系统的各处,但必须全部被提交或回滚。此时一个数据库对自己内部所做操作的提交不仅依赖本身操作是否成功,还要依赖与全局事务相关的其它数据库的操作是否成功,如果任一数据库的任一操作失败,则参与此事务的所有数据库所做的所有操作都必须回滚。

3. 2PC

3.1 理论基础

二阶段提交(Two-phaseCommit)是一种中心化的事务处理提交协议。

如上面所分析的,在分布式系统中,每个节点虽然可以知道自己的操作是成功或者失败,但是却无法知道其他节点操作的成功或失败。当一个事务包含多个节点时,为了保持事务的一致性,需要引入第三方协调者,这个协调者来统一掌控所有节点(称作参与者)的操作结果,并最终指示这些节点是否要把操作结果进行真正的提交。

二阶段提交的算法思路:

事务发起方调用协调者,协调者与各参与者之间互动,参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情况决定各参与者是要提交操作还是中止操作。

两个阶段是指:

  • 第一阶段:准备阶段(投票阶段 Vote Phase)
  • 第二阶段:提交阶段(执行阶段 Commit Phase)

3.2 PC 实现

3.2.1 准备阶段

事务发起方调用协调者,事务协调者(事务管理器)给每个参与者(资源管理器)发送 Prepare 消息,每个参与者要么直接返回失败(如数据库挂掉,网络不通),要么在本地执行事务,并写本地的 Undo 日志,但不提交

这个准备阶段又可以细分为三个步骤:

  1. 协调者节点向所有参与者节点发起 Vote Request(询问是否可以执行提交操作),并开始等待各参与者节点的响应。
  2. 参与者节点执行询问发起为止的所有事务操作,并将 Undo 信息和 Redo 信息写入日志。(注意理解这个点:若成功这里其实每个参与者已经执行了事务操作,只是执行完事务操作,并没有进行 Commit 或者 Rollback)。
  3. 各参与者节点响应协调者发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个“同意”消息;如果参与者节点的事务操作实际执行失败,则它返回一个“中止”消息。

准备阶段如下:

在这里插入图片描述

在这里插入图片描述

3.2.2 提交阶段

如果协调者收到了参与者的中止消息或者等待超时也未收到回复,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息。

参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)

在提交阶段,又分为 2 种情况。

当协调者节点从所有参与者节点获取的都是 “同意” 时:

  • 协调者节点向所有参与者节点发出“正式提交(Commit)”的请求
  • 参与者节点正式完成 Commit 操作,并释放在整个事务期间内占用的资源
  • 参与者节点向协调者节点发送“完成”消息
  • 协调者节点收到所有参与者节点反馈的“完成”消息后,完成事务

在这里插入图片描述
在这里插入图片描述

当协调者节点在第一阶段中收到的反馈有“终止”,或者等待超时后协调者节点没有获取到所有参与者节点的“同意”时:

  • 协调者节点向所有参与者节点发出“回滚操作(Rollback)”的请求
  • 参与者节点利用之前写入的 Undo 信息执行回滚,并释放在整个事务期间内占用的资源
  • 参与者节点向协调者节点发送“回滚完成”消息
  • 协调者节点受到所有参与者节点反馈的“回滚完成”消息后,取消事务

不管最后结果如何,第二阶段都会结束当前事务,释放资源。

当第一阶段的反馈结果不全是“同意”,执行流程如下:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

3.3 2PC 优缺点

3.3.1 同步阻塞问题

执行过程中,所有参与节点都是事务阻塞型的,第一阶段和第二阶段都是阻塞的。各节点都阻塞着等待其他节点的执行情况,当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态,效率低下(这一点,阿里巴巴的 Seata,采用了一种更激进的做法,第一阶段就把事务提交掉,并释放全局锁,如果第二阶段要回滚,才会把全局锁持有至第二阶段结束)。

3.3.2 单点故障

由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。

3.3.3 数据不一致

在二阶段提交的阶段二中,当协调者向参与者发送 Commit 请求之后,发生了局部网络异常或者在发送 Commit 请求过程中协调者发生了故障,这会导致只有一部分参与者接受到了 Commit 请求。而在这部分参与者接到 Commit 请求之后就会执行 Commit 操作,但是其他部分未接到 Commit 请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。

3.3.4 不确定性

协调者再发出 Commit 消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。

由于二阶段提交存在着上述的一些缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。

4. 3PC

4.1 理论基础

三阶段提交(Three-phase commit),也叫三阶段提交协议(Three-phase commit protocol),可以看做是二阶段提交(2PC)的改进版本。

与两阶段提交不同的是,三阶段提交有两个改动点:

  • 引入一种超时机制。(解决协调者单点故障的问题)
  • 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。

阶段提交有 CanCommit、PreCommit、DoCommit 三个阶段。

在第一阶段,只是询问所有参与者是否可以执行事务操作,并不在本阶段执行事务操作。当协调者收到所有的参与者都返回 YES 时,在第二阶段才执行事务操作,然后在第三阶段执行 Commit 或者 Rollback。

4.2 3PC 实现

4.2.1 CanCommit 阶段

3PC 的 CanCommit 阶段其实和 2PC 的准备阶段很像。协调者向参与者发送 Can Commit 请求,参与者如果可以提交就返回 Yes 响应,否则返回 No 响应。

这个阶段又可以细分为 2 个步骤:

  • 事务询问:协调者向参与者发送 CanCommit 请求。询问是否可以执行事务操作,并不在本地执行事务操作。然后开始等待参与者的响应。
  • 响应反馈:参与者接到 CanCommit 请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回 Yes 响应,并进入预备状态,否则反馈 No。
4.2.2 PreCommit 阶段

协调者根据参与者的反应情况来决定是否可以进行事务的 PreCommit 操作。根据响应情况,有以下两种可能:

假如协调者从所有的参与者获得的反馈都是 Yes 响应,那么就会执行事务的预执行:

  • 发送预提交请求: 协调者向参与者发送 PreCommit 请求,并进入 Prepared 阶段。
  • 事务预提交:参与者接收到 PreCommit 请求后,会执行事务操作,并将 undo 和 redo 信息记录到事务日志中。(此时没有 Commit)
  • 响应反馈: 如果参与者成功的执行了事务操作,则返回 ACK 响应,同时开始等待最终指令。

假如有任何一个参与者向协调者发送了 No 响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。

  • 发送中断请求: 协调者向所有参与者发送 abort 请求。
  • 中断事务: 参与者收到来自协调者的 abort 请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
4.3.3 DoCommit 阶段

该阶段进行真正的事务提交,也可以分为以下两种情况。

执行提交

1. 发送提交请求

协调接收到参与者发送的 ACK 响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送 DoCommit 请求。

2. 事务提交

参与者接收到 DoCommit 请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。

3. 响应反馈

事务提交完之后,向协调者发送 ACK 响应。

4. 完成事务

协调者接收到所有参与者的 ACK 响应之后,完成事务。

中断事务

协调者没有接收到参与者发送的 ACK 响应(可能是接受者发送的不是 ACK 响应,也可能响应超时),那么就会执行中断事务。

1. 发送中断请求

协调者向所有参与者发送 abort 请求。

2. 事务回滚

参与者接收到 abort 请求之后,利用其在阶段二记录的 undo 信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。

3. 反馈结果

参与者完成事务回滚之后,向协调者发送 ACK 消息。

4. 中断事务

协调者接收到参与者反馈的 ACK 消息之后,执行事务的中断。

在 DoCommit 阶段,如果参与者无法及时接收到来自协调者的 DoCommit 或者 rebort 请求时,会在等待超时之后,继续进行事务的提交。

这里引用一篇文章提到的说法:

当进入第三阶段时,说明参与者在第二阶段已经收到了 PreCommit 请求,那么协调者产生 PreCommit 请求的前提条件是他在第二阶段开始之前,收到所有参与者的 CanCommit 响应都是 Yes。一旦参与者收到了 PreCommit,意味他知道大家其实都同意修改了,所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到 Commit 或者 abort 响应,但是他有理由相信:成功提交的几率很大。

4.3 3PC 优缺点

相对于 2PC,3PC 主要解决的协调者单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行 Commit,而不会想 2PC 一样一直持有事务资源并处于阻塞状态。

但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的 abort 响应没有及时被参与者接收到,那么参与者在等待超时之后执行了 Commit 操作。这样就和其他接到 abort 命令并执行回滚的参与者之间存在数据不一致的情况。

5. TCC

5.1 理论基础

前面我们讲了 2PC 协议,3PC 协议,我们可以暂时认为 2PC 协议,3PC 协议他们是传统的事务处理机制,这里我们讲一讲 TCC(Try-Confirm-Cancel) 事务机制,相对于传统事务机制(X/Open XA Two-Phase-Commit),TCC 的特别之处在于它不依赖资源管理器(RM)对 XA 的支持,而是通过对业务逻辑(由业务系统提供的)的调度来实现分布式事务的管理。

在一个业务系统中,我们有 A 服务,提供一个操作 Op,他对外提供服务时,由于网络原因,服务状态,数据库状态等原因,这个 Op 操作,是不能确定百分百可以成功的,怎么办呢?

我们可以用这样一种思路:对这个 Op 的操作,我们第一次调用时,只是当作一个临时操作(Try),我们保留后续取消这个操作的权力,如果全局事务认为它该 Commit,那我们就对这个临时性操作做一个确定性的提交(Confirm),如果全局事务认为它该 Rollback,那我们就撤销这个操作(Cancel)。

在 TCC 事务机制中,每一个操作,都会有一个与之对应的确认和撤销操作,这个操作最终都会被确认或取消。因此,针对一个具体的业务服务,TCC 事务机制需要业务系统提供三段业务逻辑:

  1. 初步操作 Try
  2. 确认操作 Confirm
  3. 取消操作 Cancel

5.2 TCC 实现

初步操作(Try)

TCC 事务机制中的业务逻辑(Try),并不是一个完整的操作,很多情况下,他只是去检查预留确认操作所需要的资源,或者去冻结资源,它和后续的确认操作一起才能真正构成一个完整的业务逻辑。

我们可以认为:传统事务机制的业务逻辑 = TCC事务机制的 Try+TCC 事务机制的 Confirm

TCC 事务机制以初步操作(Try)为中心,即使失败,仍然有取消操作(Cancel)可以将其不良影响进行回撤。

确认操作(Confirm)

确认操作(Confirm)是对初步操作(Try)的一个补充。当 TCC 事务管理器决定 Commit 全局事务时,就会逐个执行初步操作(Try)指定的确认操作(Confirm),将初步操作(Try)未完成的事项最终完成。

取消操作(Cancel)

取消操作(Cancel)是对初步操作(Try)的一个回撤。当 TCC 事务管理器决定 Rollback 全局事务时,就会逐个执行初步操作(Try)指定的取消操作(Cancel),将初步操作(Try)已完成的操作全部撤回。

5.3 落地

如果我们想在项目中落地 TCC 分布式事务,首先需要选择某种 TCC 框架整合到项目中,在传统的(有别于 TCC 模式)业务逻辑实现中,我们写业务,一个接口一般有一个实现(这里不要抠词语考虑多实现,我们只是为了更直观地展示用 TCC 和不用 TCC 两种模式下的显著区别),现在要改造为 3 个:Try、Confirm、Cancel:

  • 先是服务调用链路依次执行 Try 逻辑。
  • 如果都正常的话,TCC 分布式事务框架推进执行 Confirm 逻辑,完成整个事务。
  • 如果某个服务的 Try 逻辑有问题,TCC 分布式事务框架感知到之后就会推进执行各个服务的 Cancel 逻辑,撤销之前 Try 阶段执行的各种操作。

在业务操作调用时,先来 Try 一下,不要把业务逻辑完成,先试试看,看各个服务能不能基本正常运转,能不能先冻结需要的资源。

如果 Try 都 OK,也就是说,底层的数据库、Redis、Elasticsearch、MQ 都是可以写入数据的,并且你保留好了需要使用的一些资源(比如冻结了一部分库存)。

接着,再执行各个服务的 Confirm 逻辑,基本上 Confirm 就可以很大概率保证一个分布式事务的完成了。

那如果 Try 阶段某个服务就失败了,比如说底层的数据库挂了,或者 Redis 挂了,等等。此时就自动执行各个服务的 Cancel 逻辑,把之前的 Try 逻辑都回滚。保证要么一起成功,要么一起失败。

如果有一些意外的情况发生了,比如说某个服务突然挂了,然后再次重启,TCC 分布式事务框架是如何保证之前没执行完的分布式事务继续执行的呢?

如果看过 byteTCC、Seata 等框架的源码,会发现事务框架都是有日志模块的,主要用来记录一些分布式事务的活动日志的,可以在日志文件里记录,也可以在数据库里记录,来保存分布式事务运行的各个阶段的状态和相关数据,如果由于其他原因导致了服务出现问题,这时候日志就会起作用了。

5.4 TCC 优缺点

  1. 实现 TCC 的业务成本,一个逻辑得写 3 套,分别对应 Try、Confirm、Cancel,而且针对不同失败情况,CC 的逻辑可能还不一样
  2. Confirm 和 Cancel 操作的执行成本
  3. 记录日志的成本和开销
  4. 复杂业务,TCC 的 CC 难以处理,难以实现
  5. 如果框架不考虑幂等,事务内 CC 实现需要考虑幂等

6. 分布式事务解决方案

排名不分先后,在前面的不一定是强烈推荐的。

1. LCN

TX-LCN 是一款事务协调性框架,框架其本身并不操作事务,而是基于对事务的协调从而达到事务一致性的效果。目前已经到 5.0.2 版本,5.0 以后框架兼容了 LCN、TCC、TXC 三种事务模式,对使用者比较简单,加注解即可实现全局事务控制,但是会有一些问题,比如集群问题,调用成环场景。如果业务简单,并发不大,还是可以使用的。

LCN 含义:

  • 锁定事务单元(Lock)
  • 确认事务模块状态(Confirm)
  • 通知事务(Notify)

2. ByteTCC

整合教程

0.4.x 版本:

0.5.x 版本:

ByteTCC 是一个基于 TCC(Try-Confirm-Cancel)机制的分布式事务管理器。兼容 JTA,可以和 spring、JavaEE 容器无缝集成。目前已经更新到 0.5.x 版本。考虑到 TCC 模式本身的复杂性,在复杂场景业务中,不推荐使用这种模式,复杂场景下,CC 逻辑的开发比较考验技术和对业务的了解,对开发人员要求较高。

另外,ByteTCC 整合时需要注意不同的 Spring Boot 版本和 ByteTCC 版本要适配;总体而言,Spring Boot 1.x 得用 0.4.x 的版本,0.5.x 版本得用 Spring Boot 2.x。

3. Seata

Seata 是阿里巴巴开源的分布式事务中间件,以高效并且对业务 0 侵的方式,解决微服务场景下面临的分布式事务问题。由于 Seata 还在持续的大迭代,目前 AT(Automatic Transaction)模式,还不能覆盖所有场景,某些场景下,还需要 MT(Manual Transaction)模式,这种模式下,分支事务需要应用自己来定义业务本身及提交和回滚的逻辑。

8 月份,发布 0.8.0 版本。

在前文也提到过,Seata 对全局锁的控制思路,会很大程度上降低分布式事务处理带来的性能损耗,由于是阿里出品,而且基于阿里巴巴在 TXC 和 GTS 上的技术积累,以及海量业务的磨练,个人对 Seata 非常期待,觉得未来会是一个非常好的选择。

4. Hmily

Hmily 是一个基于 TCC 的开源框架。基于 Java 语言来开发(JDK 1.8),支持 Dubbo、Spring Cloud、Motan 等 RPC 框架进行分布式事务处理。

5. Raincat

强一致性分布式事务,是基于二阶段提交 + 本地事务补偿机制来实现。基于 Java 语言来开发(JDK 1.8),支持 Dubbo、Spring Cloud、Motan 等 RPC 框架进行分布式事务。

6. myth

采用消息队列解决分布式事务的开源框架,基于 Java 语言来开发(JDK 1.8),支持 Dubbo、Spring Cloud、Motan 等 RPC 框架进行分布式事务。

7. TCC-transaction

是一个基于 TCC 的开源框架。

其他资料

ACID、BASE、CAP 等基础概念:

https://mp.weixin.qq.com/s/GNoNZa7DbC57bg4Fo7Qerw


本文首发于 GitChat,未经授权不得转载,转载需与 GitChat 联系。

最后更新: 2020年01月16日 10:28

原始链接: https://java4all.cn/2018/05/01/深入理解分布式事务/