vlambda博客
学习文章列表

分布式事务六种解决方案

前言

事务想必大家并不陌生,严格意义上讲事务应该具备ACID,目前常见的分布式事务解决方案包括2PC、3PC、TCC、本地消息表、消息事物、最大努力通知等。

ACID

  • 原子性:一个事物内的所有操作都要全执行或者都不执行

  • 一致性:事务前后数据的完整性必须保持一致

  • 隔离性:多个事务并发执行,不能被其他事物的操作所干扰

  • 持久性:事务一旦提交,它的改变应该是持久性的

分布式事务

分布式事务就是要在分布式系统中实现事物,它其实就是多个本地事物的结合,对于分布式事务几乎无法满足ACID,其实对于单机事物而言大部分情况也无法满足ACID,不然数据库怎么会有四种隔离级别呢?所以在分布式的领域里也无法全部满足。

2PC(二阶段提交)同步阻塞协议

二阶段提交是一种强一致性设计,2PC引入了一个事物协调者的角色来协调管理各参与者的提交和回滚,二阶段分别指:准备、提交两个阶段,它是一种尽可能保证强一致性的分布式事务,所以它是同步阻塞的,总体而言效率低,而且存在协调者单点故障问题,极端情况下无法保证数据一致性

准备阶段

协调者会给各参与者发送准备命令,它主要是为了对事物进行提交。

提交阶段

同步等待所有资源的响应之后就进入了提交阶段,这里提交阶段并不是只是提交,也可能是表示回滚。

分布式事务六种解决方案

假如在第一阶段有一个参与者失败,那么协调者就会向所有参与者发送回滚请求,也就是分布式失败

分布式事务六种解决方案

那如果二阶段提交失败怎么办?(两种情况,其实都是进行重试操作)

  1. 第二阶段执行的是回滚事务操作,那么就会不断重试,直到所有参与者都回滚,不然那些第一阶段准备成功的参与者会一直阻塞着

  2. 第二阶段执行的是提交事物操作,那么也会不断重试,因为部分参与者可能成功,这时候只能硬着头皮进行重试,直到重试成功。


首先2PC是一个同步阻塞协议,像第一阶段协调者会等待所有参与者响应才会进行下一步操作,当然第一阶段的协调者有超时机制,假设因为网络原因没有收到参与者的响应或某参与者挂了,那么超时后就会判断事物失败,向所有参与者发送回滚命令。

第二阶段协调者没法超时,因为部分参与者已经提交成功了,所有必须要不断重试。

协调者故障分析(协调者是一个单点,存在单点故障问题)

  1. 假设协调者在发送准备命令之前挂了,等于事物还没开始(影响不大)

  2. 假设协调者在发送准备命令之后挂了,这就不行了,事务已经开始了,不仅事务无法执行下去,还会因为锁定一些公共资源而阻塞系统其他操作

  3. 假设协调者在发送回滚事务命令之前挂了,这事务也无法执行下去,且在第一阶段那些准备成功的参与者都要阻塞着。(影响准备成功的参与者,基本上就是所有参与者)

  4. 假设协调者在发送回滚事务命令之后挂了,这个还行,至少命令已经发送出去,如果有网络原因导致命令发送失败,某些参与者还会因为收不到命令而阻塞着。(影响收不到命令的参与者)

  5. 假设协调者在发送提交事物命令之前挂了,这事物无法执行下去,所有资源都阻塞着。(影响所有参与者)

  6. 假设协调者在发送提交事物命令之后挂了,这个还行,至少命令已经发送出去,如果有网络原因导致命令发送失败,某些参与者还会因为收不到命令而阻塞着。(影响收不到命令的参与者)


  7. 分布式事务六种解决方案

如何解决以上问题呢?如果只是因为协调者的单点故障导致服务不可用的话,协调者故障可以通过选举得到新可用的协调者

  1. 如果处于第一阶段,其实影响不大,在第一阶段的事务肯定没有提交,此时都按回滚处理就好了

  2. 如果处于第二阶段,此时分为两种情况

    1. 假设参与者都没问题,此时新协调者可以向所有参与者确认它们的自身情况来推断下一步操作

    2. 假设参与者挂了,比如第一个参与者收到消息并执行了,然后第一个参与者与协调者都挂了,此时其他参与者都没收到请求,新的协调者介入后,协调者询问其他参与者都说OK,但是第一个参与者它是收不到请求的


分布式事务六种解决方案

问题就出在每个参与者自身的状态只有自己和协调者知道,因此新协调者无法通过在场的参与者推断出挂了的参与者处于什么状态。

这种情况,需要引入记录来判断新的协调者来的时候要不要继续确认,例如如下的信息。

参与者ID 阶段 状态
xxxxx1 prepare success
xxxxx2 prepare success
xxxxx3 commit fail


但是就算协调者知道自己该发提交请求,那么参与者在一起挂了也没有用,因为你不知道参与者在挂之前有没有提交事物操作。

  1. 如果参与者在挂之前提交成功,新协调者确定存活的参与者都没问题,需要再次发送提交事务命令才能保证数据一致

  2. 如果参与者在挂之后事务还未提交,参与者恢复了之后数据是回滚的,此时协调者必须向其他参与者发送回滚事务命令才能保持事务一致。

所以说2PC极端情况下还是无法解决数据不一致的问题

3PC (三阶段提交)

三阶段提交出现是为了解决2PC的一些问题,相比于2PC它在参与者中引入了超时机制,并且新增了一个阶段 预提交阶段 使得参与者利用这一阶段统一各自的状态。

分布式事务六种解决方案

准备阶段

与2PC不相同,准备阶段不会直接提交事务,而是会先询问此时的参与者是否有条件接收事务,所以它不会上来锁住所有资源,使得某些资源不可用的情况下所有参与者都阻塞着。


预提交阶段

预提交阶段的引入起到了统一状态的作用,它表明预提交阶段前所有参与者还未都回应,预提交阶段后,所有参与者都进行了回应,但是多一段交互也会导致性能的下降,大多数情况所有参与者都是可用状态,极端情况会出现问题,毕竟极端情况占比比较小。


提交阶段

同步等待所有资源的响应之后就进入了提交阶段,这里提交阶段并不是只是提交,也可能是表示回滚。

分布式事务六种解决方案

参与者超时机制

上面我们说了2PC是同步阻塞的,上面我们分析了协调者在发送事务(提交或回滚)命令之前挂了,其影响是最大的,所有参与者都在等待着,但是如果引入了超时机制,参与者就不会傻等了。超时机制只针对于参与者,针对协调者而言,超时的问题还是存在

  • 如果是等待提交命令超时,参与者就会提交事务了

  • 如果是等待预提交命令超时,那就该干啥干啥,反正参与者事务也没有开启

  • 超时机制也可能导致数据不一致的问题,比如在等待提交超时了,参与者默认执行的是事务提交操作,但是协调者可能发送的是事务回滚请求

3PC的引入是为了解决提交阶段2PC,协调者与参与者都挂了,新选举的协调者不知道当前应该是提交还是回滚的问题,但是这也只解决了部分问题,比如他无法确定参与者到底有没有执行事务,这个是无法确定的,所以3PC通过预提交阶段可以减少故障恢复时的复杂性,但是不能保证数据一致性了。

总结

3PC针对2PC做了一些改进,主要是引入了参与者的超时机制,并新增了预提交阶段使得故障恢复时协调者决策更清晰一些,但是性能上不如2PC,2PC和3PC都会出现数据不一致的情况

TCC(Try - Confirm - Cancel)

2PC和3PC在数据库层面用的比较多,TCC则在业务分布式事务层面比较多。想要了解更多可以查看Mysql中2PC的应用

TCC指的是Try - Confirm - Cancel

  • try:指的是预留,即资源的预留和使用,注意是预留

  • Confirm:指的是确认操作,这一步其实就是真正的执行了

  • Cancel:指的是撤销操作,可以理解为把预留阶段的动作撤销了

其实从思想上来看和2PC差不多,都是先试探性的执行,如果都可以就真正的执行了,如果不行就回滚。

分布式事务六种解决方案


如果所示,调用者发起事务,首先发给事务管理器,然后再请求服务1、服务2、服务3,都返回预定成功后,在交给事务管理器进行事务提交或回滚。

总结

TCC的整体流程还是很简单的,难点在于业务上的定义对于每一个操作你都要定义三个动作(Try-Confirm-Cancel),这对于业务的代码侵入非常大,需要根据特定的场景和业务逻辑来设计操作。

它相比于2PC、3PC使用范围更大,但是开发量也会大,不过因为它是在业务上实现的,所有TCC可以跨数据库、跨不同的业务系统来实现事务。

本地消息表

本地消息表其实就是利用各系统本地的事务来实现分布式事务

本地消息表就是有一张存放本地消息的表,一般都是与业务表放在同一个库中,利用数据库事务来操作业务表与消息表,这样就可以保证业务表执行成功的,消息表中肯定会有相应的数据信息,然后在采用定时任务或者MQ的方式做分布式事务补偿

比如拿订单服务与库存服务举例,用户下单后需要生成递单以及更新库存,如果这两个服务使用不同的库,也就是我们说的分布式事务

如果4过程成功或失败,会不会对整体数据产生影响呢?

  • 如果成功,消息表的消息状态改为成功后,表明库存已经更新了。

  • 如果没有成功,消息表的状态不会发生改变,但是可以利用定时任务筛选出消息表未成功的放入MQ中,再次进行处理(此处不考虑幂等性,由业务实现)

总结

可以看出本地消息表实现是采用数据库事务实现的,利用消息表与业务表在同库中,实现事务,后续再由MQ进行事务的补偿,如果补偿失败可以利用定时任务轮询消息表中未处理的消息继续进行处理,也可以增加重试次数,最后由人工再次处理。

本地消息表不同与2PC/3PC,它是数据最终一致性的,容忍了暂时数据不一致的情况。

消息事务

RockerMQ就很好的支持了消息事务,让我们看看如何通过消息实现事务。

第一步先给MQ发送事务消息即半消息(消息对于消费者来说还不可见),这里利用MQ的事务机制,目前RocketMQ/kafka等主流MQ都支持事务投递。然后发送成功后在执行本地事务。

第二步根据本地事务执行的结果(成功、失败)向MQ发送Commit/RollBack请求。

第三步MQ的发送方会提供一个反查事务状态的接口,如果一段时间内半消息还没收到Commit/RollBack请求,那么MQ可以通过反查结果得知发送方事务是否执行成功,然后执行Commit/RollBack

  • 如果执行Commit那么订阅方就能收到这条消息,然后在做对应的操作,做完之后在确认消费这条消息即可

  • 如果是RollBack那么订阅方收不到这条消息,等于事务没执行过(因为MQ的事务机制进行RollBack消息是消费者不可见的)


其实整个过程需要回查的原因就是考虑请求4可能由于网络等原因发送失败,但是本地事务已经提交了,那MQ服务器上这条半消息就无法实现Commit/RollBack,因此,MQ服务端会定时扫描存储于事务为提交的消息,并发起回调查询该消息的事务状态。上图的5、6、7就是MQ服务端定时回查的步骤

总结

可以看出消息事务主要是利用MQ的部分特性实现的(1.支持事务投递、2.未完成的事务回调检查(可以利用RockerMQ的特征,也可以基于MQ自己实现),通过消息发送方先执行本地事务后的结果再执行其他事务最后达到最终一致性,所以消息事务也是数据最终一致性的。

最大努力通知

其实我觉得最大努力通知就是一种柔性的事务思想,它适用于对时间不敏感的业务本地消息表以及消息事务都可以成为最大努力通知的一种方案