Raft 协议实战系列(五)—— 集群成员变更与日志压缩

5,652 阅读10分钟

本文介绍 Raft 论文描述的两个 Raft 实践必备技术——集群成员变更与日志压缩。本文重点讲解 raft 集群如何动态增删节点、集群变更时脑裂的诱因及应对方案、状态机日志膨胀的应对方案,也会讨论更正知乎某大神《Raft协议详解》文章中忽略的小误区。

注:本文原创,转载请标明出处。

笔者期望帮助读者深入理解 Raft 协议,并能付诸于工程实践中,同时解读不易理解或易误解的关键点,看完不懂你来拍我😜 。该系列从原理、源码、实践三个部分讲解 Raft 原理及算法:

  • 原理部分结合 Raft 论文讲解 Raft 算法原理,分篇遵循 Raft 的模块化思想,分别讲解 Leader election、Log replication、Safety、Cluster membership change、Log compaction 等。
  • 源码部分分析 hashicorp/raft 来学习一个工业界的 Raft 实现(hashicorp/raft 是 Consul 的底层依赖)。
  • 实践部分基于 hashicorp/raft 实现一个简单的分布式 kv 存储,作为收尾。

系列历史文章链接:


尽管我们已经通过前几篇文章了解了 Raft 算法的核心部分,但相较于算法理论来说,在工程实践中仍有一些现实问题需要我们去面对。Raft 非常贴心的在论文中给出了两个常见问题的解决方案,它们分别是:

  1. 集群成员变更:如何安全地改变集群的节点成员。
  2. 日志压缩:如何解决日志集合无限制增长带来的问题。

本文我们将分别讲解这两种技术。

1. 集群成员变更

在前文的理论描述中我们都假设了集群成员是不变的,然而在实践中有时会需要替换宕机机器或者改变复制级别(即增减节点)。一种最简单暴力达成目的的方式就是:停止集群、改变成员、启动集群。这种方式在执行时会导致集群整体不可用,此外还存在手工操作带来的风险。

为了避免这样的问题,Raft 论文中给出了一种无需停机的、自动化的改变集群成员的方式,其实本质上还是利用了 Raft 的核心算法,将集群成员配置作为一个特殊日志从 leader 节点同步到其它节点去。

1.1 直接切换集群成员配置

先说结论:所有将集群从旧配置直接完全切换到新配置的方案都是不安全的

因此我们不能想当然的将新配置直接作为日志同步给集群并 apply。因为我们不可能让集群中的全部节点在“同一时刻原子地切换其集群成员配置,所以在切换期间不同的节点看到的集群视图可能存在不同,最终可能导致集群存在多个 leader。

为了理解上述结论,我们来看一个实际出现问题的场景,图1对其进行了展现。

图1 *图1

阶段a. 集群存在 S1 ~ S3 三个节点,我们将该成员配置表示为 C-old,绿色表示该节点当前视图(成员配置)为 C-old,其中红边的 S3 为 leader。

阶段b. 集群新增了 S4、S5 两个节点,该变更从 leader 写入,我们将 S1 ~ S5 的五节点新成员配置表示为 C-new,蓝色表示该节点当前视图为 C-new。

阶段c. 假设 S3 短暂宕机触发了 S1 与 S5 的超时选主。

阶段d. S1 向 S2、S3 拉票,S5 向其它全部四个节点拉票。由于 S2 的日志并没有比 S1 更新,因此 S2 可能会将选票投给 S1,S1 两票当选(因为 S1 认为集群只有三个节点)。而 S5 肯定会得到 S3、S4 的选票,因为 S1 感知不到 S4,没有向它发送 RequestVote RPC,并且 S1 的日志落后于 S3,S3 也一定不会投给 S1,结果 S5 三票当选。最终集群出现了多个主节点的致命错误,也就是所谓的脑裂。

图2 *图2

图2来自论文,用不同的形式展现了和图1相同的问题。颜色代表的含义与图1是一致的,在 problem: two disjoint majorities 所指的时间点,集群可能会出现两个 leader。

但是,多主问题并不是在任何新老节点同时选举时都一定可能出现的,社区一些文章在举多主的例子时可能存在错误,下面是一个案例(笔者学习 Raft 协议也从这篇文章中受益匪浅,应该是作者行文时忽略了。文章很赞,建议大家参考学习):

来源:《Raft 协议详解》知乎某大神

zhuanlan.zhihu.com/p/27207160

图3 *图3

该假想场景类似图1的阶段d,模拟过程如下:

  1. S1 为集群原 leader,集群新增 S4、S5,该配置被推给了 S3,S2 尚未收到。
  2. 此时 S1 发生短暂宕机,S2、S3 分别触发选主。
  3. 最终 S2 获得了 S1 和自己的选票,S3 获得了 S4、S5 和自己的选票,集群出现两个 leader。

图3过程看起来好像和图1没有什么大的不同,只是参与选主的节点存在区别,然而事实是图3的情况是不可能出现的

注意:Raft 论文中传递集群变更信息也是通过日志追加实现的,所以也受到选主的限制。很多读者对选主限制中比较的日志是否必须是 committed 产生疑惑,回看下在《安全性》一文中的描述:

每个 candidate 必须在 RequestVote RPC 中携带自己本地日志的最新 (term, index),如果 follower 发现这个 candidate 的日志还没有自己的新,则拒绝投票给该 candidate。

这里再帮大家明确下,论文里确实间接表明了,选主时比较的日志是不要求 committed 的,只需比较本地的最新日志就行

回到图3,不可能出现的原因在于,S1 作为原 leader 已经第一个保存了新配置的日志,而 S2 尚未被同步这条日志,根据上一篇《安全性》我们讲到的选主限制S1 不可能将选票投给 S2,因此 S2 不可能成为 leader。

1.2 两阶段切换集群成员配置

Raft 使用一种两阶段方法平滑切换集群成员配置来避免遇到前一节描述的问题,具体流程如下:

阶段一

  1. 客户端将 C-new 发送给 leader,leader 将 C-old 与 C-new 取并集并立即apply,我们表示为 C-old,new
  2. Leader 将 C-old,new 包装为日志同步给其它节点。
  3. Follower 收到 C-old,new 后立即 apply,当 **C-old,new 的大多数节点(即 C-old 的大多数节点和 C-new 的大多数节点)**都切换后,leader 将该日志 commit。

阶段二

  1. Leader 接着将 C-new 包装为日志同步给其它节点。
  2. Follower 收到 C-new 后立即 apply,如果此时发现自己不在 C-new 列表,则主动退出集群。
  3. Leader 确认 C-new 的大多数节点都切换成功后,给客户端发送执行成功的响应。

图4 *图4

图4展示了该流程的时间线。虚线表示已经创建但尚未 commit 的成员配置日志,实线表示 committed 的成员配置日志。

为什么该方案可以保证不会出现多个 leader?我们来按流程逐阶段分析。

阶段1. C-old,new 尚未 commit

该阶段所有节点的配置要么是 C-old,要么是 C-old,new,但无论是二者哪种,只要原 leader 发生宕机,新 leader 都必须得到大多数 C-old 集合内节点的投票

以图1场景为例,S5 在阶段d根本没有机会成为 leader,因为 C-old 中只有 S3 给它投票了,不满足大多数。

阶段2. C-old,new 已经 commit,C-new 尚未下发

该阶段 C-old,new 已经 commit,可以确保已经被 C-old,new 的大多数节点(再次强调:C-old 的大多数节点和 C-new 的大多数节点)复制。

因此当 leader 宕机时,新选出的 leader 一定是已经拥有 C-old,new 的节点,不可能出现两个 leader。

阶段3. C-new 已经下发但尚未 commit

该阶段集群中可能有三种节点 C-old、C-old,new、C-new,但由于已经经历了阶段2,因此 C-old 节点不可能再成为 leader。而无论是 C-old,new 还是 C-new 节点发起选举,都需要经过大多数 C-new 节点的同意,因此也不可能出现两个 leader。

阶段4. C-new 已经 commit

该阶段 C-new 已经被 commit,因此只有 C-new 节点可以得到大多数选票成为 leader。此时集群已经安全地完成了这轮变更,可以继续开启下一轮变更了。

以上便是对该两阶段方法可行性的分步验证,Raft 论文将该方法称之为共同一致(Joint Consensus)

关于集群成员变更另一篇更详细的论文还给出了其它方法,简单来说就是论证一次只变更一个节点的的正确性,并给出解决可用性问题的优化方案。感兴趣的同学可以参考:《Consensus: Bridging Theory and Practice》

2. 日志压缩

我们知道 Raft 核心算法维护了日志的一致性,通过 apply 日志我们也就得到了一致的状态机,客户端的操作命令会被包装成日志交给 Raft 处理。然而在实际系统中,客户端操作是连绵不断的,但日志却不能无限增长,首先它会占用很高的存储空间,其次每次系统重启时都需要完整回放一遍所有日志才能得到最新的状态机。

因此 Raft 提供了一种机制去清除日志里积累的陈旧信息,叫做日志压缩

快照Snapshot)是一种常用的、简单的日志压缩方式,ZooKeeper、Chubby 等系统都在用。简单来说,就是将某一时刻系统的状态 dump 下来并落地存储,这样该时刻之前的所有日志就都可以丢弃了。所以大家对“压缩”一词不要产生错误理解,我们并没有办法将状态机快照“解压缩”回日志序列。

注意,在 Raft 中我们只能为 committed 日志做 snapshot,因为只有 committed 日志才是确保最终会应用到状态机的。

图5 *图5

图5展示了一个节点用快照替换了 (term1, index1) ~ (term3, index5) 的日志。

快照一般包含以下内容:

  1. 日志的元数据:最后一条被该快照 apply 的日志 term 及 index
  2. 状态机:前边全部日志 apply 后最终得到的状态机

当 leader 需要给某个 follower 同步一些旧日志,但这些日志已经被 leader 做了快照并删除掉了时,leader 就需要把该快照发送给 follower。

同样,当集群中有新节点加入,或者某个节点宕机太久落后了太多日志时,leader 也可以直接发送快照,大量节约日志传输和回放时间。

同步快照使用一个新的 RPC 方法,叫做 InstallSnapshot RPC


至此我们已经将 Raft 论文中的内容基本讲解完毕了。《In Search of an Understandable Consensus Algorithm (Extended Version)》 毕竟只有18页,更加侧重于理论描述而非工程实践。如果你想深入学习 Raft,或自己动手写一个靠谱的 Raft 实现,《Consensus: Bridging Theory and Practice》 是你参考的不二之选。

接下来我们会再提供一篇关于线性一致性和 Raft 性能优化的讨论,来填补一些从理论到工业实践的鸿沟,然后便开启源码分析新篇章~