深入理解分布式共识算法 Raft

"不可靠的网络"、"不稳定的时钟"和"节点的故障"都是在分布式系统中常见的问题,在文章开始前,我们先来看一下:如果在分布式系统中网络不可靠会发生什么样的问题。

有以下 3 个服务构成的分布式集群,并在 server_1 中发生写请求变更 A = 1,"正常情况下" server_1 将 A 值同步给 server_2 和 server_3,保证集群的数据一致性:

但是如果在数据变更时发生网络问题(延迟、断连和丢包等)便会出现以下情况:比如有两个写操作同时发生在 server_1 或 server_3 上,即便两个写操作有先后顺序,但可能由于网络延时导致各个服务中数据的不一致:

同样地情况,如果在 server_1 上发生三次写操作,在数据同步的过程中因为网络延时或网络丢包也可能会导致数据的不一致:

那么为了避免以上这些集群间数据不一致的问题,便需要分布式共识算法来协调。分布式共识算法简单来说就是如何在多个服务器间对某一个值达成一致,并且当达成一致之后,无论之后这些机器间发生怎样的故障,这个值能保持不变。本篇文章我们便对 Raft 算法进行介绍。

理解 Raft 算法

了解和学习过 Zookeeper 的同学可能听说过 Zab 算法,它用来保证 Zookeeper 中数据的 顺序一致性 。Raft 也是一种分布式共识算法,它易于理解和实现,用于保证数据的 线性一致性,是最强一致性模型。

在遵循 Raft 算法的集群中,节点会有 3 种不同的角色。当集群在初始化时,每个节点的角色都是 Follower 跟随者,它们会等待来自 Leader 节点的心跳。因为此时并没有 Leader 节点,所以会等待心跳超时。等待超时的 Follower 节点会将角色转变为 Candidate 候选者,触发一次选举,触发选举时会标记 Term 任期变量,并将自己的一票投给自己,通知其他 Follower 节点发起投票。经过投票后,收到超过半数节点票数的 Candidate 节点会成为 Leader 领导者节点,其他节点为 Follower 跟随者节点,Leader 节点会不断地发送心跳给 Follower 节点来维持领导地位:

如果每个节点每次在触发选举时都是同时超时,这样是不是导致不能完成一次选举,产生 "活锁" 问题?的确可能,不过活锁问题也很好解决:即节点超时时间在合理的范围内取随机值,这样由于它的随机性就不太可能再同时发起竞选了,这个时候其他节点便有足够的时间向其他节点索要选票。

写变更请求

当发生写变更请求时,由 Leader 节点负责处理,即使是请求到 Follower 节点,也需要转发给 Leader 节点处理。当 Leader 节点接收到写请求时,它并不立即对这个请求进行处理,而是先将请求信息 按顺序追加到日志文件中(WAL: write-ahead-log) ,如图中标记的 log_index 表示追加到的最新一条日志的序号:

在这个过程中,日志必须持久化存储。随后,Leader 节点通过 RPC 请求将日志同步到各个 Follower 节点,当超过半数节点成功将日志记录时,便认为同步成功。在这里可知 Raft 算法采用的是单主复制的模型,所以它也就会存在以下缺点:

  1. 面对大量写请求负载时系统比较难扩展,因为系统只有一个主节点,写请求的性能瓶颈由单个节点决定
  2. 当主节点宕机时,从节点提升为主节点不是即时的,可能会造成一些停机时间

随后,Leader 节点会更新最新同步日志的索引 commit_index 为 1,并通过心跳下发给各个 Follower 节点:

在这个过程中可以发现 Follower 节点只是听从并响应 Leader 节点,没有任何主动性。现在,已经完成了日志在集群间的同步,但是请求对变量 A 的修改还没有被应用(Apply)。Apply 是在 Raft 算法中经常出现的一个名词,在多数与 Raft 算法相关的文章中经常会看到 "将已提交的日志条目应用到状态机" 等类似的表述。其实 "状态机" 理解起来并不复杂,通俗的理解是 业务逻辑的载体业务逻辑的执行者,它的职责包括:

  1. 接收来自日志文件中有序的命令
  2. 执行具体的业务逻辑,在本次写请求中,业务逻辑指的便是变更 A 的值
  3. 变更应用程序的状态
  4. 返回执行结果

更加通俗的讲就是 让请求生效。将已经提交的日志应用到状态机是比较简单且自主的过程,各个服务实例会记录 apply_index 来标记应用索引,当 apply_index 小于 commit_index 时,那么证明日志文件中记录的请求信息还有部分没生效,所以需要按顺序应用,直到 apply_index = commit_index:

在这个过程中,我一直在强调 "按顺序" ,不论是日志的追加还是日志的被应用都是按顺序来的,因此才能保证数据的线性一致性。

读请求

Raft 集群处理读请求会保证读请求的线性一致性,所谓线性一致性读就是在 t1 的时间写入了一个值,那么在 t1 之后,读一定能读到这个值,不可能读到 t1 之前的值,在 Raft 算法中实现线性一致性读有以下两种方式:

ReadIndex Read

在这种方式下,当 Leader 节点处理读请求时:

  1. 首先将 commit_index 记录到本地的 read_index 变量里
  2. 向其他节点发送一次 Heartbeat,确认自己仍然是 Leader 角色
  3. Leader 节点等待自己的状态机执行,直到 apply_index 超过了 read_index,这样就能够安全的提供线性一致性读了
  4. Leader 执行 read 请求,将结果返回

在第三步中,保证 apply_index >= read_index 是为了保证所有小于等于 read_index 的请求都已经生效。

如果是 Follower 节点处理读请求也和以上过程类似,当 Follower 节点收到读请求后,直接给 Leader 发送一个获取此时 read_index 的请求,Leader 节点仍然处理以上流程然后将 read_index 返回,此时 Follower 节点等到当前的状态机 apply_index 超过 read_index 后,就可以返回结果了。

Lease Read

因为 ReadIndex Read 需要发送一次 Heartbeat 来确认 Leader 身份,存在 RPC 请求的开销,为了进一步优化,便可以采用租约(Lease)读。租约其实指的是 Leader 节点身份的过期约定时间,所以这种读请求只针对 Leader 节点,Follower 节点没有租约的概念,它通过以下公式计算:

lease_end = current_time() + election_timeout / clock_drift_bound

其中 election_timeout 为选举的超时时间,clock_drift_bound 表示时钟漂移,指的是在分布式系统中,两个或多个节点上的时钟以不同的速率运行,导致它们之间的时间差随时间不断累积和变化(也就是分布式系统中不稳定的时钟问题)。

举个简单的例子,假如选举过期时间是 10s,时钟漂移为 1.1,那么租约过期时间为:lease_end = current_time() + 10s / 1.1 ≈ current_time() + 9s,如果在处理读请求时,在租约时间内,则无需发送 Heartbeat 来明确 Leader 身份,直接等待 apply_index >= commit_index 后返回请求结果。


在以上读写流程中,Raft 分布式共识算法能让每个节点对日志的值和顺序达成共识,每个节点都存储相同的日志副本,使整个系统中的每个节点都能有一致的状态和输出,使得这些节点看起来就像一个单独的,高可用的状态机。在上文中我们提到过 Zookeeper 使用的 Zab 共识算法保证的是顺序一致性,Raft 算法保证的是线性一致性,所以借着这个引子也来谈谈我对一致性的理解。

一致性

一致性 通常指的就是数据一致性,在分布式系统中的读写请求,表现得像在单机系统上一样,符合直觉和预期。一致性模型有很多种,在这里我们只谈以下常见的几种:

线性一致性 是最强的一致性模型,也被称为强一致性,在 CAP 定理中的 C 表达的一致性含义便是线性一致性。这种一致性模型要求系统要像单一节点一样工作,并且所有操作是原子的,它有两个约束条件:

  1. 顺序记录中的任何一次读必须读到最近一次写入的数据
  2. 顺序记录要跟全局时钟下的顺序保持一致

顺序一致性 要比线性一致性弱,它只要求 同一客户端或进程的操作在排序后保持先后顺序不变 ,但 不同客户端之间的先后顺序是可以任意改变的 ,顺序一致性与线性一致性的主要区别在于 没有全局时间的限制。比如在社交网络场景下,一个人通常不关注他看到的所有朋友的帖子的顺序,但是对于某个具体朋友,仍然以正确的顺序显示帖子的顺序。

因果一致性 则是比 顺序一致性 更弱的一致性模型,因果一致性要求必须以相同的顺序看到因果相关的操作,而没有因果关系的并发操作可以被不同的进程以不同的顺序观察到。典型的例子就是社交网络中发帖和评论的关系:必须先有发帖才能对该帖子进行评论,所以发帖操作必须在评论操作之前。

最终一致性 是常见的最弱的一致性模型,所谓最终表达的含义是"对于系统到达稳定状态并没有硬性要求",即便这听起来很不靠谱,但是在业务中被应用的很多也很好,而且这种一致性模型能使系统的性能很高。

CAP 定理:C 代表一致性,当客户端访问所有节点时,返回的都是同一份最新的数据;A 代表可用性,指每次请求都能获取到非错误的响应,但不保证获取的数据是最新的;P 代表分区容错性,节点之间由于网络分区而导致消息丢失的情况下,系统仍能正常运行。

接下来我们再来谈谈脑裂问题:

脑裂问题

当集群中发生网络通讯问题时,读、写请求只能在超过半数节点的集群内生效,过半数机制 在数学上保证不可能同时存在两个Leader:

除此之外还有以下机制来避免脑裂问题:

  1. Term机制:时间上保证旧Leader会自动让位给新Leader
  2. 主动stepDown:Leader无法联系到过半数节点时主动放弃领导权
  3. 严格的投票规则:每个term每个节点只能投票给一个候选人

当网络问题恢复时,Follower 节点能通过 Leader 节点的日志同步重新追回期间错过的数据。此外,一般采用 Raft 算法的集群在部署的时都是 "奇数个节点" ,而不是偶数个节点,这其实是数学的体现,性价比更高:

如上图所示,虽然部署 4 个节点多出一个节点,但是和 3 节点集群相比,容错能力是相同的:只能容忍 1 个节点故障。在容错能力没有被提高的情况下又花费了更多的服务器成本和运维管理成本。


以上我们基本了解了 Raft 算法的内容,如果想使用 Raft 算法,对系统模型有以下要求:

  1. 服务可能宕机、停止运行,但过段时间能够恢复,但不能存在 拜占庭故障
  2. 消息可能丢失、延迟乱序或重复;可能有网络分区,并在一段时间之后恢复

巨人的肩膀

相关推荐
修己xj14 小时前
三月,我只想做好这四件事
程序员
不要秃头啊20 小时前
别再谈提效了:AI 时代的开发范式本质变了
前端·后端·程序员
jonjia21 小时前
引入新维度化解权衡难题
程序员
jonjia21 小时前
优秀的工程师如何打破规则
程序员
jonjia21 小时前
在大厂交付大型项目的策略
程序员
jonjia21 小时前
RFC 与设计文档
程序员
jonjia21 小时前
为什么你(或任何人)应该成为一名研发经理?
程序员
jonjia21 小时前
管理技术质量 (Manage Technical Quality)
程序员
jonjia21 小时前
大厂软件工程师职业发展路径
程序员
jonjia21 小时前
关于工程师与影响力
程序员