分布式事务解决方案和原理
一. 背景:
我们都知道, 在以前的 all in one 的项目开发模式下, 所有事务问题都是本地事务问题, 基本上利用mysql的优化方案和java提供的API, 可以解决绝大多数本地事务问题,而在现在的分布式及微服务的模式下,很多事务问题就不是简单的本地事务问题了,正是由于在微服务环境下存在的网络延迟问题,机器不可用问题,以及一次操作由多个系统协同完成而产生的各类问题,导致了逻辑上的一次事务违反了ACID中的特性问题。
之前的文章介绍了事务中的ACID和事务的分类,这一章节主要分析分布式环境下会产生什么事务问题,以及市场上的常见解决方案和原理,最终总结这些问题和解决方案带来的思考和扩展。
二. 知识点回顾:
1. CAP理论
可用性C:
可用性指服务一直可用,而且是正常响应时间。就好比N1和N2节点,不管什么时候访问,都可以正常的获取数据值。而不会出现问题。好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。
分区容错性P:
指的分布式系统中的某个节点或者网络分区出现了故障的时候,整个系统仍然能对外提供满足一致性和可用性的服务。也就是说部分故障不影响整体使用。事实上我们在设计分布式系统是都会考虑到bug,硬件,网络等各种原因造成的故障,所以即使部分节点或者网络出现故障,我们要求整个系统还是要继续使用的(不继续使用,相当于只有一个分区,那么也就没有一致性和可用性了)
一致性A:
在分布式系统完成某写操作后任何读操作,都应该获取到该写操作写入的那个最新的值。相当于要求分布式系统中的各节点时时刻刻保持数据的一致性。
由于CAP理论的存在,在微服务环境下的网络问题和分布式集群中的机器问题,可用性和一致性总会出现权衡点,在保证容错性的前提下,总会根据场景选择 CP 或者 AP 。
CP :如果不要求A(高可用),相当于每个请求都需要在Server之间强一致,而P(分区)会导致同步时间无限延长,如此CP也是可以保证的。很多传统的数据库分布式事务都属于这种模式。
AP :要高可用并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。现在众多的NoSQL都属于此类。
由于这些设计和实现的出现,引出了著名的 BASE 理论。
2. BASE理论
基本可用(Basically Available)
假设系统,出现了不可预知的故障,但还是能用,相比较正常的系统而言:
响应时间上的损失:正常情况下的搜索引擎 0.5 秒即返回给用户结果,而 基本可用 的搜索引擎可以在 1 秒作用返回结果。
功能上的损失:在一个电商网站上,正常情况下,用户可以顺利完成每一笔订单,但是到了大促期间,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。
软状态(Soft State)
相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种 “硬状态”。
软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。
最终一致性(Eventually Consistent)
系统能够保证在没有其他新的更新操作的情况下,数据最终一定能够达到一致的状态,因此所有客户端对系统的数据访问最终都能够获取到最新的值。
3. 小结
由CAP理论引出的BASE理论,由BASE理论得出:基于可扩展和高可用的微服务环境下,最终一致性成为了我们解决分布式事务的选择,当然我们也可以选择强一致性,但是这会影响可用性和容错性,可用性对业务系统也尤为重要。在大谈用户体验的今天,如果业务系统时常出现“系统异常”、响应时间过长等情况,这使得用户对系统的好感度大打折扣,在互联网行业竞争激烈的今天,相同领域的竞争者不甚枚举,系统的间歇性不可用会立马导致用户流向竞争对手。因此,我们只能通过牺牲一致性来换取系统的 可用性 和 分区容错 。
三. 分布式事务带来的问题
举个最简单的例子:淘宝上买一件衣服,此时下单成功,此时淘宝会在自己的商品库中减去库存,然后订单库中生成订单,现在在分布式环境下, 库存的本地事务成功提交了,但是订单却生成失败了 ,虽然对于不同的库来说,他们是不同的毫无关联的事务,但是对于用户来说,买衣服 下订单却是一个完整的事务(逻辑上) ,所以这就出现了 数据不一致 的问题。
其实分布式事务问题,从根本上来说,就是没有统一的 事务管理机制 去管理这些处在不同服务和数据库的事务,这一点很重要,我们知道这一点,就能更好的理解为什么下面说的分布式事务解决方案的出发点了。
四. 解决方案之刚性分布式事务
之所以叫刚性分布式事务,是因为保证了强一致性,这种选择舍弃了部分可用,保证了任何时间节点,数据都能保证一致。
下面介绍刚性分布式事务的解决方案:
1. 二阶段提交 2PC (Two-Phase-Commit)
从上面的分析中,我们得出来一个想法,就是我们能不能像考虑本地事务一样考虑分布式事务,这就是2PC得核心思想:
2PC方案中由三个角色:
AP: application, 应用程序,也就是业务层。哪些操作属于一个事务,就是AP定义的。
RM: Resource Manager,资源管理器。一般是数据库,也可以是其他资源管理器,比如消息队列,
文件系统。
TM: Transaction Manager ,事务管理器、事务协调者,负责接收来自用户程序(AP)发起的XA事务
指令,并调度和协调参与事务的所有RM(数据库),确保事务正确完成。
在分布式系统中,每一个机器节点虽然都能够明确知道自己在进行事务操作过程中的结果是成功还是失败,但却无法直接获取到其他分布式节点的操作结果。因此当一个事务操作需要跨越多个分布式节点的时候,为了保持事务处理的ACID特性,就需要引入一个“协调者”(TM)来统一调度所有分布式节点的执行逻辑,这些被调度的分布式节点被称为AP。TM负责调度AP的行为,并最终决定这些AP是否要把事务真正进行提交到(RM)。
之所以叫二阶段提交,因为它把一个分布式事务得执行设置成了准备和提交两个阶段
准备阶段(第一阶段) :事务管理器TM向所有的资源管理器RM 发送Prepare消息,每个参与者要么直接返回失败(如权限验证失败),要么在本地执行事务,写本地的redo和undo日志,但不提交。
提交阶段(第二阶段) :这里会分2种情况:
当TM从所有RM节点获得的消息都为”同意”时:
1)协调者节点向所有参与者节点发出”正式提交(commit)”的请求。
2)参与者节点正式完成操作,并释放在整个事务期间内占用的资源。
3)参与者节点向协调者节点发送”完成”消息。
4)协调者节点受到所有参与者节点反馈的”完成”消息后,完成事务。
如果任一参与者节点在第一阶段返回的响应消息为”中止”,或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:
1)协调者节点向所有参与者节点发出”回滚操作(rollback)”的请求。
2)参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
3)参与者节点向协调者节点发送”回滚完成”消息。
4)协调者节点受到所有参与者节点反馈的”回滚完成”消息后,取消事务。
2PC看上去好像没有什么问题,但是问题还是很多的,细想一下:
死锁问题。假如在第一个准备阶段,TM向所有的RM发送Prepare消息,并等待RM的回执消息时,其中一台RM节点出现了问题,比如宕机了,那么TM就会一直阻塞等待,产生死锁问题,所以如果我们的数据库连接池中的连接数量有限的情况下,会很容易发生死锁问题导致数据库连接不可用,从而导致服务不可用,破坏了服务的可用性。
数据不一致问题。在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。
由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)。
扩展:经典的X/OpenDTP事务模型
X/Open DTP(X/Open Distributed Transaction Processing Reference Model) 是X/Open这个组织定义的一套分布式事务的标准,也就是定义了规范和API接口,由各个厂商进行具体的实现。这个标准提出了使用二阶段提交(2PC – Two-Phase-Commit)来保证分布式事务的完整性。后来J2EE也遵循了X/OpenDTP规范,设计并实现了java里的分布式事务编程接口规范-JTA。
2. 三阶段提交 3PC (Three-Phase-Commit)
由于二阶段提交存在着阻塞死锁和单点故障等问题,所以在二阶段提交的基础上提出了三阶段提交解决方案。
在理解了2PC的基础上我们去理解3PC,3PC在2PC的基础上增加了一个准备提交(prepare to commit)阶段,所以3PC提出了三个阶段去处理一个分布式事务:
canCommit阶段(第一阶段) :事务管理器TM向所有的资源管理器RM 发送CanCommit请求, 询问是否可以执行事务提交操作。然后开始等待参与者的响应。各参与者向协调者反馈事务询问的响应,如果参与者认为自己可以顺利执行事务,就返回 Yes,否则反馈 No 响应。
preCommit阶段(第二阶段) :这里会分2种情况:
执行预提交:当TM从所有RM节点获得的消息都为”Yes”时:
1)协调者节点向所有参与者节点发出”preCommit”的请求。
2)参与者节点向协调者节点发送”ACK”响应。
3)协调者节点受到所有参与者节点反馈的”ACK”响应后,确定下一阶段是否为提交或者是终止操作。
中断事务:如果任一参与者节点响应消息为” 中止 ”的时候 或者 等待超时 ,都会去中断事务。
doCommit阶段(第三阶段) :这里也会分2种情况:
执行提交:当TM从所有RM节点获得的消息都为可执行时:
1)协调者节点向所有参与者节点发出”doCommit”的请求。
2)参与者节点向协调者节点发送”haveCommitted”响应。
3)完成事务。
中断事务:因为出现了异常,比如TM一方出现了问题,或者是TM与RM之间出现了故障。
首先TM向所有的RM发送中断请求。然后RM接收到中断请求后,会利用其在第二阶段记录的 undo 信息来执行事务回滚操作,并释放资源。接下来RM在完成事务回滚之后,向TM发送 Ack 消息。最后协调者接收到所有的 Ack 消息后,中断事务。
3PC优点 :3PC主要解决的单点故障问题,并减少阻塞,因为3PC对于TM和RM都设置了超时时间,这个优化点避免了RM在长时间无法与TM节点通讯(TM挂掉了)的情况下,无法释放资源的问题。而且3PC多设置了一个 缓冲阶段 保证了在最后提交阶段之前各参与节点的状态是一致的。
3PC存在的问题 :在doCommit阶段,如果参与者无法及时接收到来自TM的doCommit或者回滚请求时,会在等待超时之后,会继续进行事务的提交。所以,由于网络原因,TM发送的回滚响应没有及时被RM接收到,那么RM在等待超时之后执行了commit操作。这样就和其他接到回滚命令并执行回滚的RM之间存在数据不一致的情况。
五. 解决方案之柔性分布式事务
状态机 :和刚性分布式事务对比,柔性分布式事务就是保证了高可用的前提下,保证了最终一致性,舍弃了强一致性,这种选择更适用于大多数场景,在使用最终一致性的方案时,一定要提到的一个概念是状态机。什么是状态机?是一种特殊的组织代码的方式,用这种方式能够确保你的对象随时都知道自己所处的状态以及所能做的操作。它也是一种用来进行对象行为建模的工具,用于描述对象在它的生命周期内所经历的状态序列,以及如何响应来自外界的各种事件。状态机这个概念大家都不陌生,比如TCP协议的状态机。同时我们在编写相关业务逻辑的时候经常也会需要处理各种事件和状态的切换,比如switch、if/else。所以我们其实一直在跟状态机打交道,只是可能没有意识到而已。在处理一些业务逻辑比较复杂的需求时,可以先看看是否适合用一个有限状态机来描述,如果可以把业务模型抽象成一个有限状态机,那么代码就会逻辑特别清晰,结构特别规整。我们以支付为例,一笔订单可能会有等待支付、支付中、已支付等状态,这其实就是一种状态机。
在柔性分布式事务解决方案中,柔性分布式事务多是基于服务和应用层的,而2PC和3PC等刚性分布式事务解决方案都是基于DB层的,这在我们看来更容易理解和维护,如果遇到了问题,我们也可以更好的定位问题和解决问题,并且优化它。
言归正传,柔性分布式事务解决方案大致可以分为三类:
1) 补偿型
2) 异步确保性
3) 最大努力通知型
下面基于这三种类型分别举出一个对应的解决方案:
1. TCC(补偿型)
TCC分别对应Try、Confirm和Cancel三种操作,这三种操作的业务含义如下:
Try:预留资源
在调用主服务的时候,主服务首先预留资源,然后调用其他服务的try接口也预留资源,这个时候并不会执行真正的业务逻辑,只是简单的“冻结”了需要的资源。
Confirm:确认执行
假如在Try阶段 所有服务的资源都成功冻结,并且都返回了成功的响应,则主服务执行真正执行需要做的业务操作,并且调用其他服务的Confirm接口执行业务操作。
Cancel:取消执行
加如在Try阶段 存在服务响应了冻结资源失败,则需要释放这些冻结的资源,同理,主服务首先释放,然后调用其他服务的Cancel接口释放资源。
从这里能看出来,TCC方案中,所有支持分布式事务的服务都必须由Try,Confirm,Cancel三个接口,这些互操作基本上由一些TCC框架完成,当然我们也可以自己实现管理者的功能,只不过会麻烦一些,所以TCC对于这些服务来说是透明的。
这里还是举个上面买东西例子,比如我要买一件衣服,这个时候需要减少一件库存,并且账户需要减去对应的金额,这个流程如下图:
流程:
1. 用户下单,这个时候库存服务作为主服务(假设),冻结对应的库存量,但这个时候要记录下来,并不是真正的直接减少库存。
2. 然后库存服务调用账户服务的try接口 账户服务就会冻结对应的金额,同理,临时冻结。
3. 假如两个服务都try成功了,就执行Confirm逻辑真正的减去冻结的部分。
4. 假如try阶段失败了,则实行Cancel逻辑,去释放被冻结的数据的信息。
可能有些同学会有疑问,假如在执行完try阶段之后,在调用Confirm操作或者执行Cancel操作的时候,其中一台服务挂掉了怎么办,假如我们使用市场上成熟的TCC框架,它们已经解决了这个问题,会在整个执行过程中,记录对应的操作日志,当挂掉的服务重新启动的时候,会重新完成余下的操作,这个思想类似 mysql中的undo-log。
而且我们能感觉出来 TCC方案的思想其实就是来源于2PC,都是做二阶段提交,但是TCC是在业务和服务层完成的,更容易维护。
2. 基于本地消息表(异步确认型)
基于消息表的逻辑,会更容易理解和上手一些,这种方式下,所以的服务只维护本地事务和表,将大的分布式事务分解成各个服务的本地事务,异步的去保证最终一致性,举个例子大家就能理解了:
还是上面的例子,用户买了一个东西,这个时候库存服务不仅维护一张库存表,还维护一张记录这一次操作的全局事务ID,服务发送/接收方和状态等信息的 消息表 ;而账户服务不仅维护一张资金表,还维护一张记录处理其他服务的事务的消息表。
1). 用户购买一件商品,库存服务把减少库存操作和插入本地消息表的操作作为一个事物执行,保证同时失败和成功。
2). 假如步骤1成功,这个时候库存服务的消息表多了一条需要账户服务执行账户操作的记录数据。
3). 账户服务会定时去库存服务拿库存服务的消息表数据,如果发现有需要自己执行的任务,则会执行扣除金额操作,但是并不是单纯的减去金额,是把减去账户金额和把从库存服务中拿到的全局事务ID等信息作为一个完成的本地事务写入到自己的消息表中记录,然后请求库存服务删除库存服务的消息表中对应的记录。
扩展 :其实这只是其中一种基于本地消息表的分布式事务解决方案,我们可以扩展很多其他的优化方案,比如我可以在库存服务操作的时候,不去立马减去库存,而是像TCC那样先冻结部分库存,并将全局事务信息放入消息表,然后启动多个定时任务,一个去同步多个服务的事务信息,一个去检查分布式事务执行的情况,再考虑冻结的数据究竟是真正执行还是进行回滚,因为我们实现的是最终一致性,所以我们可以有很多变种的解决方案。
3. 基于MQ(最大努力通知型)
这里就直接举阿里的 RocketMQ 来说了,因为在目前的MQ中间件中,阿里的RocketMQ对消息通知方式的分布式事务解决方案支持的更好一些,当然也有其他的方案,这里就不多说了,下面是大致的流程思想:
生产者发送事务消息到MQ,这个时候并不发送给消费者,这也是RocketMQ的其中一种消息类型的特性,消息发送到MQ上在没有执行消息确认之前,消息对于消费者是不可见。
步骤1成功后,生产者执行本地事务,并提交。
步骤2成功后,生产者确认事务消息,使得发送到MQ上的事务消息对于消费者可见。
消费者获取到消息进行消费,消费完之后执行ack进行确认。
还还还是使用上面的例子(一个例子走到底)
1). 用户下单,库存服务首先向MQ发送一个事务消息,状态是待确认,这个时候账户服务是不可见的。
2). 步骤1成功后,库存服务执行减库存的本地事务。
3). 步骤2成功后,向MQ中待确认的事务消息发送确认指令。
4). 账户服务拿到该事务消息,执行减去金额的操作事务。
这里可能会存在三个问题:
回查 :生产者本地事务成功后,发送事务确认消息到消费上失败了怎么办?这个时候意味着消费者无法正常消费到这个消息。所以RocketMQ提供了 消息回查机制 ,如果事务消息一直处于中间状态,MQ会发起 重试 去查询MQ上这个事务的处理状态。一旦发现事务处理成功,则把当前这条消息设置为可见。
补偿 :另外一个问题就是消费者确认拿到了这个消息,并且开始执行该事务,但是一直操作失败,这个时候就需要消费者完善补偿逻辑了,因为生产者的事务已经提交了,而消费者需要利用重试或者补偿等逻辑务必保证自己的事务执行成功,最坏的情况可能就需要记录错误日志,并且人工介入了。
幂等 :正式由于MQ的特性,消费者可能会重复拿到同一个事务消息,因此消费者务必要考虑 幂等 问题。
六. 总结和思考
从上面对于CAP理论和BASE理论的引出,到2PC和3PC等刚性分布式事务解决方案,再到TCC和本地消息表和基于MQ的柔性分布式事务解决方案,我们可以看出来,所以的方案都是随着互联网服务架构的发展而发展,所以这些解决方案都不是一成不变的,是可以扩展和优化甚至是结合使用的。
比如我们需要针对于账户流水做强一致性分布式事务解决方案,那么我们就可以基于2PC或者3PC的思想实现自己的一套解决方案。或者我们对于一些不是要求强一致性的分布式事务,比如日志信息和非主要业务信息,就可以利用一些中间件或者中间表,也可以利用一些定时任务机制来实现补偿逻辑和最终一致性。
再比如阿里开源的分布式事务中间件 Seata ,就是基于2PC的思想实现的,这里不做扩展,感兴趣的同学可以去看文档和源码。
最后需要补充的就是针对于柔性分布式事务的分类,这里可能不是那么准确,作者也是根据个人想法分类和对应的。