参考:

《凤凰架构》,原书 --------- https://icyfenix.cn/

 

本节所说的分布式事务(Distributed Transaction)特指多个服务同时访问多个数据源的事务处理机制。


CAP定理(Consistency、Availability、Partition Tolerance Theorem),也称为Brewer定理,起源于2000年7月,是加州大学伯克利分校的Eric Brewer教授于“ACM分布式计算原理研讨会(PODC)”上提出的一个猜想,两年后,麻省理工学院的Seth Gilbert和Nancy Lynch以严谨的数学推理证明了CAP猜想。自此,CAP正式从猜想变为分布式计算领域所公认的著名定理。这个定理描述了在一个分布式系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个。

  • 一致性(Consistency):代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。一致性在分布式研究中是有严肃定义、有多种细分类型的概念,以后讨论分布式共识算法时,我们还会提到一致性,但那种面向副本复制的一致性与这里面向数据库状态的一致性从严格意义来说并不完全等同,具体差别我们将在第6章再作探讨。
  • 可用性(Availability):代表系统不间断地提供服务的能力。理解可用性要先理解与其密切相关的两个指标:可靠性(Reliability)和可维护性(Serviceability)。可靠性使用平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性使用平均可修复时间(Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,譬如99.9999%可用,即代表平均年故障修复时间为32秒。
  • 分区容忍性(Partition Tolerance):代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力。

 

假设Fenix’s Bookstore的服务拓扑如图3-6所示,一个来自最终用户的交易请求,将交由账号、商家和仓库服务集群中的某一个节点来完成响应。在这套系统中,每一个单独的服务节点都有自己的数据库[2],假设某次交易请求分别由“账号节点1”“商家节点2”“仓库节点N”联合进行响应。当用户购买一件价值100元的商品后,账号节点1首先应给该用户账号扣减100元货款,它在自己数据库扣减100元很容易,但它还要把这次交易变动告知本集群的节点2到节点N,并要确保能正确变更商家和仓库集群其他账号节点中的关联数据,此时将可能面临以下情况。

  • 一致性问题:如果该变动信息没有及时同步给其他账号节点,将有可能导致用户购买另一商品时,被分配给另一个节点处理,由于看到账号上有不正确的余额而错误地发生了原本无法进行的交易。
  • 可用性问题:如果由于要把该变动信息同步给其他账号节点,必须暂时停止对该用户的交易服务,直至数据同步一致后再重新恢复,将可能导致用户在下一次购买商品时,因系统暂时无法提供服务而被拒绝交易。
  • 分区容忍性问题:如果由于账号服务集群中某一部分节点因网络问题,无法正常与另一部分节点交换账号变动信息,此时服务集群中无论哪一部分节点对外提供的服务都可能是不正确的。整个集群不能承受由于部分节点之间的连接中断而不断继续正确地提供服务。

以上仅仅分析了用户服务集群自身的CAP问题,对于整个Fenix’s Bookstore站点来说,它更是面临着来自于用户、商家和仓库服务集群带来的CAP问题。譬如,用户账号扣款后,由于未及时通知仓库服务中的全部节点,导致另一次交易中看到仓库里有不正确的库存数据而发生超售。又譬如因涉及仓库中某个商品的交易正在进行,为了同步用户、商家和仓库的交易变动,而暂时锁定该商品的交易服务,导致可用性问题,等等。

由于CAP定理已有严格的证明,本节不去探讨为何CAP不可兼得,而是直接分析舍弃C、A、P时所带来的不同影响。

  • 如果放弃分区容忍性(CA without P),意味着我们将假设节点之间的通信永远是可靠的。永远可靠的通信在分布式系统中必定是不成立的,这不是你想不想的问题,而是只要用到网络来共享数据,分区现象就始终存在。在现实中,最容易找到放弃分区容忍性的例子便是传统的关系数据库集群,这样的集群虽然依然采用由网络连接的多个节点来协同工作,但数据却不是通过网络来实现共享的。以Oracle的RAC集群为例,它的每一个节点均有自己独立的SGA、重做日志、回滚日志等部件,但各个节点是通过共享存储中的同一份数据文件和控制文件来获取数据,通过共享磁盘的方式来避免出现网络分区。因而Oracle RAC虽然也是由多个实例组成的数据库,但它并不能称作分布式数据库。
  • 如果放弃可用性(CP without A),意味着我们将假设一旦网络发生分区,节点之间的信息同步时间可以无限制地延长,此时,问题相当于退化到前面3.2节讨论的一个系统使用多个数据源的场景之中,我们可以通过2PC/3PC等手段,同时获得分区容忍性和一致性。在现实中,选择放弃可用性的情况一般出现在对数据质量要求很高的场合中。
  • 如果放弃一致性(AP without C),意味着我们将假设一旦发生分区,节点之间所提供的数据可能不一致。选择放弃一致性的AP系统是目前设计分布式系统的主流选择,因为P是分布式网络的天然属性,你再不想要也无法丢弃;而A通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。目前大多数NoSQL库和支持分布式的缓存框架都是AP系统,以Redis集群为例,如果某个Redis节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回客户端的是不一致的数据的情况。

读到这里,不知道你是否对“选择放弃一致性的AP系统是目前设计分布式系统的主流选择”这个结论感到一丝无奈,本章讨论的话题“事务”原本的目的就是获得“一致性”,而在分布式环境中,“一致性”却不得不成为通常被牺牲、被放弃的那一项属性。

但无论如何,我们建设信息系统,终究还是要确保操作结果至少在最终交付的时候是正确的,这句话的意思是允许数据在中间过程出错(不一致),但应该在输出时被修正过来。为此,人们又重新给一致性下了定义,将前面我们在CAP、ACID中讨论的一致性称为“强一致性”(Strong Consistency),有时也称为“线性一致性”(Linearizability,通常是在讨论共识算法的场景中),而把牺牲了C的AP系统又要尽可能获得正确结果的行为称为追求“弱一致性”。不过,如果单纯只说“弱一致性”那其实就是“不保证一致性”的意思。在弱一致性里,人们又总结出了一种稍微强一点的特例,被称为“最终一致性”(Eventual Consistency),它是指如果数据在一段时间之内没有被另外的操作更改,那它最终会达到与强一致性过程相同的结果,有时候面向最终一致性的算法也被称为“乐观复制算法”。

在本节讨论的主题“分布式事务”中,目标同样也不得不从之前三种事务模式追求的强一致性,降低为追求获得“最终一致性”。由于一致性的定义变动,“事务”一词的含义其实也同样被拓展了,人们把使用ACID的事务称为“刚性事务”,而把笔者下面将要介绍的几种分布式事务的常见做法统称为“柔性事务”

 

两阶段提交

准备阶段:

又叫作投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复Prepared,否则回复Non-Prepared。这里所说的准备操作跟人类语言中通常理解的准备不同,对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条Commit Record而已,这意味着在做完数据持久化后并不立即释放隔离性(允许 STEAL 意味着可以在写入Commit Record 前持久化到磁盘,在这之后,不写入Commit Record 即不结束此次事务不释放隔离性),即仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。

提交阶段:

又叫作执行阶段,协调者如果在上一阶段收到所有事务参与者回复的Prepared消息,则先自己在本地持久化事务状态为Commit,然后向所有参与者发送Commit指令,让所有参与者立即执行提交操作;

否则,任意一个参与者回复了Non-Prepared消息,或任意一个参与者超时未回复时,协调者将在自己完成事务状态为Abort,持久化后,向所有参与者发送Abort指令,让参与者立即执行回滚操作(允许 STEAL 意味着可以在写入Commit Record 前写入到磁盘。部分参与者或许已经写入了磁盘,但若有另外的参与者没有回复Prepared消息,此时已写入磁盘的参与者收到协调者的Abort指令后,根据 undoLog 回滚)。

对于数据库来说,这个阶段的提交操作应是很轻量的,仅仅是持久化一条Commit Record而已,通常能够快速完成,只有收到Abort指令时,才需要根据回滚日志清理已提交的数据,这可能是相对重负载操作。

以上这两个过程被称为“两段式提交”(2 Phase Commit,2PC)协议,而它能够成功保证一致性还需要一些其他前提条件。

  • 必须假设网络在提交阶段的短时间内是可靠的,即提交阶段不会丢失消息。同时也假设网络通信在全过程都不会出现误差,即可以丢失消息,但不会传递错误的消息,XA的设计目标并不是解决诸如拜占庭将军一类的问题。在两段式提交中,投票阶段失败了可以补救(回滚),提交阶段失败了则无法补救(不再改变提交或回滚的结果,只能等崩溃的节点重新恢复),因而此阶段耗时应尽可能短,这也是为了尽量控制网络风险。
  • 必须假设因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能够恢复,不会永久性地处于失联状态。由于在准备阶段已经写入了完整的重做日志,所以当失联机器一旦恢复,就能够从日志中找出已准备妥当但并未提交的事务数据,进而向协调者查询该事务的状态,确定下一步应该进行提交还是回滚操作。
    • 请注意,上面所说的协调者、参与者通常都是由数据库自己来扮演的,不需要应用程序介入。协调者一般是在参与者之间选举产生,而应用程序对于数据库来说只扮演客户端的角色。两段式提交的交互时序示意图如图3-2所示。

 

两段式提交原理简单,并不难实现,但有几个非常显著的缺点。

  • 单点问题:协调者在两段式提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦宕机的不是其中某个参与者,而是协调者的话,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送Commit或者Rollback的指令,那所有参与者都必须一直等待。
  • 性能问题:在两段式提交过程中,所有参与者相当于被绑定为一个统一调度的整体,期间要经过两次远程服务调用,三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入提交记录),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止,这决定了两段式提交的性能通常都较差。
  • 一致性风险:前面已经提到,两段式提交的成立是有前提条件的,当网络稳定性和宕机恢复能力的假设不成立时,仍可能出现一致性问题。宕机恢复能力这一点不必多谈,1985年Fischer、Lynch、Paterson提出了“FLP不可能原理”,证明了如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确地达成一致性结果。该原理在分布式中是与CAP不可兼得原理齐名的理论。而网络稳定性带来的一致性风险是指:尽管提交阶段时间很短,但这仍是一段明确存在的危险期,如果协调者在发出准备指令后,根据收到各个参与者发回的信息确定事务状态是可以提交的,协调者会先持久化事务状态,并提交自己的事务,如果这时候网络忽然断开,无法再通过网络向所有参与者发出Commit指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)未提交,且没有办法回滚,产生数据不一致的问题。

为了缓解两段式提交协议的一部分缺陷,具体地说是协调者的单点问题和准备阶段的性能问题,后续又发展出了“三段式提交”(3 Phase Commit,3PC)协议。三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为 CanCommit (询问让参与者评估自身状态是否能顺利完成)、PreCommit(写 RedoLog),把提交阶段改称为 DoCommit 阶段。

其中,新增的CanCommit是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。将准备阶段一分为二的理由是这个阶段是重负载的操作。两阶段提交中一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完成提交,相当于大家都做了一轮无用功。所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小。因此,在事务需要回滚的场景中,三段式提交的性能通常要比两段式提交好很多,但在事务能够正常提交的场景中,两者的性能都很差,甚至三段式因为多了一次询问,还要稍微更差一些。

(注:增加一轮询问,如果都得到了正面响应,再开始写 RedoLog。之前两阶段,参与者第一次收到轮询问完就要开始写 redoLog)

同样也是由于事务失败回滚概率变小,在三段式提交中,如果在 PreCommit 阶段之后发生了协调者宕机,即参与者没有等到DoCommit的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待,(注:因为三阶段的PreCommit 阶段之后时,在 CanCommit  已经确认过一次,全体参与者都能完成持久化可以保障一致性,所以没收到协调者指令,也可以默认是提交事务;而在两阶段中,在轮询问后,参与者还不知道是否全体参与者都已准备好,要收到协调者的 Commit 指令才知道,这个指令实际上包含两个含义:1、全体参与者都已准备好 2、开始提交事务。三阶段将这两个意思分开,在 CanCommit  先确认完成了)  这就相当于避免了协调者单点问题的风险。三段式提交的操作时序如图3-3所示。

从以上过程可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但是对一致性风险问题并未有任何改进,甚至是略有增加的。譬如,进入PreCommit阶段之后,协调者发出的指令不是Ack而是Abort,而此时因网络问题,有部分参与者直至超时都未能收到协调者的Abort指令的话,这些参与者将会错误地提交事务,这就产生了不同参与者之间数据不一致的问题。

 

 

 

一、可靠事件队列

  1. 最终用户向Fenix’s Bookstore发送交易请求:购买一本价值100元的《深入理解Java虚拟机》。
  2. Fenix’s Bookstore首先应对用户账号扣款、商家账号收款、库存商品出库这三个操作有一个出错概率的先验评估,根据出错概率的大小来安排它们的操作顺序,这种评估一般直接体现在程序代码中,一些大型系统也可能会实现动态排序。譬如,根据统计,最有可能出现的交易异常是用户购买了商品,但是不同意扣款,或者账号余额不足;其次是仓库发现商品库存不够,无法发货;风险最低的是收款,如果到了商家收款环节,一般就不会出什么意外了。那最容易出错的就应该最先进行,即:账号扣款→仓库出库→商家收款。
  3. 账号服务进行扣款业务,如扣款成功,则在自己的数据库建立一张消息表,里面存入一条消息:【事务ID:某UUID,扣款:100元(状态:已完成仓库出库《深入理解Java虚拟机》:1本(状态:进行中),某商家收款:100元(状态:进行中”】。注意,这个步骤中“扣款业务”和“写入消息”是使用同一个本地事务写入账号服务自己的数据库的。
  4. 在系统中建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到库存和商家服务节点中去(也可以串行地发,即一个成功后再发送另一个,但在我们讨论的场景中没必要)。这时候可能产生以下几种情况。
    • 商家和仓库服务都成功完成了收款和出库工作,向用户账号服务器返回执行结果,用户账号服务把消息状态从“进行中”更新为“已完成”。整个事务顺利结束,达到最终一致性的状态。
    • 商家或仓库服务中至少有一个因网络原因,未能收到来自用户账号服务的消息。此时,由于用户账号服务器中存储的消息状态一直处于“进行中”,所以消息服务器将在每次轮询的时候持续地向未响应的服务重复发送消息。这个步骤的可重复性决定了所有被消息服务器发送的消息都必须具备幂等性,通常的设计是让消息带上一个唯一的事务ID,以保证一个事务中的出库、收款动作会且只会被处理一次。
    • 商家或仓库服务有某个或全部无法完成工作,譬如仓库发现《深入理解Java虚拟机》没有库存了,此时,仍然是持续自动重发消息,直至操作成功(譬如补充了新库存),或者被人工介入为止。由此可见,可靠事件队列只要第一步业务完成了,后续就没有失败回滚的概念,只许成功,不许失败。
    • 商家和仓库服务成功完成了收款和出库工作,但回复的应答消息因网络原因丢失,此时,用户账号服务仍会重新发出下一条消息,但因操作具备幂等性,所以不会导致重复出库和收款,只会导致商家、仓库服务器重新发送一条应答消息,此过程持续自动重复直至双方网络通信恢复正常。
    • 也有一些支持分布式事务的消息框架,如RocketMQ,原生就支持分布式事务操作,这时候上述第二、四种情况也可以交由消息框架来保障。

以上这种依靠持续重试来保证可靠性的解决方案谈不上是Dan Pritchett的首创或者独创,它在计算机的其他领域中已被频繁使用,也有了专门的名字——“最大努力交付”(Best-Effort Delivery),譬如TCP协议中未收到ACK应答自动重新发包的可靠性保障就属于最大努力交付。而可靠事件队列还有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),指的是将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息系统)来促使同一个分布式事务中的其他关联业务全部完成。

 

 

 

二、TCC 事务

TCC是另一种常见的分布式事务机制,它是“Try-Confirm-Cancel”三个单词的缩写。

前面介绍的可靠消息队列虽然能保证最终结果的相对可靠性,过程也足够简单(相对于TCC来说),但整个过程完全没有任何隔离性可言,虽然在一些业务中隔离性是无关紧要的,但在有些业务中缺乏隔离性就会带来许多麻烦。譬如在本章的场景事例中,缺乏隔离性会带来的一个明显问题便是“超售”:如两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情属于刚性事务,且隔离级别足够时是可以完全避免的,譬如,以上场景就需要“可重复读”(Repeatable Read)的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败,但用可靠消息队列就无法保证这一点,这部分属于数据库本地事务方面的知识,可以参考前面的讲解。如果业务需要隔离,那架构师通常就应该重点考虑TCC方案,该方案天生适用于需要强隔离性的分布式事务中。

在具体实现上,TCC较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。如同TCC的名字所示,它分为以下三个阶段。

  • Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需要用到的业务资源(保障隔离性)。
    • Confirm:确认执行阶段,不进行任何业务检查,直接使用Try阶段准备的资源来完成业务处理。Confirm阶段可能会重复执行,因此本阶段执行的操作需要具备幂等性。
    • Cancel:取消执行阶段,释放Try阶段预留的业务资源。Cancel阶段可能会重复执行,因此本阶段执行的操作也需要具备幂等性。

按照我们的场景事例,TCC的执行过程应该如图3-8所示。

  1. 最终用户向Fenix’s Bookstore发送交易请求:购买一本价值100元的《深入理解Java虚拟机》。
  2. 创建事务,生成事务ID,记录在活动日志中,进入Try阶段。
    • 用户服务:检查业务可行性,若可行,将该用户的100元设置为“冻结”状态,通知下一步进入Confirm阶段;若不可行,通知下一步进入Cancel阶段。
    • 仓库服务:检查业务可行性,若可行,将该仓库的1本《深入理解Java虚拟机》设置为“冻结”状态,通知下一步进入Confirm阶段;若不可行,通知下一步进入Cancel阶段。
    • 商家服务:检查业务可行性,不需要冻结资源(因为是最后一步)。
  3. 如果第2步(Try阶段)有任意一方反馈业务不可行,或任意一方超时,则将活动日志的状态记录为Cancel,进入Cancel阶段
    • 用户服务:取消业务操作(释放被冻结的100元)。
    • 仓库服务:取消业务操作(释放被冻结的1本书)。
    • 商家服务:取消业务操作。
  4. 如果第2步所有业务均反馈业务可行,将活动日志中的状态记录为Confirm,进入Confirm阶段。
    • 用户服务:完成业务操作(扣减那被冻结的100元)。
    • 仓库服务:完成业务操作(标记那1本冻结的书为出库状态,扣减相应库存)。
    • 商家服务:完成业务操作(收款100元)。
  5. 第4步如果全部完成,事务正常结束,如果第4步(Confirm 阶段)中任何一方出现异常,不论是业务异常还是网络异常,都将根据活动日志中的记录,重复执行该服务的Confirm操作,即进行最大努力交付。
  6. 第5步如果全部完成,事务宣告以失败回滚结束,如果第5步中任何一方出现异常,不论是业务异常还是网络异常,都将根据活动日志中的记录,重复执行该服务的Cancel操作,即进行最大努力交付。

由上述操作过程可见,TCC其实有点类似2PC的准备阶段和提交阶段,但TCC是在用户代码层面,而不是在基础设施层面这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。TCC在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。但是TCC也带来了更高的开发成本和业务侵入性,即更高的开发成本和更换事务实现方案的替换成本,所以,通常我们并不会完全靠裸编码来实现TCC,而是基于某些分布式事务中间件(譬如阿里开源的Seata)去完成,尽量减轻一些编码工作量。

 

三、SAGA 事务

TCC事务具有较强的隔离性,避免了“超售”的问题,而且其性能一般来说是本篇提及的几种柔性事务模式中最高的,但它仍不能满足所有的场景。TCC的最主要限制是它的业务侵入性很强这里并不是重复上一节提到的它需要开发编码配合所带来的工作量的限制,而是指它所要求的技术可控性上的约束。譬如,把我们的场景事例修改如下:由于中国网络支付日益盛行,现在用户和商家在书店系统中可以选择不再开设充值账号,至少不会强求一定要先从银行充值到系统中才能消费,允许直接在购物时通过U盾或扫码支付,在银行账号中划转货款。这个需求完全符合国内网络支付盛行的现状,却给系统的事务设计增加了额外的限制:如果用户、商家的账号余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲地自行定义,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以TCC中的第一步Try阶段往往无法施行。我们只能考虑采用另外一种柔性事务方案:SAGA事务。SAGA在英文中是“长篇故事、长篇记叙、一长串事件”的意思。

SAGA事务模式的历史十分悠久,还早于分布式事务概念的提出。它源于1987年普林斯顿大学的Hector Garcia-Molina和Kenneth Salem在ACM发表的一篇论文“SAGAS”。

文中提出了一种提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务集合。原本SAGA的目的是避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式。SAGA由两部分操作组成。

  • 将大事务拆分成若干个小事务,将整个分布式事务T分解为n个子事务命名为T1,T2,…,Ti,…,Tn。每个子事务都应该是或者能被视为原子行为。如果分布式事务能够正常提交,其对数据的影响(即最终一致性)应与连续按顺序成功提交Ti等价。
  • 为每一个子事务设计对应的补偿动作,命名为C1,C2,…,Ci,…,Cn。Ti与Ci必须满足以下条件。
    • Ti与Ci都具备幂等性。
    • Ti与Ci满足交换律(Commutative),即无论先执行Ti还是先执行Ci,其效果都是一样的。
    • Ci必须能成功提交,即不考虑Ci本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者被人工介入为止。

如果T1到Tn均成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一。

  • 正向恢复(Forward Recovery):如果Ti事务提交失败,则一直对Ti进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,譬如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti,(重试)…,Ti+1,…,Tn。
  • 反向恢复(Backward Recovery):如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直至成功为止(最大努力交付)。这里要求Ci必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。

与TCC相比,SAGA不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。譬如,前面提到的账号余额直接在银行维护的场景,从银行划转货款到Fenix’s Bookstore系统中,这步是经由用户支付操作(扫码或U盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销之前的用户转账操作,但是由Fenix’s Bookstore系统将货款转回到用户账号上作为补偿措施却是完全可行的。

SAGA必须保证所有子事务都得以提交或者补偿,但SAGA系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,譬如执行至哪一步或者补偿至哪一步了。另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程严谨地进行也需要花费不少工夫,譬如通过服务编排、可靠事件队列等方式完成,所以,SAGA事务通常也不会直接靠裸编码来实现,一般是在事务中间件的基础上完成,前面提到的Seata就同样支持SAGA事务模式。

基于数据补偿来代替回滚的思路,还可以应用在其他事务方案上,这些方案笔者就不再单独展开,放到这里一起来解释。举个具体例子,阿里的GTS(Global Transaction Service,Seata由GTS开源而来)所提出的“AT事务模式”就是这样的应用。

从整体上看,AT事务是参照了XA两段提交协议实现的,但对于XA 2PC的缺陷,即在准备阶段必须等待所有数据源都返回成功后,协调者才能统一发出Commit命令而导致的木桶效应(所有涉及的锁和资源都需要等待到最慢的事务完成后才能统一释放),AT事务设计了针对性的解决方案。大致的做法是在业务数据提交时自动拦截所有SQL,将SQL对数据修改前、修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,相当于自动记录了重做和回滚日志。如果分布式事务成功提交,那后续清理每个数据源中对应的日志数据即可;如果分布式事务需要回滚,就根据日志数据自动产生用于补偿的“逆向SQL”。基于这种补偿方式,分布式事务中涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。这种异步提交的模式,相比2PC极大地提升了系统的吞吐量水平,而代价就是大幅度牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚并不是总能成功的。譬如,在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),这时候一旦分布式事务需要回滚,就不可能再通过自动的逆向SQL来实现补偿,只能由人工介入处理了。

通常来说,脏写是一定要避免的,所有传统关系数据库在最低的隔离级别上都仍然要加锁以避免脏写,因为脏写情况一旦发生,其实也很难通过人工进行有效处理。所以GTS增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,没有获得全局锁之前就必须一直等待。这种设计以牺牲一定性能为代价,避免了两个分布式事务中包含的本地事务修改同一个数据的情况,从而避免脏写。在读隔离方面,AT事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能产生脏读(Dirty Read)。也可以采用全局锁的方案解决读隔离问题,但直接阻塞读取的话,代价就非常大了,一般不会这样做。由此可见,分布式事务中没有一揽子包治百病的解决办法,因地制宜地选用合适的事务处理方案才是唯一有效的做法。