[整理] 微服务分布式事务的 4 种解决方案
概念
分布式事务
分布式事务是指事务的参与者、支持事务的服务器、资源服务器分别位于分布式系统的不同节点之上,通常一个分布式事物中会涉及到对多个数据源或业务系统的操作。
典型的分布式事务场景:跨银行转操作就涉及调用两个异地银行服务
CAP理论
一个分布式系统不可能同时满足一致性,可用性和分区容错性这个三个基本需求,最多只能同时满足其中两项
-
一致性(C):
写操作之后的读操作,必须返回该值。意味着,数据在多个副本之间是否能够保持一致的特性。 -
可用性(A):
是指系统提供的服务必须一致处于可用状态,对于每一个用户的请求总是在有限的时间内返回结果,超过时间就认为系统是不可用的 -
分区容错性(P):
分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务,除非整个网络环境都发生故障。
CAP定理的应用
-
放弃P(CA):
如果希望能够避免系统出现分区容错性问题,一种较为简单的做法就是将所有的数据(或者是与事物先相关的数据)都放在一个分布式节点上,这样虽然无法保证100%系统不会出错,但至少不会碰到由于网络分区带来的负面影响。 -
放弃A(CP):
其做法是一旦系统遇到网络分区或其他故障时,那受到影响的服务需要等待一定的时间,应用等待期间系统无法对外提供正常的服务,即不可用 -
放弃C(AP):
这里说的放弃一致性,并不是完全不需要数据一致性,是指放弃数据的强一致性,保留数据的最终一致性。
BASE理论
全称:Basically Available(基本可用),Soft state(软状态),和 Eventually consistent([ɪ'ventʃuəli]最终一致性)三个短语的缩写。
BASE是对CAP中一致性和可用性权限的结果,是基于CAP定理演化而来的,核心思想是即使无法做到强一致性,但每个应用都可以根据自身的业务特定,采用适当的方式来使系统达到最终一致性
-
什么是基本可用呢?
假设系统,出现了不可预知的故障,但还是能用,相比较正常的系统而言:-
响应时间上的损失:
正常情况下的搜索引擎 0.5 秒即返回给用户结果,而基本可用的搜索引擎可以在 1 秒作用返回结果。 -
功能上的损失:
在一个电商网站上,正常情况下,用户可以顺利完成每一笔订单,但是到了大促期间,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。
-
-
什么是软状态呢?
相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种 “硬状态”。
软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。 -
最终一致性
系统能够保证在没有其他新的更新操作的情况下,数据最终一定能够达到一致的状态(必须有个时间期限),因此所有客户端对系统的数据访问最终都能够获取到最新的值。
最终一致性分为 5 种
-
因果一致性(Causal consistency)
指的是:如果节点 A 在更新完某个数据后通知了节点 B,那么节点 B 之后对该数据的访问和修改都是基于 A 更新后的值。于此同时,和节点 A 无因果关系的节点 C 的数据访问则没有这样的限制。 -
读己之所写(Read your writes)
这种就很简单了,节点 A 更新一个数据后,它自身总是能访问到自身更新过的最新值,而不会看到旧值。其实也算一种因果一致性。 -
会话一致性(Session consistency)
会话一致性将对系统数据的访问过程框定在了一个会话当中:系统能保证在同一个有效的会话中实现 “读己之所写” 的一致性,也就是说,执行更新操作之后,客户端能够在同一个会话中始终读取到该数据项的最新值。 -
单调读一致性(Monotonic read consistency)
单调读一致性是指如果一个节点从系统中读取出一个数据项的某个值后,那么系统对于该节点后续的任何数据访问都不应该返回更旧的值。 -
单调写一致性(Monotonic write consistency)
指一个系统要能够保证来自同一个节点的写操作被顺序的执行。
然而,在实际的实践中,这 5 种系统往往会结合使用,以构建一个具有最终一致性的分布式系统。
实际上,不只是分布式系统使用最终一致性,关系型数据库在某个功能上,也是使用最终一致性的,比如备份,数据库的复制过程是需要时间的,这个复制过程中,业务读取到的值就是旧的。当然,最终还是达成了数据一致性。这也算是一个最终一致性的经典案例。
总的来说,BASE 理论面向的是大型高可用可扩展的分布式系统,和传统事务的 ACID 是相反的,它完全不同于 ACID 的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间是不一致的。
2PC提交
二阶段提交协议是将事务的提交过程分成提交事务请求
和执行事务提交
两个阶段进行处理。
基本逻辑就是协调者给参与者发送请求,各个参与者将操作的结果反馈给协调者,协调者统一安排是提交还是终止事务。
阶段一:提交事务请求(投票阶段)
-
事务询问:协调者向所有的参与者发送事务内容,询问是否可以执行事务提交操作,并开始等待各参与者的响应
-
执行事务:各参与者节点执行事务操作,并将Undo和Redo信息记入事务日志中
-
如果参与者成功执行事务操作,就反馈给协调者Yes响应,表示事务可以执行;如果没有成功执行事务,就反馈给协调者No响应,表示事务不可以执行
二阶段提交协议的阶段一也被称为投票阶段,即各参与者投票票表明是否可以继续执行接下去的事务提交操作。
阶段二:执行事务提交(提交阶段)
- 正常提交
- 假如协调者从所有的参与者或得反馈都是Yes响应,那么就会执行事务提交。
- 发送提交请求:协调者向所有参与者节点发出Commit请求
- 事务提交:参与者接受到Commit请求后,会正式执行事务提交操作,并在完成提交之后放弃整个事务执行期间占用的事务资源
- 反馈事务提交结果:参与者在完成事物提交之后,向协调者发送ACK消息
- 完成事务:协调者接收到所有参与者反馈的ACK消息后,完成事务
- 中断事务
- 假如任何一个参与者向协调者反馈了No响应,或者在等待超市之后,协调者尚无法接收到所有参与者的反馈响应,那么就中断事务。
- 发送回滚请求:协调者向所有参与者节点发出Rollback请求
- 事务回滚:参与者接收到Rollback请求后,会利用其在阶段一种记录的Undo信息执行事物回滚操作,并在完成回滚之后释放事务执行期间占用的资源。
- 反馈事务回滚结果:参与则在完成事务回滚之后,向协调者发送ACK消息
- 中断事务:协调者接收到所有参与者反馈的ACk消息后,完成事务中断;
- 优缺点
优点:尽量保证了数据的强一致性,但也无法完全保证
缺点:
-
同步阻塞问题
每一个参与者都是事务阻塞型的 -
单点故障
由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题) -
数据不一致
在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。 -
二阶段无法解决的问题
协调者再发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。 -
太过保守
任意一个节点失败就会导致整个事务失败,没有完善的容错机制。
Seata分布式事务方案
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
Seata术语
TC:事务协调者。维护全局和分支事务的状态,驱动全局事务提交或回滚。
TM:事务管理器。定义全局事务的范围:开始全局事务、提交或回滚全局事务
RM:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
Seata的2PC方案
一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
二阶段:提交异步化,非常快速地完成。回滚通过一阶段的回滚日志进行反向补偿。
一阶段本地事务提交前,需要确保先拿到全局锁
。拿不到全局锁,不能提交本地事务。
拿全局锁的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
在数据库本地事务隔离级别读已提交或以上的基础上,Seata(AT 模式)的默认全局隔离级别是读未提交
如果应用在特定场景下,必需要求全局的读已提交
,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
Seata执行流程分析
-
每个RM使用DataSourceProxy链接数据路,目的是使用ConnectionProxy,使用数据源和数据代理的目的是在第一阶段将undo_log和业务数据放在一个本地事务提交,这样就保存了只要有业务操作就一定有undo_log
-
在第一阶段undo_log中存放了数据修改前后修改后的值,为事务回滚做好准别,所以第一阶段完成就已经将分支事务提交了,也就释放了锁资源
-
TM开启全局事务开始,将XID全局事务ID放在事务上下文中,通过feign调用也将XID传入下游分支事务,每个分支事务将自己的Branch ID 分支事务ID与XID关联
-
第二阶段全局事务提交,TC会通知各分支参与者提交分支事务,在第一阶段就已经提交了分支事务,这里各参与者只需要删除undo_log即可,并且可以异步执行,第二阶段很快可以完成
-
如果某一个分支事务异常,第二阶段就全局事务回滚操作,TC会通知各分支参与者回滚分支事务,通过XID和Branch-ID找到对应的回滚日志,通过回滚日志生成的反向SQL并执行,以完成分支事务回滚到之前
Seata的实战案列
https://github.com/seata/seata-samples
3PC提交
三阶段提,也叫三阶段提交协议,是二阶段提交(2PC)的改进版本。
与两阶段提交不同的是,三阶段提交有两个改动点。
-
引入超时机制
同时在协调者和参与者中都引入超时机制。 -
在第一阶段和第二阶段中插入一个准备阶段。
保证了在最后提交阶段之前各参与节点的状态是一致的。
三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。
第一阶段:can_commit
该阶段协调者会去询问各个参与者是否能够正常执行事务,参与者根据自身情况回复一个预估值,相对于真正的执行事务,这个过程是轻量的,具体步骤如下:
- 协调者向各个参与者发送事务询问通知,询问是否可以执行事务操作,并等待回复
- 各个参与者依据自身状况回复一个预估值,如果预估自己能够正常执行事务就返回确定信息,并进入预备状态,否则返回否定信息
第二阶段:pre_commit
本阶段协调者会根据第一阶段的询盘结果采取相应操作,询盘结果主要有三种:
- 所有的参与者都返回确定信息
- 一个或多个参与者返回否定信息
- 协调者等待超时
针对第一种情况,协调者会向所有参与者发送事务执行请求,具体步骤如下:
- 协调者向所有的事务参与者发送事务执行通知
- 参与者收到通知后,执行事务,但不提交
- 参与者将事务执行情况返回给客户端
在上面的步骤中,如果参与者等待超时,则会中断事务。 针对第二、三种情况,协调者认为事务无法正常执行,于是向各个参与者发出abort通知,请求退出预备状态,具体步骤如下:
- 协调者向所有事务参与者发送abort通知
- 参与者收到通知后,中断事务
第三阶段:do_commit
如果第二阶段事务未中断,那么本阶段协调者将会依据事务执行返回的结果来决定提交或回滚事务,分为三种情况:
- 所有的参与者都能正常执行事务
- 一个或多个参与者执行事务失败
- 协调者等待超时
针对第一种情况,协调者向各个参与者发起事务提交请求,具体步骤如下:
- 协调者向所有参与者发送事务commit通知
- 所有参与者在收到通知之后执行commit操作,并释放占有的资源
- 参与者向协调者反馈事务提交结果
针对第二、三种情况,协调者认为事务无法正常执行,于是向各个参与者发送事务回滚请求,具体步骤如下:
- 协调者向所有参与者发送事务rollback通知
- 所有参与者在收到通知之后执行rollback操作,并释放占有的资源
- 参与者向协调者反馈事务提交结果
在本阶段如果因为协调者或网络问题,导致参与者迟迟不能收到来自协调者的commit或rollback请求,那么参与者将不会如两阶段提交中那样陷入阻塞,而是等待超时后继续commit。相对于两阶段提交虽然降低了同步阻塞,但仍然无法避免数据的不一致性。
在分布式数据库中,如果期望达到数据的强一致性,那么服务基本没有可用性可言,这也是为什么许多分布式数据库提供了跨库事务,但也只是个摆设的原因,在实际应用中我们更多追求的是数据的弱一致性或最终一致性,为了强一致性而丢弃可用性是不可取的。
TCC分布式事务
TCC是服务化的两阶段编程模型,要求每个分支事务实现三个方法操作:预处理Try,确认Confirm,撤销Cancel。3个方法操作均由业务编码实现。
- Try操作做业务检查及资源预留,
- Confirm做业务确认操作,
- Cancel实现一个与Try相反的操作即回滚操作。
TM首先发起所有的分支事务Try操作,任何一个分支事务的Try操作执行失败,TM将会发起所有分支事务的Cancel操作,若Try操作全部成功,TM将会发起所有分支事务的Confirm操作,其中Confirm/Cancel操作若执行失败,TM会进行重试。
TCC的三个阶段
-
Try阶段
是做业务检查(一致性)及资源预留(隔离),此阶段仅是一个初步操作,它和后续的Confirm一起才能构成一个完整的业务逻辑 -
Confirm阶段是做确认提交
Try阶段所有分支事务执行成功后开始执行Confirm,通常情况下,采用TCC则认为Confirm阶段是不会出错的,即:只要Try成功,Confirm一定成功,若Confirm阶段真的出错,需要引入重试机制或人工处理 -
Cancel阶段是在业务执行错误需要回滚到状态下,执行分支事务的取消,预留资源的释放,通常情况下,采用TCC则认为Cancel阶段也一定是真功的,若Cance阶段真的出错,需要引入重试机制或人工处理
TM事务管理器
可以实现为独立的服务,也可以让全局事务发起方充当TM的角色,TM独立出来是为了公用组件,是为了考虑系统结构和软件的复用
TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务调用链条,用来记录事务上下文,追踪和记录状态,用于Confirm和cacel失败需要进行重试,因此需要实现 幂等
TCC的三种异常处理情况
-
幂等处理
- 因为网络抖动等原因,分布式事务框架可能会重复调用同一个分布式事务中的一个分支事务的二阶段接口。所以分支事务的二阶段接口Confirm/Cancel需要能够保证幂等性。如果二阶段接口不能保证幂等性,则会产生严重的问题,造成资源的重复使用或者重复释放,进而导致业务故障。
- 对于幂等类型的问题,通常的手段是引入幂等字段进行防重放攻击。对于分布式事务框架中的幂等问题,同样可以祭出这一利器。
- 幂等记录的插入时机是参与者的Try方法,此时的分支事务状态会被初始化为INIT。然后当二阶段的Confirm/Cancel执行时会将其状态置为CONFIRMED/ROLLBACKED。
- 当TC重复调用二阶段接口时,参与者会先获取事务状态控制表的对应记录查看其事务状态。如果状态已经为CONFIRMED/ROLLBACKED,那么表示参与者已经处理完其分内之事,不需要再次执行,可以直接返回幂等成功的结果给TC,帮助其推进分布式事务。
-
空回滚
- 当没有调用参与方Try方法的情况下,就调用了二阶段的Cancel方法,Cancel方法需要有办法识别出此时Try有没有执行。如果Try还没执行,表示这个Cancel操作是无效的,即本次Cancel属于空回滚;如果Try已经执行,那么执行的是正常的回滚逻辑。
- 要应对空回滚的问题,就需要让参与者在二阶段的Cancel方法中有办法识别到一阶段的Try是否已经执行。很显然,可以继续利用事务状态控制表来实现这个功能。
- 当Try方法被成功执行后,会插入一条记录,标识该分支事务处于INIT状态。所以后续当二阶段的Cancel方法被调用时,可以通过查询控制表的对应记录进行判断。如果记录存在且状态为INIT,就表示一阶段已成功执行,可以正常执行回滚操作,释放预留的资源;如果记录不存在则表示一阶段未执行,本次为空回滚,不释放任何资源。
-
资源悬挂
- 问题:TC回滚事务调用二阶段完成空回滚后,一阶段执行成功
- 解决:事务状态控制记录作为控制手段,二阶段发现无记录时插入记录,一阶段执行时检查记录是否存在
TCC和2PC比较
- 2PC通常都是在跨库的DB层面,而TCC则在应用层面处理,需要通过业务逻辑实现,这种分布式事务的实现方式优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突,提高吞吐量成为可能;
- 而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现Try,confirm,cancel三个操作。此外,其实现难度也比较大,需要按照网络状态,系统故障的不同失败原因实现不同的回滚策略
Hmily框架实现TCC案例
# 账户A
try:
try的幂等效验
try的悬挂处理
检查余额是否够30元
扣减30元
confirm:
空处理即可,通常TCC阶段是认为confirm是不会出错的
cancel:
cancel幂等效验
cacel空回滚处理
增加可用余额30元,回滚操作
# 账户B
try:
空处理即可
confirm:
confirm的幂等效验
正式增加30元
cancel:
空处理即可
可靠消息最终一致性
可靠消息最终一致性就是保证消息从生产方经过消息中间件传递到消费方的一致性。
有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。
RocketMQ主要解决了两个功能:
- 本地事务与消息发送的原子性问题。
- 事务参与方接收消息的可靠性。
可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景,引入消息机制后,同步的事务操作变为基于消息执行的异步操作,避免分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。
RocketMQ 思路大致为:
第一阶段Prepared消息,会拿到消息的地址。
第二阶段执行本地事务。
第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。
也就是说在业务方法内要想消息队列提交两次请求,一次发送消息和一次确认消息。
如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。
优点: 实现了最终一致性,不需要依赖本地数据库事务。
缺点: 实现难度大,主流MQ不支持,RocketMQ事务消息部分代码也未开源,出现坑可能无法处理。
最大努力通知
最大努力通知
与 可靠消息一致性
有什么不同?
-
可靠消息一致性
发起通知方需要保证将消息发出去,并且将消息发送到接收通知方,消息的可靠性由发起通知方保证 -
最大努力通知
发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是消息可能接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务,通知可靠性关键在于接收通知方。 -
两者的应用场景
- 可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易
- 最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去
-
基于MQ的ack机制实现最大努力通知
- 利用MQ的ack机制由MQ向接收通知方发送消息通知,发起方将普通消息发送到MQ
- 接收通知监听MQ,接收消息,业务处理完成回应ACK
- 接收通知方如果没有回应ACK则MQ会重复通知,按照时间间隔的方式,逐步拉大通知间隔
- 此方案适用于内部微服务之间的通知,不适应与通知外部平台
分布式事务方案对比分析
-
2PC
最大的一个诟病是一个阻塞协议。RM在执行分支事务后需要等待TM的决定,此时服务会阻塞锁定资源。由于其阻塞机制和最差时间复杂度高,因此,这种设计不能适应随着事务涉及的服务数量增加而扩展的需要,很难用于并发较高以及子事务生命周期较长的分布式服务中 -
TCC
如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面,而TCC则在应用层面处理,需要通过业务逻辑来实现。这种分布式事务的优势在于,可以让应用自定义数据操作的粒度,使得降低锁冲突,提高吞吐量成为可能。而不足之处在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现三个操作。此外,其实现难度也比较大,需要按照网络状态,系统故障等不同失败原因实现不同的策略。 -
可靠消息最终一致性事务
适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消息执行的异步操作,避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦,典型的场景:注册送积分,登陆送优惠券等 -
最大努力通知
是分布式事务中要求最低的一种,适用于一些最终一致性时间敏感度低的业务,允许发起通知方业务处理失败,在接收通知方收到通知后积极进行失败处理,无论发起通知方如何处理结果都不会影响到接收通知方的后续处理,发起通知方需提供查询执行情况接口,用于接收通知方校对结果,典型的应用场景:银行通知,支付结果通知等。
2PC | TCC | 可靠消息 | 最大努力通知 | |
---|---|---|---|---|
一致性 | 强一致性 | 最终一致 | 最终一致 | 最终一致 |
吞吐量 | 低 | 中 | 高 | 高 |
实现复杂度 | 容易 | 难 | 中 | 容易 |