框架相关(2)-- 分布式事务

分布式事务解决的用户最本质诉求是什么?数据一致。

大中企业有一个共同的诉求是数据一致,几乎覆盖到各个行业。

比如说零售行业,库存与出货的数据需要保持一致,出货量与库存数据不匹配,显而易见会出问题,拿到订单却没货了,或者有货却下不了订单。

比如说金融行业,转账数据搞错了,A扣款了,B没加上,马上该用户投诉了;A没扣款,B却加上了,产生资损。又比如从总账户中买了基金、股票后余额不对了,等等,都会导致严重问题。

以前多数企业的数据规模相对较小,很多操作是单机完成,数据库本地事务可以搞定,所以数据一致问题不那么明显。随着互联网技术快速发展,数据规模增大,分布式系统越来越普及,采用分布式数据库或者跨多个数据库的应用在中大规模企业普遍存在,服务化也是广泛应用,由于网络的不可靠和机器不可靠,数据不一致问题很容易出现。

 

事务的基本特性:

Atomicity(原子性):是指事务是一个不可分割的整体,所有操作要么全做,要么全不做;只要事务中有一个操作出错,回滚到事务开始前的状态,那么之前已经执行的所有操作都是无效的,都应该会滚到开始前的状态。

Consistency(一致性):指事务执行前后,数据从一个状态到另一个状态必须是一致的,比如A向B转账(A、B的总金额就是一个一致性状态),不可能出现A扣了钱, B却没收到的情况。

Isolation(隔离性):多个并发事务之间相互隔离,不能相互干扰。这里的并发事务指的是两个事务操作了同一份数据的情况,要求不能出现脏读、幻读的情况。常用手段就是通过数据库的相关锁机制来保证。

Durablity(持久性):事务完成后,对数据库的更改是永久保存的,不能回滚。


分布式事务

分布式事务是为了解决微服务架构(形式都是分布式系统)中不同节点之间的数据一致性的问题。这个一致性问题本质上解决的也是传统事务需要解决的问题,即一个请求在多个微服务调用链中,所有服务的数据处理要么全部成功,要么全部回滚。当然分布式事务问题的形式可能与传统事务会有比较大的差异,但是问题本质是一致的,都是要求解决数据的一致性问题,并且满足事务的基本特性(ACID)。

举个例子,在一个JVM进程中如果需要同时操作数据库的多条记录,而这些操作需要在一个事务中,那么我们就可以通过数据库提供的事务机制(一般是数据库锁)来实现。而随着这个JVM进程(应用)被拆分成了微服务架构,原本一个本地逻辑执行单元被分到了多个独立的微服务中,这些微服务又分别操作不同的数据库和表,服务之间通过网络调用。

在微服务中的分布式事务问题

让我门想象一下一个传统的单体应用,它的业务由三个模块构建而成,他们使用了一个单一本地数据源。很显然,本地事务可以保证整个业务过程的数据一致性。

微服务架构中一些事情需要被改变,这三个被上文提及到的模块,被设计为三个不同数据源之上的三个服务,业务过程将由3个服务的调用来完成。

此时,每个服务内部的数据一致性仍由本地事务来保证。而整个业务层面的全局数据一致性要如何保障呢?这就是微服务架构下面临的,典型的分布式事务需求:我们需要一个分布式事务的解决方案保障业务全局的数据一致性。

关于分布式事务,工程领域主要讨论的是强一致性和最终一致性的解决方案。典型方案包括,对业务无侵入的和对业务有侵入的两类:

业务无侵入方案

    * 两阶段提交(2PC, Two-phase Commit)、三阶段提交(3PC)

业务侵入方案

    * TCC 补偿模式

    * 基于消息的最终一致性

 

分布式事务解决方案

(1)2PC方案——强一致性

基于XA协议的两阶段提交方案

交易中间件与数据库通过 XA 接口规范,使用两阶段提交来完成一个全局事务, XA 规范的基础是两阶段提交协议。

第一阶段是表决阶段,所有参与者都将本事务能否成功的信息反馈发给协调者;第二阶段是执行阶段,协调者根据所有参与者的反馈,通知所有参与者,步调一致地在所有分支上提交或者回滚。

两阶段提交方案应用非常广泛,几乎所有商业OLTP数据库都支持XA协议。但是两阶段提交方案锁定资源时间长,对性能影响很大,基本不适合解决微服务事务问题。

2PC

阶段1:请求阶段(commit-request phase,或称表决阶段,voting phase)

协调者节点向所有参与者节点询问是否可以执行提交操作,并开始等待各参与者节点的响应。

参与者节点执行询问发起为止的所有事务操作,并将Undo信息和Redo信息写入日志。

各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个”同意”消息;如果参与者节点的事务操作实际执行失败,则它返回一个”中止”消息。 有时候,第一阶段也被称作投票阶段,即各参与者投票是否要继续接下来的提交操作。。

阶段2:提交阶段(commit phase)

在该阶段,协调者将基于第一个阶段的投票结果进行决策:提交或取消。当且仅当所有的参与者同意提交事务协调者才通知所有的参与者提交事务,否则协调者将通知所有的参与者取消事务。参与者在接收到协调者发来的消息后将执行响应的操作。

成功:

1)当协调者节点从所有参与者节点获得的相应消息都为”同意”时:

2)协调者节点向所有参与者节点发出”正式提交”的请求。

3)参与者节点正式完成操作,并释放在整个事务期间内占用的资源。

4)参与者节点向协调者节点发送”完成”消息。

5)协调者节点收到所有参与者节点反馈的”完成”消息后,完成事务。

失败:

1)如果任一参与者节点在第一阶段返回的响应消息为”终止”,或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:

2)协调者节点向所有参与者节点发出”回滚操作”的请求。

3)参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。

4)参与者节点向协调者节点发送”回滚完成”消息。

5)协调者节点收到所有参与者节点反馈的”回滚完成”消息后,取消事务。

有时候,第二阶段也被称作完成阶段,因为无论结果怎样,协调者都必须在此阶段结束当前事务。

2PC 存在的问题

1)同步阻塞问题

它的执行过程中间,节点都处于阻塞状态。即节点之间在等待对方的相应消息时,它将什么也做不了。特别是,当一个节点在已经占有了某项资源的情况下,为了等待其他节点的响应消息而陷入阻塞状态时,当第三个节点尝试访问该节点占有的资源时,这个节点也将连带陷入阻塞状态

2)单点故障

由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)

3)数据不一致

如果出现协调者和参与者都挂了的情况,有可能导致数据不一致。


3PC

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

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

    # 引入超时机制。同时在协调者和参与者中都引入超时机制。

    # 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。

也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段

CanCommit阶段

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

1.事务询问协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。

2.响应反馈参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No

PreCommit阶段

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

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

1.发送预提交请求协调者向参与者发送PreCommit请求,并进入Prepared阶段。

2.事务预提交参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。

3.响应反馈如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。

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

1.发送中断请求协调者向所有参与者发送abort请求。

2.中断事务参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。

doCommit阶段

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

执行提交

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

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

3.响应反馈事务提交完之后,向协调者发送Ack响应。

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

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

1.发送中断请求协调者向所有参与者发送abort请求

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

3.反馈结果参与者完成事务回滚之后,向协调者发送ACK消息

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


2TCC Try-Confirm-Cancel)补偿模式——最终一致性

有些场景下,我们根据自己的真实需要,并不需要纯的2PC,比如你只关心数据的原子性与最终一致性,那2PC阶段的阻塞是你不能忍受的,那就有聪明的人想到了一种新的办法。就是我们今天要说的柔性事务TCC。「柔」主要是相对于「传统」ACID的刚而言,柔性事务只需要遵循BASE原则。而TCC是柔性事务的一种实现。TCC是三个首字母,Try-Confirm-Cancel,具体描述是将整个操作分为上面这三步。两个微服务间同时进行Try,在Try的阶段会进行数据的校验,检查,资源的预创建,如果都成功就会分别进行Confirm,如果两者都成功则整个TCC事务完成。如果Confirm时有一个服务有问题,则会转向Cancel,相当于进行Confirm的逆向操作。

TCC方案在电商、金融领域落地较多。TCC方案其实是两阶段提交的一种改进。其将整个业务逻辑的每个分支显式的分成了Try、Confirm、Cancel三个操作。Try部分完成业务的准备工作,confirm部分完成业务的提交,cancel部分完成事务的回滚。基本原理如下图所示。

事务开始时,业务应用会向事务协调器注册启动事务。之后业务应用会调用所有服务的try接口,完成一阶段准备。之后事务协调器会根据try接口返回情况,决定调用confirm接口或者cancel接口。如果接口调用失败,会进行重试。

其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:

    # Try阶段主要是对业务系统做检测及资源预留

    # Confirm阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。

    # Cancel阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

基本实现原理

这些TCC的框架,基本都是通过「注解」的形式,在注解中声明Confirm方法与Cancel方法,再通过AOP对带点该注解的方法统一进行拦截,之后根据结果分别再执行 Confirm 或者 Cancel。

代码类似这个样子:

@Compensable(confirmMethod = "confirmRecord",cancelMethod = "cancelRecord", transactionContextEditor =MethodTransactionContextEditor.class)

public String record(TransactionContext transactionContext,CapitalTradeOrderDto tradeOrderDto) {

confirm方法

public void confirmRecord(TransactionContext transactionContext,CapitalTradeOrderDto tradeOrderDto) {

cancel方法:

public void cancelRecord(TransactionContext transactionContext,RedPacketTradeOrderDto tradeOrderDto) {

基于类似的框架,可以比较方便的满足我们的业务使用场景。

TCC方案让应用自己定义数据库操作的粒度,使得降低锁冲突、提高吞吐量成为可能。 当然TCC方案也有不足之处,集中表现在以下两个方面:

    # 对应用的侵入性强。业务逻辑的每个分支都需要实现try、confirm、cancel三个操作,应用侵入性较强,改造成本高。

    # 实现难度较大。需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口必须实现幂等。

上述原因导致TCC方案大多被研发实力较强、有迫切需求的大公司所采用。微服务倡导服务的轻量化、易部署,而TCC方案中很多事务的处理逻辑需要应用自己编码实现,复杂且开发量大。


3)基于消息的最终一致性

消息一致性方案是通过消息中间件保证上、下游应用数据操作的一致性。基本思路是将本地操作和发送消息放在一个事务中,保证本地操作和消息发送要么两者都成功或者都失败。下游应用向消息系统订阅该消息,收到消息后执行相应操作。

所谓的消息事务就是基于消息中间件的两阶段提交,本质上是对消息中间件的一种特殊利用,它是将本地事务和发消息放在了一个分布式事务里,保证要么本地操作成功并且对外发消息成功,要么两者都失败,开源的RocketMQ就支持这一特性,具体原理如下:

1、A系统向消息中间件发送一条预备消息

2、消息中间件保存预备消息并返回成功

3、A执行本地事务

4、A发送提交消息给消息中间件

通过以上4步完成了一个消息事务。对于以上的4个步骤,每个步骤都可能产生错误,下面一一分析:

步骤一出错,则整个事务失败,不会执行A的本地操作

步骤二出错,则整个事务失败,不会执行A的本地操作

步骤三出错,这时候需要回滚预备消息,怎么回滚?答案是A系统实现一个消息中间件的回调接口,消息中间件会去不断执行回调接口,检查A事务执行是否执行成功,如果失败则回滚预备消息

步骤四出错,这时候A的本地事务是成功的,那么消息中间件要回滚A吗?答案是不需要,其实通过回调接口,消息中间件能够检查到A执行成功了,这时候其实不需要A发提交消息了,消息中间件可以自己对消息进行提交,从而完成整个消息事务

基于消息中间件的两阶段提交往往用在高并发场景下,将一个分布式事务拆成一个消息事务(A系统的本地操作+发消息)+ B系统的本地操作,其中B系统的操作由消息驱动,只要消息事务成功,那么A操作一定成功,消息也一定发出来了,这时候B会收到消息去执行本地操作,如果本地操作失败,消息会重投,直到B操作成功,这样就变相地实现了A与B的分布式事务。原理如下:

    # 在系统A处理任务A前,首先向消息中间件发送一条消息

    # 消息中间件收到后将该条消息持久化,但并不投递。此时下游系统B仍然不知道该条消息的存在

    # 消息中间件持久化成功后,便向系统A返回一个确认应答

    # 系统A收到确认应答后,则可以开始处理任务A

    # 任务A处理完成后,向消息中间件发送Commit请求。该请求发送完成后,对系统A而言,该事务的处理过程就结束了,            此时它可以处理别的任务了

    # 消息中间件收到Commit指令后,便向系统B投递该消息,从而触发任务B的执行

    # 当任务B执行完成后,系统B向消息中间件返回一个确认应答,此时,这个分布式事务完成

如果任务A处理失败,那么需要进入回滚流程:

    # 若系统A在处理任务A时失败,那么就会向消息中间件发送Rollback请求。系统A发完之后便可以认为回滚已经完成,它          便可以去做其他的事情

    # 消息中间件收到回滚请求后,直接将该消息丢弃不投递给系统B

新的问题

上面所介绍的Commit和Rollback都属于理想情况,但在实际系统中,Commit和Rollback指令都有可能在传输途中丢失。那么当出现这种情况的时候,消息中间件是如何保证数据一致性呢?——答案就是超时询问机制。

系统A除了实现正常的业务流程外,还需提供一个事务询问的接口,供消息中间件调用。当消息中间件收到一条事务型消息后便开始计时,如果到了超时时间也没收到系统A发来的Commit或Rollback指令的话,就会主动调用系统A提供的事务询问接口询问该系统目前的状态。该接口会返回三种结果:

    # 提交:若获得的状态是“提交”,则将该消息投递给系统B

    # 回滚:若获得的状态是“回滚”,则直接将条消息丢弃

    # 处理中:若获得的状态是“处理中”,则继续等待

投递过程的可靠性保证

我们知道当上游系统A发出commit请求之后认为事务已经完成,便可以处理其他的任务了;那么消息中间件是怎么保证消息一定会被下游系统B成功消费呢?这是使用消息中间件投递过程的可靠性来保证的

消息中间件向系统B投递完消息后便进入阻塞等待状态,如果消息在传递过程中丢失或者消息的确认应答在返回途中丢失,那么消息中间件在等待超时后会重新投递直到消息被系统B成功消费为止

为什么是重新投递而不是回滚?

这就涉及到整套分布式事务系统的实现成本问题。如果回滚的话,系统A就要提供回滚接口,这增加了开发成本,业务系统的复杂度也会随之提高

异步与同步

上游系统A向消息中间件提交完消息后便可以去做别的事情。然而消息中间件将消息投递给下游系统B后,它会阻塞等待直到下游系统返回B确认应答。为什么要这么设计呢?

首先,上游系统和消息中间件之间采用异步通信是为了提高系统并发度。业务系统直接和用户打交道,用户体验尤为重要,因此这种异步通信方式能够极大程度地降低用户等待时间;

下游系统与消息中间件采用同步虽然降低系统并发度,但实现成本较低。在对并发度要求不是很高或者服务器资源较为充裕的情况下,我们可以选择使用同步来降低系统的复杂度。

虽然上面的方案能够完成A和B的操作,但是A和B并不是严格一致的,而是最终一致的,我们在这里牺牲了一致性,换来了性能的大幅度提升。当然,这种玩法也是有风险的,如果B一直执行不成功,那么一致性会被破坏,具体要不要玩,还是得看业务能够承担多少风险。

消息方案从本质上讲是将分布式事务转换为两个本地事务,然后依靠下游业务的重试机制达到最终一致性。基于消息的最终一致性方案对应用侵入性也很高,应用需要进行大量业务改造,成本较高。


选择建议

在面临数据一致性问题的时候,首先要从业务需求的角度出发,确定我们对于3 种一致性模型的接受程度,再通过具体场景来决定解决方案。

从应用角度看,分布式事务的现实场景常常无法规避,在有能力给出其他解决方案前,2PC也是一个不错的选择。

对购物转账等电商和金融业务,中间件层的2PC最大问题在于业务不可见,一旦出现不可抗力或意想不到的一致性破坏,如数据节点永久性宕机,业务难以根据2PC的日志进行补偿。金融场景下,数据一致性是命根,业务需要对数据有百分之百的掌控力,建议使用TCC这类分布式事务模型,或基于消息队列的柔性事务框架,这两种方案都在业务层实现,业务开发者具有足够掌控力,可以结合SOA框架来架构,包括Dubbo、Spring Cloud等。

侵入式方案的不足:

要求在应用的业务层面把分布式事务技术约束考虑到设计中,通常每一个服务都需要设计实现正向和反向的幂等接口。这样的设计约束,往往会导致很高的研发和维护成本。


4FESCAR方案Fast & EaSy Commit And Rollback阿里开源高效简单易用的分布式事务解决方案

一个理想的分布式事务解决方案应该:像使用本地事务一样简单,业务逻辑只关注业务层面的需求,不需要考虑机制上的约束。我们要设计一个对业务无侵入的方案,所以从业务无侵入的XA方案来思考:是否可以在XA的基础上演进,解决调XA方案面临的问题呢?

首先,如何定义分布式事务?

可以把一个分布式事务理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个满足ACID的本地事务。这是我们对分布式事务结构的解基本认识,与XA是一致的。

其次,与XA的模型类似,FESCAR有3个基本组件来协议分布式事务的处理过程:

    # 事务协调器(TC):维护全局事务的运行状态,负责协调驱动全局事务的提交或回滚。

    # Transaction Manager(TM):定义全局事务的范围:开始全局事务,提交或回滚全局事务。

    # 资源管理器(RM):管理分支事务的资源,与TC通信以注册分支事务和报告分支事务的状态,并驱动分支事务提交或         回滚。

一个典型的分布式事务过程:

(1)TM向TC申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的XID。

(2)XID在微服务调用链路的上下文中传播。

(3)RM向TC注册分支事务,将其纳入XID对应全局事务的管辖。

(4)TM向TC发起针对XID的全局事务提交或回滚。

(5)TC调度XID下管辖的全部分支事务完成提交或回滚请求。

至此,Fescar的协议机制总体上看与XA是一致的。

Fescar与XA的差别在哪里?

1、架构层次

XA方案的RM实际上是在数据库层,RM本质上就是数据库自身(通过提供支持XA的驱动程序来供应用使用)。

而Fescar的RM是以二方包的形式作为中间件层部署在应用程序这一侧的,不依赖与数据库本身对协议的支持,当然也不需要数据库支持XA协议。这点对于微服务化的架构来说是非常重要的:应用层不需要为本地事务和分布式事务两类不同场景来适配两套不同的数据库驱动。

这个设计剥离了分布式事务方案对数据库在协议支持上的要求。

2、两阶段提交

先来看一下XA的2PC过程:

无论Phase2的决议是commit还是rollback,事务性资源的锁都是要保持到Phase2完成才释放。

设想一个正常运行的业务,大概率是90%以上的事务最终应该是成功提交的,我们是否可以在Phase1就将本地事务提交呢?这样90%以上的情况下,可以省去Phase2持锁的时间,整体提高效率。

这个设计,在绝大多数场景减少了事务持锁的时间, 从而提高了事务的并发度。

你可能感兴趣的:(框架相关(2)-- 分布式事务)