1、分布式理论
1.1 分布式系统
分布式因为网络的不确定性,节点故障等情况,会带来各种复杂的问题。我们在学习分布式的相关理论时,一定要明确这样一个道理,就是:网络不可靠,网络分区以及节点宕机是常态,另外网络带宽资源是及其珍贵的,我们必须在网络不可靠、分区以及节点宕机的前提下,构建高性能、高可用的分布式系统。
分布式环境的问题有:
- 通信异常:从集中式向分布式演变过程中,必然会引入网络因素,而由于网络本身的不可靠性,因此也引入了额外的问题。分布式系统需要在各个节点之间进行网络通信,因此当网络通信设备故障就会导致无法顺利完成一次网络通信,就算各节点的网络通信正常,但是消息丢失和消息延时也是非常普遍的事情。
- 网络分区(脑裂):网络发生异常情况导致分布式系统中部分节点之间的网络延时不断增大,最终导致组成分布式系统的所有节点,只有部分节点能够正常通行,而另一些节点则不能。我们称这种情况叫做网络分区(脑裂),当网络分区出现时,分布式系统会出现多个局部小集群(多个小集群可能又会产生多个master节点),所以分布式系统要求这些小集群要能独立完成原本需要整个分布式系统才能完成的功能,这就对分布式一致性提出了非常大的挑战。
- 节点故障:节点宕机是分布式环境中的常态,每个节点都有可能会出现宕机或僵死的情况,并且每天都在发生。
- 三态:由于网络不可靠的原因,因此分布式系统的每一次请求,都存在特有的“三态”概念,即:成功,失败与超时。在集中式单机部署中,由于没有网络因素,所以程序的每一次调用都能得到“成功”或者“失败”的响应,但是在分布式系统中,网络不可靠,可能就会出现超时的情况。可能在消息发送时丢失或者在响应过程中丢失,当出现超时情况时,网络通信的发起方是无法确定当前请求是否被成功处理的,所以这也是分布式事务的难点。
1.2 CAP理论
1.2.1 C: 一致性
每次读操作都能保证返回的是最新数据,在分布式系统中,如果能针对一个数据项的更新执行成功后,所有的用户都可以读到其最新的值,这样的系统就被认为具有严格的一致性。
1.2.2 A:可用性
任何一个没有发生故障的节点,会在合理的时间内返回一个正常的结果,也就是对于用户的每一个请求总是能够在有限的时间内返回结果;
1.2.3 P:分区容错性
当节点间出现网络分区(不同节点处于不同的子网络,子网络之间是联通的,但是子网络之间是无法联通的,也就是被切分成了孤立的集群网络),照样可以提供满足一致性和可用性的服务,除非整个网络环境都发生了故障。
CAP理论指出:CAP三者只能取其二,不可兼得。
分析:如果我们要使网络分区不存在,就必须将系统部署在单个节点上,因为网络总是会出现故障,分区总是存在的,所以当部署在单节点上,可以同时保证CP,但是这时候,就没什么意义了,这都不是分布式了,同时单点故障可能会发生,就不会保证A可用性。
我们必须明确一点,对于分布式系统而言,由于网络是不可靠的,分区容错性是必须要满足的,因为分区的出现是必然,也是必须要解决的问题。所以,P必须要保证,那么我们就要在C和A之间做权衡。
- 有两个或以上节点时,当网络分区发生时,集群中两个节点不能相互通信(也就是说不能保证可用性A)。此时如果保证数据的一致性C,那么必然会有一个节点被标记为不可用的状态,违反了可用性A的要求,只能保证CP。
- 反正,如果保证可用性A,即两个节点可以继续各自处理请求,那么由于网络不通不能同步数据,必然又会导致数据的不一致,只能保证AP。
那么如何在可用性和一致性进行权衡,所以就出现了各种一致性的理论与算法。
1.3 BASE理论
BASE是指基本可用(Basically Available)、软状态( Soft State)、最终一致性( Eventual Consistency)。BASE是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于CAP定理逐步演化而来的,其核心思想是即使无法做到强一致性(Strong consistency),但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。
1.3.1基本可用:
基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性——但请注意,这绝不等价于系统不可用。以下两个就是“基本可用”的典型例子。
-
响应时间上的损失:正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障(比如系统部分机房发生断电或断网故障),查询结果的响应时间增加到了1~2秒。
-
功能上的损失:正常情况下,在一个电子商务网站上进行购物,消费者几乎能够顺利地完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。
1.3.2 弱状态
弱状态也称为软状态,和硬状态相对,是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
1.3.3 最终一致性
最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
注意: 最终一致性是一种特殊的弱一致性:系统能够保证在没有其他新的更新操作的情况下,数据最终一定能够达到一致的状态,因此所有客户端对系统的数据访问都能够获取到最新的值。同时,在没有发生故障的前提下,数据达到一致状态的时间延迟,取决于网络延迟、系统负载和数据复制方案设计等因素。
2 分布式一致性算法
在分布式系统中,各个节点之间在物理上相互独立,通过网络进行沟通和协调。由于存在事务机制,可以保证每个独立节点上的数据操作可以满足ACID。但是,相互独立的节点之间无法准确的知道其他节点中的事务执行情况。所以从理论上讲,两台机器理论上无法达到一致的状态。如果想让分布式部署的多台机器中的数据保持一致性,那么就要保证在所有节点的数据写操作,要不全部都执行,要么全部的都不执行。但是,一台机器在执行本地事务的时候无法知道其他机器中的本地事务的执行结果。所以他也就不知道本次事务到底应该commit还是 roolback。所以,常规的解决办法就是引入一个“协调者”的组件来统一调度所有分布式节点的执行。使用2PC,3PC可以实现分布式的强一致性和分布式事务。
何为分布式事务? 通常把一个数据库内部的事务处理,如对多个表的操作,作为本地事务看待。数据库的事务处理对象是本地事务,而分布式事务处理的对象是全局事务。 所谓全局事务,是指分布式事务处理环境中,多个数据库可能需要共同完成一个工作,这个工作即是一个全局事务,例如,一个事务中可能更新几个不同的数据库,对数据库的操作发生在系统的各处但必须全部被提交或回滚,此时一个数据库对自己内部所做操作的提交不仅依赖本身操作是否成功,还要依赖与全局事务相关的其它数据库的操作是否成功,如果任一数据库的任一操作失败,则参与此事务的所有数据库所做的所有操作都必须回滚。 一般情况下,某一数据库无法知道其它数据库在做什么。因此,在一个分布式事务处理环境中,交易中间件是必需的,由它通知和协调相关数据库的提交或回滚,而一个数据库只将其自己所做的操作(可恢复)影射到全局事务中。
其实我们也可以将2PC,3PC算法看做是和paxos一样的用来对某个决议达成共识的算法,这里的决议就是是否要提交这个事务从而更新数据。但是2PC,3PC存在很多问题,比如太过保守、单点问题,所以才会产生具有高度容错性的paxos算法。
2.1 2PC
两阶段提交主要保证了分布式事务的原子性:即所有结点要么全部成功要么全部失败
参与者将操作结果报告给协调者,再由协调者根据所有参与者的反馈情况决定各参与者是否要提交还是回滚。2PC就是用来解决分布式事务的原子性问题。所谓的两个阶段是指:第一阶段:准备阶段(投票阶段)和第二阶段:提交阶段(执行阶段)。
2.1.1 prepare
事务协调者(事务管理器)给每个参与者(资源管理器)发送Prepare消息,每个参与者要么直接返回失败(如权限验证失败),要么在本地执行事务,写本地的redo和undo日志,但不提交,然后向协调者反馈YES或NO。 其具体过程:
协调者节点向所有参与者节点询问是否可以执行提交操作(vote),并开始等待各参与者节点的响应。 参与者节点执行询问发起为止的所有事务操作,并将Undo信息和Redo信息写入日志。(注意:若成功这里其实每个参与者已经执行了事务操作) 各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个”同意”消息;如果参与者节点的事务操作实际执行失败,则它返回一个”中止”消息。
2.1.2 commit
如果协调者收到了任意一个参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)。 注意:在2PC中,当等待超时,会进行回滚操作。
提交阶段可以分两种情况进行讨论:
2.1.2.1 执行事务提交
当协调者节点从所有参与者节点获得的相应消息都为”同意”时:
- 协调者节点向所有参与者节点发出”正式提交(commit)”的请求。
- 参与者节点正式完成操作,并释放在整个事务期间内占用的资源。
- 参与者节点向协调者节点发送”完成”消息。
- 协调者节点受到所有参与者节点反馈的”完成”消息后,完成事务。
2.1.2.2 中断事务
如果任一参与者节点在第一阶段返回的响应消息为”中止”,或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:
- 协调者节点向所有参与者节点发出”回滚操作(rollback)”的请求。
- 参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
- 参与者节点向协调者节点发送”回滚完成”消息。
- 协调者节点受到所有参与者节点反馈的”回滚完成”消息后,取消事务。
不管最后结果如何,第二阶段都会结束当前事务。二阶段提交确实能够提供原子性的操作。
2.1.3 2PC优缺点
优点: 2PC的优点是很显然的,原理简单,实现方便。目前,绝大多数关系型数据库都是采用两阶段提交协议来完成分布式事务处理的。(也就是上边的2pc过程应用于关系型数据库的分布式事务)
站在协调者的角度,在发起投票之后就进入了 WAIT 状态,等待所有参与者回复各自事务执行状态,并在收到所有参与者的回复后决策下一步是发送 commit 或 rollback 信息。站在参与者的角度,当回复完协调者的投票请求之后便进入 READY 状态(能够正常执行事务),接下去就是等待协调者最终的决策通知,一旦收到通知便可依据决策执行 commit 或 rollback 操作。
缺点:2PC的缺点也很致命:同步阻塞,单点问题,数据不一致,太过保守
1、同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态,各个参与者在等待协调者发出提交或中断请求时,会一直阻塞,而协调者的发出时间要依赖于所有参与者的响应时间,如果协调者宕机了(单点),那么他就一直阻塞在这,而且无法达成一致(3PC引入了超时提交解决)。
2、单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)。
3、数据不一致。出现分区,或者网络故障。在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致的现象。
4、太过保守:2pc没有设计相应的容错机制,当任意一个参与者节点宕机,那么协调者超时没收到响应,就会导致整个事务回滚失败。
5、二阶段无法解决的问题:协调者(在第二阶段)发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。 由于二阶段提交存在着诸如同步阻塞、单点问题、脑裂等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。
针对上述问题可以引入 超时机制 和 互询机制 在很大程度上予以解决。
对于协调者来说如果在指定时间内没有收到所有参与者的应答,则可以自动退出 WAIT 状态,并向所有参与者发送 rollback 通知。对于参与者来说如果位于 READY 状态,但是在指定时间内没有收到协调者的第二阶段通知,则不能武断地执行 rollback 操作,因为协调者可能发送的是 commit 通知,这个时候执行 rollback 就会导致数据不一致。
此时,我们可以介入互询机制,让参与者 A 去询问其他参与者 B 的执行情况。如果 B 执行了 rollback 或 commit 操作,则 A 可以大胆的与 B 执行相同的操作;如果 B 此时还没有到达 READY 状态,则可以推断出协调者发出的肯定是 rollback 通知;如果 B 同样位于 READY 状态,则 A 可以继续询问另外的参与者。只有当所有的参与者都位于 READY 状态时,此时两阶段提交协议无法处理,将陷入长时间的阻塞状态。
2.2 3PC
3PC,即三阶段提交,是2阶段提交的改进版,其将二阶段提交协议的“准备阶段”一份为二,形成了can commit,pre commit,do commit三个阶段。
与两阶段提交不同的是,三阶段提交有两个改动点。
1、引入超时机制。 (超时提交策略,当第三阶段参与者等待协调者超时后会提交事务,解决参与者同步阻塞问题,同时能在发生单点故障时,继续达成一致)
2、在第一阶段和第二阶段中插入一个准备阶段。(也是为了减少同步阻塞的发生范围)
2.2.1 Can Commit
该阶段协调者会去询问各个参与者是否能够正常执行事务,参与者根据自身情况回复一个预估值,即尝试锁定资源,这个过程是轻量的,具体步骤如下:
1.事务询问 协调者向参与者发送CanCommit请求。询问是否可以执行事务 (锁定资源)。然后开始等待参与者的响应。
2.响应反馈 参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务(锁定资源),则返回Yes响应,并进入预备状态。否则反馈No
2.2.2 Pre Commit
协调者根据参与者的反应情况来决定是否可以继续事务的PreCommit操作。根据响应情况,有以下两种可能。
1、协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。
1.发送预提交请求 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
2.事务预提交 参与者接收到PreCommit请求后,会使用can commit阶段锁定的资源,执行事务操作,并将undo和redo信息记录到事务日志中。
3.响应反馈 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。
2、有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。
- 发送中断请求 协调者向所有参与者发送abort请求。
- 中断事务 参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
2.2.3 Do Commit
该阶段进行真正的事务提交,也可以分为以下两种情况。
2.2.3.1 执行提交
1.发送提交请求 协调接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
2.事务提交 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
3.响应反馈 事务提交完之后,向协调者发送Ack响应。
4.完成事务 协调者接收到所有参与者的ack响应之后,完成事务。
2.2.3.2 中断事务
协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。
1.发送中断请求 协调者向所有参与者发送abort请求
2.事务回滚 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
3.反馈结果 参与者完成事务回滚之后,向协调者发送ACK消息
4.中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
注意: 在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者rebort请求时,会在等待超时之后,会继续进行事务的提交。
引入超时提交的依据:
其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么协调者产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。(一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。
2.2.4 3PC的优缺点
一旦进入阶段三:可能会出现:1 协调者出现问题(单点) 2 协调者参与者之间的网络出现故障
二阶段无法解决的问题:协调者(在第二阶段)发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。
相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit(所以产生新的协调者之后,可以确定事务的状态,这就解决了单点)。而不会一直持有事务资源并处于阻塞状态。
3PC无法解决:数据不一致以及太过保守问题。
但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
3 分布式事务方案
3.1 两阶段提交
两阶段提交上面已经介绍了,这里就不再重复了
3.2 TCC
3.2.1 Try
尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)
3.2.2 Confirm
真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作要求具备幂等设计,Confirm 失败后需要进行重试。
3.2.3 Cancel
在业务执行错误时取消执行,释放 Try 阶段预留的业务资源。Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致,要求满足幂等设计。
3.2.4 异常情况
- 空回滚
在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法,Cancel 方法需要识别出这是一个空回滚,然后直接返回成功。
出现原因是当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行Try阶段,当故障恢复后,分布式事务进行回滚则会调用二阶段的Cancel方法,从而形成空回滚。
- 幂等性
由于任何一个请求都可能出现网络异常,出现重复请求,所以所有的分布式事务分支,都需要保证幂等性
- 悬挂
悬挂就是对于一个分布式事务,其二阶段 Cancel 接口比 Try 接口先执行,try方法要识别出这是一个悬挂
出现原因是在 RPC 调用分支事务try时,先注册分支事务,再执行RPC调用,如果此时 RPC 调用的网络发生拥堵,RPC 超时以后,TM就会通知RM回滚该分布式事务,可能回滚完成后,Try 的 RPC 请求才到达参与者真正执行。
场景举例
- 业务处理请求4的时候,Cancel在Try之前执行,cancel需要处理空回滚
- 业务处理请求6的时候,Cancel重复执行,需要幂等
- 业务处理请求8的时候,Try在Cancel后执行,try需要处理悬挂
面对上述复杂的网络异常情况,目前看到各家建议的方案都是业务方通过唯一键,去查询相关联的操作是否已完成,如果已完成则直接返回成功。相关的判断逻辑较复杂,易出错,业务负担重。
子事务屏障
子事务屏障技术的原理是,在本地数据库,建立分支事务状态表sub_trans_barrier,唯一键为全局事务id-子事务id-子事务分支名称(try|confirm|cancel)
- 开启本地事务
- 对于当前操作op(try|confirm|cancel),insert ignore一条数据gid-branchid-op,如果插入不成功(唯一键冲突),提交事务返回成功(常见的幂等控制方法)
- 如果当前操作是cancel,那么再insert ignore一条数据gid-branchid-try,如果插入成功(注意是成功),则提交事务返回成功(插入成功表示出现空回滚,cancel直接返回成功;如果后序出现悬挂,则try插入gid-branchid-try时,会出现唯一键冲突异常,)
- 调用屏障内的业务逻辑,如果业务返回成功,则提交事务返回成功;如果业务返回失败,则回滚事务返回失败
在此机制下,解决了网络异常相关的问题
- 空补偿控制--如果Try没有执行,直接执行了Cancel,那么Cancel插入gid-branchid-try会成功,不走屏障内的逻辑,保证了空补偿控制
- 幂等控制--任何一个分支都无法重复插入唯一键,保证了不会重复执行
- 防悬挂控制--Try在Cancel之后执行,那么插入的gid-branchid-try不成功,就不执行,保证了防悬挂控制
3.2.5 总结
TCC的Confirm/Cancel阶段在业务逻辑上是不允许返回失败的,如果因为网络或者其他临时故障,导致不能返回成功,TM会不断的重试,直到Confirm/Cancel返回成功。
TCC特点如下:
- 并发度较高,无长期资源锁定。
- 开发量较大,需要提供Try/Confirm/Cancel接口。
- 一致性较好,不会发生SAGA已扣款最后又转账失败的情况
- TCC适用于订单类业务,对中间状态有约束的业务
3.3 Saga
3.3.1 Saga的组成
- 每个Saga由一系列sub-transaction Ti 组成
- 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果
可以看到,和TCC相比,Saga没有“预留”动作,它的Ti就是直接提交到库。
Saga的执行顺序有两种:
- T1, T2, T3, ..., Tn
- T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n
相对应的,Saga定义了两种恢复策略:
-
forward recovery,向前恢复,重试失败的事务,假设每个子事务最终都会成功。适用于必须要成功的场景,执行顺序是类似于这样的:T1, T2, ..., Tj(失败), Tj(重试),..., Tn,其中j是发生错误的sub-transaction。该情况下不需要Ci。
-
backward recovery,向后恢复,补偿所有已完成的事务,如果任一子事务失败。即上面提到的第二种执行顺序,其中j是发生错误的sub-transaction,这种做法的效果是撤销掉之前所有成功的sub-transation,使得整个Saga的执行结果撤销。
显然,向前恢复没有必要提供补偿事务,如果你的业务中,子事务(最终)总会成功,或补偿事务难以定义或不可能,向前恢复更符合你的需求。
理论上补偿事务永不失败,然而,在分布式世界中,服务器可能会宕机,网络可能会失败,甚至数据中心也可能会停电。在这种情况下我们能做些什么? 最后的手段是提供回退措施,比如人工干预。
3.3.2 Saga的使用条件
Saga看起来很有希望满足我们的需求。所有长活事务都可以这样做吗?这里有一些限制:
-
Saga只允许两个层次的嵌套,顶级的Saga和简单子事务
-
在外层,全原子性不能得到满足。也就是说,sagas可能会看到其他sagas的部分结果
-
每个子事务应该是独立的原子行为
-
在我们的业务场景下,各个业务环境(如:航班预订、租车、酒店预订和付款)是自然独立的行为,而且每个事务都可以用对应服务的数据库保证原子操作。
补偿也有需考虑的事项:
- 补偿事务从语义角度撤消了事务Ti的行为,但未必能将数据库返回到执行Ti时的状态。(例如,如果事务触发导弹发射, 则可能无法撤消此操作)
但这对我们的业务来说不是问题。其实难以撤消的行为也有可能被补偿。例如,发送电邮的事务可以通过发送解释问题的另一封电邮来补偿。
对于ACID的保证:
Saga对于ACID的保证和TCC一样:
- 原子性(Atomicity):正常情况下保证。
- 一致性(Consistency),在某个时间点,会出现A库和B库的数据违反一致性要求的情况,但是最终是一致的。
- 隔离性(Isolation),在某个时间点,A事务能够读到B事务部分提交的结果。
- 持久性(Durability),和本地事务一样,只要commit则数据被持久。
Saga不提供ACID保证,因为原子性和隔离性不能得到满足
和TCC对比
Saga相比TCC的缺点是缺少预留动作,导致补偿动作的实现比较麻烦:Ti就是commit,比如一个业务是发送邮件,在TCC模式下,先保存草稿(Try)再发送(Confirm),撤销的话直接删除草稿(Cancel)就行了。而Saga则就直接发送邮件了(Ti),如果要撤销则得再发送一份邮件说明撤销(Ci),实现起来有一些麻烦。
如果把上面的发邮件的例子换成:A服务在完成Ti后立即发送Event到ESB(企业服务总线,可以认为是一个消息中间件),下游服务监听到这个Event做自己的一些工作然后再发送Event到ESB,如果A服务执行补偿动作Ci,那么整个补偿动作的层级就很深。
不过没有预留动作也可以认为是优点:
- 有些业务很简单,套用TCC需要修改原来的业务逻辑,而Saga只需要添加一个补偿动作就行了。
- TCC最少通信次数为2n,而Saga为n(n=sub-transaction的数量)。
- 有些第三方服务没有Try接口,TCC模式实现起来就比较tricky了,而Saga则很简单。
- 没有预留动作就意味着不必担心资源释放的问题,异常处理起来也更简单(请对比Saga的恢复策略和TCC的异常处理)。
3.3.3 Saga相关实现
Saga Log
Saga保证所有的子事务都得以完成或补偿,但Saga系统本身也可能会崩溃。Saga崩溃时可能处于以下几个状态:
- Saga收到事务请求,但尚未开始。因子事务对应的微服务状态未被Saga修改,我们什么也不需要做。
- 一些子事务已经完成。重启后,Saga必须接着上次完成的事务恢复。
- 子事务已开始,但尚未完成。由于远程服务可能已完成事务,也可能事务失败,甚至服务请求超时,saga只能重新发起之前未确认完成的子事务。这意味着子事务必须幂等。
- 子事务失败,其补偿事务尚未开始。Saga必须在重启后执行对应补偿事务。
- 补偿事务已开始但尚未完成。解决方案与上一个相同。这意味着补偿事务也必须是幂等的。
- 所有子事务或补偿事务均已完成,与第一种情况相同。
为了恢复到上述状态,我们必须追踪子事务及补偿事务的每一步。我们决定通过事件的方式达到以上要求,并将以下事件保存在名为saga log的持久存储中:
-
Saga started event 保存整个saga请求,其中包括多个事务/补偿请求
-
Transaction started event 保存对应事务请求
-
Transaction ended event 保存对应事务请求及其回复
-
Transaction aborted event 保存对应事务请求和失败的原因
-
Transaction compensated event 保存对应补偿请求及其回复
-
Saga ended event 标志着saga事务请求的结束,不需要保存任何内容
通过将这些事件持久化在saga log中,我们可以将saga恢复到上述任何状态。
由于Saga只需要做事件的持久化,而事件内容以JSON的形式存储,Saga log的实现非常灵活,数据库(SQL或NoSQL),持久消息队列,甚至普通文件可以用作事件存储, 当然有些能更快得帮saga恢复状态。
注意事项
对于服务来说,实现Saga有以下这些要求:
- Ti和Ci是幂等的。
- Ci必须是能够成功的,如果无法成功则需要人工介入。
- Ti - Ci和Ci - Ti的执行结果必须是一样的:sub-transaction被撤销了。
第一点要求Ti和Ci是幂等的,举个例子,假设在执行Ti的时候超时了,此时我们是不知道执行结果的,如果采用forward recovery策略就会再次发送Ti,那么就有可能出现Ti被执行了两次,所以要求Ti幂等。如果采用backward recovery策略就会发送Ci,而如果Ci也超时了,就会尝试再次发送Ci,那么就有可能出现Ci被执行两次,所以要求Ci幂等。
第二点要求Ci必须能够成功,这个很好理解,因为,如果Ci不能执行成功就意味着整个Saga无法完全撤销,这个是不允许的。但总会出现一些特殊情况比如Ci的代码有bug、服务长时间崩溃等,这个时候就需要人工介入了。
第三点乍看起来比较奇怪,举例说明,还是考虑Ti执行超时的场景,我们采用了backward recovery,发送一个Ci,那么就会有三种情况:
- Ti的请求丢失了,服务之前没有、之后也不会执行Ti
- Ti在Ci之前执行
- Ci在Ti之前执行
对于第1种情况,容易处理。对于第2、3种情况,则要求Ti和Ci是可交换的(commutative),并且其最终结果都是sub-transaction被撤销。
3.3.4 Saga协调
协调saga:saga的实现包含协调saga步骤的逻辑。当系统命令启动saga时,协调逻辑必须选择并告知第一个saga参与者执行本地事务。一旦该事务完成,saga的排序协调选择并调用下一个saga参与者。这个过程一直持续到saga执行了所有步骤。如果任何本地事务失败,则saga必须以相反的顺序执行补偿事务。构建一个saga的协调逻辑有几种不同的方法:
- 编排(Choreography):在saga参与者中分配决策和排序。他们主要通过交换事件进行沟通。
- 控制(Orchestration):在saga控制类中集中saga的协调逻辑。一个saga控制者向saga参与者发送命令消息,告诉他们要执行哪些操作。
3.3.4.1 编排
基于编排的saga:实现sagas的一种方法是使用编排。当使用编排时,没有中央协调员告诉saga参与者该做什么。相反,sagas参与者订阅彼此的事件并做出相应的响应。
通过这个sagas的路径如下:
- Order Service在APPROVAL_PENDING状态下创建一个Order并发布OrderCreated事件。
- Consumer Service消费OrderCreated事件,验证消费者是否可以下订单,并发布ConsumerVerified事件。
- Kitchen Service消费OrderCreated事件,验证订单,在CREATE_PENDING状态下创建故障单,并发布TicketCreated事件。
- Accounting服务消费OrderCreated事件并创建一个处于PENDING状态的Credit CardAuthorization。
- Accounting Service消费TicketCreated和ConsumerVerified事件,收取消费者的信用卡,并发布信用卡授权活动。
- Kitchen Service使用CreditCardAuthorized事件并更改AWAITING_ACCEPTANCE票的状态。
- Order Service收到CreditCardAuthorized事件,更改订单状态到APPROVED,并发布OrderApproved事件。
创建订单saga还必须处理saga参与者拒绝订单并发布某种失败事件的场景。例如,消费者信用卡的授权可能会失败。saga必须执行补偿交易以撤消已经完成的事情。图中显示了AccountingService无法授权消费者信用卡时的事件流。
事件顺序如下:
- Order服务在APPROVAL_PENDING状态下创建一个Order并发布OrderCreated事件。
- Consumer服务消费OrderCreated事件,验证消费者是否可以下订单,并发布ConsumerVerified事件。
- Kitchen服务消费OrderCreated事件,验证订单,在CREATE_PENDING状态下创建故障单,并发布TicketCreated事件。
- Accounting服务消费OrderCreated事件并创建一个处于PENDING状态的Credit CardAuthorization。
- Accounting服务消费TicketCreated和ConsumerVerified事件,向消费者的信用卡收费,并发布信用卡授权失败事件。
- Kitchen服务使用信用卡授权失败事件并将故障单的状态更改为REJECTED。
- 订单服务消费信用卡授权失败事件,并将订单状态更改为已拒绝。
可靠的基于事件的通信
在实施基于编排的saga时,您必须考虑一些与服务间通信相关的问题。第一个问题是确保saga参与者更新其数据库并将事件作为数据库事务的一部分发布。
您需要考虑的第二个问题是确保saga参与者必须能够将收到的每个事件映射到自己的数据。
编组的saga的好处和缺点
基于编舞的saga有几个好处:
- 简单:服务在创建,更新或删除业务时发布事件对象
- 松耦合:参与者订阅事件并且彼此之间没有直接的了解。
并且有一些缺点:
- 更难理解:与业务流程不同,代码中没有一个地方可以定义saga。相反,编排在服务中分配saga的实现。因此,开发人员有时很难理解给定的saga是如何工作的。
- 服务之间的循环依赖关系:saga参与者订阅彼此的事件,这通常会创建循环依赖关系。例如,如果仔细检查图示,您将看到存在循环依赖关系,例如订单服务、会计服务、订单服务。虽然这不一定是个问题,但循环依赖性被认为是设计问题。
- 紧密耦合的风险:每个saga参与者都需要订阅所有影响他们的事件。例如,会计服务必须订阅导致消费者信用卡被收费或退款的所有事件。因此,存在一种风险,即需要与Order Service实施的订单生命周期保持同步更新。
3.3.4.2 控制
控制是实现sagas的另一种方式。使用业务流程时,您可以定义一个控制类,其唯一的职责是告诉saga参与者该做什么。 saga控制使用命令/异步回复样式交互与参与者进行通信。
- Order Service首先创建一个Order和一个创建订单控制器。之后,路径的流程如下:
- saga orchestrator向Consumer Service发送Verify Consumer命令。
- Consumer Service回复Consumer Verified消息。
- saga orchestrator向Kitchen Service发送Create Ticket命令。
- Kitchen Service回复Ticket Created消息。
- saga协调器向Accounting Service发送授权卡消息。
- Accounting服务部门使用卡片授权消息回复。
- saga orchestrator向Kitchen Service发送Approve Ticket命令。
- saga orchestrator向订单服务发送批准订单命令。
使用状态机建模SAGA ORCHESTRATORS
建模saga orchestrator的好方法是作为状态机。状态机由一组状态和一组由事件触发的状态之间的转换组成。每个transition都可以有一个action,对于一个saga来说是一个saga参与者的调用。状态之间的转换由saga参与者执行的本地事务的完成触发。当前状态和本地事务的特定结果决定了状态转换以及执行的操作(如果有的话)。对状态机也有有效的测试策略。因此,使用状态机模型可以更轻松地设计、实施和测试。
图显示了Create Order Saga的状态机模型。此状态机由多个状态组成,包括以下内容:
- Verifying Consumer:初始状态。当处于此状态时,该saga正在等待消费者服务部门验证消费者是否可以下订单。
- Creating Ticket:该saga正在等待对创建票证命令的回复。
- Authorizing Card:等待Accounting服务授权消费者的信用卡。
- OrderApproved:表示saga成功完成的最终状态。
- Order Rejected:最终状态表明该订单被其中一方参与者们拒绝。
SAGA ORCHESTRATION和TRANSACTIONAL MESSAGING
基于业务流程的saga的每个步骤都包括更新数据库和发布消息的服务。例如,Order Service持久保存Order和Create Order Saga orchestrator,并向第一个saga参与者发送消息。一个saga参与者,例如Kitchen Service,通过更新其数据库并发送回复消息来处理命令消息。 Order Service通过更新saga协调器的状态并向下一个saga参与者发送命令消息来处理参与者的回复消息。服务必须使用事务性消息传递,以便自动更新数据库并发布消息
让我们来看看使用saga编排的好处和缺点。
基于ORCHESTRATION的SAGAS的好处和缺点
基于编排的saga有几个好处:
- 更简单的依赖关系:编排的一个好处是它不会引入循环依赖关系。 saga orchestrator调用saga参与者,但参与者不会调用orchestrator。因此,协调器依赖于参与者,但反之亦然,因此没有循环依赖性。
- 较少的耦合:每个服务都实现了由orchestrator调用的API,因此它不需要知道saga参与者发布的事件。
- 改善关注点分离并简化业务逻辑:saga协调逻辑本地化在saga协调器中。域对象更简单,并且不了解它们参与的saga。例如,当使用编排时,Order类不知道任何saga,因此它具有更简单的状态机模型。在执行创建订单saga期间,它直接从APPROVAL_PENDING状态转换到APPROVED状态。 Order类没有与saga的步骤相对应的任何中间状态。因此,业务更加简单。
业务流程也有一个缺点:
- 在协调器中集中过多业务逻辑的风险。这导致了一种设计,其中智能协调器告诉哑巴服务要做什么操作。幸运的是,您可以通过设计独立负责排序的协调器来避免此问题,并且不包含任何其他业务逻辑。
4 本地消息表
写本地消息和业务操作放在一个事务里,保证了业务和发消息的原子性,要么他们全都成功,要么全都失败。
容错机制:
- 扣减余额事务 失败时,事务直接回滚,无后续步骤
- 轮序生产消息失败, 增加余额事务失败都会进行重试
本地消息表的特点:
- 不支持回滚
- 轮询生产消息难实现,如果定时轮询会延长事务总时长,如果订阅binlog则开发维护困难
适用于可异步执行的业务,且后续操作无需回滚的业务
5 事务消息(可靠消息最终一致性)
RocketMq的事务消息 笔者觉得事务消息属于本地消息表的一种方式
事务消息发送及提交:
- 发送消息(half消息)
- 服务端存储消息,并响应消息的写入结果
- 根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)
- 根据本地事务状态执行Commit或者Rollback(Commit操作发布消息,消息对消费者可见)
补偿流程:
对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”, Producer收到回查消息,返回消息对应的本地事务的状态,为Commit或者Rollback
6最大努力通知
6.1 实现方式
发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。适用于一些最终一致性时间敏感度低的业务,且被动方处理结果不影响主动方的处理结果。典型的使用场景:如银行通知、商户通知等。
实现关键:
1、有一定的消息重试通知机制。因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。
2、消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。
最大努力通知与可靠消息一致性有什么不同?
1、解决方案思想不同。可靠消息一致性,通知发起方需要保证将消息发出去,并且将消息发到通知接收方,可靠消息一致性中消息的可靠性关键由通知发起方来保证。 最大努力通知,通知发起方尽最大的努力将业务处理结果通知给通知接收方,但是可能消息接收不到,此时需要通知接收方主动调用通知发起方的接口查询业务处理结果,最大努力通知的可靠性关键在通知接收方。
2、两者的业务应用场景不同 可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易。 最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。
3、技术解决方向不同 可靠消息一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。 最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消费(业务处理结果)。
6.2 案例
一个短信发送平台,背景是公司内部有多个业务都有发送短信的需求,如果每个业务独立实现短信发送功能,存在功能实现上的重复。因此专门做了一个短信平台项目,所有的业务方都接入这个短信平台,来实现发送短信的功能。简化后的架构如下所示:
短信发送流程
业务方发送一次短信的流程包含如下步骤:
- 业务方将短信发送请求提交给短信平台;
- 短信平台接收到要发送的短信,记录到数据库中,并标记其状态为”已接收";
- 短信平台调用外部短信发送供应商的接口,发送短信。外部供应商的接口也是异步将短信发送到用户手机上,因此这个接口调用后,立即返回,进入第4步;
- 更新短信发送状态为"已发送";
- 短信发送供应商异步通知短信平台短信发送结果。而通知可能失败,因此最多只会通知N次。;
- 短信平台接收到短信发送结果后,更新短信发送状态,可能是成功,也可能失败(如手机欠费)。到底是成功还是失败并不重要,重要的是我们知道了这调短信发送的最终结果;
- 如果最多只通知N次,如果都失败了的话,那么短信平台将不知道短信到底有没有成功发送。因此短信发送供应商需要提供一个查询接口,以方便短信平台驱动的去查询,进行定期校对。
在这个案例中,短信发送供应商通知短信平台短信发送结果的过程中,就是最典型的最大努力通知型方案,通知了N次就不再通知。通过提供一个短信结果查询接口,让短信平台可以进行定期的校对。而由于短信发送业务的时间敏感度并不高,比较适合采用这个方案。
需要注意的是,短信结果查询接口很重要,必须要进行定期校对。因为后期要进行对账,笔者在做这个项目的时候,一个月的短信发送总量在高峰期可以达到1亿条左右,即使一条短信只要5分钱,一个月就有500W。
7 AT事务模式
AT 模式是一种无侵入的分布式事务解决方案。在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。
7.1 一阶段:
在一阶段,Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。
7.2 二阶段
7.2.1 二阶段提交
二阶段如果是提交的话,因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。
7.2.3 二阶段回滚
二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。