众所周知,事务可以保证一组操作要么全都正确执行,要么全都不执行。目前的数据库只能支持同一个数据库中的事务。但现在的系统往往采用微服务架构,业务系统拥有独立的数据库,因此就出现了跨多个数据库的事务需求,这个时候就需要使用分布式事务啦!
什么是分布式事务?
随着微服务架构的普及,一个大型业务系统往往由若干个子系统构成,这些子系统又拥有各自独立的数据库。往往一个业务流程需要由多个子系统共同完成,而且这些操作可能需要在一个事务中完成。在微服务系统中,这些业务场景是普遍存在的。此时,我们就需要在数据库之上通过某种手段,实现支持跨数据库的事务支持,这就是分布式事务。
最典型的分布式事务例子就是用户下单过程,我们按最简单的来说,肯定会有下面两个步骤:
- 用户下单,订单系统会生成一条订单
- 订单创建成功后,支付系统进行支付
上面两个步骤分别是在订单系统和支付系统中完成。以前我们在单机运行的情况下很容易就解决。但是在现在这个微服务架构中,这两个步骤涉及两个系统,涉及两个数据库,此时我们必须在数据库和应用系统之间,通过某项手段,实现分布式事务,要么全都正确执行,要么全都不执行。
分布式事务协议
两阶段提交协议 2PC
在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
两阶段
准备阶段(投票阶段)
事务协调者(事务管理器)给每个参与者(资源管理器)发送Prepare消息,每个参与者要么直接返回失败(如权限验证失败),要么在本地执行事务,写本地的redo和undo日志,但不提交,到达一种“万事俱备,只欠东风”的状态。
提交阶段(执行阶段)
如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)
缺点
- 同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。
- 单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
- 数据不一致。在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。
- 二阶段无法解决的问题:协调者再发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举
由于二阶段提交存在着诸如同步阻塞、单点问题、脑裂等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。
三阶段提交协议 3PC
与两阶段提交不同的是,三阶段提交有两个改动点:
- 引入超时机制。同时在协调者和参与者中都引入超时机制。
- 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。
三阶段
CanCommit阶段
- 事务询问 协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
- 响应反馈 参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No
PreCommit阶段
两种情况:
假如协调者从所有的参与者获得的反馈都是Yes响应:
- 发送预提交请求 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
- 事务预提交 参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
- 响应反馈 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。
假如有任何一个参与者向协调者发送了No响应,或者等待超时之后:
- 发送中断请求 协调者向所有参与者发送abort请求。
- 中断事务 参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
doCommit阶段
两种情况:
协调者接收到参与者发送的ACK响应:
- 发送提交请求 协调接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
- 事务提交 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
- 响应反馈 事务提交完之后,向协调者发送Ack响应。
- 完成事务 协调者接收到所有参与者的ack响应之后,完成事务。
协调者没有接收到参与者发送的ACK响应,或者响应超时:
- 发送中断请求 协调者向所有参与者发送abort请求
- 事务回滚 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
- 反馈结果 参与者完成事务回滚之后,向协调者发送ACK消息
- 中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
2PC与3PC的区别
相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。 而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
分布式解决方案
前言
当你觉得你的系统中使用分布式事务的时候,你一定要问问自己真的需要吗?很多时候我们并不需要,也许我们只要将两个微服务聚合成一个单机服务,从而可以使用数据库的本地事务。因为你只要使用了分布式事务,必然会增加系统的复杂度。 当然有时候我们是真的需要,下面我们介绍几种常见的方案!
基于XA的分布式事务
基于XA的分布式事务其实就是2PC协议的实现。所以它比较简单,成本较低,但是其单点问题,以及不能支持高并发(由于同步阻塞)依然是其最大的弱点。但是Mysql、Oracle都在使用这种方案。
基于消息的最终一致性方案
这种实现分布式事务的方式需要通过消息中间件来实现。假设有A和B两个系统,分别可以处理任务A和任务B。此时系统A中存在一个业务流程,需要将任务A和任务B在同一个事务中处理。下面来介绍基于消息中间件来实现这种分布式事务。
- 在系统A处理任务A前,首先向消息中间件发送一条消息
- 消息中间件收到后将该条消息持久化,但并不投递。此时下游系统B仍然不知道该条消息的存在。
- 消息中间件持久化成功后,便向系统A返回一个确认应答;
- 系统A收到确认应答后,则可以开始处理任务A;
- 任务A处理完成后,向消息中间件发送Commit请求。该请求发送完成后,对系统A而言,该事务的处理过程就结束了,此时它可以处理别的任务了。
- 但commit消息可能会在传输途中丢失,从而消息中间件并不会向系统B投递这条消息,从而系统就会出现不一致性。这个问题由消息中间件的事务回查机制完成,下文会介绍。
- 消息中间件收到Commit指令后,便向系统B投递该消息,从而触发任务B的执行;
- 当任务B执行完成后,系统B向消息中间件返回一个确认应答,告诉消息中间件该消息已经成功消费,此时,这个分布式事务完成。
从上面的过程中,我们可以看出:
- 消息中间件扮演者分布式事务协调者的角色。
- 系统A完成任务A后,到任务B执行完成之间,会存在一定的时间差。在这个时间差内,整个系统处于数据不一致的状态,但这短暂的不一致性是可以接受的,因为经过短暂的时间后,系统又可以保持数据一致性,满足BASE理论。
上述过程中,如果任务A处理失败,那么需要进入回滚流程,如下图所示:
- 若系统A在处理任务A时失败,那么就会向消息中间件发送Rollback请求。和发送Commit请求一样,系统A发完之后便可以认为回滚已经完成,它便可以去做其他的事情。
- 消息中间件收到回滚请求后,直接将该消息丢弃,而不投递给系统B,从而不会触发系统B的任务B。
上面所介绍的Commit和Rollback都属于理想情况,但在实际系统中,Commit和Rollback指令都有可能在传输途中丢失。那么当出现这种情况的时候,消息中间件是如何保证数据一致性呢?——答案就是超时询问机制。
所以系统A除了实现正常的业务流程外,还需提供一个事务询问的接口,供消息中间件调用。当消息中间件收到一条事务型消息后便开始计时,如果到了超时时间也没收到系统A发来的Commit或Rollback指令的话,就会主动调用系统A提供的事务询问接口询问该系统目前的状态。该接口会返回三种结果:
- 提交 若获得的状态是“提交”,则将该消息投递给系统B。
- 回滚 若获得的状态是“回滚”,则直接将条消息丢弃。
- 处理中 若获得的状态是“处理中”,则继续等待。
上游的的消息投递保证了,那么下游的消息投递的可靠性保证怎么解决呢?
当上游系统执行完任务并向消息中间件提交了Commit指令后,便可以处理其他任务了,此时它可以认为事务已经完成,接下来消息中间件一定会保证消息被下游系统成功消费掉!那么这是怎么做到的呢?这由消息中间件的投递流程来保证。
消息中间件向下游系统投递完消息后便进入阻塞等待状态,下游系统便立即进行任务的处理,任务处理完成后便向消息中间件返回应答。消息中间件收到确认应答后便认为该事务处理完毕!
如果消息在投递过程中丢失,或消息的确认应答在返回途中丢失,那么消息中间件在等待确认应答超时之后就会重新投递,直到下游消费者返回消费成功响应为止。当然,一般消息中间件可以设置消息重试的次数和时间间隔,如果重试多次之后仍然投递失败,那么这条消息就需要人工干预。
如果消费超时,则需要一直重试,这里就会出现一个问题:消息接收端需要保证幂等。这又是另外一个问题,比如使用版本控制。
这里还有一个问题就是,上游系统A向消息中间件提交Commit/Rollback消息采用的是异步方式,消息中间件和下游系统之间为什么要采用同步通信呢?
- 游系统和消息中间件之间采用异步通信是为了提高系统并发度。业务系统直接和用户打交道,用户体验尤为重要,因此这种异步通信方式能够极大程度地降低用户等待时间。此外,异步通信相对于同步通信而言,没有了长时间的阻塞等待,因此系统的并发性也大大增加。但异步通信可能会引起Commit/Rollback指令丢失的问题,这就由消息中间件的超时询问机制来弥补。
- 消息息中间件和下游系统之间采用同步通信是因为同步虽然降低系统并发度,但实现成本较低。因此,在对并发度要求不是很高的情况下,或者服务器资源较为充裕的情况下,我们可以选择同步来降低系统的复杂度。
TCC编程式补偿性事务
TCC即为Try Confirm Cancel,它属于补偿型分布式事务。顾名思义,TCC实现分布式事务一共有三个步骤:
- Try:尝试待执行的业务
这个过程并未执行业务,只是完成所有业务的一致性检查,并预留好执行所需的全部资源 - Confirm:执行业务
这个过程真正开始执行业务,由于Try阶段已经完成了一致性检查,因此本过程直接执行,而不做任何检查。并且在执行的过程中,会使用到Try阶段预留的业务资源。 - Cancel:取消执行的业务
若业务执行失败,则进入Cancel阶段,它会释放所有占用的业务资源,并回滚Confirm阶段执行的操作。
就举下单支付的例子:
Try:
- 从用户账户扣除100元(预留业务资源)
Confirm:
- 订单生成
- 商户账户增加100元
Concel:
- 将用户账户增加100元
- 将商户账户减去100元
- 出现任何异常,释放所有占用的业务资源,并回滚Confirm阶段执行的操作。
对于TCC来说适合一些:
- 强隔离性,严格一致性要求的活动业务。
- 执行时间较短的业务