分布式事务
说明
为了表示对作者的尊重,这里指出文章的链接:面试必问:分布式事务六种解决方案——敖丙,笔者认为该博主是一个大牛,很多知识可以值得学习,本博客仅仅为了学习和总结分布式知识而做出的总结,其他引用了大部分博主的博客文章。
什么是分布式事务
分布式事务顾名思义就是要在分布式系统中实现事务,它其实是由多个本地事务组合而成。
至于说分布式事务之前,我还是得先说说 ACID,然后再来介绍下什么是分布式事务和常见的分布式事务包括 2PC、3PC、TCC、本地消息表、消息事务、最大努力通知。
事务的四大特性
数据库事务特性:原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)、持久性(Durabiliy),简称为ACID原则。
- 原子性:组成一个事务的多个数据库操作是一个不可分割的原子单元,只有所有操作都成功,整个事务才会提交。任何一个操作失败,已经执行的任何操作都必须侧小,让数据库返回初始状态。
- 一致性:事务操作成功后,数据库所处的状态和他的业务规则是一致的。既数据不会被破坏。如A转账100元给B,不管操作算法成功,A和B的账号总额是不变的。
- 隔离性:在并发数据操作时,不同事务拥有各自的数据空间,它们的操作不会对彼此产生干扰。
- 持久性:一旦事务提交成功,事务中的所有操作都必须持久化到数据库中。
分布式事务
对于分布式事务而言几乎满足不了 ACID,其实对于单机事务而言大部分情况下也没有满足 ACID,不然怎么会有四种隔离级别呢?所以更别说分布在不同数据库或者不同应用上的分布式事务了。常见的分布式事务包括 2PC、3PC、TCC、本地消息表、消息事务、最大努力通知。
2PC
2PC(Two-phase commit protocol),也叫二阶段提交。 二阶段提交是一种强一致性设计,2PC 引入一个事务协调者的角色来协调管理各参与者(也可称之为各本地资源)的提交和回滚,二阶段分别指的是准备(投票)和提交两个阶段。
两个阶段的具体流程如下:
- 准备阶段协调者会给各参与者发送准备命令,你可以把准备命令理解成除了提交事务之外啥事都做完了。
- 同步等待所有资源的响应之后就进入第二阶段即提交阶段(注意提交阶段不一定是提交事务,也可能是回滚事务)。
- 假如在第一阶段所有参与者都返回准备成功,那么协调者则向所有参与者发送提交事务命令,然后等待所有事务都提交成功之后,返回事务执行成功。
让我们来看一下流程图。
2PC 会出现的问题
1)第二阶段提交失败
这里有两种情况。
第一种是第二阶段执行的是回滚事务操作,那么答案是不断重试,直到所有参与者都回滚了,不然那些在第一阶段准备成功的参与者会一直阻塞着。
第二种是第二阶段执行的是提交事务操作,那么答案也是不断重试,因为有可能一些参与者的事务已经提交成功了,这个时候只有一条路,就是头铁往前冲,不断的重试,直到提交成功,到最后真的不行只能人工介入处理。
另外说明:
首先 2PC 是一个同步阻塞协议,像第一阶段协调者会等待所有参与者响应才会进行下一步操作,当然第一阶段的协调者有超时机制,假设因为网络原因没有收到某参与者的响应或某参与者挂了,那么超时后就会判断事务失败,向所有参与者发送回滚命令。
在第二阶段协调者的没法超时,因为按照我们上面分析只能不断重试!
2)协调者故障分析
协调者是一个单点,存在单点故障问题。
协调者的单点故障问题可以分为六种:协调者在发送准备命令之前挂了、在发送准备命令之后挂了、在发送回滚事务命令之前挂了、在发送回滚事务命令之后挂了、在发送提交事务命令之前挂了、在发送提交事务命令之后挂了。
(1)假设协调者在发送准备命令之前挂了,还行等于事务还没开始。
(2)假设协调者在发送准备命令之后挂了,这就不太行了,有些参与者等于都执行了处于事务资源锁定的状态。不仅事务执行不下去,还会因为锁定了一些公共资源而阻塞系统其它操作。
(3)假设协调者在发送回滚事务命令之前挂了,那么事务也是执行不下去,且在第一阶段那些准备成功参与者都阻塞着。
(4)假设协调者在发送回滚事务命令之后挂了,这个还行,至少命令发出去了,很大的概率都会回滚成功,资源都会释放。但是如果出现网络分区问题,某些参与者将因为收不到命令而阻塞着。
(5)假设协调者在发送提交事务命令之前挂了,这个不行,这里所有资源都阻塞着。
(6)假设协调者在发送提交事务命令之后挂了,这个还行,也是至少命令发出去了,很大概率都会提交成功,然后释放资源,但是如果出现网络分区问题某些参与者将因为收不到命令而阻塞着。
单点故障解决方案:协调者故障,通过选举得到新协调者
因为协调者单点问题,因此我们可以通过选举等操作选出一个新协调者来顶替。
1)如果处于第一阶段,其实影响不大都回滚好了,在第一阶段事务肯定还没提交。
2)如果处于第二阶段
(1)假设参与者都没挂,此时新协调者可以向所有参与者确认它们自身情况来推断下一步的操作。
(2)假设有个别参与者挂了!这就有点僵硬了,比如协调者发送了回滚命令,此时第一个参与者收到了并执行,然后协调者和第一个参与者都挂了。此时其他参与者都没收到请求,然后新协调者来了,它询问其他参与者都说OK,但它不知道挂了的那个参与者到底O不OK,所以它傻了。
问题其实就出在每个参与者自身的状态只有自己和协调者知道,因此新协调者无法通过在场的参与者的状态推断出挂了的参与者是什么情况。
但是就算协调者知道自己该发提交请求,那么在参与者也一起挂了的情况下没用,因为你不知道参与者在挂之前有没有提交事务。
如果参与者在挂之前事务提交成功,新协调者确定存活着的参与者都没问题,那肯定得向其他参与者发送提交事务命令才能保证数据一致。
如果参与者在挂之前事务还未提交成功,参与者恢复了之后数据是回滚的,此时协调者必须是向其他参与者发送回滚事务命令才能保持事务的一致。
所以说极端情况下还是无法避免数据不一致问题。
总结2PC
2PC 是一种尽量保证强一致性的分布式事务,因此它是同步阻塞的,而同步阻塞就导致长久的资源锁定问题,总体而言效率低,并且存在单点故障问题,在极端条件下存在数据不一致的风险。
3PC
3PC的引入
3PC 的出现是为了解决 2PC 的一些问题,相比于 2PC 它在参与者中引入了超时机制,并且增加了预提交阶段使得故障恢复之后协调者能统一各参与者的状态,使决策复杂度降低。 3PC 包含了三个阶段,分别是准备阶段、预提交阶段和提交阶段,对应的英文就是:CanCommit、PreCommit 和 DoCommit。
相比2PC变更
3PC看起来是把 2PC 的提交阶段变成了预提交阶段和提交阶段,但是 3PC 的准备阶段协调者只是询问此时的参与者是否有条件接这个事务。
而预提交阶段就是和 2PC 的准备阶段一样,除了提交事务之外啥事都做完了。
3PC变更影响
我们先来看一下 3PC 的阶段变更有什么影响。
首先准备阶段的变更成不会直接执行事务,而是会先去询问此时的参与者是否有条件接这个事务,因此不会一来就干活直接锁资源,使得在某些资源不可用的情况下所有参与者都阻塞着。
而预提交阶段的引入起到了一个统一状态的作用,表明在预提交阶段前还有部分参与者没回应,在预处理阶段表明所有参与者都已经回应了。假如你是一位参与者,你知道自己进入了预提交状态那你就可以推断出来其他参与者也都进入了预提交状态。
提交阶段和 2PC 的一样,让我们来看一下图。
不管哪一个阶段有参与者返回失败都会宣布事务失败,这和 2PC 是一样的。
当然到最后的提交阶段或者是回滚阶段和 2PC 一样只要是提交请求就只能不断重试。
我们再来看下参与者超时能带来什么样的影响。
我们知道 2PC 是同步阻塞的,上面我们已经分析了协调者挂在了提交请求还未发出去的时候是最伤的,所有参与者都已经锁定资源并且阻塞等待着。
那么引入了超时机制,参与者就不会傻等了,如果是等待提交命令超时,那么参与者就会提交事务了,因为都到了这一阶段了大概率是提交的,如果是等待预提交命令超时,那该干啥就干啥了,反正本来啥也没干。
然而超时机制也会带来数据不一致的问题,比如在等待提交命令时候超时了,参与者默认执行的是提交事务操作,但是有可能执行的是回滚操作,这样一来数据就不一致了。
3PC 的引入是为了解决提交阶段 2PC 协调者和某参与者都挂了之后新选举的协调者不知道当前应该提交还是回滚的问题。
新协调者来的时候发现有一个参与者处于预提交或者提交阶段,那么表明已经经过了所有参与者的确认了,所以此时执行的就是提交命令。
所以说 3PC 就是通过引入预提交阶段来使得参与者之间的状态得到统一,也就是留了一个阶段让大家同步一下。
但是这也只能让协调者知道该如果做,但不能保证这样做一定对,这其实和上面 2PC 分析一致,因为挂了的参与者到底有没有执行事务无法断定。
所以说 3PC 通过预提交阶段可以减少故障恢复时候的复杂性,但是不能保证数据一致,除非挂了的那个参与者恢复。
总结一下
3PC 相对于 2PC 做了一定的改进:在参与者中引入了超时机制,并且增加了预提交阶段使得故障恢复之后协调者能统一各参与者的状态,使决策复杂度降低,但整体的交互过程更长了,性能有所下降,并且还是会存在数据不一致问题。
所以 2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。
TCC
2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务,分布式事务不仅仅包括数据库的操作,还包括发送短信等,这时候 TCC 就派上用场了!
TCC 指的是Try - Confirm - Cancel。
Try 指的是预留,即资源的预留和锁定,注意是预留。
Confirm 指的是确认操作,这一步其实就是真正的执行了。
Cancel 指的是撤销操作,可以理解为把预留阶段的动作撤销了。
其实从思想上看TCC和 2PC 差不多,都是先试探性的执行,如果都可以那就真正的执行,如果不行就回滚。
比如说一个事务要执行A、B、C三个操作,那么先对三个操作执行预留动作。如果都预留成功了那么就执行确认操作,如果有一个预留失败那就都执行撤销动作。
我们来看下流程,TCC模型还有个事务管理者的角色,用来记录TCC全局事务状态和提交或者回滚事务。
可以看到流程还是很简单的,难点在于业务上的定义,对于每一个操作你都需要定义三个动作分别对应Try - Confirm - Cancel。
本地消息表
本地消息表利用了 各系统本地的事务来实现分布式事务,本地消息表顾名思义就是会有一张存放本地消息的表,一般都是放在数据库中,然后在执行业务的时候 将业务的执行和将消息放入消息表中的操作放在同一个事务中,这样就能保证消息放入本地消息表中业务肯定是执行成功的。然后再去调用下一个操作,如果下一个操作调用成功了那就把消息表的消息状态可以直接改成已成功。
如果调用失败也没事,会有 后台定时任务去读取本地消息表,筛选出还未成功的消息再调用对应的服务,服务更新成功了再变更消息的状态。
这时候有可能消息对应的操作不成功,因此也需要重试,重试就得保证对应服务的方法是幂等的,而且一般设置重试的最大次数,超过最大次数可以记录让人工处理。
可以看到本地消息表其实实现的是最终一致性,容忍了数据暂时不一致的情况。
消息事务
RocketMQ 就很好的支持了消息事务,让我们来看一下如何通过消息实现事务。
- 先给 Broker 发送事务消息即半消息,半消息是指这个消息对消费者来说不可见,然后Broker发送成功后发送方再执行本地事务。
- 再根据本地事务的结果向 Broker 发送 Commit 或者 RollBack 命令。并且 RocketMQ 的发送方会提供一个反查事务状态接口,如果Broker在一段时间内没有收到任何操作请求,那么 Broker 会通过反查事务状态接口得知发送方事务是否执行成功,然后执行 Commit 或者 RollBack 命令。
- 如果是 Commit 那么订阅方就能收到这条消息,然后再做对应的操作,做完了之后再消费这条消息即可。
- 如果是 RollBack 那么订阅方收不到这条消息,等于事务就没执行过。
可以看到通过 RocketMQ 还是比较容易实现的,RocketMQ 提供了事务消息的功能,我们只需要定义好事务反查接口即可。
最大努力通知
其实我觉得本地消息表和事务消息都可以算最大努力通知。
就本地消息表来说会有后台定时任务去查看未完成的消息,然后去调用对应的服务,当一个消息多次调用都失败的时候可以记录下然后引入人工,或者直接舍弃。这其实算是最大努力通知。
事务消息也是一样,当半消息被commit了之后确实就是普通消息了,如果订阅者一直不消费或者消费不了则会一直重试,到最后进入死信队列。其实这也算最大努力通知。
所以最大努力通知其实只是表明了一种柔性事务的思想:我已经尽力我最大的努力想达成事务的最终一致了。
适用于对时间不敏感的业务,例如短信通知。