Raft协议先行了解
总体过程速览
-
假设我们只使用一个节点,可以很容易的达成协议或者共识。
-
但是现在我们思考,假如有多个节点呢?
-
多个节点之间达成协议或者共识就叫做分布式共识。
-
而Raft就是一个实现分布式共识的协议。
-
一个节点可以有3个状态:
- 追随者
- 候选人
- 领导者
-
所有节点都以跟随者状态开始
-
如果追随者没有收到领导者的消息(心跳包机制),那么他们就可以成功候选人
-
候选人向其他节点发送投票
-
节点将用他们的投票来回复
-
如果候选人获得了大多数节点的选票,则候选人成为领导者
-
这个过程叫做领导选举
-
领导被选举出来之后,系统的所有更改现在都经过领导者
-
每一个更改都作为一个条目添加到节点的日志中
-
此日志条目当前未提交,因此不会更新节点的值
-
要提交条目,节点首先将其复制到跟随着节点
-
然后领导者等待,直到大多数节点都写入了条目
-
该条目现在已在领导节点上提交,节点状态是5
-
然后领导者通知追随者该条目已经提交了
-
集群现在已经就系统状态达成了共识
- 这个过程叫做日志复制
领导选举
- 在Raft中有两个超时设置来控制选举
Term:任期,每次开始一次新的选举,term++
-
第一个超时时间是选举超时
-
选举超时是追随者等待成为候选人的时间,意思就是假如150ms - 300ms的时间之内没有人进行选举,也没有候选人,那么追随者就可以成功候选人进行选举。
-
选举超时随机在 150 毫秒到 300 毫秒之间
-
选举超时后,跟随者成为候选人并开始新的选举任期
-
候选人可以给自己投票,并且向其他节点发送请求投票消息
-
如果接收节点在这个任期内还没有投票,那么它将投票给候选人
-
然后节点重置其选举超时
-
一旦候选人获得多数选票,它就成为领导者
-
领导者开始向其追随者发送附加条目消息
-
这些消息按照心跳超时指定的时间间隔发送
-
追随者收到消息之后响应每个附加条目的消息
-
这个选举任期将一直持续到跟随者停止接收心跳并成为候选人为止
-
让我们停止领导者并观察重新选举的发生(B宕机了)
-
要求多数选票保证每个任期只能选出一名领导人
-
如果两个节点同时成为候选者,则可能会发生分裂投票
-
两个节点都开始了同一任期的选举
-
并且每个都先于另一个到达一个跟随者节点
-
现在每个候选人有 2 票,并且不能再获得这个任期
-
此时无法进行选举,节点将等待新的选举并重试
-
节点 C 在第 5 期获得多数票,因此成为领导者
日志复制
-
一旦我们选出了领导者,我们需要将对我们系统的所有更改复制到所有节点
-
这是通过使用用于心跳的相同附加条目消息来完成的
-
我们来看一下这个过程:
- 首先客户端发送一条更改请求
- 这个更改会附加到领导者的日志当中
-
然后在下一次心跳的时候将更改发送给关注者
-
一旦大多数追随者承认,条目就会被提交
-
提交成功之后,向客户端发送响应
-
Raft甚至可以在网络分区时保持一致
- 由于我们的分区,现在又两位不同任期的领导人
- 让我们添加另一个客户端并尝试更新两个领导者
- 一个客户端将尝试将节点 B 的值设置为“3”
-
节点 B 无法复制到多数,因此它的日志条目保持未提交状态
-
另一个客户端将尝试将节点 D 的值设置为“8”
-
这会成功,因为它可以复制到大多数
-
现在恢复网络分区
-
节点 B 将看到更高的选举任期并下台
-
节点 A 和 B 都将回滚它们未提交的条目并匹配新领导者的日志
- 我们的日志现在在整个集群中是一致的
Raft协议进阶
特此声明,从这里开始,笔者参考了博客《深度解析 Raft 分布式一致性协议》 -Q的博客
Raft是什么?
在分布式系统中,为了消除单点提高系统可用性,通常使用副本来进行容错,但是这样会带来另外一个问题,即如何保证多个副本之间的一致性?
共识算法就是用来做这个事情的,它保证即使在小部分节点故障的情况下,系统仍然可以正常的对外提供服务。
谁在使用Raft协议?
典型的有:
- etcd
- consul
Raft总体描述
Raft使用法定人数的机制来实现共识和容错,我们将对Raft集群的操作成为提案,每当发起一个提案,必须得到大多数节点的同意才可以提交。
这里的“提案”我们可以先狭义地理解为对集群的读写操作,“提交”理解为操作成功。
首先Raft必须存在一个主节点,我们作为客户端集群发起的所有操作都必须经由主节点处理。所以Raft核心算法中的第一部分就是选主。
那么主节点需要承载什么工作呢?它会负责接收客户端发过来的请求,将操作包装成日志同步给其他节点,在保证大部分节点都同步了本次操作之后,就可以安全的给客户端回应响应了。这一部分工作在Raft核心算法中叫做日志复制。
可以看到主节点起到了核心的作用。所以在选主的时候一定要谨慎,要有符合条件的节点才可以成为主节点。此外,主节点在处理操作日志的时候也要谨慎,为了保证集群对外展现的一致性,不可以覆盖或者删除前任主节点已经处理成功的操作日志。这一部分叫做安全性
所以Raft核心算法无非就是由这三个子问题组成的:
- 选主
- 日志复制
- 安全性
除了核心算法之外,Raft还提供了几个工程实践中必须要面对的问题的解决方案。
- 日志无限制增长的问题。我们必须有一些方法来避免无休止的磁盘占用和过久的日志重放。这部分叫做日志压缩。
- 集群成员变更的问题。一个Raft集群不可能永远是固定的几个节点,总有扩容和缩容的需求,或者是节点需要替换的时候。直接更换集群成员可能会导致严重的脑裂问题。因此Raft给出了一种安全变更集群成员的方式。这一部分叫做集群成员变更
Tips:几个分布式的重要概念:‘
网络分区:网络分区是一种故障类型。通常情况下,网络分区指的是在分布式集群中,节点之间由于网络不通,导致集群中节点形成不同的子集,子集中节点间的网络相通,而子集和子集间网络不通。网络分区是子集与子集之间在网络上相互隔离了。
脑裂问题:以redis为例子:在哨兵架构中,redis的集群脑裂是某个master所在机器突然脱离了正常的网络,导致redis master节点跟redis slave节点和sentinel集群处于不同的网络分区,此时因为sentinel集群无法感知到master的存在,哨兵可能就会认为master宕机了,然后开启选举,将其他slave切换成了master,这个时候集群里就会有两个master,也就是所谓的脑裂。
选主过程
节点角色
- Leader: 所有请求的处理者,接收客户端发起的操作请求,写入本地日志后同步至集群其它节点。
- Follower: 请求的被动更新者,从 leader 接收更新请求,写入本地文件。如果客户端的操作请求发送给了 follower,会首先由 follower 重定向给 leader。
- Candidate: 如果 follower 在一定时间内没有收到 leader 的心跳,则判断 leader 可能已经故障,此时启动 leader election 过程,本节点切换为 candidate 直到选主结束。
任期
每次开启一次新的选举,称为一个任期,每一个任期都由一共严格递增的整数与之关联。
每当 candidate 触发 leader election 时都会增加 term,如果一个 candidate 赢得选举,他将在本 term 中担任 leader 的角色。但并不是每个 term 都一定对应一个 leader,有时候某个 term 内会由于选举超时导致选不出 leader,这时 candicate 会递增 term 号并开始新一轮选举。
来自《深度解析Raft分布式一致性协议》 -Q的博客
term有点儿像一个逻辑时钟,有了它,就可以发现哪些节点的状态已经过期。
节点之间通过RPC来通信,主要有两类RPC请求:
- 用于
candidate
拉票选举 - 用于
leader
向其他节点复制日志以及同步心跳
Raft的选主过程是基于一种心跳机制,集群中每个节点刚启动的时候都是follower
身份,leader
会周期性的向所有节点发送心跳包来维持自己的权威。但是如果一个follower
在一段时间之内没有接收到leader
发送的心跳包,那么它就会发起新的选举。
那么这个的时间该如何设定呢?如果所有节点在同一时刻启动,经过同样的超时时间后同时发起选举那么就很难选择出来一个节点,效率十分底下。Raft巧妙的使用了一个随机化的定时器,让每一个节点在一定的范围内随机生成。
具体过程我们在上面已经讲过一次了,所以这里就不讲了,可以看这个网站里面的动画。
https://thesecretlivesofdata.com/raft/
https://raft.github.io/
这里考虑一种选举失败的场景:
Candidate 在等待投票回复的时候,可能会突然收到其它自称是 leader 的节点发送的心跳包,如果这个心跳包里携带的 term 不小于 candidate 当前的 term,那么 candidate 会承认这个 leader,并将身份切回 follower。这说明其它节点已经成功赢得了选举,我们只需立刻跟随即可。但如果心跳包中的 term 比自己小,candidate 会拒绝这次请求并保持选举状态。
再考虑一种选举超时的场景:
第三种可能的结果是 candidate 既没有赢也没有输。如果有多个 follower 同时成为 candidate,选票是可能被瓜分的,如果没有任何一个 candidate 能得到大多数节点的支持,那么每一个 candidate 都会超时。此时 candidate 需要增加自己的 term,然后发起新一轮选举。如果这里不做一些特殊处理,选票可能会一直被瓜分,导致选不出 leader 来。这里的“特殊处理”指的就是前文所述的随机化选举超时时间。
日志复制
状态机复制:状态机复制是实现容错服务的一种常规方法,主要通过复制服务器,并协调客户端和这些服务器镜像间的交互来达到目标。这个方法也同时提供了理解和设计复制管理协议的一套基本框架。
来自维基百科
共识算法通常基于状态复制机(Replicated State Machine)模型,所有节点从同一个 state 出发,经过一系列同样操作 log 的步骤,最终也必将达到一致的 state。也就是说,只要我们保证集群中所有节点的 log 一致,那么经过一系列应用(apply)后最终得到的状态机也就是一致的。
具体步骤如下:
- Leader 为客户端提供服务,客户端的每个请求都包含一条即将被状态复制机执行的指令。
- Leader 把该指令作为一条新的日志附加到自身的日志集合,然后向其它节点发起附加条目请求(AppendEntries RPC),来要求它们将这条日志附加到各自本地的日志集合。
- 当这条日志已经确保被安全的复制,即大多数(N/2+1)节点都已经复制后,leader 会将该日志 apply 到它本地的状态机中,然后把操作成功的结果返回给客户端。
整个集群的日志模型可以宏观表示为下图(x ← 3 代表 x 赋值为 3):
每条日志除了存储状态机的操作指令外,还会拥有一个唯一的整数索引值(log index)来表明它在日志集合中的位置。此外,每条日志还会存储一个 term 号(日志条目方块最上方的数字,相同颜色 term 号相同),该 term 表示 leader 收到这条指令时的当前任期,term 相同的 log 是由同一个 leader 在其任期内发送的。
当一条日志被 leader 节点认为可以安全的 apply 到状态机时,称这条日志是 committed(上图中的 committed entries)。那么什么样的日志可以被 commit 呢?答案是:当 leader 得知这条日志被集群过半的节点复制成功时。因此在上图中我们可以看到 (term3, index7) 这条日志以及之前的日志都是 committed,尽管有两个节点拥有的日志并不完整。
Raft 保证所有 committed 日志都已经被持久化,且“最终”一定会被状态机apply。
注:这里的“最终”用词很微妙,它表明了一个特点:Raft 保证的只是集群内日志的一致性,而我们真正期望的集群对外的状态机一致性需要我们做一些额外工作,这一点在《线性一致性与读性能优化》一章会着重介绍。
日志复制流程图解
我们通过 Raft 动画 来模拟常规日志复制这一过程:
如上图,S1 当选 leader,此时还没有任何日志。我们模拟客户端向 S1 发起一个请求。
S1 收到客户端请求后新增了一条日志 (term2, index1),然后并行地向其它节点发起 AppendEntries RPC。
S2、S4 率先收到了请求,各自附加了该日志,并向 S1 回应响应。
所有节点都附加了该日志,但由于 leader 尚未收到任何响应,因此暂时还不清楚该日志到底是否被成功复制。
当 S1 收到2个节点的响应时,该日志条目的边框就已经变为实线,表示该日志已经安全的复制,因为在5节点集群中,2个 follower 节点加上 leader 节点自身,副本数已经确保过半,此时 S1 将响应客户端的请求。
leader 后续会持续发送心跳包给 followers,心跳包中会携带当前已经安全复制(我们称之为 committed)的日志索引,此处为 (term2, index1)。
所有 follower 都通过心跳包得知 (term2, index1) 的 log 已经成功复制 (committed),因此所有节点中该日志条目的边框均变为实线。
对日志一致性的保证
前边我们使用了 (term2, index1) 这种方式来表示一条日志条目,这里为什么要带上 term,而不仅仅是使用 index?原因是 term 可以用来检查不同节点间日志是否存在不一致的情况,阅读下一节后会更容易理解这句话。
Raft协议做出了两个保证:
- 如果不同的节点日志集合中的两个日志条目拥有相同的term和index,那么他们一定存储了相同的指令。为什么可以作出这种保证?因为 Raft 要求 leader 在一个 term 内针对同一个 index 只能创建一条日志,并且永远不会修改它。
- 如果不同的节点日志集合中的两个日志条目拥有相同的term和index,那么他们之前的所有的日志条目也全部相同。这是因为 leader 发出的 AppendEntries RPC 中会额外携带上一条日志的 (term, index),如果 follower 在本地找不到相同的 (term, index) 日志,则拒绝接收这次新的日志。
如何处理日志不一致的问题
实际上,真实的集群情况是很复杂的,各种时间点的宕机等复杂的情况会导致很多日志不一致的场景。那么如何处理这种场景呢?
解决方法非常的简单粗暴:
Raft强制要求follower必须复制leader的日志集合来解决不一致的问题
也就是说,follower 节点上任何与 leader 不一致的日志,都会被 leader 节点上的日志所覆盖。这并不会产生什么问题,因为某些选举上的限制,如果 follower 上的日志与 leader 不一致,那么该日志在 follower 上一定是未提交的。未提交的日志并不会应用到状态机,也不会被外部的客户端感知到。
要使得 follower 的日志集合跟自己保持完全一致,leader 必须先找到二者间最后一次达成一致的地方。因为一旦这条日志达成一致,在这之前的日志一定也都一致(回忆下前文)。这个确认操作是在 AppendEntries RPC 的一致性检查步骤完成的。
Leader 针对每个 follower 都维护一个 next index,表示下一条需要发送给该follower 的日志索引。当一个 leader 刚刚上任时,它初始化所有 next index 值为自己最后一条日志的 index+1。但凡某个 follower 的日志跟 leader 不一致,那么下次 AppendEntries RPC 的一致性检查就会失败。在被 follower 拒绝这次 Append Entries RPC 后,leader 会减少 next index 的值并进行重试。
最终一定会存在一个 next index 使得 leader 和 follower 在这之前的日志都保持一致。极端情况下 next index 为1,表示 follower 没有任何日志与 leader 一致,leader 必须从第一条日志开始同步。
针对每个 follower,一旦确定了 next index 的值,leader 便开始从该 index 同步日志,follower 会删除掉现存的不一致的日志,保留 leader 最新同步过来的。
整个集群的日志会在这个简单的机制下自动趋于一致。此外要注意,leader 从来不会覆盖或者删除自己的日志,而是强制 follower 与它保持一致。
因此我们可以看到日志的正确性是非常重要的,所以我们一定要保证日志的正确性,所以我们接下来来讲安全性和正确性
安全性和正确性
前面的章节我们讲述了 Raft 算法是如何选主和复制日志的,然而到目前为止我们描述的这套机制还不能保证每个节点的状态机会严格按照相同的顺序 apply 日志。想象以下场景:
- Leader 将一些日志复制到了大多数节点上,进行 commit 后发生了宕机。
- 某个 follower 并没有被复制到这些日志,但它参与选举并当选了下一任 leader。
- 新的 leader 又同步并 commit 了一些日志,这些日志覆盖掉了其它节点上的上一任 committed 日志。
- 各个节点的状态机可能 apply 了不同的日志序列,出现了不一致的情况。
因此我们需要对“选主+日志复制”这套机制加上一些额外的限制,来保证状态机的安全性,也就是 Raft 算法的正确性。
对选举的限制
我们再来分析下前文所述的 committed 日志被覆盖的场景,根本问题其实发生在第2步。Candidate 必须有足够的资格才能当选集群 leader,否则它就会给集群带来不可预料的错误。Candidate 是否具备这个资格可以在选举时添加一个小小的条件来判断,即:
每个 candidate 必须在 RequestVote RPC 中携带自己本地日志的最新 (term, index),如果 follower 发现这个 candidate 的日志还没有自己的新,则拒绝投票给该 candidate。
Candidate 想要赢得选举成为 leader,必须得到集群大多数节点的投票,那么它的日志就一定至少不落后于大多数节点。又因为一条日志只有复制到了大多数节点才能被 commit,因此能赢得选举的 candidate 一定拥有所有 committed 日志。
因此前一篇文章我们才会断定地说:Follower 不可能比 leader 多出一些 committed 日志。
比较两个 (term, index) 的逻辑非常简单:如果 term 不同 term 更大的日志更新,否则 index 大的日志更新。
对提交的限制
Leader 只允许 commit 包含当前 term 的日志。
集群成员变更
这个东西主要解决的问题是:如何安全的改变集群的节点数据。
在前文的理论描述中我们都假设了集群成员是不变的,然而在实践中有时会需要替换宕机机器或者改变复制级别(即增减节点)。一种最简单暴力达成目的的方式就是:停止集群、改变成员、启动集群。这种方式在执行时会导致集群整体不可用,此外还存在手工操作带来的风险。
为了避免这样的问题,Raft 论文中给出了一种无需停机的、自动化的改变集群成员的方式,其实本质上还是利用了 Raft 的核心算法,将集群成员配置作为一个特殊日志从 leader 节点同步到其它节点去。
未完待续。。。
日志压缩
我们知道 Raft 核心算法维护了日志的一致性,通过 apply 日志我们也就得到了一致的状态机,客户端的操作命令会被包装成日志交给 Raft 处理。然而在实际系统中,客户端操作是连绵不断的,但日志却不能无限增长,首先它会占用很高的存储空间,其次每次系统重启时都需要完整回放一遍所有日志才能得到最新的状态机。
因此 Raft 提供了一种机制去清除日志里积累的陈旧信息,叫做日志压缩。
快照(Snapshot)是一种常用的、简单的日志压缩方式,ZooKeeper、Chubby 等系统都在用。简单来说,就是将某一时刻系统的状态 dump 下来并落地存储,这样该时刻之前的所有日志就都可以丢弃了。所以大家对“压缩”一词不要产生错误理解,我们并没有办法将状态机快照“解压缩”回日志序列。
注意,在 Raft 中我们只能为 committed 日志做 snapshot,因为只有 committed 日志才是确保最终会应用到状态机的。
上图展示了一个节点用快照替换了 (term1, index1) ~ (term3, index5) 的日志。
快照一般包含以下内容:
- 日志的元数据:最后一条被该快照 apply 的日志 term 及 index
- 状态机:前边全部日志 apply 后最终得到的状态机
当 leader 需要给某个 follower 同步一些旧日志,但这些日志已经被 leader 做了快照并删除掉了时,leader 就需要把该快照发送给 follower。
同样,当集群中有新节点加入,或者某个节点宕机太久落后了太多日志时,leader 也可以直接发送快照,大量节约日志传输和回放时间。
同步快照使用一个新的 RPC 方法,叫做 InstallSnapshot RPC。
可以参考这一篇文章:
https://juejin.cn/post/6907151199141625870#heading-22