分布式事务

作者: 程序员小2 | 来源:发表于2022-07-22 14:43 被阅读0次

    https://zhuanlan.zhihu.com/p/437577902

    分布式事务是系统拆分后需要解决的一大问题,市面上能搜到的相关资料和概念很多,比如 ACID、XA、2PC、3PC、TCC、SAGA、AT、CAP、Base、最终一致性 ....... ,一只手完全数不过来,但是这些概念中间的联系是什么却很少有资料描述清楚,以致于学习的人好像懂了,好像又没懂,没办法把这些知识串联起来。

    而这里我们会把分布式事务知识串联起来成为体系,理清楚这些概念中的逻辑。虽然知识点有点多篇幅会有点长,但正如文章标题,这篇文章可以帮你通关分布式事务问题。


    一、单机事务的完美解决方案(ACID)

    1.1、什么是事务

    所谓事务,就是说如何保证一组(多个)数据操作在执行的过程中,要么全部执行成功,要么全部失败,事务成功执行后事务所变更的数据不会丢失,事务失败后数据要回到事务开始之前的模样。 简洁点说就是一个事务里涉及到的 多个操作,要么全部执行、要么全部不执行。

    1.2、事务的最终目的(数据的一致性)

    在我们理解了事务的概念后,我们需要清楚另外一个更重要的问题,那就是实现事务的最终目的是什么? 也就是说如果一组事务里的操作,有些执行了,有些没有执行,此时会产生什么问题。 这时你可能会想到,要是你给你爸妈了1W块钱,结果你的账户钱扣了,但是你爸妈的账户里并没有加上1W块钱,也许你就该怀疑,你努力赚钱的意义是什么了。而这里你账户减少了1W块钱,但是你爸妈账户没有加上这1W,这个就是“数据一致性问题”。

    1.3、如何保证数据的一致性(AID)

    A(原子性)、C(一致性)、I(隔离性)、D(持久性)。C 是事务最终的目标,那么A、I、D 就是为实现这个目标努力的打工仔,如果这几个打工仔不能正常工作的话,那么一致性就得不到保障。

    原子性:这个很容易理解,因为它和事务的描述很像,保证一组操作要么全部执行、要么全部不执行,如果不保证原子性的话,那么就可能同一个事务里一个操作执行了,而另外一个操作失败,所以会造成数据不一致的影响。

    隔离性: 这个是说多个事务之间的操作不会相互影响, 它们之间是相互隔离的。 如果不具备隔离性的话,那么两个事务的操作就好像两个人同时在一个画布上画画,你画的是猪,他画的是狗,结果两个人在画布上涂涂改改,最后画出来的就是一个四不像了。也就是说不保证隔离性,你修改数据的时候其它人也可以修改,那么也会造成数据不一致。

    持久性:持久性是说,事务一旦提交了,那么所产生的数据变更就不会因任何意外(数据库故障、服务器宕机)而导致数据丢失,因为一旦事务产生的部分数据丢了,那么还是会造成数据的不一致。

    1.4、AID的具体实现

    原子性的实现

    如何实现一组操作,要么全部执行,要么全部都不执行呢? 让操作全部执行看起来很简单,因为这是程序正常的执行完所有操作的结果。所以这里的关键是如何让事务在不具备完成的条件时,让全部操作不执行,也就是说当事务里某个操作不满足执行条件时,就算有一部分操作执行成功了,那么我们依然可以通过某种方式可以把之前执行成功的操作给撤销。

    为了解决上面的问题,数据库提供了一个用于记录历史数据的日志 undo log。 这个日志会在事务执行前,首先对事务相关的原始数据进行记录,当事务需要进行回滚撤销之前已经完成的操作时,通过undo log就可以把数据恢复到事务开始之前,这样也就好像什么事情都没发生过一样。

    隔离性的实现

    隔离性的保障在各个领域都有一套通用的机制,那就是锁,多个事务需要操作同一个数据时,首先要获得这个数据的锁,才能进行后续的操作,也只有前一个事务释放了锁之后,后面的事务才能拿到锁, 所以通过锁来隔离不同事务的操作,从而保证事务的隔离性。

    持久性的实现

    持久性是说,只要事务提交了,那么事务所变更的数据就不会丢失(不管此时出现了什么故障),在计算机里面能保证数据不丢失的唯一方式就是数据保存到磁盘了。 也就是说,持久性的难题是在于,当程序或者硬件服务器出现故障时,如何保证已经提交的事务所产生的数据能写入到磁盘去(因为此时事务产生的数据还也许在内存,没有写到磁盘去),所以事务提交之后,如果发生故障,数据是很有可能丢失的。

    为了解决这个问题,所以就提供了一种数据重做日志(redo log),在事务提交之前,首先会把事务需要变更的数据提前以日志的形式记录到磁盘中去(因为日志是以顺序IO的形式写入的,所以性能非常高),这样在事务提交之后就算发生故障,事务所产生的数据丢失了,那么也可以通过之前记录的redo log 来对数据进行重做。

    1.5、ACID总结

    在单机的事务实现里,通过ACID得到了完美的解决。通过加锁,把需要操作相同数据的事务进行隔离,保证事务之间的操作相互不会产生影响,从而实现事务之间的隔离性。在事务提交之前,首先记录数据修改之前的日志(undo log) 和事务需要变更数据的日志(redo log),来保证事务不论在哪个阶段都能通过undo log对事务数据进行回滚,把数据恢复到事务开始之前的模样,然后通过redo log 来保证事务在提交之后,不论数据库或服务器出现什么故障,都能把事务未成功写入到磁盘的数据进行重做。 最终通过undo log 和redo log 保证了事务的原子性和持久性。

    二、分布式事务能否完美解决(XA、2PC、3PC)

    系统拆分为分布式之后,我们的事务概念边界逐渐扩大了, 由最开始只存在于一个进程的事务,而演变成多个进程,甚至多个不同类型的进程事务。所以基于单进程的ACID实现机制已经无法满足于新的事务形式了。虽然形式不同,但是前人的优秀经验还是可以借鉴的,所以基于ACID的事务实现思路,由此衍生出了XA、2PC、3PC事务模型和协议。

    2.1、XA事务模型

    单机事务的解决方案已经由ACID完美解决,基本上也形成了一套不可动摇的规范,那么分布式事务的规范又由谁来定义呢,而XA就是立志于解决分布式事务并形成一套统一的规范。

    从单进程事务演变成多进程事务时,场景发生了改变,之前是一个人做一件事,现在变成了多个人协作做一件事了,一个人想对事务进行回滚还是提交 是很容易达成一致的,因为决定权在自己手上,不需要考虑其它人。但是如何协调多个人一起进行统一的操作就是一个难题了,所以此时就必须要有一个统一的人来协调多个节点的操作,达到多个节点操作的一致性。

    所以建立在每个节点都能保证自己节点的ACID特性前提上,XA的核心目标是解决如何协调多个节点之间的操作一致性。也就是如何在一个节点不满足事务要求需要回滚的时候,可以让大家一起回滚事务,如果大家都满足事务完成条件时,可以让大家一起提交事务。

    XA 首先定义了两种角色,全局的事务管理器(Transaction Manager) 和 局部资源管理器(resource Manager)。这里全局事务管理器就是来协调各个节点统一操作的角色,通常我们也称为事务协调者。局部资源管理器也就是参与事务执行的进程,通常我们也称为事务参与者。 事务的执行过程由协调者统一来决策,其它节点只需要按照协调者的指令来完成具体的事务操作即可。而协调者在协商各个事务节点的过程中、什么情况下决定集体提交事务,什么情况下又决定集体回滚事务,这里取决于XA事务模型里使用了哪种协商协议(2PC、3PC)。

    2.2、2PC(两阶段提交协议)

    2PC 协议的核心思路是协调者通过和参与者通过两个阶段的协商达到最终操作的一致性,首先第一阶段的目的是确认各个参与者可否具备执行事务的条件。然后根据第一阶段各个参与者响应的结果,制定出第二阶段的事务策略。如果第一阶段有任意一个参与者不具备事务执行条件,那么第二阶段的决策就是统一回滚,只有在所有参与者都具备事务执行的条件下,才进行整体事务的提交。

    准备阶段:

    首先协调者向所有参与者发起Prepare指令, 参与者收到指令后首先检查是否具备事务执行条件,在具备条件后,参与者开始对事务相关的数据进行加锁、然后再生成事务相关日志(redo log、undo log),最后参与者会根据这两个操作的执行情况来向协调者响应成功或失败。

    image.png

    提交阶段

    当协调者收到所有参与者的响应结果后,协调者会根据结果来做出最终决策,如果所有参与者都响应成功,那么协调者会决定提交事务,并且记录全局事务信息(事务信息、状态为commit),然后向所有参与者发送commit指令,参与者收到commit指令后会对上一阶段生成的事务数据进行最后的提交。

    image.png

    当有任意一个参与者响应失败(或者超时),协调者会决定回滚事务,并且记录全局事务状态(事务信息、状态为abort),然后向所有参与者发送abort指令,当参与者收到abort指令后,会进行事务回滚,清除上一个阶段生成的redo log 和undo log。

    [图片上传失败...(image-4b3b9c-1651584413630)]

    2PC的异常场景处理机制

    2PC在一切正常的情况下显得很完美,一切都可以顺利的进行,不过总有一些意外是可观存在的,比如说: 在协商的过程中 参与者挂掉怎么办? 协调者挂掉怎么办?? 消息在网络传输的过程中丢失了怎么办? 在遇到这些问题之后,看2PC是如何进行应对的。

    参与者挂掉

    如果在第一阶段,协调者发送Prepare指令给所有的参与者后,参与者挂掉了,那么此时协调者因为迟迟收不到参与者的消息而导致超时,所以协调者在超时之后会统一发送abort指令进行事务回滚。

    如果在第二阶段,协调者发送commit或者abort指令给所有参与者后,参与者挂掉了,那么协调者会在超时之后进行消息重发,直到参与者恢复后收到到commit或者abort ,向协调者返回成功。

    协调者挂掉

    协调者在第一阶段发送Prepare指令后挂掉,那么此时参与者此时会一直得不到协调者下一步的指令,那么此时参与者会一直陷入阻塞状态,资源也会一直被锁住,直到协调者恢复之后向参与者发出下一步的指令。

    协调者在第二阶段挂掉,那么此时协调者已向所有者发出最后阶段的指令了,所以收到指令的参与者会完成最后的commit或rollback操作,对于参与者来说事务已经结束,所以不存在阻塞和锁的问题, 当协调者恢复后,会把事务日志状态标记为结束。

    因为网络分区消息丢失

    在第一阶段,协调者发送给参与者的消息丢失了,那么此时参与者会因为没有收到消息不会执行任何动作,所以也不会响应协调者任何消息,此时协调者会因为没有收到参与者的响应而超时,所以协调者会决定决定回滚事务,向所有参与者发送abort指令。

    在第二阶段,无论是协调者发送给参与者消息丢失、还是参与者响应协调者消息丢失,都会导致协调者超时,所以这种时候协调者会进行重试,直到所有参与者都响应成功。

    极端情况下数据不一致的风险

    我们发现在一般的的场景里,出现了问题2PC好像都能解决 ,但我们去抽丝剥茧的挖细节的时,就会发现2PC在某些场景会出现数据不一致的情况。

    比如说,协调者在第二阶段向部分参与者发送了commit指令后挂了,那么此时收到了commit指令的参与者会进行事务提交,然后未收到消息的参与者还是等着协调者的指令,所以这个时候会产生数据的不一致,此时必须要等协调者恢复之后重新发送指令,参与者才能达到最终的一致状态。

    还有如果在第二阶段网络发生问题导致部分消息丢失,有些参与者收到了commit指令,有些参与者还没有收到commit指令,结果收到了指令的参与者提交了事务,没收到消息的参与者还在等指令,它不知道该进行回滚还是提交,这个时候同样也会产生数据不一致的问题。

    2PC遗留问题

    我们如果理解了2PC的实现机制后,不难发现其中存在几方面的问题。

    1、性能问题

    从事务开始到事务最终提交或回滚,这期间所有参与者的资源一致处于锁定状态,所以注定2PC的性能不会太高。

    2、数据不一致风险

    从上面我们分析知道,极端情况下不管是由于协调者故障,还是网络分区都会有导致数据不一致的风险。

    3、协调者故障导致的事务阻塞问题

    在两阶段提交协议里,我们会发现协调者是一个至关重要角色,参与者无论任何时候出问题,都会在因为协调者没收到参与者的消息而超时,协调者超时之后任然能做出下一步的决策,但是协调者问题后,流程就没办法继续了,此时参与者因为没有收到协调者下一步指令不知道是该进行commit还是rollback(这里也许有人会有疑问,既然协调者可以超时,那参与者为什么不可以超时呢,这个问题放到3PC解答),所有的参与者必须等待协调者恢复之后才能做出下一步的动作。

    2PC总结:

    1、2PC 核心是通过两个阶段的协商达到最终操作的一致性, 第一阶段的目的是确认各个参与者可否具备执行事务的条件。根据第一阶段的结果然后第二阶段再决定整体事务是进行提交还是回滚。

    2、2PC大部分情况都能协商各个参与者达成一致,但是在极端情况下(协调者挂了、网络分区),还是会产生数据不一致问题,除此之外协调者单点故障会造成事务阻塞,然后2PC整个事务过程是会锁定资源的,所有性能也不高。


    2.3、3PC(三阶段提交协议)

    通过3PC的名字我们也可能很自然的想到它是2PC的一个升级版,我们直觉也没错,3PC就是想要解决2PC的遗留问题而诞生的。3PC包括询问阶段、准备阶段、提交阶段,相对于2PC来说增加了一个询问阶段,然后准备阶段和2PC的准备阶段大致类似,只是增加了参与者允许超时的机制,至于3PC的提交阶段和2PC的提交阶段逻辑一样。 所以我们重点了解一下询问阶段,还有第二阶段的参与者超时机制。

    询问阶段

    询问阶段的目的是核实所有参与者可否具备事务执行条件。首先协调者开启事务事务,然后向所有参与者发送CanCommit 指令询问参与者是否具备事务执行条件,参数者收到指令后,会根据检测自己当前状态,判断是否具备事务执行条件,然后向协调者响应成功或失败。

    [图片上传失败...(image-af27bc-1651584413630)]

    如果协调者收到任何一个参与者失败响应,那么此时会协调者会记录全局事务信息(事务信息、状态为abort),然后向所有参与者发送abort指令。只有当所有参与者都响应成功之后,此时进入第二阶段,协调者首先会记录全局事务信息(事务信息、状态为Precommit),然后向所有参与者发送precommit指令。

    准备阶段

    进入准备阶段后,协调者会所有参与者发送precommit指令。参与者收到指令后,会开启本地事务,锁定事务对应的资源,然后记录事务日志(Redo log Undo log),最后参与者根据本地事务的结果向协调者响应成功或失败。

    [图片上传失败...(image-93b56c-1651584413630)]

    当协调者收到任何一个参与者响应失败,此时协调者会记录全局事务信息(事务信息、状态为abort),然后向所有参与者发送abort指令。只有当所有参与者都响应成功之后,此时进入第三阶段,协调者首先会记录全局事务信息(事务信息、状态为commit),然后向所有参与者发送docommit指令。

    这里与2PC不同的是,在进入准备阶段后,如果参与者迟迟没有收到协调者的消息(网络分区或协调者故障),那么此时参与者会有超时机制,当参与者超时之后会执行统一默认策略进行事务commit。

    这里需要解释一下为什么2PC里不允许参与者超时,而3PC里参与者就可以超时。 这里核心的原因就是因为3PC增加了询问阶段,一方面:因为能进入准备阶段那么意味着所有参与者都通过了询问阶段,所有的参与者达成了一致,并且具备执行事务的条件了,那么基本上可以确定参与者都可以成功执行事务的。另外一方面 ,如果协调者挂了然后恢复后,协调者可以查看此时的全局事务状态为Precommit,那此时协调者就知道自己挂掉之前是向参与者发送的什么指令,而且协调者也可以推断出,自己挂掉后参与者会因为超时统一执行commit操作,这样协调者就能根据当前的状况作出下一步的决定。

    但是2PC不一样,因为此时协调者和参与者才进行第一轮协商,全局事务信息还没有记录任何状态,每个参与者都无法感知到其它参与者的事务情况。如果参与者有超时机制,参与者在超时之后如果执行Commit,那么如果有其它的参与者响应协调者的是失败,那么全局事务最终的决策就是abort,那么此时参与者之间数据就不一致了。 如果参与者超时之后进行Rollback,那么假如参与者响应给协调者的消息是OK,但是因为网络延时,参与者超时了,但是最终协调者还是受到了参与者OK的指令,此时全局事务会进入commit阶段,那么也会造成数据的不一致。 另外一方面,协调者挂了恢复后,因为没有判断依据,它也不知道各个参与者当前具体的情况,也没办法做出任何决策。

    提交阶段

    如果在准备阶段任意一个参与者响应失败或者协调者超时,协调者会决定进行事务回滚,首先记录全局事务日志状态为回滚,然后向所有参与者发送abort指令,参与者收到abort指令会回滚本地事务,清除本地事务日志,然后响应协调者。

    [图片上传失败...(image-20d0c1-1651584413630)]

    如果所有参与者都响应成功,那么协调者会决定提交事务,首先会记录全局事务信息状态为commit,然后向所有参与者发送commit指令,参与者收到commit指令后会对上一阶段生成的事务信息进行最后的commit。

    image.png

    最后协调者收到所有参与者的成功响应后,将全局事务信息记录为事务完成,否则任意一个协调者返回失败或者超时,协调者都会一直进行重试,直到成功。

    3PC与2PC对比

    1、性能问题(相比2PC性能更差)

    2PC需要锁定资源,并且时间取决于最慢的一个参与者,在3PC里这样的情况并未发生任何变化,3PC也还是需要锁定资源,同样也是必须要等待所有参与者响应才能进行下一步流程,反而3PC增加了一个阶段的协商通讯,这就使得3PC通信成本更高,性能反而会更差。

    2、协调者故障导致的事务阻塞问题(解决)

    因为3PC增加了询问阶段,然后在准备阶段增加了参与者超时机制,所以协调者故障并不会一直阻塞着事务进行,参与者超时之后会进行事务commit。

    3、数据不一致的风险(还是存在)

    如果协调者第二阶段的决策是abort,此时协调者把abort指令发送给了部分参与者之后挂掉了,那么收到了abort指令的参与者进行了数据回滚,但是没有收到abort指令的参与者会根据超时机制进行事务commit,最终就会有部分参与者rollback了,部分参与者进行了commit,最后数据不一致。

    3PC总结:

    1、3PC 增加了一个询问阶段、同时增加了超时机制,虽然阶段了协调者故障导致阻塞的问题,但是数据不一致的风险还是存在,而且性能因为多了一次网络交互,反而变得更慢。 正是因为这些原因 3PC也一直是处于一个理论的模型,而没有实践下去。


    2.4、XA 强一致事务模型总结

    1、XA在单机事务ACID上的经验,重新定义了分布式事务的强一致事务模型和规范。大家只要遵循这套规范,各个应用之间就可以通过XA实现分布式事务。

    2、XA定义了一种分布式事务模型,抽象出了全局的事务管理器(协调者) 和 局部资源管理器(参与者)两个角色,协调者来统一协调各个参与者来达到操作的一致性,参与者根据协调者的指令来进行本地事务的处理。 协调者和参与者协商是通过用2PC的3PC协商协议达到操作的一致性。

    3、最终经过理论和实践,不管是用2PC还是3PC协议始终都存在数据不一致的风险,而且因为每个参与者的本地事务的锁都需要等到整体事务结束才能真正的释放,所以XA的性能问题也是一直被人诟病。

    4、强一致事务模型是否还能完美的应用到分布式事务环境,到这里这条路线多少有些迷茫了,因为无论怎么优化和增加协商的次数都还是会存在数据不一致的问题,而且强一致事务模型性能实在令人堪忧,直到CAP的出现才让大家明白,在分布式环境下完美的强一致是不可能实现的,于是分布式事务的路线,逐渐开始放弃了追求完美强一致的路线,退而求其次的追求弱一致的解决方案。


    三、不可能完美(CAP)

    强一致的事务一致性方案在单机事务能完美实现,但是在分布式事务场景并没有到达预期的效果,这其中本质的问题就在于单机事务和分布式事务所遇到的场景发生了变化,在单机事务里只需要单纯的考虑数据一致性的问题。但是分布式事务场景则需要兼顾数据一致性、多节点的可用性、网络分区多个问题, 所以强一致的事务模型始终无法完美的解决分布式事务场景。直到CAP理论出现逐渐成为了分布式计算领域公认的一个定理,此时大家才彻底放弃了继续在强一致的模型上继续耕耘。

    3.1、CAP 定义

    CAP 理论主要描述了在一个分布式系统中,它的一致性、可用性、分区容错性同时只能满足两个,必然会牺牲一个。

    一致性(C): 客户端发出去的请求能读取到最新的写入结果。

    可用性(A): 可用性就是客户端发出的每个请求都能得到一个非错误的响应。

    分区容错性(P): 当出现消息丢失或者网络分区错误时,系统能够继续运行。

    3.2、网络分区发生的必然性( 只能在C与A中二选一)

    因为网络环境的不可靠,分布式环境中节点也必需要通过网络才能建立通讯,所以分布式环境中是无法避免网络分区问题的,如果因为发生了网络分区系统就无法使用,那么分布式系统就没有存在价值了,所以说在分布式环境中必须要满足分区容错性,那么系统在发生网络分区的时候系统只能在一致性和可用性之间权衡。

    3.3、以业务角度理解CAP

    用户向 用户服务集群发起一笔扣款50的请求,此时请求被发送到用户节点1处理,用户节点1处理完之后把变更的数据再同步到用户节点2中去,不过就在节点1同步到节点2的时候,此时发生网络分区,节点1无法于节点2建立通讯,而正好此时用户又向节点2发起了一个余额查询请求。 那么此时我们会面临下面两种选择:

    [图片上传失败...(image-6a8235-1651584413630)]

    1、保证分区容忍性和可用性

    如果在发生网络分区时,为了保证系统的可用性,此时节点2继续向外正常提供服务。但是此时节点2中的数据与最新的数据是不一致的,所以在发生网络分区的时候,此时为了保证系统的可用性,就会牺牲掉数据的一致性。

    2、保证分区容忍性和一致性

    如果在发生网络分区时,系统为了要避免对外提供不一致的业务数据,那么此时节点2会向用户返回错误,直到节点2同步到了最新的数据为止,在此之前节点2都是不可用状态。所以此时在发生网络分区的时候,为了保证系统的数据的一致性,就会牺牲掉系统的可用性。

    所以根据推论证明,在发生网络分区的时候,分布式系统最多只能同时满足两个,如果选择AP就会牺牲C,如果选择CP就会牺牲A,CAP无法同时满足。

    3.4、可用性和一致性如何选择?

    既然分布式系统里分区容错必须容忍,那么也就意味着CAP里面只能在C或者A中进行选择了。 那么C和A又该如何选择呢?

    分析这个问题时,我们首先得回忆一下分布式系统的初衷, 不管是冗余节点,还是拆分业务 ,这些措施的核心目的都是为了提高系统整体的可用性,所以说如果选择了CP,那么就会造成系统只要一发生网络分区就导致部分功能不可用,结果因为系统分布式反而降低了我们系统可用性,那么这是否就违背了我们系统进行分布式的初衷? 所以从这一点来看,注定我们绝大部分的情况都是选择 AP,而非CP。

    总结:

    1、CAP理论的出现结束了XA 强一致事务模型来解决分布式事务的继续探索之路。

    2、CAP通过理论证明了 在分布式系统里 可用性、分区容错性、一致性无法同时满足,最多同时只能满足两个。

    3、分布式系统里网络分区无法避免,所以只能在满足P的情况下选择CP或者AP。

    4、分布式系统的初衷是提高系统整体的可用性,所以通常来说在CP和AP之间会选择AP。


    四、接受不完美(BASE)

    CAP理论已经证明在分布式系统中CAP同时只能满足CP或者AP, 三个是无法同时满足的,并且“分区容错性”是分布式系统中必须要满足的,而“可用性”又是系统进行分布式设计的主要目的,所以最终来看,在分布式系统中我们的系统通常需要牺牲“一致性”来保证分区容错性和可用性。

    但是,牺牲是否就意味着完全放弃一致性呢? 并不是的,这里的牺牲是指“一段时间”,在这段时间过后,我们的系统最终还是要恢复到一致性的状态,通常我们也称为最终一致性。Base理论就是基于最终一致性模型,提出的一套实践理论,Base理论从基本可用、软状态、最终一致性 三个层面指导我们进行分布式系统设计。

    4.1、基本可用(Basically Available)

    系统发生故障时,允许牺牲一部分功能的可用性。 基本可用包括多个方面的指导含义,一方面是在系统故障时,我们可以牺牲一些非核心功能的可用性来换取核心功能的可用性。另外一方面我们也可以牺牲一些用户体验, 比如 增加超时时间,只要系统恢复错误的时间快于请求超时时间,那么对于客户端来说整体系统还是可用的。

    4.2、软状态(Soft State)

    可以允许系统存在一些中间的状态,因为网络有延时,而这些延时可能会导致部分数据存在一定时差的不一致(比如说数据从一台服务器同步到另外一台服务器,数据要经过网络同步,那么就必然会有一个时间差)。 所以说为了描述这些场景可能导致的数据不一致状态,我们系统可以设计一些软状态、 比如说订单有支付中、退款中、发货中,因为有这些软状态的设计,所以我们的系统可以在数据不一致的情况继续保持可用性。

    4.3、最终一致性(Eventually Consistent)

    最终一致性是说,系统可以允许一段时间的不一致,但是经过一段时间后,最终数据要恢复一致。

    4.4、Base理论总结

    Base理论是在CAP的理论基础上做了进一步的延伸,CAP只告诉了我们什么可以,什么不可以,但是Base则是更具体的告诉我们具体应该从哪些方面去做,它的核心思想是,我们尽全力保证系统的可用性(AP),为实现这个目标,在对应的场景中,我们设计一些软状态、牺牲一些非核心功能的可用性来保障核心功能的正常、允许一段时间的数据的不一致性,只能保证数据的最终一致性。


    五、不完美的事务解决方案(最终一致性)

    在CAP终结了在强一致事务上继续探索的路后,基于Base理论 倡导的弱一致性的分布式事务实现方案逐渐兴起,比如有基于消息、TCC、SAGA 、AT等模式实现分布式事务最终一致性的解决方案。

    (一)基于消息模式的实现方案

    基于事件消息消息队列实现一致性的模型中,事务发起方在完成本地事务后,会向调用方发送一个消息,收到消息的事务参与者会执行本地事务,多个事务合作的逻辑依次类推。 各个事务之间通过消息通知达实现分布式事务的操作,达到数据最终一致性。

    1、消息模式实现过程

    以用户购买商品为例,用户进行一次购买涉及到扣减余额(用户服务)、扣减商品库存(商品服务),生成订单信息(订单服务)多个服务的事务处理。我们下面用消息队列来解决用户购买场景的分布式事务。

    第一阶段:

    1、用户服务 -执行本地事务: 执行本地事务,扣减用户余额。 生成事件表信息(事务ID,事务状态(库存扣减中))。

    2、用户服务 -向库存服务发送消息:发送消息给商品服务进行库存扣减。(如果不能保证消息发送的可靠性,那么此时就需要步骤3定时进行消息重发。)

    3、用户服务-定时任务:定时查询事件表信息 中事务状态,如果事务状态为(库存扣减中),那么向库存服务发送消息。如果事务状态为(订单生成中),那么向订单服务发送消息。

    第二阶段:

    1:商品服务-幂等性校验:收到库存扣消息消息,首先进行幂等性校验,该事务ID是否处理过,未处理则进行步骤2。已处理则进行步骤3.

    2、商品服务-执行本地事务:执行本地事务,扣减库存,记录已处理的事务ID(用于幂等性校验)。

    3、商品服务-向用户服务发送消息:发送扣减库存成功消息。

    第三阶段:

    用户服务-修改事件状态: 收到库存扣减成功消息,修改事件表事务状态为(订单生成中)。

    用户服务-向订单服务发送消息: 向订单服务发送生成订单消息。

    第四阶段:

    1:订单服务-幂等性校验:收到库生成订单消息,首先进行幂等性校验,该事务ID是否处理过,未处理则进行步骤2。已处理则进行步骤3.

    2、订单服务-执行本地事务:执行本地事务,生成订单信息,记录已处理的事务ID(用于幂等性校验)。

    3、订单服务-向用户服务发送消息:发送扣订单创建成功消息。

    第五阶段:

    用户服务-事务完成: 收到订单生成成功消息,修改事件表事务状态为(事务完成)。

    [图片上传失败...(image-3b9603-1651584413630)]

    2、需要注意的地方

    1、如何保证本地事务完成后,消息一定成功发送到下游服务。

    因为本地事务和发送消息属于两个进程,所以两个操作无法保证同时成功、同时失败。 要解决这个问题通常使用本地事件消息表、和可靠消息队列两种方案。

    本地事件消息表:因为消息可能发送失败,那么我们就需要在本地记录一个消息事件表,保证然后保证业务和消息事件表在一个本地事务写入到数据库,这样就能保证本地事务完成了,那么本地事件消息表就一定能成功写入。然后通过一个额外的定时任务,定时查询消息事务表里的事务状态,如果事务状态没有变成预期的状态,那么就会一直重试把消息发送给下一个业务流程的服务,直到事务完成,这样也就保证了消息一定会发送到下游服务。

    可靠消息队列:如果消息队列支持事务消息(比如说RocketMQ),那么通过消息队列的事务机制也可以保证本地事务和消息发送的事务,在上面案例中可以省略掉定时任务的功能。

    2、保证消息的幂等性

    因为消息有重发机制,所以接收消息的服务要具备业务操作的幂等性。这里我们可以记录一下业务唯一识别编号(比如全局事务ID),每次消费时核对一下对应事务ID的事务有没有处理过,对于处理过的消息直接忽略。

    3、消息模式总结

    消息模式优势:

    1、性能高(相对于XA):基于消息队列事务之间没有任何阻塞,都是纯异步执,所以性能比较高。

    2、实现简单(相对于其它最终一致性方案):设计和实现上相对简单,不需要过多的考虑失败的情况。。

    消息模式劣势:

    1、不适合业务需要回滚场景:基于消息每个事务都是异步执行的,所以需通常我们要把最可能出错的服务放到最前面,因为一旦向客户端响应成功,但最终异步执行后续事务的时候失败,如果此时再进行回滚已经不太合适了(已经响应客户端成功了),所以一旦事务检查具备条件开始执行,那么就会一直重试直到事务完成或者人工干预完成。

    2、不具备资源隔离性: 两个操作相同资源的事务可以同时进行,按上面的案例:商品总库存为3,两个事务同时下单购买2个数量的商品,在验证时都能通过,而扣减的时候已经没办法回滚业务了,结果最终就会出现商品超卖的情况。

    3、有业务侵入:需要基于实现方案,修改业务代码。

    消息模式适用场景:

    本身业务比较简单,对事务实时性要求不高(异步和定时任务即时性不高),资源可进行一定程度调度(出现超支可以调度补充)、业务一般不会失败或者就算失败也不会对业务有很大影响, 比如说:通知、数据同步类似场景。


    (二)TCC模式

    使用消息队列的方式,一旦开始事务,那么意味着后续就必须要完成事务,没有回滚的操作,也不具备隔离性,所以需要一种可以回滚业务、并且保证资源隔离性的事务模型。

    而TCC为此提供了一个很好的解决方法,首先TCC在事务执行前,通过预留资源的方式把需要的资源事先隔离出来,这样就保证这部分的资源不被其它事务再次使用,然后为操作的失败制定了一个补偿操作来进行业务回滚,所以TCC的模式里是允许业务失败的。

    TCC分为Try、Confirm、Cancel三个方法, Try阶段主要是进行资源预留,如果Try阶段执行成功,那么本次事务所需要的资源都已经获得,这样在事务提交的时候肯定能提交成功。如果Try阶段失败,那么此时就会调用Cancel操作进行资源释放。

    Try阶段: 该阶段主要做业务可行性检查并预留事务所需要的资源,保证预留出来的资源与其它的事务隔离开来。

    Confirm:当所有事务参与者在Try阶段都执行成功后,进入Confirm阶段。此阶段将直接使用Try阶段预留资源进行业务操作。

    cancel: 任意一个参与者在当Try阶段执行失败,则整体进入Cancel阶段,此阶段将释放Try阶段预留的资源。

    1、TCC实现过程

    以用户购买商品为例,用于以金额100 下单购买了一件商品,此时购买涉及到扣减余额(用户服务)、扣减商品库存(商品服务),生成订单信息(订单服务)多个服务的事务处理。我们下面TCC事务模型来解决用户购买场景的分布式事务。

    Try阶段:业务检查,预留业务资源。

    用户服务:修改用户冻结余额+100,修改用户可用余额-100。

    商品服务:商品可用库存-1,商品冻结库存+1。

    订单服务:因为订单创建不存在资源竞争,所以可以不用做资源预留操作,创建订单也不会存在业务上的失败,所以这个操作可以放到confirm里做。

    [图片上传失败...(image-8c7ab7-1651584413630)]

    Confirm阶段:使用Try阶段的预留资源。

    用户服务:用户冻结余额-100。

    商品服务:商品冻结库存-1.

    订单服务:创建订单信息。

    [图片上传失败...(image-fa77e-1651584413630)]

    Cancel阶段:释放Try阶段预留的资源。

    用户服务:用户冻结余额-100,用户可用余额+100。

    商品服务:商品可用库存+1,商品冻结库存-1。

    订单服务:如果Try阶段没有创建订单的话,那么这里是一个空操作。

    [图片上传失败...(image-40e8fc-1651584413630)]

    2、TCC模型中需要注意的问题

    幂等问题(Confirm、Cancel被多次调用)

    因为操作有重试机制,所以不管是Confirm操作还是Cancel操作,都需要具备操作幂等性,这里通常会通过保存一份本地事务日志,通过事务编号,来识别操作是否已经处理过。

    空回滚问题(Try未执行成功的情况执行Cancel)

    以上面的业务为例,如果在Try阶段 调用商品服务的Try方法,此时商品服务的Try方法并未执行成功。因为Try阶段有服务失败,所以整体事务会进入Cancel阶段,因为Try阶段并没有预留资源成功,此时调用商品服务的Cancel业务就会导致问题。

    解决这个问题关键就是在Cancel阶段需要知道Try阶段有没有执行成功,如果执行成功了才执行释放资源的逻辑,Try没有执行成功则不执行具体的释放资源逻辑。所以在Try阶段的时候,如果执行成功则需要向本地写入一份事务日志,记录当前Try操作成功。在Cancel阶段则可以通过是否存在Try阶段的事务日志来判断是否需要执行具体业务逻辑。

    资源悬挂问题 (Try比Cancel后执行)

    资源悬挂问题发生在一种比较极端的情况, 如果发送Try请求时发生网络延时,那么请求方会认为业务失败,此时会进入Cancel阶段,当所有的Cancel都执行完之后,整个事务结束。但是之前发生网络延时的请求最终还是发送到了对应的服务,此时会执行Try操作,但是事务已经结束了,没有任何后续流程来提交或者释放Try阶段预留的资源。

    解决这个问题,可以在Cancel阶段也保留一条事务日志,Try执行之前验证Cancel阶段的日志如果存在则不执行具体业务。

    3、TCC模式总结

    TCC优点:

    1、性能高:没有全局锁,本地事务锁在本地操作完成后马上会释放,不会像2PC、3PC 一样整个事务执行的过程都会锁住资源,所以TCC性能非常高。

    2、具备隔离性: 通过隔离资源达到事务隔离的目的,先预留资源,再真正使用资源,避免了出现两个事务并发时可能导致的同一个资源被使用多次的问题,适合资源敏感的场景。

    3、允许事务失败:可以进行事务回滚。

    TCC缺点:

    1、业务侵入性强: 需要修改原来的结构设计来预留资源, 需要在原有的方法基础上把业务拆分为Try、Confirm、Cancel三个方法。

    TCC适用场景:

    有资源隔离性要求、并且对业务系统有控制权,有修改结构的权限。


    (三)SAGA模式

    TCC需要修改原来的数据结构设计,实现这个需要对资源有可控权,但是某些场景我们无法修改表结构设计(比如第三方系统),那么TCC就的方法就不适用了。

    SAGA的思想和TCC类似,TCC是先预留资源,如果不行时再释放,而SAGA的逻辑则是先直接使用资源,如果不行时再补偿回去。 这里的补偿既可以通过回滚的方式实现补偿,也可以单纯的通过业务补偿来实现(比如购买的商品库存不足了,商家可以退钱)。

    SAGA通过补偿的方式就不需要向TCC一样对资源具备控制权,也不需要修改设计,只需要针对原来的每个接口,制定一个补偿策略接口即可。 当事务执行失败时,逐个调用对应的补偿接口,实现事务的回滚即可。

    1、SAGA的两种模式

    1、正向恢复: 正向恢复模式不提供补偿机制,如果某一个阶段失败了则采取重试机制,保证最终执行成功。

    2、反向恢复:反向恢复则是提供补偿机制,为每个接口都提供对应的补偿接口,当某一个接口执行失败时(依次执行A、B、C接口,C接口执行失败),则按逆序依次调用对应接口的补偿接口(依次调用B、C 的补偿接口)。

    2、SAGA实现过程

    以用户下单购买商品为例,,此时购买涉及到扣减余额(用户服务)、扣减商品库存(商品服务),生成订单信息(订单服务)多个服务的事务处理。我们下面SAGA事务模型来解决用户购买场景的分布式事务。

    正向恢复模式: 依次调用用户服务扣减余额、调用商品服务扣减库存、调用订单服务创建订单,当某个服务返回失败、则进行重试,直到成功或者人工干预。。

    [图片上传失败...(image-7a6b58-1651584413630)]

    反向恢复模式:

    依次调用用户服务扣减余额、调用商品服务扣减库存、调用订单服务创建订单,当某个服务返回失败(比如这里订单创建后失败了),则依次逆序调用对应服务的补偿接口进行业务补偿,补偿失败则进行重试,直到成功或者人工干预。

    [图片上传失败...(image-168e4d-1651584413630)]

    3、使用SAGA注意点

    和TCC一样,SAGA也存在幂等、空回滚、悬挂等问题,解决方案可参考TCC。

    4、SAGA总结

    SAGA 模式的优点:

    1、允许事务失败:可以进行事务补偿(相对于消息模式)。

    2、对资源控制没要求(相对于TCC)。

    3、比较简单(相对于TCC),可以灵活的拆分事务粒度,针对细粒度的接口制定对应的补偿方案即可,容易理解和实现,适合用于长事务的解决方案。

    SAGA模式的缺点:

    1、不具备隔离性,若涉及业务多,可能会出现某些业务在最终一致性前被再次操作,导致错误

    2、有业务侵入性:需要对根据对应接口制定补偿策略。

    SAGA 模式的适用场景:

    1、有第三方系统参与对资源没有控制权,无法实现TCC模式的场景。

    2、遗留系统服务长事务场景。

    3、对隔离性没要求而又需要补偿机制的场景。


    (四)AT模式

    不论是消息模式、TCC、还是SAGA 都对业务有一定程度的侵入性,就这一点来说对于开发人员来说是非常不友好的,那么有没有一种模式能够把这些回滚操作都自动化呢,这样的话研发人员既不需要对业务进行改造,又不用过渡关心分布式事务的处理机制,而 AT模式的出现则是广大开发人员的福音。

    分布式开源框架Seata 中提出了一种AT模式分布式事务解决方案,AT模式吸收了SAGA模式的思想,采用补偿回滚事务的模式,只不过这个补偿不需要研发人员去编写,AT模式中会针对每一个事务接口都自动生成对应的回滚SQL,当事务需要进行回滚时,会自动执行对应的回滚SQL,整个过程完全不需要开发人员参与和实现。

    AT模式整个实现过程是基于两阶段提交协议,协商过程中包含事务协调者(TC)、事务发起者(TM)、资源所有者(RM)。协商过程和2PC类似,总体的逻辑就是RM在执行SQL的时候首先会生成一个用于回滚的逆向SQL,然后所有RM会向TC汇报本地事务执行状态,当TC收到任何一个RM的事务失败状态,那么TC会标记全局事务进行回滚,然后向所有RM发送回滚指令,收到回滚指令的RM会根据自己本地的逆向SQL进行事务回滚。

    1、Seata AT模式交互过程:

    1、TM 向TC发起全局事务(这里的TM本身也是一个RM)。

    2、TC向TM返回全局事务ID。

    3、TM执行本地事务,并生成逆向的SQL (保存到undo_log表里)和锁数据(这的锁数据是Seata生成的锁,不是实际加在数据库的锁,只对通过Seata执行的事务有效,这些锁信息保存到了lock_table里)。

    4、TM向TC注册分支事务。

    5、TC进行加锁,加锁成功则会向TM返回成功,TM继续往下执行。

    6、TM提交本地事务。

    7、TM向TC上报分支事务状态。

    8、TM调用下游服务RM。

    9、RM执行本地事务,并生成逆向的SQL 和锁数据。

    10、RM向TC注册分支事务。

    11、TC加锁成功会向RM返回成功,RM继续往下执行。

    12、RM提交本地事务。

    13、RM上报分支事务状态 (如果RM上报本地事务失败,TC收到后,会决定整体事务进行回滚,然后向所有RM发送回滚指令,收到回滚指令的RM会执行本地的逆向SQL回滚事务)。

    14、RM向TM返回成功。

    15、TM标记事务结束。

    [图片上传失败...(image-3acdad-1651584413630)]

    2、AT模式的总结

    AT模式优点

    1、无业务侵入性: 集成Seta框架后,使用只需一个注解即可。

    2、性能高: 不对数据库加锁,本地事务执行完马上释放。

    3、具备隔离性: 使用全局锁则可保证事务隔离性,但全局锁对性能有影响。

    AT模式缺点

    场景局限: 暂时来看AT模式其实是一个比较理想的分布式事务解决方案,就是现阶段使用场景还有局限与数据库层面的事务。

    补偿模式的局限: 基于补偿模式有些场景是不合适,特别是金融转账对资源非常敏感的,这种场景采用TCC模式更合适。


    六、终极大总结

    1、在单机的事务实现里,通过ACID得到了完美的解决。

    2、分布式事务初期,XA尝试以ACID类似的模式完美的解决,但不管是2PC还是3PC 都无法避免一致性问题,而且用强一致事务模型的方式性能太差。

    2、CPA告诉大家,分布式事务无法完美解决,C、A、P无法同时满足,并且P必须满足、A又是大部分场景的选择,所以通常只能选择牺牲C。

    3、CAP告诉了我们什么可以,什么不可以,但Base 对CAP理论进一步进行了延伸,总结出了一套基于AP 事务的实践方向,我们可以通过牺牲一段时间的一致性,设计一些软状态,甚至舍弃一些非必要功能牺牲一些用户体验来保证系统的可用性。

    4、既然强一致事务模型也没办法保证完美的数据一致性,何况强一致事务模型的性能那么令人堪忧,那么我们又何必再执着于最求强一致性呢,所以基于Base理论的最终一致性事务方案逐渐发展起来,逐渐衍生出了基于消息模式、TCC、SAGA、AT几种最终一致性事务模式。

    5、基于消息模式的实现方案简单、但对代码有侵入性、不具备隔离性、也不适合需要回滚的事务。TCC具备隔离性、可回滚,但侵入性太强,代码和数据都要自己能完全控制。 SAGA 可回滚、对资源控制没要求,隔离性也可以通过加锁解决,但还是有侵入性、而且基于补偿模式在某些资源非常敏感的场景不适用。AT 模式使用简单、具备隔离性、可回滚、无代码侵入,唯一的问题就先阶段AT模式只能解决数据库层面的分布式事务,还有基于补偿模式的局限性,在某些资源非常敏感的场景不适用。

    6、所以总结来看,首先最终一致性方案都要比强一致性方案的性能要高,另外强一致事务模型所支持数据库/中间件也不多,所以在这两者上,我们一般都会选择最终一致性事务模型。在最终一致性方案里,大部分场景使用AT模式即可,TCC可以作为补充方案解决资源敏感的场景,消息模式和SAGA可以作为AT和TCC模式无法实现的特定场景解决方案。

    各种方案综合对比

    [图片上传失败...(image-a41001-1651584413629)]

    参考书籍:

    正本清源分布式事务之Seata(全彩) 姜宇,冯艳娜 网天猫¥77.39去购买

    深入理解分布式事务 原理与实战 肖宇 冰河 9787111692天猫¥78.80去购买

    相关文章

      网友评论

        本文标题:分布式事务

        本文链接:https://www.haomeiwen.com/subject/pcpiyrtx.html