[分布式]-Raft论文研读

前言

本文是对 Raft 论文的研读。跳过了原文中评价 Paxos 以及对 Raft 性质进行实验评估的部分。文中出现的黄字部分或者引用体部分为本人的个人理解或者在论文之外的其它文章阅读到的相关内容,作为补充

摘要

Raft 是在用于管理一系列日志副本的场景下的 一致性算法,它的效果与 Paxos 相同,性能与 Paxos 也不相上下。但它的架构与 Paxos 不同,更容易理解,从而也为构建一个实际性的系统提供了更好的基础。

为了提高算法的可理解性,Raft拆分出一致性的关键元素,包括 leader的选举日志的复制成员变更 以及 安全性。而且它会强制执行一种更强的一致性,减少了系统中需要考虑的状态的数量 。

Raft提供了一种新的机制用于变更集群中的成员,即 overlapping majority 机制,确保成员变更时的安全性。

介绍

一致性算法能使一系列计算机像一个一致的整体那样运行,能容忍部分计算机出现错误,即使有部分计算机错误,整个整体也能正常运行。因此,一致性算法在构建一个可靠的大型的软件系统中起着非常关键的作用。

在过去很长一段时间 Paxos 占据了关于一致性算法的所有讨论。但它很难理解,以至于我们决定找出一个新的更容易理解的一致性算法来为系统搭建提供一个更好的基础。我们最初的目的比较独特 —— 可理解性:是否可以定义一种一致性算法用于实际的系统中,而且远比 Paxos 容易理解。更重要的,我们希望这个算法能促进那些参与系统构建的人对整个算法的认知,不仅仅为了算法能运作,更要能明确算法为何能运作。

于是,一致性算法 —— Raft 诞生了。为了提高可理解性,我们使用了分解 (即将整个算法划分为 leader 选举,log 复制等不同部分),状态空间约简 (即减少服务器间不一致的状态数,或称软状态数) 这两种非常重要的思想。

Raft在很多方面与现有的一致性算法相似,但它拥有一些独特的特点:

  • leader 在集群中的领导力更强:例如,日志的条目只会从 leader 传递到其它服务器,这简化了对于日志副本的管理;
  • leader 的选举机制:Raft 使用 随机计时器 来选举 leader,每个 follower 在自己的计时器结束时就会发起选举;
  • 成员变更:Raft 对于变更集群中的服务器的机制使用了一种新的 联合共识 的方式,这种方式会使变更前后两个配置中的服务器群体在过渡期间重合,从而整个集群可以在配置变更期间继续正常地运行。

Raft 比其它一致性算法更简单,更容易理解,它完全足够达到一个实际系统的需求。

复制状态机

复制状态机 R e p l i c a t e d   S t a t e   M a c h i n e s Replicated\ State\ Machines Replicated State Machines 这种方法中,一个集群中的状态机对同一个状态会计算得到一模一样的 多个拷贝副本,从而尽管集群中有些服务器宕机了,整个系统也能继续运行。复制状态机在分布式系统中被用于解决一系列 容错问题

复制状态机通常使用 副本日志 replicated log 实现,每个服务器会存储一个包含一系列命令的日志,状态机会按顺序地执行这些命令,只要每个状态机都是正常的,他们就会演变到相同的状态,得到相同的输出序列。

那么,确保副本日志的一致性就是一致性算法的工作:一台服务器上的共识模块会接收来自客户端的命令,然后追加到它的日志中,同时与其它服务器的共识模块通信,确保所有服务器上每份日志 最终 都能包含相同顺序的相同命令,即便有的服务器故障了

只要所有命令被顺利地复制了,每个服务器的状态机就会按照顺序执行这些命令,随后就会将输出结果响应给客户端。从而,整个集群对外表现为一个独立的,高可靠性的状态机

对一个应用在实际系统中的一致性算法来说,需要有以下的属性:

  • 确保 安全,即便遇到网络延迟,网络分区,丢包等情况,绝对不会返回一个错误的结果;
  • 具备可用性,只要集群中有大部分的服务器是可运行的而且可以互相通信以及跟客户端通信。例如,一个拥有五台服务器的集群可以容许任意两台服务器的宕机,这些故障的服务器可以在后续恢复后可以根据存储的数据恢复状态数据,然后回到集群中;
  • 不依赖于时间来确保日志的一致性,不确保某个时间内必然达到一致性,因为错误的时钟或者极端的消息延迟会导致可行性的问题;
  • 在大部分情况下,一个命令只要集群中大部分服务器都响应了 RPC 请求就算完成了,小部分响应较慢的服务器不应该影响整个系统的性能表现

朝着可理解性设计

我们一方面认为 Paxos 很难理解,无法为教育或者一个实际系统的构建提供一个良好的基础;另一方面,一致性算法在大型的软件系统中又是非常重要,因此我们尝试是否可以设计一个更可选,比 Paxos 具有更优良属性的一致性算法,结果,我们得到了 Raft 这个算法

我们对 Raft 有这么几个目标:

  1. 必须能为系统构建提供完善而实际的基础,减少开发者们额外的设计工作
  2. 必须在任何情况下都保证安全,在某些典型的运行条件下保证可行
  3. 对于各种普遍的操作行为,需要能高效地完成
  4. 最重要也最难的,要具有可理解性,这不仅要求让大多数人都能理解整个算法,而且要让人能对这个算法拥有足够的认知,这样才能让系统构建者们可以去围绕算法拓展出一些真实实现中不可避免的所需要的东西

一致性算法 Raft

Raft 首先选举出一个 leader,由 leader 负责管理日志副本,同时接收来自客户端的日志条目,并将这些条目复制到其它的服务器上。leader 的机制简化了对于日志副本的管理,例如,一个 leader 可以决定将新的条目存放在日志的哪个部分而无须询问其它的服务器,而且数据流向很简单,只会从 leader 流向其它服务器。当 leader 发生故障或丢失连接,一个新的 leader 就会被选举出来

通过 leader 机制,Raft 将一致性问题分解成三个相对独立的子问题:

  1. leader 选举:当现存的 leader 故障时必须选举出一个新的 leader
  2. log 复制:leader 必须接收来自客户端的日志条目然后将他们在集群中复制,强制其它服务器的日志跟他的保持一致
  3. 安全性:Raft 中最关键的安全性就是状态机安全性 S t a t e   M a c h i n e   S a f e t y   P r o p e r t y State\ Machine\ Safety\ Property State Machine Safety Property,如果有一个服务器已经将一个日志条目应用到它的状态机中,那么其它服务器就不能在同一个日志索引 index 处存储一个不同的命令

Raft 的基础

一个 Raft 集群包含一系列服务器,比较典型的是 5 个服务器,此时系统能容忍两台服务器的故障。在任意时刻,每个服务器只会是三种状态中的一种:leaderfollower,或者 candidate。在正常的情况下,系统中只会有一台 leader,其余的均为 follower

follower 是被动的,它们不会自己发出请求,而只会响应来自 leader 以及 candidate 的请求

leader 会处理所有的客户端请求,如果一个客户端与一个 follower 发起了通信,follower 会将通信重定向转发到 leader

Raft 将时间划分为多个任期 term,每个 term 的时间长度是任意的,term 会按照连续的数字进行编号,每个 term 开始于一次选举而不是一个 Leader 的诞生,在选举阶段,一个或多个 candidate 会尝试去成为 leader,赢得选举的 candidate 在这个 term 余下的时间中会作为 leader

有些情况下,选举结果会分裂,此时这个 term 没有 leader 出现,将会直接结束。然后一次新的选举一个新的 term 会很快开始。

Raft 保证在一个给定的 term 中只会有最多一个 leader 出现

不同的服务器可能会在不同的时间观察 term 之间的过渡,有时候一个服务器可能不会去观察一次选举,甚至不去观察整个 term

term 可以看成 Raft 中的一种 逻辑上的时钟,使得系统中的服务器可以发现一些过期的信息,例如一个过时的 leader。每个服务器都会存储当前所在 term 的编号,这个编号会随着时间推移单调严格地递增

当前 term 的编号会在服务器之间通信时交换,如果一个服务器的编号比其它服务器的小,它就会更新自己的编号为这个较大值;如果一个 candidate 或者一个 leader 发现自己的编号过时了,它就会还原到 follower 状态;如果一个服务器收到了一个带有已过时 term 编号的请求,它将会拒绝该请求

Raft 中服务器间通信使用 RPC,基本的一致性算法只需要两种 RPC:RequestVote RPC,会由 candidate 在选举期间发起;
AppendEntries RPC,会由 leader 发起,为了复制 log 条目以及作为心跳的机制

除此之外后续还会讲到第三种 RPC,用于在服务器间传输快照

如果发起的 RPC 没有及时收到响应,服务器会重试 RPC,而且为了更好的性能,它们会并行地发起 RPC

Leader 的选举

Raft 使用 心跳机制 来触发 leader 选举

当服务器开始运行时都是 follower。只要一个服务器能收到来自 leader 或 candidate 的有效的 RPC,它都会保持 follower 的身份。leader 会周期性地发送心跳给所有的 follower 以维持它们的身份,发送的心跳是 不带任何 log 条目的 AppendEntries RPC

如果一个 follower 超过一段时间,我们称为 election timeout,没有收到任何通信,那么它会认为没有存活的 leader,然后开始一次选举,选出新的 leader

为了开始一次选举,一个 follower 需要增加它的 current term 数值,然后转变为 candidate 状态,接着它将会为自己投一票,然后向集群中其它的服务器发起 RequestVote RPC

一个 candidate 会保持自己 candidate 的身份,直到以下三种情况发生:

  1. 它赢得了选举。那么它会转变为 leader 状态
  2. 另一个服务器确立了自己为 leader。那么它会转变为 follower 状态
  3. 持续一段时间都没有赢家出现。那么它也会转变为 follower 状态

当一个 candidate 在同一个 term 中得到了整个集群中 大部分服务器的票,它就赢得了本次选举。在一个给定的 term 中,每个服务器会根据 first-come-first-served 原则将它唯一的一票投给最多一个 candidate

majority 原则确保了在一个 term 里最多只会有一个 candidate 能赢得选举,这就是选举安全性 E l e c t i o n   S a f e t y   P r o p e r t y Election\ Safety\ Property Election Safety Property

一旦一个 candidate 赢得了选举,它就成为了 leader,然后它将会向其它服务器发送心跳消息来确立它的身份,同时阻止新的选举过程

在等待投票到来的同时,一个 candidate 可能收到来自另一个服务器的AppendEntries RPC,声明该服务器已成为 leader。如果这个 RPC 请求中携带的该 leader 的 term 编号大于等于这个 candidate 的 current term,那么这个 candidate 就会认为这个 leader 是合法的 leader,然后自己回到 follower 的状态。反之,如果该 leader 的 term 编号小于这个 candidate 的 current term,那么 candidate 就会拒绝该 RPC,然后继续保持 candidate 的状态

第三种可能的情况就是每个 candidate 既没有赢得选举,也没有输掉选举:如果同时有多个 follower 成为了 candidate,选票就会被分散开,导致没有任何一个 candidate 能获得大多数的票。当这种情况发生时,每个 candidate 都会超时,然后增加自己的 term 编号,开始新的一次选举,然后发起新的一轮 RequestVote RPC。但是,如果没有采取额外的措施,票数分裂是有可能无止尽发生的

Raft 使用了随机化选举超时 randomized election timeouts 来确保票数分裂 split votes 很少出现,且可以快速被处理

首先,服务器会从一个固定的范围中随机地得到一个数作为选举超时的时长,如 150~300ms。这就使得每个服务器的选举时间被区分开,从而大多数情况下只会有一个服务器发生超时,有机会在其它服务器超时发生前发起选举并赢得选举然后发送心跳给它们

每个 candidate 会在一次选举开始的时候 重启 它的随机选举超时,然后等待超时时间到达,之后才会开始新的下一次选举。这减少了下一次选举时再次出现票数分裂的可能性

log 复制

一旦选举出了一个 leader,它就会开始接收客户端的请求并为之服务。每个客户端的请求都包含一个需要被复制状态机执行的命令leader 会将命令追加到他的 log 中作为 log 中新的条目,然后向每个其它服务器发起 AppendEntries RPC 让他们复制该条目。

当条目被安全地复制了,leader 就会将把条目应用 (apply) 到状态机中,然后向客户端返回结果,如果 follower 宕机或者运行缓慢,或者发生了丢包现象,leader 就会无止尽地重试 AppendEntries RPC 直到所有 follower 都存储了所有的日志条目,尽管它可能已经将结果回复给了客户端,所以,条目被 apply 并不要求所有 follower 都存储好了条目

当每个日志条目被 leader 接收后,会包含着一条状态机命令以及 term 编号,这些 term 编号有助于发现各个服务器之间 log 不一致的情况

每个 log entry 会包含一个数字索引 index,标志着它在整个 log 中的位置。每个条目的 index 都不同,但不同条目间 term 编号有可能相同

leader 需要确定什么时候足够安全可以把 log entry 应用到状态机中,一个被应用到状态机中的 entry 称为 committed entryRaft 保证所有的 committed entry 是持久性的,且最终会被所有可用的状态机执行

当创建该 entry 的 leader 成功在大部分服务器中复制了这个 entry,这个 entry 就会被提交,这同时也提交了 leader 的 log 中所有之前的 entry

leader 会持续维护它已提交的最高的 index,然后在 AppendEntries RPC 中携带这个 index,让其它服务器也能知晓这个最高 index,只要他们对比发现有某个 log 条目已经被提交了,它就会把这个条目应用到自己的状态机中

Raft 通过确保以下两个属性来组成日志匹配性 L o g   M a t c h i n g   P r o p e r t y Log\ Matching\ Property Log Matching Property:如果两个条目在不同的 log 中,但是具有相同的 index 相同的 term 编号,那么他们一定存储着相同的命令 command;而且这些日志中在该 index 之前的所有条目也一定都是相同的

前一个属性来源于 leader 在一个给定的 term 内只会创建一个带有给定 index 的条目,且 log 中的条目在 log 中的位置永远不会改变;

后一个属性是通过 AppendEntries RPC 带来的一种简单的一致性检查 consistency check 所保证的:在发送一个 AppendEntries RPC 的时候,leader 会携带自己的 log 中处在新的要发送的 entries 之前那个的 entry 的 index 跟 term 编号,如果 follower 在自己的 log 中没有找到带有相同 index 跟 term 的条目,它就会拒绝发来的新条目。只要 AppendEntries RPC 成功返回,leader 就可以知道 follower 的日志跟自己的完全相同

正常的运行情况下,leader 跟 follower 间的 log 会保持一致,因此 AppendEntries RPC 总是能成功返回。但 leader 崩溃可能导致 log 不一致,它可能在崩溃前没有将它 log 中的所有条目都完全复制给 follower。在一系列 leader 或 follower 崩溃的情况下,这些不一致性会更加严重:follower 可能不具有某些 leader 具有的条目;可能具有某些 leader 不具有的条目;也可能两种情况同时发生。丢失或者额外的条目可能跨越多个 term。

在 Raft 中,leader 会强制让 follower 的日志复制它的日志以此处理不一致性,这意味着 follower 中有冲突的 entry 将会被 leader 的 log 中的 entry 覆盖掉。

为了让 follower 的日志跟自己的日志达到一致的状态,leader 需要找到两份日志中一致的且是最新的 index,即 找到一个最新的 index,满足在该 index 以及该 index 之前的所有 index 上两份日志存储的 entry 都是相同的。然后删除掉 follower 中在该 index 后的所有 entry,再把自己的 log 中在该 index 后的所有 entry 发送给 follower

这些动作将会作为对前述讲到的一致性检查 consistency check 的响应而发生。leader 会对每一个 follower 保存着一个 nextIndex 参数,这个参数代表着 leader 将会发送给某个 follower 的下一个 entry 的 index 值。当一个服务器刚刚成为 leader 时,它会初始化所有 nextIndex 的值为自己的 log 中最后一个 index 的下一个 index 值

如果一个 follower 的 log 与 leader 的不一致,那么一致性检查 AppendEntries RPC 将会调用失败。在失败后,leader 就会对 nextIndex 进行减量操作然后重试 AppendEntries RPC,最终 nextIndex 将会到达一个 leader 的 log 跟该 follower 的 log 能匹配上的 index 值,此时 AppendEntries RPC 也会成功返回,同时 follower 的 log 中所有与 leader 冲突的 entry 也已被移除,并追加了来自 leader 的 log entry。只要 AppendEntries RPC 成功返回,follower 的 log 就与 leader 的一致了,在该 term 剩下的时间中也会保持着这种状态

如果需要的话,协议可以优化以减少失败的 AppendEntries RPC 的次数,例如,在拒绝一个 AppendEntries RPC 的时候,follower 可以携带 有冲突的 entry 的 term 编号以及该 term 中存储的第一个 index。有了这些信息,leader 在对 nextIndex 进行减量操作时可以直接跳过在该 term 中的有冲突的 entry。这样的话,对于每一个带有冲突 entry 的 term,都只需要一次 AppendEntries RPC 来确认是否冲突即可,而不是像原来那样,每一个 entry 都需要一个 AppendEntries RPC 来确认是否冲突。在实际中,我们并不确定这种优化是否必要,因为 AppendEntries RPC 的失败并不会频繁发生,而且不太可能会出现很多的不一致的 entry ,所以这种优化可能并不是必要的

通过这种机制,一个新出现的 leader 不需要专门采取特别的行动去恢复 log 的一致性。它只需要开始正常的运行,log 之间就会随着 AppendEntries RPC 的一致性检查的一次次错误后最终成功而变得一致。

一个 leader 永远不会覆盖或者删除他自己的 log 中的 entry,这就是 leader 只追加属性, L e a d e r   A p p e n d − O n l y   P r o p e r t y Leader\ Append-Only\ Property Leader AppendOnly Property

这种日志复制的机制也表现出理想的共识属性:只要集群中大部分的服务器是正常运行的,Raft 就能够接收,复制,应用新的 log entry,在正常的情况下只通过一轮 RPC 就可以将新 entry 复制给集群中的大部分服务器,单个运行缓慢的 follower 不会影响整体的性能

Safety

前述内容讲述了 Raft 如何选举 leader 以及如何复制 log entries。但目前为止所谈论到的机制尚未足够确保每个状态机都能正确地按照同样顺序执行相同的命令。例如,一个 follower 可能会在 leader commit 一些 log entry 的时候变得不可用,后续它可能被选举为 leader 然后用一些 entry 覆盖了这部分被 commit 的 entry,这样的话,不同的状态机就可能执行了不同的命令序列

这一节将会通过在哪个服务器可能被选举为 leader 这个问题上加一个限制来完善 Raft。这个限制确保任何一个给定 term 的 leader 会包含所有过往的 term 中已提交的 entry,即 leader 完整性属性, L e a d e r   C o m p l e t e n e s s   P r o p e r t y Leader\ Completeness\ Property Leader Completeness Property,通过这个限制,关于提交操作 commitment 的规则将会更加准确

选举限制

在任何使用到 leader 机制的一致性算法中,leader 最后都必须存储所有已提交的 log entry。Raft 使用了一种简单的方法确保 在每个 leader 刚刚成为 leader 的那一刻开始,就已拥有所有过往的 term 中已提交的 entry,不需要传送某些 entry 给它。这也意味着 log entry 只会以一种方向流动,即从 leader 到 follower;而且 leader 永远不会覆盖在他们的 log 中已存在的 entry

Raft 在投票的过程中会防止一个 candidate 赢得选举,除非其 log 包含了所有的已提交的 entry。一个 candidate 为了当选必须获得集群中的大部分服务器的支持,这意味着每个已提交的 entry 一定会出现在这些服务器中的至少一个中因为 entry 被提交意味着它一定存在大多数的服务器中了。如果 candidate 的 log 与这个大部分中的每个服务器的 log 都至少能一样新 (up-to-date),那自然也会跟拥有每个已提交 entry 的服务器一样 up-to-date,所以它就会拥有所有的已提交 entry。

RequestVote RPC 实现了这个限制:这个 RPC 包含了有关 candidate 的 log 的信息如果投票人 voter 的 log 比 candidate 的还要 up-to-date。那么投票人就会拒绝给 candidate 投票

Raft 通过比较两份 log 中的最后一个 entry 的 term 跟 index 来判断哪一个 log 更 up-to-date。如果两个 log 中最后一个 entry 拥有不同 term 编号,那么拥有更大 term 的 log 更新;如果两个 log 中最后一段 entry 的 term 相同,那么哪个 log 在这段相同 term 的区间内拥有更多的 entry 谁就更 up-to-date

提交来自过往 term 的 entry

前面讲到,一个 leader 知道一旦一个 entry 在大部分的服务器上存储了,这个 entry 就会被提交。如果一个 leader 在提交这个 entry 前挂机了,将来的 leader 将会尝试继续完成 entry 的复制。

但一个 leader 并不能迅速地推断出一个来自以前的 term 的 entry 在它被存储到大部分服务器时被提交了,有可能一个旧的 entry 虽然被存储到大部分服务器上了,但仍然被一个后续的没有被复制到这个 entry 的 leader 给覆盖了。为了消除这样的问题,Raft 从不会通过计数副本数来提交来自过往 term 的 entry,只会通过计数副本来提交 leader 当前 term 的 entry。一旦一个当前 term 的 entry 被提交了,那么由于日志匹配性 L o g   M a t c h i n g   p r o p e r t y Log\ Matching\ property Log Matching property 的存在,所有较早的 entry 也会间接被提交。

这提供了一定的鲁棒性,某个 entry 被复制到大部分服务器上了,此时应该被提交,但 leader 宕机了没有正常提交。但后续的 leader 会拥有这个 entry,因为在选举规则中我们知道能成为 leader 的服务器一定跟大部分服务器的 log 状态一样 up-to-date。这个 leader 不用额外去处理那个需要被提交但未提交的 entry,它只需要正常运行,在接收到下一个 entry 时,为了把新的 entry 复制给其它服务器,根据 Log Matching Property 以及 consistency check,其它先前没有接收到旧的 entry 的服务器也会被复制旧的 entry,这就完成了旧 entry 的继续复制。当新 entry 提交了,所有累积到这个 entry 为止的 entry 也被提交了。因此不用担心未被提交的 entry。从这个角度来看,entry 是否提交,归根到底还是取决于是否被复制到大多数服务器上

某些情况下 leader 也能安全地推断出一个旧的 log entry 被提交了,例如,一个 entry 在每个服务器上都存储了。但 Raft 采取了一种更保守的方法

Raft 的提交的规则 commitment rules 引起了这种额外的复杂性,因为在 leader 复制过往 term 的 entry 时,这些 entry 会保持他们原先的 term 编号。这种方法让 log entry 更好理解,因为它们在任何时间任何 log 间都保持相同的 term 编号

安全性论证

现在我们要更明确地论证 leader 完整性,Leader Completeness Property,是成立的。我们假定 Leader Completeness Property 并不成立,然后求证一个与之矛盾的事件

假设 term T 的 leader 在其 term 内 commit 了一个 entry,但这个 entry 并没有被某些后续的 term 的 leader 所存储。假定 term U 是后续所对应的 leader 未存储那个 entry 的 term 中最小的一个 term,即 T 跟 U 之间所有的 term 中的leader 都具有那个已提交 entry

  1. leaderU 在当选 leader 的时候,log 中不存在那个已提交的 entry
  2. leaderT 将 entry 复制到了集群中大部分的服务器上,而 leaderU 收到了集群中大部分服务器的投票。因此,这些投票者中至少一个服务器既接收到了 leaderT 的 entry,又投票给了 leaderU。这个投票者就是产生矛盾的关键所在
  3. 这个投票者在投票给 leaderU 之前一定已经从 leaderT 接收了那个已提交的 entry,否则它会拒绝 leaderT 的 AppendEntries RPC
  4. 投票者在它投票给 leaderU 的时候仍然存储着 entry,因为每个介于中间的 leader 都包含这个 entry,leader 从不会移除 entry,而且 follower 只会在和 leader 不一致时才移除 entry
  5. 投票者将票投给了 leaderU,所以 leaderU 的 log 肯定跟投票者的一样 up-to-date。这就带来了两个矛盾
  6. 第一,如果投票者跟 leaderU 的 log 中最后一个 term 相同,那么 leaderU 的 log 的内容一定至少跟投票者的一样多,所以它的 log 包含了投票者的 log 的所有 entry。这就矛盾了,因为投票者包含了所有已提交的entry,按照这种思路的话,leaderU 也会拥有所有已提交的 entry,但一开始我们的假设的 leaderU 并非如此
  7. 除此之外,leaderU 的 log 中最后的 term 肯定大于投票者的,而且,还要大于 leaderT 的,因为投票者的 log 的最后一个 term 至少都是 T。在 leaderT 之后,leaderU 之前的创造了 leaderU 的 log 中上一个 entry 的 leader 肯定在它的 log 中包含了那个已提交的 entry (根据我们的假设)。那么,通过 Log 匹配性 Log Matching Property,leaderU 的 log 肯定也包含了那个已提交 entry,这跟我们的假设相矛盾
  8. 论证完成。综上,在 T 之后的所有 term 的 leader 一定包含了来自 term T 的,在 term T 提交的所有的 entry
  9. Log Matching Property 确保了将来的leader也会包含那些被间接提交的entry

通过 Leader Completeness Property,我们可以证明状态机安全性 S t a t e   M a c h i n e   S a f e t y   P r o p e r t y State\ Machine\ Safety\ Property State Machine Safety Property,即如果一个服务器已经应用 apply 了一个给定的 index 上的 entry 到它的状态机中,那么没有其它服务器会在相同的 index 处 apply 一个不同的 entry。

在一个服务器 apply 了一个 entry 到它的状态机中的时候,它的 log 中直到该 entry 处的部分肯定就跟 leader 中的一模一样了,而且这个 entry 肯定被提交了。考虑在任意服务器都 apply 了某个给定的 entry 的 term 中最小的那个 term,Log Completeness Property 会确保所有更大的 term 的 leader 会存储相同的 entry,所以在以后的 term 中 apply 了该 index 的服务器所 apply 的将会是相同的值。因此,状态机安全性 State Machine Safety Property 是成立的

最后,Raft 要求服务器按照 log 中 index 的顺序去 apply entries。与 State Machine Safety Property 结合起来,就意味着所有服务器将会准确地按照相同顺序 apply 相同的 entry 集合到它们的状态机中

follower 和 candidate 宕机

到目前为止我们都聚焦在 leader 的故障。follower 跟 candidate 的故障要比 leader 故障处理起来更简单,而且两者处理的方式是相同的

如果一个 follower 或 candidate 宕机了,那么后续发给它的 Request Vote RPC 或者 AppendEntries RPC 将会失败。Raft 处理这些失败的方式为无止尽地重试;如果宕机的服务器重启了,那么 RPC 就能成功完成了

如果一个服务器在完成了 RPC,但还未回复时宕机了,那么他在从重启后将会接收到相同的 RPC。Raft 中的 RPC 是幂等的 (idempotent),所以这样不会出现什么后果。例如,如果一个 follower 收到一个 AppendEntries RPC,其中包含了一些已经存在在它的 log 中的 entry,那么它会忽视掉 RPC 中的那些 entry,只接收它未包含的 entry

计时和可用性

我们对 Raft 的要求之一是,安全性不应依赖于时间:系统不应该仅仅因为某些事件 event 比预期的发生得过快或过慢就产出不正确的结果

但是,可用性 (指系统及时地回复客户端的能力) 肯定难以避免地 (inevitably) 依赖于时间。例如,如果消息通信的耗时比服务器两次宕机之间的时间还长,即比一个服务器无故障时间还长,那么 candidate 将无法等待足够长的时间来赢得选举,因为消息通信时间比服务器无故障时间还长,那么在 Request Vote RPC 返回之前,candidate 可能就已经故障了 。因此集群将不会有稳定的 leader 存在,Raft 也就无法很好地稳定地提供服务

leader 选举 Leader Election 是 Raft 中时间因素非常关键的一个方面。只要系统满足下面的时间要求 timing requirement,Raft 就可以正常选举并保持有稳定的 leader 存在:

b r o a d c a s t T i m e < < e l e c t i o n T i m e o u t < < M T B F broadcastTime << electionTimeout << MTBF broadcastTime<<electionTimeout<<MTBF

在这个不等式中,broadcastTime 指的是一个服务器并行的发送 RPC 给集群中的每个服务器并收到它们的回复的平均耗时;

electionTimeout 即一个 follower 尝试发起选举前未收到 leader 心跳的时间;

MTBF 指的是一个服务器发生两次故障间的平均时间,即一个服务器的平均无故障时间

广播时间应该要比选举超时少一个数量级,leader 才能可靠地发送必要的心跳消息从而防止 follower 开始选举。考虑到选举超时使用到了随机化的方法,这个不等式也让投票分裂不太可能会发生

选举超时应该要比 MTBF 少一个数量级,这样系统才能稳定地提供服务,不会发生频繁的 leader 选举跟更换。

当 leader 宕机时,系统将会变得不可用,持续时间大约与选举超时相等,我们希望这种情况只会占全部时间的一小部分

广播时间跟 MTBF 是底层系统的属性,我们难以干涉。但选举超时就是我们必须选择的东西。Raft 中的 RPC 要求接收者持久化相关的信息,所以广播时间需要 0.5ms 到 20ms,取决于存储技术。这就导致选举超时可能要在 10ms 到 500ms 之间。典型的服务器的 MTBF 为几个月或更久,这很容易就能满足时间要求

集群成员变更membership changes

到目前为止,我们一直认为集群的配置 configuration,即参与整个一致性算法的服务器集合,是固定的。在实际中,有时候有必要去改变这个配置。例如当服务器故障时替换掉,或者改变副本数

尽管这可以通过让整个集群下线,更新配置文件,然后重启集群来完成,但这会让整个集群在更新 changeover 期间不可用。而且,如果其中有需要手动进行的操作,就需要冒着 操作员出错 的风险

为了避免这些问题,我们决定让配置变更的过程可以自动化,而且将他们合并到 Raft 一致性算法中

为了让配置变更的机制能够安全,在过渡期间必须不存在有可能两个 leader 在同个 term 中一起被选举出来的时间点

不幸的是,任何使服务器直接从旧的配置转变到新的配置的方法都是不安全的。由于不可能做到原子性地在同一时刻变更所有的服务器,所以在过渡期间,整个集群可能会潜在地分裂为两个独立的部分

为了确保安全性 safety,配置变更必须使用一种两阶段 two-phrase 方法。有很多种方式可以实现两阶段

例如,一些系统使用第一阶段来使旧的配置失效,这样它就不能处理客户端请求;然后第二阶段使新的配置起作用

在 Raft 中,集群首先转变到一个过渡性配置,我们称之为联合共识 joint consensus;一旦 joint consensus 被提交了,系统就转变到了新的配置。joint consensus 结合了旧的配置也结合了新的配置:

  • log entry 会复制到两个配置中的所有服务器
  • 来自两个配置中的任何一个服务器都有可能成为 leader
  • 对选举和 entry 提交 commitment 的同意,分别需要旧配置和新配置中的多数服务器同意

joint consensus 允许每一个服务器在不同的时间过渡到另一个配置,而无需为了安全性而妥协。而且,joint consensus 允许集群在配置变更期间继续服务客户端的请求

集群的配置信息使用副本 log 中的特殊的 entry 进行存储以及通信。

当 leader 收到变更配置的请求时,它将 joint consensus 配置信息以 entry 形式存储到 log 中然后复制这个 entry。只要一个给定的服务器将这个存有新配置的 entry 添加到 log 中时,在将来的决策中它就会使用这个配置,因为一个服务器会使用 log 中最新的配置,不管这个配置对应的 entry 是否已经提交。这意味着 leader 会使用 joint consensus 的规则来决定什么时候这个 joint consensus 的 entry 要提交

如果 leader 宕机了,新的 leader 必须要么从旧的配置,要么从 joint consensus 中选出,取决于赢得选举的 candidate 是否收到了 joint consensus 对应的 entry。无论如何,新配置中的服务器是不能在这个期间做出单方面的决定的

一旦 joint consensus 被 commit 了,旧配置跟新配置都不能在没有另一个配置同意的情况下做出决定 decision。而且 Log Completeness Property 确保只有拥有 joint consensus 对应 entry 的服务器才可以被选举为 leader

此时,leader 已经可以安全地生成描述新配置的 entry 并向集群复制。再次说明,这个配置只要被见到,就会对服务器开始起作用

当新配置被提交了,旧配置就无关紧要了,而且未处于新配置的服务器可以被关机。

旧配置跟新配置没有能够单方面做出决定的时间,这就确保了安全性

重配置 reconfiguration 还有三个问题存在:

第一个是,新的服务器可能初始并不存储着任何entry,如果它们在这种状态下被加入到集群中,那么将需要花费一些时间来追上集群中存储entry的进度,而这段时间可能无法 commit 新的 entry

为了避免可用性间隙 availability gap,Raft 在配置变更之前增加了一个阶段,在这个阶段中新的服务器会以不投票成员 non-voting member 的身份加入集群,leader 会把 log entry 复制给他们,但它们不会被视为多数服务器的组成部分。只要新服务器追上了集群中其它服务器的进度,重配置就能正常进行了

第二个问题是集群的 leader 可能不是新配置中的一部分,这种情况下,leader 将会在它 commit 了新配置的 entry 后回到 follower 状态,这意味着存在一段时间,即 leader 在 commit 新配置的 entry 的时候,它在管理着一个它并不存在其中的集群;它复制了 entry 但却没有把自己算在多数服务器中

leader 的过渡在新配置被提交时发生,因为这是新配置可以独立运行的第一个时间点。在新配置中选出一个 leader 是一直都可能做到的。在这个时间点之前,可能只有一个属于旧配置的服务器才能被选为 leader

第三个问题是被移除的服务器,即不在新配置中的服务器,可能扰乱整个集群。这些服务器将不会收到心跳 heartbeat,所以它们将会超时然后开始新的选举。它们会发送携带新 term 号的 Request Vote RPC,而这会导致当前的 leader 转变为 follower 状态

最后,新配置中一个新的 leader 将会被选出来,但被移除的服务器将会再次超时然后重复刚刚的过程,导致可用性降低

为了防止这个问题,当服务器相信当前的 leader 存活时,它将会无视 Request Vote RPC。具体来说,如果一个服务器在当前 leader 的最小选举超时内收到一个 Request Vote RPC,它不会更新它的 term 号或者投出它的票

这并不影响正常的选举,因为正常情况下每个服务器在开始一次选举前会等待至少一个最小选举超时的时间,所以无视在这个时间内受到的 Request Vote RPC 的话是可以的。但这能避免来自被移除服务器的干扰:如果一个 leader 可以让心跳到达集群,那么它将不会被更大的 term 编号罢免 depose 掉

单节点变更 也是变更集群成员结构的一种方式:在这种方式中,集群每次只会变更一个节点,具体来说,如果要把一个 3 节点集群变更为一个 5 节点集群,那么需要两次变更操作,一次把集群变更为 4 节点集群,然后再变更为 5 节点集群。这种方式保证了每次变更前后的两个配置中的多数派一定存在交集,在投票时也就不可能产生两个 leader,做到安全地变更配置。使用这种方式的话,集群中就不用引入 联合共识 的过渡状态

log压缩

Raft 中的 log 会随着正常的运行过程中合并更多客户端的请求而变得越来越多,但在一个实际系统中,它并不能没有上限地增长。随着 log 变得越来越长,它会占据更多的空间,调出时也会需要更多时间。没有一些机制来丢弃掉积攒在 log 中的过时信息最终会导致可用性问题

拍摄快照是最简单的压缩方法。在快照机制中,整个系统当前的状态会被写到一个快照 snapshot 中存储在持久化存储中,然后直到生成快照时为止的所有 log 就都被丢弃。快照机制 Snapshotting 在 Chubby 和 Zookeeper 中都有使用到,本节剩下的内容将会描述 Raft 中的快照机制

增量式的压缩方法,例如 log 清除 log cleaning,和 log-structured merge tree(LSM tree),也是可行的。这些方法每一次只会操作数据中的一部分,所以它们随着时间推移将压缩操作的负载按照时间线分散得更加均匀

它们首先找出数据中一个已经积累了很多被删除和被覆盖的对象的区域,然后更紧凑地重写这个区域中存活的对象,然后释放掉这个区域。这与 snapshotting 相比需要显著的额外机制以及复杂性,因为 snapshotting 一直都是在整个数据集上操作,从而简化了问题

在 Raft 的快照机制中,每个服务器独立地拍摄快照,覆盖自己的 log 中已经 commit 的 entry。大部分工作在于状态机将自己当前的状态写到快照中。Raft 也在快照中包含了一些元数据:
last included index 是快照替换掉的部分的日志中最后一个 entry 的 index,或者说状态机已经 apply 的最后一个 entry 的 index;
last included term 就是这个 entry 的 term。

这些数据被保存来支持 AppendEntries RPC 完成发送处于这份快照后的第一个 log entry 时的一致性检查 consistency check,因为处理这个 entry 时需要知道先前的 log index 以及 term。为了支持集群 membership changes,快照也包含了 log 中 最新的配置信息。一旦服务器写好了快照,它会删除掉直到最后一个被包含的 index 之前的所有 log entry,以及所有之前的快照

尽管正常情况下服务器独立地拍摄快照,但 leader 需要偶尔把快照发送给落后的 follower。这通常在 leader 已经丢弃掉下一个它需要发送给 follower 的 entry 时发生。幸运的是这种情况在正常情况下不可能出现,一个已经跟上 leader 进度的 follower 将会拥有这个 entry。但是一个速度异常缓慢的 follower 或者一个新加入集群的服务器将不会如此,那么将这样一个 follower 达到最新的进度的方法就是让 leader 通过网络发送给它一个快照

leader 使用一个新的 RPC 名为 InstallSnapshot 来发送快照给那些落后甚远的 follower。

当一个 follower 收到了这种 RPC 以及其中的 snapshot 时,它必须决定要对当前的 log entries 进行什么操作。

通常情况下 snapshot 会包含不存在在接收者的 log 中的新的信息。这种情况下,follower 丢弃掉整个 log,被快照完全取代,而且可能存在与快照冲突的未提交 entry;如果相反,follower收到的快照描述了自己的 log 的某部分前缀,那么被快照覆盖的 entries 会被删掉,而在快照后的 entries 仍然有效而被保留

这种快照的方法背离了 Raft 的强 leader 法则,因为 follower 可以在 leader 不知情的情况下拍摄快照。但是我们认为这种背离是合理的。

尽管拥有一个 leader 可以帮助在达到一致性的过程中避免产生有冲突的决定,但一致性在拍摄快照时就已经达到了,所以没有决定是冲突的。数据仍然只会从 leader 流向 follower,只有 follower 会重新组织他们的数据

我们考虑一种 leader-based 的方法,只有 leader 会生成一个快照,然后它会将快照发送给其它的 follower。这种方法有两个缺点:

首先,发送快照给每个 follower 需要花费网络带宽而且会减慢整个快照处理的过程。每个 follower 已经有用于生成自己的快照所需的信息,而且一个服务器生成一个自己本地状态的快照比在网络上发送然后接收一个快照的成本更少。快照机制更大的意义在于数据的压缩,而不是一致性

第二,leader 的实现会更复杂,例如,leader 需要并行地发送快照给 follower 以及复制新的 log entries 给它们,才不会阻塞新的客户端请求

影响快照机制的性能的问题还有两个。首先,服务器必须决定什么时候拍摄快照,如果一个服务器过于频繁地拍摄快照,会浪费磁盘带宽和资源;如果过少拍摄,就需要冒着耗尽自身存储容量的风险,而且使得重启时重载 log 所需的时间更长。一个简单的策略就是在 log 达到一个固定的字节大小时就拍摄一次快照,如果这个大小设置得比一个快照期望的大小大得多,那么用于快照的磁盘带宽的开销就会小

第二个性能问题是写一个快照可能会花费大量的时间,我们并不想让这耽误了正常的运作。解决方法就是使用写时复制 copy-on-write 技术,新的更新在没有影响到正在写的快照时可以被接受。例如操作系统的写时复制技术

客户端交互

这一小节叙述客户端如何与 Raft 交互,包括客户端如何找到集群的 leader 以及 Raft 如何支持线性化语义学 linearizable semantics

线性化语义学,通俗点说就是线性一致性,即强一致性,指的是一个操作执行后要能立刻对所有个体都可见,而且只被执行一次。

这些问题适用于所有基于共识 consensus-based 的系统,Raft 的解决方案与其它系统也很相似

Raft 中的客户端将它们所有的请求发给 leader。当一个客户端刚刚上线,它会连接到一个随机选出来的服务器。如果客户端第一个选择不是 leader,这个服务器将会拒绝客户端的请求并提供有关它所知道的最近的 leader 的信息,因为 AppendEntries RPC 包含了 leader 的网络地址。如果 leader 宕机了,客户端的请求将会超时,接着客户端会对随机选出的服务器再次尝试请求

我们对 Raft 的目标是实现 linearizable semantics,即每个操作只在它的调用跟响应之间的时间内瞬间执行且只执行一次。但是根据目前为止所描述的,Raft 可能多次执行一个命令:例如,如果 leader 在 commit 了 entry 后但还没响应客户端时宕机了,客户端就会对一个新的 leader 重试这个命令,导致这个命令被执行了第二次

解决方法就是 让客户端对每个命令分配唯一的序列号,然后状态机对每个客户端跟踪记录最新的已经处理过的序列号,以及对应的响应。如果它收到了一个对应序列号已经被执行过的命令,它就会迅速地响应,不会重新执行这个请求

只读 Read-Only 的请求可以不会对 log 有任何写入地被处理。但是没有额外的措施的话,这可能会有返回了陈旧数据的风险,因为正在回复请求的 leader 可能已经被一个新的它所不知情的 leader 取代了 。线性 linearizable 的读一定不能返回过时的数据,Raft 在不使用 log 的情况下需要两个额外的预防措施来确保这件事

首先,leader 必须拥有已提交 entry 的最新信息,leader Completeness property 确保了一个 leader 拥有所有的已提交 entry,但在他的任期 term 刚开始时,它可能不知道这些 entry 是什么,为了找到答案,它需要在它的 term 提交一个 entry

Raft 通过让每个 leader 在任期开始时提交一个空白的无操作 no-op entry 到 log 中来处理这个问题,通过提交这个无操作 entry,leader 就能拥有已提交 entry 的最新信息

第二,一个 leader 必须在处理一个只读请求前检查 check 是否自己已经被罢免 depose 了,因为如果一个更新的 leader 已经被选举出来,它的信息可能就过时了。

Raft 通过让每一个 leader 在响应只读请求前向集群中多数服务器交换心跳消息来处理这个问题。或者,leader 可以依靠心跳机制来提供一种租赁形式,但这需要依赖时钟来满足安全性 (假定会出现有界的时钟偏移)

猜你喜欢

转载自blog.csdn.net/Pacifica_/article/details/127863287