分布式事务解决方案
在分布式时代,分库分表是非常常见的,在微服务系统中,各个系统通常有自己的独立的数据库,所以,事务很难靠数据库本身保证,只能靠业务系统来控制。这就是分布式事务的由来。
在分布式系统中,每一个机器节点虽然都能够明确知道自己在事务操作过程中的结果是成功或失败,但却无法直接获取到其他分布式节点的操作结果。(如转账,分别调用两个系统一个减钱,一个加钱,如何保证事务的ACID)。因此当一个事务操作需要跨越多个分布式节点的时候,为了保持事务处理的ACID特性,就需要引入第三方“协调者(Coordinator)”(ZK)的组件来统一调度所有分布式节点的执行逻辑,这些被调度的分布式节点则被称为“参与者(Participant)”。协调者负责调度参与者的行为,并最终决定这些参与者是否要把事务真正提交。基于这个思想,衍生出了二阶段提交和三阶段提交协议。
2PC用在数据库层面解决数据库之间的分布式事务;因此实际应用中往往不会采用这两个方案。
二阶段提交,Two-Phase Commit,2PC :
为了使基于分布式系统架构下的所有的节点在进行事务的处理过程中能够保持原子性和一致性而设计的一种方案。
目前绝大部分的关系型数据库都是采用二阶段提交协议来完成分布式事物处理的,利用该协议能够非常方便地完成所有分布式事物参与者的协调,统一决定事务的提交或回滚,从而能够有效地保证分布式数据一致性。
过程说明:将事务的提交过程分成了两个阶段来进行处理
阶段一:执行事务
1、事务询问:协调者向所有的参与者发送事务内容,询问是否可以执行事务提交操作,并开始等待各参与者的响应
2、执行事务:各参与者节点执行事务操作,并将Undo和Redo信息记入事务日志中
3、各参与者向协调者反馈事务询问的响应:如果各参与者成功执行了事务操作,那么就反馈给协调者Yes响应,表示事务可以执行;如果参与者没有成功执行事务,那么就反馈给协调者No响应,表示事务不可以执行
阶段二:提交事务
协调者根据阶段一中各参与者的反馈情况来决定是否可以进行事务提交操作,正常情况下,包含以下两种可能:
1、执行事务提交
假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务提交。
(1)发送提交请求:协调者向所有参与者节点发出Commit请求
(2)事务提交:参与者接受到Commit请求后,会正式执行事务提交操作,并在完成提交后释放整个事务执行期间占用的事务资源
(3)反馈事务提交结果:参与者在完成事务提交之后,向协调者发送Ack(ackonwledge 确认)消息
(4)完成事务:协调者收到所有参与者反馈的Ack消息后,完成事务(?不会出现提交失败的情况吗)
2、中断事务
假如任何一个参与者向协调者反馈了No响应,或者在等待超时之后,协调者尚无法结构到所有参与者的反馈响应,那么就会中断事务
(1)发送回滚请求:协调者向所有参与者节点发出Rollback请求
(2)事务回滚:参与者收到Rollback请求后,会利用其在阶段一中记录的Undo信息来执行事务回滚操作,并在完成回滚之后释放整个事务期间占用的资源
(3)反馈事务回滚结果:参与者在完成事务回滚之后,向协调者发出Ack(ackonwledge 确认)消息
(4)中断事务:协调者收到所有参与者反馈的Ack消息后完成事务中断
所以二阶段提交的核心就是对每个事务采用先尝试后提交的处理方式。
优点:原理简单,实现方便
缺点:同步阻塞、单点问题、脑裂、太过保守。
同步阻塞:在二阶段提交的执行过程中,所有参与该事务操作的逻辑都出于阻塞状态,也就是说,各个参与者在等待其他参与者响应的过程中,将无法进行其他任何操作。
单点问题:协调者出于非常重要的角色,一旦协调者出现问题,特别实在阶段二出现问题,那么其他参与者将会一直出于锁定事务资源的状态中,而无法继续完成事务操作。
数据不一致:如果协调者在向所有的参与者发送Commit请求之后,由于网络问题或者协调者尚未向所有的参与者发送完Commit请求后发生了崩溃,那么导致只有部分参与者收到了Commit
请求,于是出现整个分布式系统数据不一致的情况。
太过保守:事务提交询问过程如果部分参与者故障导致协调者无法获取响应信息的话,协调者只能依靠自身超时机制来判断是否需要中断事务。即没有设计较为完善的容错机制,任何一个
节点的失败都会导致整个事务的失败
三阶段提交,Three-Phase Commit,3PC:
在二阶段提交协议的基础上进行了改进。其将二阶段提交协议的阶段一“提交事务请求”过程一分为二,形成了由CanCommit、PreCommit和doCommit三个阶段组成的事务处理协议。
阶段一:CanCommit
1、事务询问:协调者向所有的参与者发送一个包含事务内容的CanCommit请求,询问是否可以执行事务提交操作,并开始等待各参与者的响应
2、各参与者向协调者反馈事务询问的响应:参与者在接受到协调者的CanCommit请求后,评估其自身能否可以顺利执行事务(此时并没有执行事务,如尝试获取数据库锁),那么反馈Yes响应,
并进入预备处理状态,否则反馈No
阶段二:PreCommit
协调者根据各参与者的反馈情况来决定是否可以进行事务的PreCommit操作,包含两种可能:
1、执行事务预提交
假如协调者从所有的参与者获得的反馈都是Yes,那么就会执行事务预提交
(1)发送预提交请求:协调者向所有的参与者节点发送PreCommit请求
(2)事务预提交:参与者收到PreCommit请求后,会执行事务操作,并将Undo和Redo信息记录到事务日志中
(3)各参与者向协调者反馈事务执行的响应:如果各参与者成功执行了事务,那么就会反馈给协调者Ack响应,同时等待最终的指令:提交(commit)或终止(abort),如果在等待
超时之后仍没有收到协调者的响应,那么各参与者都会执行Commit事务提交。
2、中断事务
假如任何一个参与者向协调者反馈了No响应,或者在等待超时之后,协调者尚无法结构到所有参与者的反馈响应,那么就会中断事务
(1)发送中断请求:协调者向所有参与者节点发出abort请求
(2)中断事务:无论是收到协调者的abort请求或者在等待协调者请求过程中出现超时,参与者都会中断事务
阶段三:DoCommit
该阶段进行真正的事务提交,存在以下两种可能情况:
1、执行提交
协调者出于正常工作状态,并且收到了来自所有参与者的阶段二的Ack响应
(1)发送提交请求:向所有的参与者发送DoCommit请求
(2)事务提交:参与者收到DoCommit请求后,会正式执行事务提交操作,并在完成提交之后释放整个事务执行期间占用的事务资源
(3)反馈事务提交结果:参与者在完成事务提交之后,向协调者发送Ack消息
(4)完成事务:协调者接受到所有参与者反馈的Ack消息后,完成事务
2、中断事务
协调者出于正常工作状态,并且有任意一个参与者向协调者反馈了No响应,或者在等到超时之后,协调者仍没有收到部分参与者的反馈响应
(1)发送中断请求:协调者向所有的参与者节点发送Abort请求
(2)事务回滚:参与者收到Abort请求后,会利用其在阶段二中的Undo信息执行事务回滚操作,并在完成回滚之后释放在整个事务执行期间占用的资源
(3)反馈事务回滚结果:参与者在完成事务回滚之后,向协调者发送Ack消息
(4)中断事务:协调者收到所有参与者反馈的Ack消息后,中断事务
需要注意的是,一旦进入阶段三,可能会出现以下两种故障
1、协调者出现问题
2、协调者和参与者之间的网络出现故障
无论出现哪种情况,最终都会导致参与者无法及时接收到来自协调者的DoCommit或者Abort请求,针对这样的情况,参与者都会在等待超时之后,继续执行事务的提交。
优点:相比于二阶段提交,相对减少了同步阻塞范围,能够在协调者出现故障之后继续达成一致(如阶段二的超时都执行提交)
缺点:在参与者收到PreCommit消息后,如果网络出现分区,如果网络出现分区,部分节点无法与协调者进行通信,在超时后仍然后执行事务的提交。若其他节点在这一阶段执行出现问题,协调者发送的是回滚操作,那么就会出现数据不一致的情况。
TCC:
TCC是支付宝提出的分布式事务解决方案,是try、confirm、cancel的简写。TCC是应用层面的分布式系统的分布式事务解决方案。
其核心思想是:针对每一个操作,都要注册一个与其对应的确认和补偿(撤销)操作。
服务设计:
每个分布式事务的参与者都需要实现3个接口:try、confirm、cancel。try操作作为第一阶段,负责资源的检查和预留;confirm操作为第二阶段提交操作,执行真正
的业务;cancel是预留资源的取消。
TCC分为2个阶段:
1)准备阶段
调用方调用各个服务的try接口,各个服务执行资源检查和锁定,看自己是否有能力完成,如果允许,则锁定资源
2)提交阶段
如果各个服务的try接口都返回了yes,则进入提交阶段,执行真正的业务,调用方调用各个服务的confirm接口,然后各个服务对try阶段锁定的资源进行处理。
如果try阶段有一方返回了no,或者超时,调用方调用各个服务的cancel接口,释放锁定的资源。
对于异常情况,不管是confirm还是cancel失败了,处理方法就是不断重试。
TCC服务要保证第一阶段try操作成功之后,二阶段Confirm操作一定能成功。
流程示例:
以转账为例,有2两个服务,扣钱服务、加钱服务,都各自实现try、confirm、cancel接口。
需求:现在账号A要给账号B转30元
阶段1:准备阶段
调用者调用2个服务的try接口。
1):扣钱服务的try接口对A中的余额进行验证,如果可用余额不少于30元,则冻结30元,返回yes,否则返回no。
2):加钱服务的try接口对B账号进行校验,如是否合法可用状态,是则返回yes,否则返回no
这个阶段保证了扣钱可扣,加钱可加
阶段2:提交阶段
两个服务的try接口都返回了yes,调用者调用2个服务的confirm接口。扣钱服务实际扣减A的30元,加钱服务实际给B加30元
try接口如果有返回no或者超时的,调用者调用2个服务的cancel接口,扣钱服务解冻A的30元,加钱服务什么也不用做。
异常控制:
下面是典型的异常情况
1、空回滚
现象是try没执行就调用了cancel,例如在某些异常情况下,调用try时出现异常,try接口实际没有被调用,那么就没有返回yes;按照正常流程进入第二
阶段,调用cancel接口,这就造成了空回滚。
解决方案:让cancel能够识别出这是一个空回滚,可以记录事务执行状态,cancel中判断try是否执行了
2、幂等
无论是网络数据包重传,还是异常事务的补偿执行,提交阶段可能会重复调用confirm和cancel,所以要实现幂等,保证多次执行效果一致。
解决方案:记录事务执行状态,如果执行过了,就不再执行
3、悬挂
现象是先执行了cancel,后执行了try,造成资源没人释放。例如调用try时网络拥堵超时,被认为失败啊,然后调用cancel,这时事务相当于结束了,但后来
网络好之后try开始执行了,锁定了相关资源,因为事务已经结束,confirm和cancel都不会再调用,造成资源悬挂,无法释放
解决方案:还是记录事务执行状态,try执行时判断cancel是否执行了
4、业务数据可见性控制
TCC服务的一阶段try操作会做资源的预留,在二阶段操作执行之前,如果其他事务需要读取被预留的资源数据,那么处于中间状态的业务数据该如何向用户展示,需要业务在实现时考虑清楚;通常的设计原则是:宁可不展示、少展示,也不多展示、错展示。
5、业务数据并发访问控制
TCC服务的一阶段Try操作预留资源之后,在二阶段操作执行之前,预留的资源都不会被释放,如果此时其他分布式事务修改这些业务资源,会出现分布式的并发问题。因此用户在实现TCC服务时,需要考虑业务数据的并发控制,尽量将逻辑锁粒度降到最低,以最大限度的提高分布式事务的并发性;
总结
优点:可靠性高,实用性高
缺点:开发复杂度高,需要多次DB操作,性能有一定损耗。
消息事务(最终一致性):
消息事务其实就是基于消息中间件的两阶段提交,将本地事务和发消息放在同一个事务里,保证本地操作和发送消息同时成功
最终一致性通常都是使用消息中间件来实现的,系统结构如下:
用户向系统A发起转账请求,A先在自己的数据库中扣钱,然后通过消息中间件告诉B应该加钱,B收到消息后在自己的数据库中加钱。
但是这里有个问题,A更新数据库和给消息中间件发送消息是2个操作,可能会出现如下场景:
1):先更新数据库,成功了,但发送消息失败了,重发多次还是失败
2):先发消息成功了,但数据库更新失败,消息撤不回来了
因为这两个操作不是原子性的,所以先发谁都可能存在问题。可以将更新数据库和给消息中间件发送消息放到一个事务中吗?
这样就保证了原子性,但是也存在下面两个问题:
1):如果消息发送失败,有两种可能
(1):消息中间件根本没有收到消息的异常,这时数据库回滚没有问题,A没有扣钱,B也没有加钱
(2):消息中间件收到消息后response时出错了,这时数据库回滚A没有扣钱,但实际上B收到了消息,加钱了,
这就出现了不一致的问题
2):如果发消息时网络延迟很高,数据库事务一直被拖着,导致性能很差。
因此,将更新数据库和发消息到消息中间件也是不可取的。
解决方法:为了保证原子性,增加一个消息表,使A扣钱和写消息放在一个事务中,A不直接往消息中间件发送消息,而是把消息写入消息表
通过一个后台程序不断的把消息写入消息中间件。
这个后台程序源源不断的把消息表中的消息发到消息中间件,如果失败就重试,就可以保证:
1、消息不会丢失
2、顺序不乱
但是可能会有消息重复的情况,因为消息发送失败可能是写入失败,也可能写入成功但响应失败,后台都会重试,所以消息可能会重复,这个问题需要B系统来处理。
系统B需要考虑2个问题:
1、消息丢失
B从消息中间件中拿到消息,还没处理完就宕机了,这条消息怎么办?这时,需要通过ACK机制来处理,消费成功的发送ACK,对于没有ACK的消息,消息中间件会再次推送。
2、消息重复
ACK机制也存在消息重复的情况,比如B已经 处理完一条消息,发ACK时失败了,那么这条消息还会被推送过来,还有后台程序也可能发送重复的消息,对于消息重复的问题,可以再加一张判重表,记录处理成功的消息,每次收到消息,先通过判重表判断一下,如果重复了就不处理,实现幂等性。
这样,最终的结构为:
这就是最终一致性解决分布式事务问题的基本思路,A保证消息不丢,B保证消息不漏和幂等。
Seata分布式事物框架:
Seata 框架中一个分布式事务包含 3 种角色:
「Transaction Coordinator (TC)」:事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。
「Transaction Manager (TM)」:控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。
「Resource Manager (RM)」:控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。
Seata 框架「为每一个 RM 维护了一张 UNDO_LOG 表」,其中保存了每一次本地事务的回滚数据。
具体流程:
1、首先 TM 向 TC 申请「开启一个全局事务」,全局事务「创建」成功并生成一个「全局唯一的 XID」。
2、XID 在微服务调用链路的上下文中传播。
3、RM 开始执行这个分支事务,RM 首先解析这条 SQL 语句,「生成对应的 UNDO_LOG 记录」。下面是一条 UNDO_LOG 中的记录,UNDO_LOG 表中记录了分支 ID,全局事务 ID,以及事务执行的 redo 和 undo 数据以供二阶段恢复。
4、RM 在同一个本地事务中「执行业务 SQL 和 UNDO_LOG 数据的插入」。在提交这个本地事务前,RM 会向 TC「申请关于这条记录的全局锁」。
5、如果申请不到,则说明有其他事务也在对这条记录进行操作,因此它会在一段时间内重试,重试失败则回滚本地事务,并向 TC 汇报本地事务执行失败。
6、RM 在事务提交前,「申请到了相关记录的全局锁」,然后直接提交本地事务,并向 TC「汇报本地事务执行成功」。此时全局锁并没有释放,全局锁的释放取决于二阶段是提交命令还是回滚命令。
7、TC 根据所有的分支事务执行结果,向 RM「下发提交或回滚」命令。
1)RM 如果「收到 TC 的提交命令」,首先「立即释放」相关记录的全局「锁」,然后把提交请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。异步队列中的提交请求真正执行时,只是删除相应 UNDO LOG 记录而已。
2)RM 如果「收到 TC 的回滚命令」,则会开启一个本地事务,通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。将 UNDO LOG 中的后镜与当前数据进行比较,
如果不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理。
如果相同,根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句并执行,然后提交本地事务达到回滚的目的,最后释放相关记录的全局锁。
END.