分布式事务的概念及实现方案
基本概念
事务
由多个计算任务构成的一组具有明确边界的工作集合。事务当中可能包括接口访问、网络通信、数据获取和处理。严格的事务实现应该具备具有原子性、一致性、隔离性、持久性四个特性。
原子性(Atomicity):一个事务中的任务要么全部完成,要么全部失败。没有中间状态。
一致性(Consistency):事务涉及的资源或者数据在事务前后遵循某种约束,事务的完成或失败不会影响此状态。
隔离性(Isolation):不同事务之间的操作互不影响,并发的事务其中间状态对其他事务不可见。
持久性(Durability):事务一旦完成,则状态永久有效。
分布式事务
在分布式系统中,事务的访问涉及的资源、参与计算的节点都部署在不同的节点上,这种情况下涉及到的事务称为分布式事务。
从系统整体的架构角度看,分布式事务涉及的场景可以分为两类。第一类是,事务本身只涉及单个应用,但是涉及多个数据存储,一笔交易需要访问多个数据存储才能最终完成。第二类,是事务本身涉及多个应用,同时每个应用可能连接着一个或者多个数据存储,一笔交易需要协同多个独立的应用访问多个数据存储最终才能完成。
一致性
严格地讲,一致性并不是事务本身的特性。可以看到,一致性所讨论的所谓“约束”是随着业务场景变化而变化的。
一致性的保证,除了数据库层面需要有相应的机制以外,应用层面首先需要进行相应的考虑。例如,对于典型的两个账户转账的例子,应用需要保证在同一个事物里面分别对转出账户和转入账户发起了减少和增加金额的操作,如果任何一个缺失,即使使用了事务,从业务角度看也是违反了一致性约束的。
在应用层面确保了业务的正确性之后,再从数据库层面进行审视。还是转账的例子,假设某一个数据库的事务支持出现了问题,在一个事务当中发生某种故障之后,发现转出账户上钱已经相应减少,而转入账户上钱并没有增加,那么很明显这里违反了业务的一致性约束。但仔细辨析后会发现,从事务本身考虑,这个场景实际上首先是违反了事务的原子性的,即应该同时完成的任务只完成了一半。所以,这个场景下面,一致性的体现实际上最终是由原子性保证的。再比如,考虑同一笔账户上同时发生的两笔转账交易,如果A事务扣减100元,B事务扣减50元,如果两个事务都提交之后,发现账户实际扣减了50元而不是150元,那么很明显也违反了业务上的一致性约束。这种情况,实际上是由事务的隔离性保证的。
所以,总的来说,一致性更多的是数据存储之上的业务约束保证。其描述的特性,在事务角度是分别有原子性和隔离性两种特性在不同场景下分别予以保证的。
强一致性与最终一致性
在讨论事务的一致性,尤其是分布式事务的一致性的时候,涉及到了分布式系统和事务这两个问题范畴,而这两个问题范畴中对于一致性都各自有定义并且比较难以理解。如果进行仔细的研究分辨就会发现,事务范畴内讨论的一致性问题和分布式系统范畴内所讨论的一致性问题并非同一个问题,他们的问题提出场景和相应的解决模型都不相同。但不幸的是,这两个问题经常被放在一起讨论,并且会进行类比,造成了一定的困惑。
由于两个问题本身的复杂性和现有常见讨论造成的信息混杂,这里不对一致性进行深入的讨论。
针对分布式事务涉及到的一致性问题做如下的假设:
分布式事务和分布式系统涉及的共识问题是两个问题。
分布式事务所讨论的一致性按照普遍的名称,称为为强一致性和最终一致性
将系统作为整体看待,分布式事务所述强一致性与事务的一致性讨论相同的问题,即在事务执行前后系统满足某种业务约束。
最终一致性是指,在分布式事务提交后,业务一致性约束可能不会得到立即满足,而是会在未来的某个时间点得到满足,并且一定会发生。
典型实现
强一致性实现
Two/Three Phase Commit
两阶段提交协议,简写2PC,是解决分布式系统一致性问题的算法。分布式事务的一些解决方案采用了该协议。
在两阶段提交协议中,有两种角色:
协调者 coordinator:在系统中通常只有一个,负责协调决策
参与者 cohorts/participants:在系统中有多个,具备实现本地事务的能力
两阶段协议的流程可以描述如下:
阶段一,投票阶段
协调者向所有参与者发起询问,是否可以提交事务
每个参与者为参与事务做准备工作,如锁定资源、预先分配资源等,将结果记录日志
参与者将事务准备阶段的处理结果返回协调者,如果成功,则回应可以提交
阶段二,提交阶段
协调者根据参与者返回的结果进行决策,如果全部返回成功则提交事务,如果有任何一个参与者返回失败,则回滚事务。
参与者收到协调者的通知后执行相应的操作,完成事务的最终提交或者回滚。
2PC做出如下假设:
任意节点间都可以互相通信。
所有的参与者都保证本地事务能够成功提交或者回滚,即参与者本身具备本地事务的管理能力。
参与者在第一阶段返回结果前,都会将结果写入持久化日志,这样,即使在这时候节点发生故障,投票的结果也不会丢失。
所有的节点都不会发生永久性的故障,即节点可以从故障中恢复过来。
从上述2PC的描述中可以看到,协议本身存在一些缺陷:
由于在第一阶段需要预先分配或者锁定资源,导致在后续整个事务完成前资源都被占用,从而导致整体并发性能较低
网络超时问题。在两个阶段中,都有可能发生网络超时。第一阶段,如果参与者返回结果超时,则协调者可以认为其失败,回滚事务;第二阶段,如果参与者返回最终提交/回滚的结果超时,则可以进行重试操作;或者协调者可以将其移出集群,这样最终数据仍旧是一致的;比较麻烦的是,在第二阶段,如果协调者不能将最终的决策结果发送给参与者,这样参与者则不知道如何进行下一步动作(决策信息不足),这样陷入了无法决策的尴尬场景。
为了解决两阶段提交中存在的上述问题,提出了三阶段提交协议,其三个阶段分别为:
投票阶段:协调者向参与者询问是否可以执行提交,参与者返回结果。
预提交阶段:如果所有参与者都返回成功,则协调者向所有参与者发出预提交的指令,参与者不进行真正的提交,但是锁定响应的资源,并返回预提交的结果。
提交阶段:如果所有参与者在预提交阶段都返回成功,则协调者发出最后的的提交指令,参与者执行最终的提交动作。
与两阶段提交不同的是,在投票阶段,参与者不会锁定资源,这样就避免了由于该阶段资源锁定导致的性能下降。在最终的提交阶段,如果出现超时问题,由于参与者在预提交阶段已经收到了成功指令,可以认为其他所有参与者也同意了这笔提交,则可以直接将状态修改为成功。
DTP与XA
Distributed Transaction Processing(DTP) 是 X/Open开源标准制定组织制定的分布式事务模型,该模型规定了由应用程序(Application Program, AP),多个资源管理节点(Resource Managers, RMs),以及事务管理节点(Transaction Manager, TM)构成的逻辑架构下的分布式事务模型(在此基础上进行适当地扩展,分布式系统可以由TM Domain构成,每个TM Domain由一组独立的AP, RMs, TM构成;各个TM Domain之间可以构成一个全局事务,每个TM Domain包含一个分支事务)。其中AP发起事务,由TM协调各个RM完成分布式事务,每个RM具有本地事务的独立管理能力。下图是DTP模型的示意图。
DTP模型
DTP的事务提交协议遵循两阶段提交协议2PC(实际为:two phase commit with presumed rollback)。XA协议详细规定了TM和RM之间双向交互的接口规范。
最终一致性实现
针对子事务进行补偿
一阶段提交+补偿
在Sharding-JDBC中提到了一种结合一阶段提交和补偿模式的方案(Sharding-JDBC将其称为最大努力送达型,为了与后续采用消息队列的模式区分,这里不采用该名称),该方案示意图如下:
此模式下,无需引入事务管理器,事务的参与方之间各自提交本地事务,互不影响。由框架在事务之前记录事务各个参与方涉及的sql语句,将其写入与业务独立的事务库,并且监听各个独立事务的执行结果,如果某一个发生失败,则进行一定次数的重试,如果仍旧失败,则由独立的异步送达服务后续读取并再进行重试执行。
很明显,这种模式下有如下假设:事务经过一定次数的重试后最终一定能够成功。另外,这种模式一个明显的薄弱环节是在整个过程中引入了事务库,如果在事务的执行过程中事务库本身发生了故障,那么重试机制将无从保障。
基于消息:可靠消息/最大努力送达
基于最终一致性的思想,一些分布式事务的解决方案通过引进消息服务来进行完成。相关的实现方案版本较多,这里根据补偿职责归属于消息生产者或者消费者进行划分,分别将其称为可靠消息和最大努力送达两种实现模式。
可靠消息
可靠消息模式下,事务的补偿由消息的生产者负责,消息的生产者也是业务的主动方。其模式示意如下:
其步骤概述为:
业务的主动方在同一个本地事务中,提交业务数据和消息数据。
本地事务提交后,业务处理服务通过实时消息服务通知业务的被动方,通知成功后由实时消息服务删除消息数据。
如果通知失败,则由消息回复系统扫描通知失败的消息,通过消息服务重试通知。
业务被动方接收到消息通知后,进行本地事务处理并提交。
可以看到,这种模式有一些前提假设:
假定实时消息服务和消息恢复系统不会发生不可恢复的故障
业务主动方的业务流程,不依赖于业务被动方
被动方应该保证业务的幂等性
这种模式下,由于事务补偿职责由业务主动方负责,业务的被动方实施成本低。但是,对于业务的主动方来说,在主要业务流程之外需要维护事务补偿机制,与主要业务产生了耦合。如下可靠消息实现方案,消除了这种耦合:
其步骤概述为:
业务主动方在本地事务提交前,向实施消息服务请求发送消息,实时消息服务记录待发送消息。
请求成功后,业务主动方提交本地事务,并且在成功后向实时消息服务确认发送消息。如果本地事务提交失败,则取消发送消息。
实时消息服务根据确认或取消发送的指令,选择是否发送消息。后续步骤同上述可靠消息。
如果业务主动方未向实时消息服务发送确认或者取消发送指令,则由消息状态确认系统主动发起查询,向业务处理服务查询消息发送状态。
相比于第一种实现方案,上述实现额外引入如下成本:
一次事务中,业务主动方需要与实时消息服务进行两次交互。
业务主动方需要实现消息状态确认接口
最大努力送达
不同于可靠消息模式,最大努力送达模式下,由业务主动方进行一定次数的尝试(最大努力),最终一致性保证的职责由业务被动方负责。这种模式下,业务被动方的改造成本更高。
与可靠消息类似,这种模式也要求主动方业务不依赖于被动方业务,但需要被动方实现定期校对系统,定期向主动方发起查询,获取一段时间内丢失的消息,进行事务的补偿。
可以看到,不管是一阶段提交+补偿的方案或者是基于消息的方案,都存在一个问题,如果补偿机制失效(实际工程中不可能保证100%的可用性),那么将会出现业务的不一致,这个时候势必会造成对业务的影响。针对这种情况,需要考虑在系统中引入持久化记录或者日志功能记录所有未完成事务的状态,并且在必要时候需要进行人工干预。
补偿事务模式
最终一致性实现面临的一个问题是如果事务由于某种原因失败,如何恢复资源的问题。由于为了提供系统并发而采用最终一致性,所以在通常的实现中并不会对资源加锁,这就导致在并发的场景下,如果要恢复资源,不能简单执行回滚,因为很可能资源已经被其他访问修改。在另外的一些场景下面,甚至资源不能进行完全对等的反向操作,例如对于某些紧缺资源,用户在购买后取消订阅同样需要付费。
在最终一致性前提下实现分布式式事务,面临的一个问题是如果事务由于某种原因失败,如何恢复资源的问题。由于为了提供系统并发而采用最终一致性,所以在通常的实现中并不会对资源加锁,这就导致在并发的场景下,如果要恢复资源,不能简单执行回滚,因为很可能资源已经被其他访问修改。在另外的一些场景下面,甚至资源不能进行完全对等的反向操作,例如对于某些紧缺资源,用户在购买后取消订阅同样需要付费。
为了解决上述问题,需要引入补偿事务模式。即需要单独定义一个操作,在事务出现可能的失败之后,通过执行这个补偿操作来完成相应的恢复。
需要注意的是,补偿操作同样可能失败,因此为了进行重试操作,所定义的补偿动作需要满足幂等性。最后,经过重试,补偿可能由于比较严重的故障仍旧不能成功,因此如果需要提供较高的可靠性,系统可能需要引入日志记录或者其他功能对失败的补偿进行记录,后续在故障恢复后进行人工干预或者执行其他操作。
TCC
TCC即Try Confirm/Cancel的简称,是一种分布式事务的实现模式,由支付宝公司在2008年提出,并且得到较大范围的推广使用。下图是TCC模式的具体描述:
类似于两阶段提交协议,TCC将事务的执行划分为两个阶段,如果第一阶段Try所有事务的参与者都回复可以执行,则执行Confirm操作进行提交;如果第一阶段Try操作有任意失败返回,则执行Cancel操作取消事务。两个阶段所执行的操作定义如下:
第一阶段,Try操作(尝试执行业务):
完成所有业务检查(一致性)
预留必须的业务资源
第二阶段,Confirm操作(确认执行业务):
真正执行业务
不作任何业务检查
只使用Try阶段预留的业务资源
Confirm操作需要满足幂等性
第二阶段,Cancel操作(取消执行业务):
释放Try阶段预留的业务资源
Cancel操作需要满足幂等性
在上面的描述中,“预留必须的业务资源”和“真正执行业务”这两个描述都是比较笼统的。从可以找到的资料来看,这里的描述对应的实际实现是根据实际业务场景而变化的。
考虑一个用户进行某项业务,然后赠与积分的场景,这里假设积分是一个单独服务。从业务的角度讲,对于向客户赠予积分,所谓预留必须的资源来保证后续的Confirm操作能够成功,并不需要在数据库层面做什么操作。一种实现是记录一下需要增加相应的积分即可,例如在表中增加一个字段对要增加的积分值进行记录。甚至,可以什么都不做,在最终的Confirm阶段再更新或者插入数据即可。
再考虑一个在一个分布式的应用中进行转账的场景。那么在Try阶段,对于转出账户来说,预留必须的业务资源意味着需要尽可能保证后续的Confirm操作能够成功,如果什么都不做或者仅仅记录需要扣减的金额,考虑并发的场景,则很可能在后续的Confirm操作中由于业务规则限制而不能操作成功。因此,需要在转出账户上直接扣减相应的金额,在后续的Confirm阶段则无需进行任何实际操作;而如果由于某些异常导致需要取消事务,在Cancel阶段只需要增加响应的金额即可。
不同于2PC事务的提交与回滚由TM直接协调RM完成的方式,TCC模式从应用层面的操作出发定义分布式事务的两个阶段,而且不同的阶段操作需要从实际业务出发实际确定,基本无法使用统一的实现。因此,如果采用TCC模式,需要在应用层面做比较多的工作,如果是项目改造,则需要在应用层面进行,这带来了比较高的开发成本。
可见,TCC模式是一个满足最终一致性的分布式事务实现模式,综合借鉴了补偿事务模式和两阶段提交思路。其优点是在第一阶段不锁定资源,这样为高并发提供了保证;另外,由于两个阶段的操作在应用层面进行实现,所以提供较高的业务灵活性。相应的,不满足强一致性、开发成本较高是其缺点。
Saga
Saga一词最早于八十年代晚期由普林斯顿大学针对长事务(Long Lived Transactions)提出,本身实际就是表示一组由多个子事务构成的全局事务,这些子事务之间没有强依赖关系。
Saga通过协调每个子事务Ti的调用完成整个事务,通过定义每个子事务Ti对应的补偿事务Ci来完成回滚操作。假设一个全局事务应该由T1...Tn构成,当执行至第Tm(m<=n)个事务时发生了异常,则Saga协调所有补偿事务完成整体回滚操作,从整体上看,执行顺序可能是T1...Tm,Cm...C1(这里如果没有强依赖关系,Cm到C1的取消无需严格按照T1至Tm的调用顺序进行反向操作)。
Saga的可以分为两种实现方式:协调模式和编排模式。这两种模式的实现中都需要引入稳定、可靠的消息服务,整体事务的可靠性依赖于消息服务的可用性等级。
协调模式下,各个事务参与方向消息服务发布自己的子事务成功或者失败的消息,其他事务参与方对自己感兴趣的事务进行订阅,由消息驱动整个事务的最终完成或者回滚。
编排模式引入编排器来完成整个事务的调度,编排器在事务的各个阶段发布消息,由事务参与方消费消息并向编排器返回执行结果,编排器根据结果选择进行下一阶段调度或者回滚事务。
隔离性缺失及其对策
所有最终一致性的模式下,由于并发的全局事务间并不具备隔离性,即在完成全局事务的最终提交前,其他事务可以看到彼此的中间状态(虽然对于本地子事务来说,是已经提交的状态)。
由于不满足隔离性,带来了如下问题:
更新覆盖:一个事务覆盖了另一个事务的更新导致业务异常。
例如,一个下单操作,需要先生成订单,再检查库存,最后完成订单创建;假设用户点击下单后立刻返回并取消了订单,那么由于时间的不确定性,执行顺序可能看起来是:
生成订单,状态为CREATING
调用库存服务,检查库存是否足够
取消订单,状态修改为CANCELED
库存检查结果通过,修改状态为CREATED
用户的意愿是取消订单,但最终订单是成功创建的状态。
脏读:一个事务读取到了另一个事务未提交的状态。
例如,对某个客户的账户,并发一个退款业务和一个支取业务,执行顺序可能如下排列:
退款交易,增加客户账户额度
调用订单服务,修改订单状态
支取交易,检查额度满足支取限制,成功返回
订单状态修改失败,取消增加客户账户额度。
最终退款交易并没有发生,但是成功完成了一笔额度限制之外的支取,造成了业务风险。
不可重复读和幻读:一个事务在读取过程中,由于另一个事务对数据进行了修改,两次读取到了不一样的值或者读取到了不该看到的数据。这两种情况对于业务本身的危害相对较小。
针对隔离性缺失可能导致的上述问题,可以在设计阶段引入一些对应的办法进行解决。例如,对于更新覆盖,可以通过增加语义锁的方式进行,上述示例中可以直接判断订单状态为CREATING 而不再更新为取消;对于脏读问题,可以调整业务的执行顺序,例如将增加额度放在整个退款交易事务的最后执行。对于幻读问题,可以通过实例化冲突等方式予以解决等等。
综合比较
综合上述讨论,可以看出针对全局事务的解决方案都有各自的特点,没有一个最优的方案,需要结合实际的应用场景选择合适的模式。总的来说,有如下特点:
两阶段提交是强一致性实现,适合对一致性要求比较高的场景。但其问题是并发性能低,在实际的业务场景中,往往要求比较高的并发性能,因此极少采用。
除两阶段提交外的其他所有解决方案,都是最终一致性实现,全局事务不满足隔离性要求,部分实现还存在不满足原子性(子事务补偿失败)的问题。所以在实际应用中需要结合业务实际对这些问题考虑响应的解决办法。
可靠消息、最大努力送达和一阶段提交+补偿几个方案,实现对业务本身的侵入相对较小,通过引入主业务之外的补偿服务可以获取最终一致性。其中一阶段提交+补偿无需引入额外的中间件,实现最简单;可靠消息和最大努力送达主要的改造成本分别位于业务的主动方和被动方,适合交互系统间无法协同改造的场景。
TCC和SAGA方案更贴合实际场景,方案更加完善,但不适合存量应用改造,对业务侵入性大。
扫描二维码推送至手机访问。
本文链接:https://suyu.net/post/28.html
转自:简书,作者:程序员顺仔,链接:https://www.jianshu.com/p/f35ac38a71da