分布式事务方案

2021/7/21 springcloud微服务

# 事务

事务提供一种机制将一个活动涉及的所有操作纳入到一个不可分割的执行单元,组成事务的所有操作只有在所有操作均能正常执行的情况下方能提交,只要其中任一操作执行失败,都将导致整个事务的回滚。简单地说,事务提供一种“要么什么都不做,要么做全套(All or Nothing)”机制。

严格意义上的事务实现应该是具备原子性、一致性、隔离性和持久性,简称 ACID。

  • 原子性(Atomicity),可以理解为一个事务内的所有操作要么都执行,要么都不执行。
  • 一致性(Consistency),可以理解为数据是满足完整性约束的,也就是不会存在中间状态的数据,比如你账上有400,我账上有100,你给我打200块,此时你账上的钱应该是200,我账上的钱应该是300,不会存在我账上钱加了,你账上钱没扣的中间状态
  • 隔离性(Isolation),指的是多个事务并发执行的时候不会互相干扰,即一个事务内部的数据对于其他事务来说是隔离的。
  • 持久性(Durability),指的是一个事务完成了之后数据就被永远保存下来,之后的其他操作或故障都不会对事务的结果产生影响。

而通俗意义上事务就是为了使得一些更新操作要么都成功,要么都失败。

# 分布式事务

# 什么是分布式事务?

分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器**「分别位于不同的分布式系统的不同节点之上」**。

一个大的操作由N多的小的操作共同完成。而这些小的操作又分布在不同的服务上。针对于这些操作,「要么全部成功执行,要么全部不执行」

# 为什么会有分布式事务?

直接通过例子:

转账是最经典的分布式事务场景,假设用户 A 使用银行 app 发起一笔跨行转账给用户 B,银行系统首先扣掉用户 A 的钱,然后增加用户 B 账户中的余额。

如果其中某个步骤失败,此时就有可能会出现 2 种**「异常」**情况:

  • 1.用户 A 的账户扣款成功,用户 B 账户余额增加失败
  • 2.用户 A 账户扣款失败,用户 B 账户余额增加成功。

对于银行系统来说,以上 2 种情况都是**「不允许发生」**,此时就需要事务来保证转账操作的成功。

在**「单体应用」中,我们只需要贴上@Transactional注解就可以开启事务来保证整个操作的「原子性」**。

但是看似以上简单的操作,在实际的应用架构中,不可能是单体的服务,我们会把这一系列操作交给**「N个服务」去完成,也就是拆分成为「分布式微服务架构」**。

拆分成分布式微服务架构

比如下订单服务,扣库存服务等等,必须要**「保证不同服务状态结果的一致性」**,于是就出现了分布式事务。

# 分布式理论

# CAP定理

在一个分布式系统中,以下三点特性无法同时满足,「鱼与熊掌不可兼得」

一致性(C): 在分布式系统中的所有数据备份,「在同一时刻是否拥有同样的值」。(等同于所有节点访问同一份最新的数据副本)

可用性(A): 在集群中一部分节点**「故障」后,集群整体「是否还能响应」**客户端的读写请求。(对数据更新具备高可用性)

分区容错性(P): 即使出现**「单个组件无法可用,操作依然可以完成」**。

具体地讲在分布式系统中,在任何数据库设计中,一个Web应用**「至多只能同时支持上面的两个属性」**。显然,任何横向扩展策略都要依赖于数据分区。因此,设计人员必须在一致性与可用性之间做出选择。

熟悉CAP的人都知道,三者不能共有,如果感兴趣可以搜索CAP的证明,在分布式系统中,网络无法100%可靠,分区其实是一个必然现象,如果我们选择了CA而放弃了P,那么当发生分区现象时,为了保证一致性,这个时候必须拒绝请求,但是A又不允许,所以分布式系统理论上不可能选择CA架构,只能选择CP或者AP架构。

对于CP来说,放弃可用性,追求一致性和分区容错性,我们的zookeeper其实就是追求的强一致。

对于AP来说,放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性,这是很多分布式系统设计时的选择,后面的BASE也是根据AP来扩展。

# BASE理论

在分布式系统中,我们往往追求的是可用性,它的重要程序比一致性要高,那么如何实现高可用性呢?

前人已经给我们提出来了另外一个理论,就是BASE理论,它是用来对CAP定理进行进一步扩充的。BASE理论指的是:

  • **「Basically Available(基本可用)」**分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。
  • **「Soft state(软状态)」**允许系统中存在中间状态,这个状态不影响系统可用性,这里指的是CAP中的不一致。
  • **「Eventually consistent(最终一致性)」**最终一致是指经过一段时间后,所有节点数据都将会达到一致。

BASE理论是对CAP中的一致性和可用性进行一个权衡的结果,理论的核心思想就是:我们无法做到强一致,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。

# 柔性事务和刚性事务

柔性事务满足BASE理论(基本可用,最终一致)

刚性事务满足ACID理论

柔性事务分为

  • 两阶段型
  • 补偿型
  • 异步确保型
  • 最大努力通知型

传统单机环境下数据库的ACID事务满足了分布式环境下的业务需要,以上几种事务类似就是针对分布式环境下业务需要设定的。

# XA和Jta规范

# 分布式事务的解决方案

# 1.两阶段提交(2PC)

2PC(Two-phase commit protocol),中文叫二阶段提交。 二阶段提交是一种强一致性设计,2PC 引入一个事务协调者的角色来协调管理各参与者(也可称之为各本地资源)的提交和回滚,二阶段分别指的是准备(投票)和提交两个阶段。

mysql的事务就是通过**「日志系统」**来完成两阶段提交的。

两阶段协议可以用于单机集中式系统,由事务管理器协调多个资源管理器;也可以用于分布式系统,「由一个全局的事务管理器协调各个子系统的局部事务管理器完成两阶段提交」

这个协议有**「两个角色」**,A节点是事务的协调者,B和C是事务的参与者。

事务的提交分成两个阶段

# 第一个阶段是**「投票阶段」**

  1. 协调者首先将命令**「写入日志」**
  2. **「发一个Prepare命令」**给B和C节点这两个参与者
  3. B和C收到消息后,根据自己的实际情况,「判断自己的实际情况是否可以提交」
  4. 将处理结果**「记录到日志」**系统
  5. 将结果**「返回」**给协调者

假如在第一阶段有一个参与者返回失败,那么协调者就会向所有参与者发送回滚事务的请求,即分布式事务执行失败。

2PC-第一阶段

# 第二个阶段是**「决定阶段」**

当A节点收到B和C参与者所有的确认消息后

  1. 「判断」所有协调者「是否都可以提交」
    1. 如果可以则**「写入日志」**并且发起Commit命令
    2. 有一个不可以则**「写入日志」**并且发起Rollback命令
  2. 参与者收到协调者发起的命令,「执行命令」
  3. 将执行命令及结果**「写入日志」**
  4. **「返回结果」**给协调者

2PC-第二阶段

第二阶段提交失败出现的情况

  1. 第二阶段执行的是回滚事务操作,那么答案是不断重试,直到所有参与者都回滚了,不然那些在第一阶段准备成功的参与者会一直阻塞着。
  2. 第二阶段执行的是提交事务操作,那么答案也是不断重试,因为有可能一些参与者的事务已经提交成功了,这个时候只有一条路,就是头铁往前冲,不断的重试,直到提交成功,到最后真的不行只能人工介入处理。

细节:2PC 是一个同步阻塞协议,像第一阶段协调者会等待所有参与者响应才会进行下一步操作,当然第一阶段的协调者有超时机制,假设因为网络原因没有收到某参与者的响应或某参与者挂了,那么超时后就会判断事务失败,向所有参与者发送回滚命令。在第二阶段协调者的没法超时,因为按照我们上面分析只能不断重试!

也可参考如下图:

image-20210719161459519 image-20210719161517296

# 协调者故障分析

  1. 假设协调者在发送准备命令之前挂了,等于事务还没开始,不用处理。
  2. 假设协调者在发送准备命令之后挂了,这就不太行了,有些参与者等于都执行了处于事务资源锁定的状态。不仅事务执行不下去,还会因为锁定了一些公共资源而阻塞系统其它操作。
  3. 假设协调者在发送回滚事务命令之前挂了,那么事务也是执行不下去,且在第一阶段那些准备成功参与者都阻塞着。
  4. 假设协调者在发送回滚事务命令之后挂了,这个还行,至少命令发出去了,很大的概率都会回滚成功,资源都会释放。但是如果出现网络分区问题,某些参与者将因为收不到命令而阻塞着。
  5. 假设协调者在发送提交事务命令之前挂了,这个不行,傻了!这下是所有资源都阻塞着。
  6. 假设协调者在发送提交事务命令之后挂了,这个还行,也是至少命令发出去了,很大概率都会提交成功,然后释放资源,但是如果出现网络分区问题某些参与者将因为收不到命令而阻塞着。

# 协调者故障,通过选举得到新协调者分析

因为协调者单点问题,因此我们可以通过选举等操作选出一个新协调者来顶替。

  • 如果处于第一阶段,其实影响不大都回滚好了,在第一阶段事务肯定还没提交。
  • 如果处于第二阶段,假设参与者都没挂,此时新协调者可以向所有参与者确认它们自身情况来推断下一步的操作。
    • 假设有个别参与者挂了!这就有点僵硬了,比如协调者发送了回滚命令,此时第一个参与者收到了并执行,然后协调者和第一个参与者都挂了。
    • 此时其他参与者都没收到请求,然后新协调者来了,它询问其他参与者都说OK,但它不知道挂了的那个参与者到底O不OK,所以它傻了。

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

虽然协议上没说,不过在实现的时候我们可以灵活的让协调者将自己发过的请求在哪个地方记一下,也就是日志记录,这样新协调者来的时候不就知道此时该不该发了?

  • 但是就算协调者知道自己该发提交请求,那么在参与者也一起挂了的情况下没用,因为你不知道参与者在挂之前有没有提交事务。
    • 如果参与者在挂之前事务提交成功,新协调者确定存活着的参与者都没问题,那肯定得向其他参与者发送提交事务命令才能保证数据一致。
    • 如果参与者在挂之前事务还未提交成功,参与者恢复了之后数据是回滚的,此时协调者必须是向其他参与者发送回滚事务命令才能保持事务的一致。

所以说极端情况下还是无法避免数据不一致问题。

# 可能存在的问题

  • 「单点故障」:一旦事务管理器出现故障,整个系统不可用
  • 「数据不一致」:在阶段二,如果事务管理器只发送了部分 commit 消息,此时网络发生异常,那么只有部分参与者接收到 commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
  • 「响应时间较长」:整个消息链路是串行的,要等待响应结果,不适合高并发的场景
  • 「不确定性」:当协事务管理器发送 commit 之后,并且此时只有一个参与者收到了 commit,那么当该参与者与事务管理器同时宕机之后,重新选举的事务管理器无法确定该条消息是否提交功。

# 小结

2PC 是一种尽量保证强一致性的分布式事务,因此它是同步阻塞的,而同步阻塞就导致长久的资源锁定问题,总体而言效率低,并且存在单点故障问题,在极端条件下存在数据不一致的风险。

2PC 适用于数据库层面的分布式事务场景,而我们业务需求有时候不仅仅关乎数据库,也有可能是上传一张图片或者发送一条短信。

# 2.三阶段提交(3PC)

三阶段提交又称3PC,相对于2PC来说增加了CanCommit阶段超时机制。 如果这段时间内没有收到协调者的commit请求,那么就会自动进行commit,解决了2PC单点故障的问题。

但是性能问题和不一致问题仍然没有根本解决

流程:

  • 第一阶段:「CanCommit阶段」 这个阶段所做的事很简单,就是协调者询问事务参与者,你是否有能力完成此次事务。

    • 如果都返回yes,则进入第二阶段
    • 有一个返回no或等待响应超时,则中断事务,并向所有参与者发送abort请求
  • 第二阶段:「PreCommit阶段」 此时协调者会向所有的参与者发送PreCommit请求,参与者收到后开始执行事务操作,并将Undo和Redo信息记录到事务日志中。参与者执行完事务操作后(此时属于未提交事务的状态),就会向协调者反馈“Ack”表示我已经准备好提交了,并等待协调者的下一步指令。

  • 第三阶段:「DoCommit阶段」 在阶段二中如果所有的参与者节点都可以进行PreCommit提交,那么协调者就会从“预提交状态”转变为“提交状态”。然后向所有的参与者节点发送"doCommit"请求,参与者节点在收到提交请求后就会各自执行事务提交操作,并向协调者节点反馈“Ack”消息,协调者收到所有参与者的Ack消息后完成事务。 相反,如果有一个参与者节点未完成PreCommit的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送abort请求,从而中断事务。

三阶段提交

# 3PC 的阶段变更有什么影响?

首先准备阶段的变更成不会直接执行事务,而是会先去询问此时的参与者是否有条件接这个事务,因此不会一来就干活直接锁资源,使得在某些资源不可用的情况下所有参与者都阻塞着。

预提交阶段的引入起到了一个统一状态的作用,它像一道栅栏,表明在预提交阶段前所有参与者其实还未都回应,在预处理阶段表明所有参与者都已经回应了。

假如你是一位参与者,你知道自己进入了预提交状态那你就可以推断出来其他参与者也都进入了预提交状态。

但是多引入一个阶段也多一个交互,因此性能会差一些,而且绝大部分的情况下资源应该都是可用的,这样等于每次明知可用执行还得询问一次。

# 参与者超时能带来什么样的影响?

我们知道 2PC 是同步阻塞的,上面我们已经分析了协调者挂在了提交请求还未发出去的时候是最伤的,所有参与者都已经锁定资源并且阻塞等待着。

那么引入了超时机制,参与者就不会傻等了,如果是等待提交命令超时,那么参与者就会提交事务了,因为都到了这一阶段了大概率是提交的,如果是等待预提交命令超时,那该干啥就干啥了,反正本来啥也没干

然而超时机制也会带来数据不一致的问题,比如在等待提交命令时候超时了,参与者默认执行的是提交事务操作,但是有可能执行的是回滚操作,这样一来数据就不一致了

当然 3PC 协调者超时还是在的,具体不分析了和 2PC 是一样的。

# 3PC解决的问题

从维基百科上看,3PC 的引入是为了解决提交阶段 2PC 协调者和某参与者都挂了之后新选举的协调者不知道当前应该提交还是回滚的问题。

新协调者来的时候发现有一个参与者处于预提交或者提交阶段,那么表明已经经过了所有参与者的确认了,所以此时执行的就是提交命令。

所以说 3PC 就是通过引入预提交阶段来使得参与者之间的状态得到统一,也就是留了一个阶段让大家同步一下。

但是这也只能让协调者知道该如果做,但不能保证这样做一定对,这其实和上面 2PC 分析一致,因为挂了的参与者到底有没有执行事务无法断定。

所以说 3PC 通过预提交阶段可以减少故障恢复时候的复杂性,但是不能保证数据一致,除非挂了的那个参与者恢复。

# 小结

所以 2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。

3PC 很少有具体的实现,3PC 只是纯的理论上的东西,而且可以看到相比于 2PC 它是做了一些努力但是效果甚微,所以只做了解即可。

# 3.补偿事务(TCC)

2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务,分布式事务不仅仅包括数据库的操作,还包括发送短信等,这时候 TCC 就派上用场了!

TCC其实就是采用的补偿机制,其核心思想是:「针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作」。它分为三个阶段:Try - Confirm - Cancel

  • Try阶段主要是对**「业务系统做检测及资源预留」**,其主要分为两个阶段
  • Confirm 阶段主要是对**「业务系统做确认提交」**,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
  • Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,「预留资源释放」

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

比如说一个事务要执行A、B、C三个操作,那么先对三个操作执行预留动作。如果都预留成功了那么就执行确认操作,如果有一个预留失败那就都执行撤销动作。

我们来看下流程,TCC模型还有个事务管理者的角色,用来记录TCC全局事务状态并提交或者回滚事务。

TCC模型

可以看到流程还是很简单的,难点在于业务上的定义,对于每一个操作你都需要定义三个动作分别对应Try - Confirm - Cancel

因此 TCC 对业务的侵入较大和业务紧耦合,需要根据特定的场景和业务逻辑来设计相应的操作。

还有一点要注意,撤销和确认操作的执行可能需要重试,因此还需要保证操作的幂等

TCC 事务机制相比于上面介绍的2PC,解决了其几个缺点:

  • 1.「解决了协调者单点」,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。
  • 2.「同步阻塞」:引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。
  • 3.「数据一致性」,有了补偿机制之后,由业务活动管理器控制一致性

总之,TCC 就是通过代码人为实现了两阶段提交,不同的业务场景所写的代码都不一样,并且很大程度的**「增加」了业务代码的「复杂度」**,因此,这种模式并不能很好地被复用。

相对于 2PC、3PC ,TCC 适用的范围更大,但是开发量也更大,毕竟都在业务上实现,而且有时候你会发现这三个方法还真不好写。不过也因为是在业务上实现的,所以TCC可以跨数据库、跨不同的业务系统来实现事务

# 4.本地消息表

本地消息表流程

执行流程:

  • 消息生产方,需要额外建一个消息表,并**「记录消息发送状态」**。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。

    • 如果消息发送失败,会进行重试发送。
  • 消息消费方,需要**「处理」这个「消息」**,并完成自己的业务逻辑。

    • 此时如果本地事务处理成功,表明已经处理成功了

    • 如果处理失败,那么就会重试执行。

      • 如果是**「业务上面的失败」,可以给生产方「发送一个业务补偿消息」**,通知生产方进行回滚等操作。
  • 生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。

可以看到本地消息表其实实现的是最终一致性,容忍了数据暂时不一致的情况。

注:可能不一定会选择消息队列来处理,也可能有其他方案,例如:定时任务扫描

# 5.消息事务

消息事务的原理是将两个事务**「通过消息中间件进行异步解耦」**,和上述的本地消息表有点类似,但是是通过消息中间件的机制去做的,其本质就是'将本地消息表封装到了消息中间件中'。

执行流程:

  • 发送prepare消息到消息中间件

  • 发送成功后,执行本地事务

    • 如果事务执行成功,则commit,消息中间件将消息下发至消费端
    • 如果事务执行失败,则回滚,消息中间件将这条prepare消息删除
    • RocketMQ 的发送方会提供一个反查事务状态接口,如果一段时间内消息没有收到任何操作请求,那么 Broker 会通过反查接口得知发送方事务是否执行成功,然后执行 Commit 或者 RollBack 命令。
  • 如果是 Commit 那么订阅方就能收到这条消息,然后再做对应的操作,做完了之后再消费这条消息即可。如果消费失败,则不断重试。如果是 RollBack 那么订阅方收不到这条消息,等于事务就没执行过。

消息事务流程

这种方案也是实现了**「最终一致性」,对比本地消息表实现方案,不需要再建消息表,「不再依赖本地数据库事务」了,所以这种方案更适用于高并发的场景。目前市面上实现该方案的「只有阿里的 RocketMQ」**。

# 6.最大努力通知

其实我觉得本地消息表也可以算最大努力,事务消息也可以算最大努力。

就本地消息表来说会有后台任务定时去查看未完成的消息,然后去调用对应的服务,当一个消息多次调用都失败的时候可以记录下然后引入人工,或者直接舍弃。这其实算是最大努力了。

事务消息也是一样,当半消息被commit了之后确实就是普通消息了,如果订阅者一直不消费或者消费不了则会一直重试,到最后进入死信队列。其实这也算最大努力。

所以最大努力通知其实只是表明了一种柔性事务的思想:我已经尽力我最大的努力想达成事务的最终一致了。

适用于对时间不敏感的业务,例如短信通知。

# 7.Sagas 事务模型

Saga事务模型又叫做长时间运行的事务

其核心思想是**「将长事务拆分为多个本地短事务」,由Saga事务协调器协调,如果正常结束那就正常完成,如果「某个步骤失败,则根据相反顺序一次调用补偿操作」**。

# 总结

可以看出 2PC 和 3PC 是一种强一致性事务,不过还是有数据不一致,阻塞等风险,而且只能用在数据库层面。

而 TCC 是一种补偿性事务思想,适用的范围更广,在业务层面实现,因此对业务的侵入性较大,每一个操作都需要实现对应的三个方法。

本地消息、事务消息和最大努力通知其实都是最终一致性事务,因此适用于一些对时间不敏感的业务。

# 参考