分布式集群理论和分布式事务协议
# 各种事务
事务最先起源于数据库,但是随着业务的衍生,逐渐伸展到系统程序中,而在业务扩大的时候,事务也随之扩大,在缓存、消息队列、分布式中,都有可能遇到事务。由于用户的不断增加,单个服务显然是扛不住压力的,从以前的单个服务访问单个数据库,扩张到现在的多个服务访问多个数据库,我们都需要保证事务的准确性
而这种集群系统的一致性涉及到的东西过多,不能简单的使用 ACID 的手段处理,因为那样会付出很多不切实际的代价。为了在保证性能的情况下保持一致性,我们退而求其次,将时间跨度拉长,将一致性从二元属性转换为不同强度的多元属性
以服务的大小来分级,服务大致分为以下几类:
- 局部事务:又称本地事务,最原始的数据库事务,以 ACID 来保证数据的一致性
- 全局事务:指单个服务访问多个数据库的情况,理论上真正的全局事务没有单个服务这一约束,因为它本身就是从 DTP(分布式事务处理)中拆出来的概念,即协调操控多个数据源的情况。全局事务可以 3PC、2PC 来保证一致性
- 共享事务:指多个服务访问同一个数据源的情况。但是该情况与实际生产系统中的压力方向相悖,毕竟一个服务集群中数据库才是压力最大又最不容易延伸的重灾区
- 分布式事务:指多个服务访问多个数据库的情况。下面重点介绍如何保证该事务的一致性
# 分布式事务
# CAP 理论
CAP 也就是 Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性) 这三个单词首字母组合,对于一个分布式系统来说,当设计读写操作时,只能满足 CP 或者 AP
- 一致性(Consistency):所有节点访问同一份最新的数据副本
- 可用性(Availability):非故障的节点在合理的时间内返回合理的响应(不是错误或者超时的响应)
- 分区容错性(Partition tolerance):分布式系统出现网络分区的时候,仍然能够对外提供服务
分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。 为啥呢? 举个例子:若系统出现分区,系统中的某个节点在进行写操作。为了保证 C,必须要禁止其他节点的读写操作,这就和 A 发生冲突了。如果为了保证 A,其他节点的读写操作正常的话,那就和 C 发生冲突了。如果同时保证了 CA,那意味着我们将假设节点之间的通信永远是可靠的,永远不可能出现分区,但是由于自然灾害、机房爆炸、网络故障等问题,分区必然存在
因此 P 是分布式事务必要的特性,不能舍弃,接下来我们可以选择性的舍弃 C 或者 A
选择 CP 还是 AP 的关键在于当前的业务场景,没有定论,比如对于需要确保强一致性的场景如银行一般会选择保证 CP。ZK 就是著名的 CP 架构,它的 ZAB 协议可以保证系统强一致性
而现在大多数的服务是 AP 架构,某个节点出现故障不影响其他的节点对外提供服务。但是我们就是为了保证一致性才发明 CAP 理论的,使用技术的结果与发明技术的本意相悖,那该技术真的是正确答案吗?
其实 CAP 的设计多少有些无奈,毕竟在服务集群中完全保证之前的一致性是非常消耗资源的事情,我们需要给 ACID 中的一致性重新下个定义,即无论如何数据都会一致这种情况叫做强一致性,而 CAP 中的尽可能保证数据一致性的行为叫做弱一致性
在弱一致性的基础上,我们又总结出稍微强一点的特性叫最终一致性,它指:如果数据在一段时间内没有被修改,服务集群最终会达到与强一致性相同的结果。人们把 ACID 的事务叫做刚性事务,而分布式事务的常见做法被叫做柔性事务
# BASE 理论
BASE 是 Basically Available(基本可用) 、Soft-state(软状态) 和 Eventually Consistent(最终一致性) 三个短语的缩写
BASE 理论的核心思想是即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性,这样就可以保持系统整体主要可用
- 基本可用:系统出现故障时,处理用户请求的时间可能变长,或者系统的部分非核心功能无法使用
- 柔性事务:又称软状态,指允许系统中的数据存在中间状态,允许一段时间内集群中数据不一致的情况发生
- 最终一致性:强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态
# 分布式事务协调协议
分布式事务协议和分布式一致性算法是有区别的,我们在分布式中可能遇到一下类别的问题:
- 1,选举和共识,即在集群中选举出一个公认的 leader 或者针对数据库中或者系统中某个值达成一致。这个值的修改过程较短,不容易出错
- 2,事务,即让分布式机器协调运转,并且执行的事务较困难,过程容易出错
前者是使用分布式一致性算法来处理的,它更关注的是如何在分布式系统中维护数据的一致性状态。后者是使用分布式事务协调协议来处理的,更关注多个参与节点对一个事务的提交或回滚达成一致
# 为什么需要分布式事务协调协议
如果一个系统将转账业务拆分成了两部分,一部分是自己扣钱,另一部分是对方加钱,这两个服务部署在不同的机器上,万一在消息的传播过程中某个系统宕机了,我们需要保证两边的数据的一致性,我们所有服务的数据处理要么都成功要么都失败,即所有服务的原子性问题
可能有人会问,为什么不使用传统的 ACID 来保证事务的一致性。答案是这种情况和普通的数据库事务不一样,以往的事务只需要保证自己库里的数据一致性即可,并且一个服务只使用一个数据库,因此保证了一个数据库的一致性就保证了服务的一致性。但是现在如果一个服务使用多个数据源呢,通过之前的强一致性未免会出现非常多不必要的问题
举个最需要处理的例子,三个数据源,单独使用时每一个数据源都可以保证一致性,假如现在在一个事务中同时使用他们,并且需要向三个数据源都写入数据,此时前两个已经提交了,最后一个在提交时出现了异常需要回滚,此时三个数据源都需要回滚,但是提交的就是回滚了也无济于事
此时我们需要一个全局的 ACID 来保证局部的 ACID。而且这里的一致性也只能是写一致性,保证读一致性实在是太重了
# 刚性事务实现
# 两阶段提交 2PC
3PC(三阶段提交)和 2PC(两阶段提交)是分布式事务管理中的协议,主要用于确保分布式系统中多个节点在执行事务时的一致性。这些协议属于分布式一致性算法的一种,具体来说,它们是分布式事务管理中的协调协议
算法思路大致为,参与者将操作成败告诉协调者,协调者根据所有参与者结果的返回情况来决定该事务是否提交,亦或是回滚
2PC 的落地实现以数据库 XA 协议为主,但需注意单点故障问题,3PC 理论优美但实践少,因其脑裂风险难以规避
# 算法过程
两阶段指准备阶段和提交阶段
准备阶段:事务发起者首先向协调者发起事务请求,然后协调者会给所有参与者发送 prepare 请求(其中包括事务内容),参与者收到 prepare 消息后,他们会开始执行事务(但不提交),之后参与者就向协调者反馈是否准备好了
提交阶段:如果所有的参与者都返回了准备好了的消息,这个时候就进行事务的提交,协调者此时会给所有的参与者发送 Commit 请求,当参与者收到 Commit 请求的时候会执行前面执行的事务的提交操作,提交完毕之后将给协调者发送提交成功的响应;如果并不是所有参与者都返回了准备好了的消息,那么此时协调者将会给所有参与者发送回滚事务的 rollback 请求。当然这个回滚的操作相对重负载了,毕竟需要回滚之前已提交的事务
# 2PC 的前提
2PC 解决了原子性的问题,但是保证一致性还需要一些前提条件:
- 在 2PC 之下的层级,必须要处理拜占庭将军问题。即网络在提交阶段的短时间内时可靠的,允许丢失数据,但是不能传输错误的消息。这也是在提交时只发生 commit 的原因,我们让传输的数据尽可能短以减少网络风险
- 必须假设因为网络分区、机器故障或者其他原因导致失联的节点最终能够恢复,不会永久性的处于失联状态
# 2PC 的缺点
1,单点故障问题:如果协调者挂了那么整个系统都处于不可用的状态了,如果参与者挂了整个机器都会超时。这块不像是该层级需要处理的问题,在构建集群时做个自动选举即可
2,同步阻塞问题:即当协调者发送 prepare 请求,参与者收到之后如果能处理那么它将会进行事务的处理但并不提交,这个时候会一直占用着资源不释放,如果此时协调者挂了,那么这些资源都不会再释放了,这会极大影响性能。就算节点不挂,这个事务也会以整个集群最慢的节点结束
3,数据不一致问题:提交阶段协调者只发送了一部分的 commit 请求就挂了,那么也就意味着,收到消息的参与者会进行事务的提交,而后面没收到的则不会进行事务提交,那么这时候就会产生数据不一致性问题
4,保守策略导致过度回滚:任何参与者故障或网络问题,都会导致整个事务回滚,即使其他参与者都准备好了,3pc 会重点处理这个问题
总而言之 2PC 的缺点就是协调者在不同时期下线的话会出现的问题
# 三阶段提交 3PC
# 算法思路
首先询问参与者是否可以完成该事务,根据收到的反馈决定进行 2PC 或者取消事务,3PC 通过一系列的超时机制很好的缓解了阻塞问题
# 算法过程
CanCommit 阶段:协调者向所有参与者发送 CanCommit 请求,参与者收到请求后会根据自身情况查看是否能执行事务,如果可以则返回 YES 响应并进入预备状态,否则返回 NO。CanCommit 阶段只是询问,不会锁定任何资源
PreCommit 阶段:协调者根据参与者返回的响应来决定是否可以进行下面的 PreCommit 操作。如果上面参与者返回的都是 YES,那么协调者将向所有参与者发送 PreCommit 预提交请求,如果在第二阶段协调者收到了任何一个 NO 的信息,或者在一定时间内并没有收到全部的参与者的响应,那么就会中断事务。此阶段参与者执行事务,写 Undo/Redo 日志
DoCommit 阶段:如果协调者收到了所有参与者在 PreCommit 阶段的 YES 响应,那么协调者将会给所有参与者发送 DoCommit 请求,参与者收到 DoCommit 请求后则会进行事务的提交工作,DoCommit 阶段参与者如未收到协调者发送的提交事务的请求,它会在一定时间内进行事务的提交,因为在第一阶段所有的协调者全部返回了可以执行事务的响应,所以它认为其他机器都可以执行
# 3PC 的缺点
比如在 PreCommit 阶段,当一个参与者收到了请求之后其他参与者和协调者挂了或者出现了网络分区,这个时候收到消息的参与者都会进行事务提交,这就会出现数据不一致性问题。但是 2PC 其他缺点全部处理了。因此近似没有缺点
# 柔性事务实现
上面介绍的都是刚性事务,即在事务执行的时间后我们强制数据一致性,而柔性事务则遵循 BASE 理论,即我们允许数据不一致的时间增长,但是系统一定需要达到一个最终一致性状态,刚性事务的实现侧重回滚和探测,而柔性事务则侧重补偿和重试
这里额外提一点,在大厂开发的时候需要协调多个系统时大多时候是不会实现柔性或者刚性事务的,原因是业务调用链路太长,多个业务可能使用的框架或者系统都不一样,如果需要实现分布式事务,那么肯定需要使用统一的协调者,那么就需要提供类似重试接口一类的东西。在大多数业务场景下可能是不能保证多个业务方提供统一的接口的。甚至有可能调用方不是公司内部的,可能调用其他公司提供的接口,这时候要让大伙实现一个统一的框架是非常困难的
因此大厂的做法都是业务自身保证,比如升单掉支付的场景中,如果调用支付接口失败了,我们在订单业务中实现重试或者保存错误信息然后返回给用户,而支付方也自己保证接口的幂等性。所以以下提供的三个方法,只有在涉及到金融交易,比如在跨行转账、支付结算等场景中,才会用到
# 可靠事件队列
假如一个事务需要 A 服务、B 服务以及 C 服务,A 服务先执行本地事务,需要做两件事,一是更新自己的服务对应的数据库,二是向消息队列中发送事务消息
在系统中建立消息服务,B 和 C 接受到消息后开始执行对应事务。这里两个服务的事务执行可以是并行的。消息队列会持续的向因为网络问题未响应的服务一直发送消息,简单来说就是不停的重试,这个步骤的可重复性决定了所有的事务消息都必须是幂等的,通常的设计是向每一个消息都带上唯一的事务 ID
B 服务和 C 服务可能由于业务问题(比如没有库存了)返回失败,但是由于 A 服务没有回滚的操作,因此消息队列还是会一直发送消息直至有库存或者人工介入。可靠事件队列一旦第一步业务完成了,就只许成功,没有失败回滚的概念
该方式尽最大可能交付,可能参考了 TCP 的设计。并且不具备回滚的操作,但是对于大部分分布式事务来说都够用了,他非常适合先调用 A,在 A 执行完毕后需要调用 B 和 C,这三个机器需要保证一致性的情况
说白了这种方式就是不断的重试。同时,与该方法类似的方法叫本地消息表,其原理是将消息写入 db,然后 B 和 C 服务不断的去 db 中查数据执行事务,最终保证执行成功
# TCC 事务
它是 Try-Confirm-Cancel 三个单词的缩写,它的实现较为繁琐,是一种业务侵入性较强的事务方案,优点是解决了多个事务的隔离性(可靠事件队列的超时超时显然不能满足多个事务并行执行的需求)
我们需要在业务代码中实现 TCC,它的过程如下:

- Try:尝试执行阶段,预留好所有的业务资源(隔离性),并且完成业务可执行检查(保证一致性,检查网络通不通或者机器是否正常运行)。如果有任意的机器由于任何原因未能冻结资源,都会进入 Cancel 阶段
- Confirm:确认执行阶段,使用上一个阶段准备的资源完成业务处理。如果事务进入该阶段出现任何异常,会进入 Cancel 阶段,也可以进行重试操作,如果返回成功了,我们会记录事务的处理流程。由于会进行重试,我们的操作应当保证幂等性
- Cancel:取消执行阶段,释放 Try 阶段预留的资源,由于该阶段会重复执行(因为 Cancel 阶段也会出现异常,此时也会进行重试),因此对应的操作需要具备幂等性
从 3PC 到 TCC,可以看出多个事物处理隔离性的方式基本是预先检查以及操作补偿。同时请注意,全局事务发起方应当保证自己是集群部署,不会挂机,就算挂了也有一定的重试机制去补偿操作。保证协调者的可用性后,事务参与者的一致性由 TCC 来保证了
在实现分布式事务时,防悬挂和空回滚是针对网络不可靠性的两种关键防御机制,主要发生在二阶段中
空回滚值的是没执行 Try 却收到 Cancel,通常是 Try 阶段网络超时导致的。处理方案是 Cancel 逻辑需要先检查 Try 是否执行过。我们通过一个事务控制表记录 Try 状态。如果发现没执行过,就记录已空回滚并直接返回,不执行业务回滚逻辑
防悬挂指的是空回滚后,那个迟到的 Try 又执行了,导致业务资源被锁死。处理方案为 Try 执行前也检查控制表。如果发现已有已回滚记录,说明自己是迟到请求,直接拒绝执行,防止业务数据不一致
# SAGA 事务
TCC 是柔性事务模式中性能最高的事务了,但是不能满足所有的业务场景,举个例子:
某个购物平台的付款服务需要接通银行的转账功能,但是银行不会提供冻结款项、解冻、减扣这样的操作,TCC 的 Try 一般不会实现,此时我们需要考虑 SAGA 来实现事务
SAGA 原先的目的是为了避免大事务长时间锁定数据库资源,后来才发展成将一个分布式环境的大事物分解成多个本地事务的设计模式,SAGA 事务由以下两个步骤组成:
- 将大事物拆分成小事务,如果分布式事务可以提交,其最终一致性应当与连续按顺序提交多个小事务等价
- 为每一个子事务设计对应的补偿动作,需要满足以下条件
1,每一个子事务与对应的补偿事务都具备幂等性 2,每一个子事务与对应的补偿事务都满足交换律,无论先执行哪一个最结果都不影响 3,每一个补偿事务都必须能成功提交,出现异常需要不停重试
如果子事务执行失败,那么要么进行重试,要么进行补偿。与 TCC 相比,SAGA 不需要为资源设计冻结状态与撤销冻结的操作。但是执行 SAGA 的程序代码本身就可能会崩溃,所以它必须被设计成与数据库类似的日志机制
同时,事务发起方需要按照集群部署,并且保证每个副本上数据的强一致性,不管是使用 TCC 也好 Paxus 也罢,每个机器上的数据应当一样
# Seata
Seata 是一款开源的分布式事务解决方案,由阿里巴巴中间件团队发起。它的名字是 Season Algorithmic Transaction Architecture 的缩写
seata 在微服务架构下,保证跨多个独立数据库/服务的数据操作,依然满足 ACID 特性中的 原子性(A) 和 一致性(C)
Seata 支持的四种事务模式:
1,AT 模式(自动事务模式)—— 最常用、默认,基于两阶段提交(2PC) 的增强版,通过全局锁和回滚日志实现,只需使用 @GlobalTransactional 标注全局事务的入口方法就可以使用了
一阶段直接提交本地事务,释放本地锁,性能损耗低。二阶段如需要回滚,Seata 客户端自动根据回滚日志进行反向补偿。这种方式的实现是基于对数据库的代理和拦截实现
2,TCC 模式(Try-Confirm-Cancel),基于补偿型事务,需要业务方手动实现三个阶段的方法
- Try:资源检查和预留(如:冻结库存、预扣款)
- Confirm:确认执行(如:实际扣款、减库存)—— 必须幂等
- Cancel:取消预留(如:解冻库存、返还预扣款)—— 必须幂等
Seata 通过调用业务代码中的 Try、Confirm 和 Cancel 方法,并在每个阶段记录相关的操作日志,来实现分布式事务的一致性
3,Saga 模式(长事务解决方案),基于状态机的最终一致性事务,每个参与者提交本地事务。如果某个参与者失败,逆向执行前面已成功参与者的补偿操作。在跨系统、流程长、对最终一致性可接受的业务可以用 saga(如:机票+酒店+租车的旅行预订)
核心思想是一个事务拆成多个小事务,如果事务执行失败则回滚。seate 的实现是服务之间通过消息事件通信,每个服务监听事件并触发下一步
4,XA 模式(传统标准),基于数据库原生 XA 协议的两阶段提交
- 一阶段:XA prepare,锁定资源但不提交
- 二阶段:XA commit/rollback
特点是强一致性,但是性能较差资源锁定时间长,并发度低,要求数据库实现 XA 接口(MySQL、Oracle等都支持),这也是和默认方式不同的地方