vlambda博客
学习文章列表

浅谈分布式事务那些事

本篇文章我们重点讨论关于分布式事务的一些相关知识点。这是学习分布式系统的一个必不可少的技术。我们最常见的案例就是银行转账问题,A账户向B账户转100元,那么A账户余额要减少100,B账户上要增加100。两个步骤必须都要成功才算成功,只成功一个的话应当回滚掉。如果A和B不在同一个环境或者系统上,这个事务就是分布式事务了,那么在这种情况下,如何保证事务的正确执行,有哪些执行方案呢?

CAP理论和BASE理论

在将分布式事务的开始,我们先从几个分布式系统方面的基础知识点说起,首先是大名鼎鼎的CAP理论,CAP三个字母代表了三个单词,Consistency(一致性),Availability(可用性),Partition Tolerance(分区容忍性)。CAP理论指的是任何一个分布式系统,最多只能保证CAP三项中的两项。

首先,一致性,它表示所有的数据节点上的数据要保证是一致且正确的。可用性指的是每一个操作总是能够在一定时间内返回结果即是说每个读写操作都是成功的。我们平时经常看到的一些对于系统稳定性的描述都是达到了几个9,比如3个9就是99.9%,4个9就是99.99%。这就意味着系统只有在极少数情况下才会发生故障或错误。分区容忍性指的是当出现部分节点故障时,分布式系统仍然能够正常运行。

常见的分布式系统应用架构都是AP或者CP,因为如果没有P那本质上就是个单机应用谈不上分布式了。工作中最常用到的几个注册中心,ZooKeeper就是基于CP架构的,Eureka是基于AP的。

在工程实践中,基于CAP理论又演化出一个新的理论-BASE理论。Base代表了三个单词:Basically Available(基本可用),Soft State(软状态)和Eventally Consistent(最终一致性)。它的核心思想是最终一致性,即无法做到强一致性,但我们可以根据实际业务情况,使系统达到最终一致性。

首先,Base理论中的基本可用,就是不追求CAP中的任何时候的读写操作都是成功的,但系统能够保证基本运行。比如我们平时双十一的时候可能会出现排队或者失败的情况,其实就是牺牲了部分可用性来保证系统的稳定。

软状态可以对标ACID中的强一致性,ACID中要么全做要么全不做,所有用户看到的数据都是绝对一致的。但软状态是允许出现数据不一致的时刻,相当于是一个中间状态,几个数据节点的数据出现了延迟的情况。

最终一致性指的是数据不能一直处于软状态的情况,最终还是要达到所有节点数据一致的。这也是我们大部分开发过程中所追求的。

数据一致性模型

数据一致性模型一般分为弱一致性和强一致性,上述的BASE理论是实现的最终一致性其实就是弱一致性,而强一致性有时候也称为线性一致性,就是每次更新操作后,其他每个进程获取到的数据都是最新的,这种方式对用户友好,即用户之前做了什么操作,下一步就能保证得到什么。但这种方式会牺牲系统的可用性,可以理解为我们实现了CP牺牲了A。

除了强一致性之外的一致性模型都是弱一致性,也就是说系统不承诺更新操作后一定能保证下次能读取到最新数据,要能正确读取到这个数据需要等待一段时间,这个时间差称作“不一致窗口”。

最终一致性是弱一致性的特例,它强调的是所有节点的数据副本,经过一段时间后,一定能达到全部一致。它的不一致窗口的时间主要受通信延迟,系统负载和复制副本的个数影响。其根据不同的保证也可以分为不同模型,包括因果一致性和会话一致性等。

因果一致性要求有因果关系的操作顺序得到保证,非因果关系的操作顺序则无所谓。

会话一致性将对系统数据的访问过程框定在了一个会话当中,约定了系统能保证在同一个有效的会话中实现“读己之所写”的一致性,就是在你的一次访问中,执行更新操作之后,客户端能够在同一个会话中始终读取到该数据项的最新值。

那么为了实现数据一致性,我们就自然要回到我们一开始就要说的分布式事务的概念了,它不同于我们平时单机应用上的服务,由于在数据分布在多台服务器上,我们就不能用传统的方式来保证事务的正确提交,这就引出了集中针对分布式事务的解决方案。

2PC 两阶段提交

两阶段提交(2PC,Two-phase Commit Protocol)是非常经典的强一致性、中心化的原子提交协议。这个算法包含了两类角色,协调者(Coordinator)和参与者(Participants)。所谓的两阶段指的是准备阶段(Commit-request)和执行阶段(Commit)。

在准备阶段的时候,协调者通知各个参与者准备提交事务,询问它们是否接受。参与者们会各自反馈自己的响应,同意或者取消(故障),在这个阶段里面,所有参与者都没有进行commit事务操作。

协调者收到参与者们的反馈结果后,进行决策,如果所有的参与者都表示同意,则要提交事务,否则就是取消。此时通知所有参与者们进行事务提交/取消,参与者们接受到协调者的通知后进行事务操作commit/rollback。

但是两阶段提交存在着一些问题,列举如下:

  • 资源被同步阻塞:在执行过程中,所有参与者都是事务独占的,也就意味着,当参与者占用公共资源的时候,其他节点访问公共资源会处于阻塞状态。

  • 协调者可能出现单点故障:协调者作为这个算法中的核心角色,一旦发生故障,参与者会一直阻塞下去。如果在第二阶段发生的,所有的参与者还处于锁定事务资源的状态,但收不到协调者的决策通知,导致一直锁定无法继续完成事务。

  • Commit阶段出现数据不一致:在第二阶段中,协调者发起了commit的通知,但由于网络等原因导致部分节点收到部分节点每收到通知,会导致没收到的还处于阻塞状态,收到的会进行commit操作,从而出现了数据不一致问题。

XA规范

因为讲了二阶段提交,那么这里也顺便提一下我们被问到MySQL时经常会说的XA规范,因为它实际上是实现了二阶段提交的。它是由 X/Open 组织提出的分布式事务规范,XA 规范主要定义了事务协调者(Transaction Manager)和资源管理器(Resource Manager)之间的接口。

XA

事务管理器担任着协调者的角色,它来负责协调和管理事务,提供给AP应用程序编程接口并管理资源管理器。事务管理器向事务指定标识,监视它们的进程,并负责处理事务的完成和失败。资源管理器,可以理解为一个DBMS系统,或者消息服务器管理系统。应用程序通过资源管理器对资源进行控制,资源必须实现XA定义的接口。资源管理器提供了存储共享资源的支持。

目前,主流数据库都提供了对 XA 的支持,在 JMS 规范中,即 Java 消息服务(Java Message Service)中,也基于 XA 定义了对事务的支持。

根据2PC的规范,XA也是将事务分为两个步骤准备(Prepare)和提交(Commit)阶段。准备阶段就是TM向RM发送Prepare命令,准备提交,RM执行数据操作后,返回TM结果。TM根据收到的结果,进入提交阶段,通知RM进行Commit或者Rollback操作。

在MySQL中,有两种XA事务,一种是内部XA,一种是外部XA。

在 MySQL 的 InnoDB 存储引擎中,开启 binlog 的情况下,MySQL 会同时维护 binlog 日志与 InnoDB 的 redo log,为了保证这两个日志的一致性,MySQL 使用了 XA 事务,由于是在 MySQL 单机上工作,所以被称为内部 XA。内部 XA 事务由 binlog 作为协调者,在事务提交时,则需要将提交信息写入二进制日志,也就是说,binlog 的参与者是 MySQL 本身。

外部 XA 就是典型的分布式事务,MySQL 支持 XA START/END/PREPARE/Commit 这些 SQL 语句,通过使用这些命令,可以完成分布式事务。

3PC 三阶段提交

三阶段提交(3PC,Three-phase commit)是基于2PC的改进版本,它引入了两个改动点。

  1. 引入超时机制,同时在协调者和参与者都加入超时机制。

  2. 把2PC的第一个阶段拆分成了两步:询问,然后再锁资源,最后真正提交。

它的三个阶段叫做Can Commit, PreCommit和Do Commit。

CanCommit 阶段

和2PC的准备阶段很像,就是协调者向参与者发起CanCommit 请求,参与者返回yes或no。

PreCommit阶段

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

  • 全部返回ok

  • 如果全部返回OK的话,就要进行事务预提交,协调者向参与者发送PreCommit请求,参与者接受到后进行事务操作但不提交,如果成功执行了则返回ACK响应。

  • 部分返回ok

  • 如果部分返回OK可能指的是有部分参与者返回了NO或者是超时后协调者依然未收到参与者的反馈。那么此时进行中断事务,协调者发送中断请求给参与者,参与者收到后进行abort中断。

DoCommit阶段

此阶段进行真正的提交,跟2PC的最终阶段有点相似。如果协调者上一步收到了所有的ACK则会通知参与者进行提交操作,参与者收到后进行提交并反馈协调者ACK。但如果协调者上一步未收到ACK响应,同样也要执行中断事务。如果超过超时时间参与者都没有收到协调者的通知,则自动进行Commit。

很显然3PC由于协调者和参与者都有了超时机制(2PC只有协调者有),可以保证资源不会因为协调者的故障而一直锁定,并且由于多了一个PreCommit阶段,使得参与者在提交之前的状态是一致的。但这并不能解决最终可能出现的一致性问题,因为在上面DoCommit阶段也有介绍,如果参与者未收到协调者的通知,达到了超时时间后,依然会进行提交,这就出现了数据的不一致性。

TCC(Try-Confirm-Cancel)

TCC(Try-Confirm-Cancel)的概念来源于 Pat Helland 发表的一篇名为“Life beyond Distributed Transactions:an Apostate’s Opinion”的论文。它的三个字母也对应了三个阶段:

  • Try阶段:调用Try接口,尝试执行业务,完成业务检查,预留业务资源。

  • Confirm阶段:确认执行业务操作,不做业务检查,只使用Try阶段预留的业务资源。

  • Cancel阶段:跟Confirm互斥,两者只能进入其中一个。在业务执行错误的时候进行回滚,执行业务取消,释放资源。

Try阶段失败可以Cancel,但Confirm/Cancel阶段没有,为了解决此问题,TCC中添加了事务日志,如果Confirm或者Cancle阶段出错,是允许进行重试的,所以这两个接口需要支持幂等,如果重试依然失败那就要靠人工介入了。

TCC的特点

通过上面的描述,很显然,TCC相对于之前的2PC等方式,它关注的是业务层而不是数据库或者存储资源层面的事务。它的核心思想是针对每个业务操作,都要添加相应的确认和补偿操作,同时把相关的处理从数据库拿到了业务层,以此实现了跨数据库的事务。

但正因为它是在业务层进行事务的处理,因此对微服务的侵入性强,业务逻辑的每个分支都要实现Try,Confirm,Cancel三个操作,而且Confirm,Cancel还要实现幂等。另外TCC 的事务管理器要记录事务日志,也会损耗一定的性能。

在业务中引入 TCC 一般是依赖单独的 TCC 事务框架,最常见的就是Seata框架了,这个可以自己尝试去使用体验下。

基于消息补偿的最终一致性

在实际生产工作中,我们还经常会用到一种方案,基于消息补偿的方式,它是一种异步事务机制。常见的实现方案有本地消息表、消息队列等。

本地消息表

首先说下本地消息表,它最初是由 ebay 的工程师提出,核心思想是将分布式事务拆分成本地事务进行处理,通过消息日志的方式来异步执行。所以其实就是利用了各系统本地的事务实现了分布式事务。在本地要创建一张消息表,业务执行的时候也要往这个消息表里面存放一条数据,这样可以保证存放消息和业务数据都是同时成功或失败的。成功存放后再进行后续的业务操作,如果成功了则将消息状态更新为成功。如果失败了,首先会有个定时任务经常去扫描未成功的任务去执行,如果失败也是可以重试的,所以要保证业务处理接口的幂等。

所以能看出本地消息表的方式是能保证最终一致性的,但可能出现部分时间的数据不一致。

可靠消息队列

我们常见的消息队列中RocketMQ就支持消息事务。所以我这里讲下RocketMQ实现分布式事务。这部分我从阿里云的文档上找的,感兴趣的可以自己去看,显示几个概念:

  • 半事务消息:暂不能投递的消息,发送方已经成功地将消息发送到了消息队列RocketMQ版服务端,但是服务端未收到生产者对该消息的二次确认,此时该消息被标记成“暂不能投递”状态,处于该种状态下的消息即半事务消息。

  • 消息回查:由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,消息队列RocketMQ版服务端通过扫描发现某条消息长期处于“半事务消息”时,需要主动向消息生产者询问该消息的最终状态(Commit或是Rollback),该询问过程即消息回查。

交互流程如下

交互流程

事务消息发送步骤如下:

  1. 发送方将半事务消息发送至消息队列RocketMQ版服务端。

  2. 消息队列RocketMQ版服务端将消息持久化成功之后,向发送方返回Ack确认消息已经发送成功,此时消息为半事务消息。

  3. 发送方开始执行本地事务逻辑。

  4. 发送方根据本地事务执行结果向服务端提交二次确认(Commit或是Rollback),服务端收到Commit状态则将半事务消息标记为可投递,订阅方最终将收到该消息;服务端收到Rollback状态则删除半事务消息,订阅方将不会接受该消息。

事务消息回查步骤如下:

  1. 在断网或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达服务端,经过固定时间后服务端将对该消息发起消息回查。

  2. 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。

  3. 发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行操作。

尽最大努力通知

和上面的可靠消息队列方式类似的,还有一种方案叫做尽最大努力通知。它的核心思想是发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。一般也是通过MQ去实现的。

它和基于可靠消息一致的区别如下(摘自某博客):

1、解决方案思想不同

可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。

2、两者的业务应用场景不同

可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易。最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。

3、技术解决方向不同

可靠消息一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消费(业务处理结果)。

总结

2PC和3PC都是一种强一致性事务,基于数据库层面,但也存在一些数据不一致的风险。TCC是一种补偿性的事务思想,由于需要在业务层实现,所以对业务侵入大。基于消息补偿的方式,TCC还有尽最大努力通知其实都是一种柔性事务,都是保证了最终一致性,允许出现部分时刻数据不一致的情况。

这篇文章有点水,其实就是讲了点概念,很多还是摘录自其他博客和拉勾教育-分布式技术原理与实战45讲这门课程的,就当个读书笔记看吧,至少扩展了一些对分布式事务的基础概念的了解。