Raft算法 | 青训营笔记
这是我参与「第五届青训营」伴学笔记创作活动的第6天
今天的课程内容主要是分布式理论,因为笔者之前了解过很多共识算法相关的内容,所以今天会给大家分享Raft算法的基本概念和流程,帮助大家快速理解Raft
前言
由于Paxos算法十分晦涩难懂并且实现复杂,因此需要对Paxos算法进行简化。该文所讲解的Raft算法便是在Paxos的基础上建立的,接下来我会按照原论文的顺序和梗概介绍Raft算法,并会附上一些简单的例子,对于多数读者,这应该都是易于理解的。
复制状态机
复制状态机是什么?
简单来说,就是具有相同初始状态的多个节点,以同样的指令进行操作,会得到相同的结果。
举个例子:假设多个节点都有一个map<Integer,Integer>,map初始均为空,这时如果client发送一个put(1,2)请求,那么每个节点的map都会执行这条指令,最终结果都保存了该条键值对。
那么节点是如何实现数据同步的呢?这就需要上图中的Consensus Module(一致性模块)了,一致性模块接收来自外部的指令以及与其他节点的一致性模块进行通信,这些模块将会将操作以日志的形式记录下来,同时,它们之间还会进行沟通,使得每个节点的操作指令在日志中的顺序是相同的。因此,我们的节点只需要按照日志来执行具体操作即可,这便保证了数据之间的一致性。
Paxos算法有什么问题?
- Paxos算法晦涩难懂,只有少数人能够理解
- Paxos原论文中只提出了basic paxos,只能应对单决策问题;对于multi paxos,原论文中并没有提及,工程上落地也较为困难
对于basic paxos和multi paxos,网上有不少对这方面的解释,我个人的理解是basic paxos能保证单条日志记录的一致,而multi paxos是能确定多条日志记录的一致性
如何设计一个更好的可以替代Paxos的算法?
原论文中作者给出的答案是designing for understandability,即可理解性,但这种评判标准过于主观,因此需要从客观角度来阐述优化方向:
- 把问题分解,分解成相对独立的、易于理解的子问题
- 减少状态的数量,以避免我们需要考虑更多的特殊情况
Raft算法
Part 1: 简略概括
通过以下内容,读者可以建立起对Raft算法的基本认识,其中的部分概念在这个阶段不需要完全搞懂,在后面的阶段中会有更加详细的阐述,读者只需要留下些印象即可。等到真正了解了Raft算法之后,读者再回头阅读这部分内容,会有更深的理解。
基本思想
我们需要选出一个leader,由它来负责整个日志的管控。leader接收来自client的日志条目,并把条目复制到其他节点(称之为follower)上,同时告诉这些节点什么时候才能够把这些日志条目应用于状态机上。
通过选举leader,一致性问题被划分为了三个子问题:
- 领导选举:当现存的leader出现故障时,应该选举一个新的leader
- 日志复制:leader接收来自client的日志记录,并且复制到其他节点,同时,强制其他节点与自己的日志保持一致
- 安全性:如果某个节点已经应用了某条日志记录,那么其他节点在同一个索引位置不能应用不同的日志记录
变量
原文中为State,这里译为变量是为了读者更易理解
所有服务器上的持久性变量
(持久性:在响应RPC之前,这些变量会写到磁盘中去)
| 参数 | 解释 |
|---|---|
| currentTerm | 服务器已知的最新任期(初始值为0,单调递增) |
| votedFor | 当前任期内收到投票的candidateId |
| log[] | 日志条目:包含每个用于状态机的命令以及leader收到该条目时的任期 |
所有服务器上的易失性变量
| 参数 | 解释 |
|---|---|
| commitIndex | 已知已提交的日志条目的最大索引(初始值为0,单调递增) |
| lastApplied | 已被应用到状态机上的日志条目的最大索引(初始值为0,单调递增) |
leader服务器上的易失性变量
(选举后重新初始化)
| 参数 | 解释 |
|---|---|
| nextIndex[] | 对于每一台服务器,发送到该服务器上的下一个日志条目的索引(初始化为leader最后的日志条目的索引+1) |
| matchIndex[] | 对于每一台服务器,已知的已经复制到该服务器的最高日志条目的索引(初始值为0,单调递增) |
追加条目RPC
由leader调用,用于日志条目的复制,同时也作为心跳信息
| 参数 | 解释 |
|---|---|
| term | leader的任期 |
| leaderId | 通过leaderId其他节点可以把来自client的请求重定向到leader |
| prevLogIndex | 新日志条目之前的日志条目的索引 |
| prevLogTerm | 新日志条目之前的日志条目的任期 |
| entries[] | 需要被保存的日志条目,如果为空的话作为心跳使用 |
| leaderCommit | leader已提交的日志条目的最大索引 |
| 返回值 | 解释 |
|---|---|
| term | 当前的任期,对于leader,它会更新自己的任期 |
| success | 具体取决于参数值中的prevLogIndex、prevLogTerm是否与follower的日志条目匹配 |
接收者的实现
- 如果leader的term<currentTerm,即当前leader的任期小于返回值中的term,响应false
- 如果接收者的日志条目不匹配参数值中的prevLogIndex、prevLogTerm,响应false
- 如果已有的日志条目与新接收到的日志条目产生了冲突(索引相同但任期不同),删除掉已有的该条目及其之后的所有日志条目
- 追加日志中尚未存在的新条目
- 如果leaderCommit>commitIndex,即leader已提交的日志条目的最大索引大于接收者已提交的日志条目的最大索引,则重设接收者的commitIndex为min(leaderCommit,上一个新条目的索引)
请求投票RPC
由candidate调用用来征集选票
| 参数 | 解释 |
|---|---|
| term | candidate的任期 |
| candidateId | candidate的Id |
| lastLogIndex | candidate最后一个日志条目的索引 |
| lastLogTerm | candidate最后一个日志条目的任期 |
| 返回值 | 解释 |
|---|---|
| term | 当前的任期,对于candidate,它会更新自己的任期 |
| voteGranted | candidate赢得该选票时返回true |
接收者的实现
- 如果term<currentTerm,即candidate的任期小于接收者的任期,响应false
- 如果votedFor为空或者为candidateId,并且候选人的日志至少和自己一样新,那么就投票给他
服务器需要遵守的规则
所有服务器
- 如果commitIndex>lastApplied,那么lastApplied会递增,并将log[lastApplied]应用到状态机
- 如果接收到的RPC请求或者响应中,term>currentTerm,则令currentTerm=term,并切换为follower状态
follower
- 响应来自candidate和leader的请求
- 如果超出一定时间没有收到来自leader的心跳/日志,或者没有给别的节点投票,自己就会称为candidate
candidate
-
在转变成candidate后立刻开始选举
-
- 自增currentTerm
- 给自己投票
- 重置选举超时计时器
- 发送请求投票给其他所有节点
-
如果接收到大多数节点的选票,那么自己就称为leader
-
如果接收到来自新的leader的追加日志RPC,则转变为follower
-
如果选举过程超时,则再发起一轮选举
leader
-
选举完成后,首先向其他节点发送心跳,在一定空闲时间后不停地重复发送,以防止follower超时
-
如果接收到client的请求,将日志条目先追加到本地日志,在条目被应用到状态机之后响应client
-
如果leader的lastLogIndex≥nextIndex[i],则向该follower发送从nextIndex开始的所有日志条目
-
- 如果成功:更新相应follower的nextIndex以及matchIndex
- 如果因为日志不一致而失败,则递减nextIndex并重试
-
假设存在N满足N>commitIndex,使得大多数的matchIndex[i]≥N以及log[N].term==currentTerm成立,则令commitIndex==N
特性
| 特性 | 解释 |
|---|---|
| 选举安全 | 对于一个给定的任期号,最多只会有一个leader被选举 |
| leader只追加 | leader不会删除或重写自己的日志,只会追加 |
| 日志匹配 | 如果两个日志在某一相同索引位置日志条目的任期号相同,那么我们就可以认为这两个日志从头到该索引位置之间的内容完全一致 |
| leader完全特性 | 如果某个日志条目在某个任期号已被提交,那么这个条目必然会出现在更大任期号的所有leader中 |
| 状态机安全 | 如果某一服务器已经将给定索引位置的日志条目应用至状态机中,那么其他任何服务器在该索引位置不会应用不同的日志条目 |
Part2: 正式介绍
Raft基础
Raft集群中的节点可以分处三种不同的状态:leader、candidate、follower,这就类似于总统选举,总统的权利最大,因此由他来负责最重要的事情总是没错,但想要成为总统,还需要获得选民的支持,只有获得多数派的选票,竞选者才能真正成为总统,每一届总统如果不发生意外的话,他能一直担任总统,因此他会一直向其他人证明他还活着以保留他的权力,但如果在任期内出现了意外,其他竞选者们便会争抢这个位置,重复上述过程。但还有一种情况,如果总统消失了一段时间,竞争者们认为总统已经死亡,便开始新一届的选举,选出新总统后老总统却又突然出现了,在这种情况下,老总统意识到了新总统的出现,会自觉退位,成为一位普通的选民。
上述过程对应到Raft中即为下图:
现实世界中会以任期区分新老总统,Raft中同样会给每个leader区分任期(term),term在Raft中充当全局的逻辑时钟,能检测一些过期的信息:
- 如果一个服务器的当前任期号比其他人小,那么他会更新自己的term到较大的term
- 如果一个候选人或者领导人发现自己的任期号过期了,那么他会立即恢复成follower状态
- 如果一个节点接收到一个包含过期的任期号的请求,那么他会直接拒绝这个请求
前面的文章中提到过Raft将一致性问题划分为了三个子问题,下面我们就来具体看一下这三个子问题如何解决
领导选举
Raft采用了一种心跳机制来触发leader的选举:leader会周期性地向其他节点发送心跳包来证明自己的存活,只要当前任期的leader依然存活,follower就不会进行新一届的选举;但如果在一段时间内follower即没有收到来自当前任期的leader的请求/心跳,也没有收到来自其他candidate的请求投票,那么就会触发选举,follower就会转变为candidate状态。
选举过程具体是这样的:follower自增currentTerm,转换为candidate状态,同时调用请求投票RPC,向其他节点请求投票,candidate会持续当前状态直到以下三件事情之一发生:
- 它自己赢得了选举
- 其他的节点成为leader
- 没有人获胜(再次超时)
自己赢得了选举
当一个candidate从大多数节点(超过半数)获得了同一个任期的选票,candidate就赢得了当前任期的选举并成为了leader,每一个节点对于一个任期最多只会投一张票,原则是先来先服务。上述规则确保了最多只有一个candidate赢得选举,当某个candidate赢得选举后,它会立即向其他节点发送心跳以防止其他节点再次选举。
其他的节点成为leader
如果当前candidate在等待选票时收到了其他leader的请求或者心跳,并且该leader的term≥当前candidate的currentTerm,那么candidate就认为该leader是有效的,会转变为follower状态,否则,拒绝响应该leader,并继续保持candidate状态。
没有人获胜
如果选票被平分给不同的candidate,那么这轮选举将没有leader,candidate等待选票结果超时之后,会进行下一轮选举。如果没有其他机制,下一轮选举依然有概率产生不出leader,为了优化这个问题,Raft使超时时间随机化,选举的超时时间在一个固定区间内随机选择,这样就使得大多数情况下只有一个服务器超时成为candidate,即使他们同时成为了candidate,并且出现了选票平分的情况,超时时间也并不相同,因此将有一个candidate更早开始请求投票,就降低了再次平分的概率。
日志复制
一旦一个leader被选举出来,那么它就开始为client提供服务。client的请求最终都会到达leader节点,leader首先将该请求作为日志条目记录到本地日志中,然后并行地调用追加日志RPC,复制该条目到其他节点上。当这条日志安全地被复制到其它节点后,leader会将该日志条目应用到状态机上然后返回结果给client。如果follower崩溃或者丢包,leader会不断尝试追加日志到其他节点(尽管已经响应了client),直到所有的follower都存储了该条目。
日志的形式如上图所示,每条日志条目都包含两部分内容:来自client的指令和节点收到该指令时的任期,日志条目在日志中的位置称为索引,以整数形式存在。
leader来决定什么时候把日志条目应用到状态机是安全的,这种安全应用到状态机的日志条目的状态为commited(已提交),Raft保证所有已提交的日志条目都是持久化的并且最终会被所有可用的状态机执行。一旦leader成功将某条日志条目成功复制到大多数节点之后,日志条目就会被提交。同时,leader的日志中之前的所有日志条目都会被提交,包括其他leader创建的条目。leader记录了即将被提交的日志条目的索引,这个索引会附在追加日志RPC中,以便其他节点能够确认leader的提交位置。一旦follower确认了某条日志条目已被提交,那么它也会将这条日志条目应用到本地的状态机上。
为什么要这么做? / 这么做有什么好处?
日志匹配性——Log Matching Property
- 如果在不同的日志中两个条目具有相同的索引和任期,那么它们存储了相同的指令
- 如果在不同的日志中两个条目具有相同的索引和任期,那么它们之前的所有日志也都相同
第一条特性如何保证?
对于一个给定的任期和索引,一个leader最多创建一个条目,这就保证了相同任期和索引下条目的唯一性,因此其中指令必定相同。
第二条特性如何保证?
追加条目RPC时的一致性检查:在调用追加条目RPC时,参数中包含了prevLogIndex和prevLogTerm,即新添加条目前的索引位置和任期,follower收到请求后会比对对应索引位置的任期是否相同,如果不同,则拒绝接收新的日志条目。
一致性检查就像一个归纳步骤,刚开始时日志为空,满足一致性,一致性检查在扩展时又维护了一致性。
正常情况下,一致性检查都会返回成功,但如果出现了网络分区、节点宕机的情况导致了不同节点的日志产生了分歧该如何处理呢?
Raft是通过强制覆盖来使日志保持相同的。
leader节点维护了nextIndex[]数组,表示需要发送给follower的下一个条目的索引,初始化为leader的最后一个条目的index+1,如果在一致性检查时发生了错误,leader就会减小对应的nextIndex的值再次重试,最终nextIndex会在某个位置使得leader和follower的日志达成一致。这时RPC就会成功,leader发送来的日志条目就会覆盖follower原有的日志条目。
通过一致性检查,leader在复制日志时无需进行特殊操作就能使得leader的日志和follower的日志保持一致,leader从不会删除或重写自己的日志。
这也体现出了Raft的特性:只要大多数节点正常工作,系统依然能够保持稳定。
安全性
选举限制
对于所有的一致性算法,leader都必须存储所有已提交的条目。在某些一致性算法中,某个节点即使不包含所有已提交的条目,也能成为leader,因此,这就需要一些额外的机制来保证丢失的条目传输给leader,这种机制毫无疑问增加了算法的复杂性。
Raft采用了一种更为简单的机制来保证每一个leader都拥有之前任期所有已提交的日志条目,这就使得数据传输是单向的,日志条目只会从leader流转到follower,并且leader永远不会覆盖本地已存在的日志条目。具体做法是:candidate调用请求投票RPC之后,follower收到该请求时会进行两份日志的比对,看看candidate和自己的日志谁比较新,如果candidate和大多数节点的日志一样新,那么就保证了candidate持有所有已提交的条目。
新的定义:
- 如果两份日志最后一个条目的任期号不同,则任期号更大的更新
- 如果两份日志最后一个条目的任期号相同,则索引更大的更新
提交之前任期内的日志条目
关于这点原论文举了一个例子,有兴趣的同学可以去看一下,对于这点我本人也不是很理解,为什么不能直接提交之前任期的条目
leader无法直接决定是否提交之前任期的日志条目,它只能通过follower的反馈来决定是否提交当前任期的日志条目。但由于日志匹配特性,之前的条目会间接提交。
节点崩溃
到目前为止,我们只关注了leader崩溃时的情况,而follower和candidate崩溃的情况是比较好解决的,只需要leader重复调用RPC即可,直至成功返回。
时间和可用性
Raft的要求之一就是安全性不能依赖于时间:整个系统不能因为某些事件运行的比预期的快一点或慢一点就产生了错误的结果。但是,可用性(系统可以及时的响应client)不可避免的要依赖于时间。
leader选举部分是对时间要求最高的部分,应满足如下不等式:
广播时间(boardcastTime) << 选举超时时间(election Timeout) << 平均故障间隔时间(MTBF)
广播时间:节点并行调用RPC发送信息给其他节点并接收响应的平均时间
选举超时时间:follower在一段时间内没有收到来自leader的消息,它就会认为这个系统中没有leader
平均故障间隔时间:对于一个节点,发生两次故障之间的平均时间
广播时间应比选举超时时间小上一个数量级,原因是如果选举超时时间太过贴近广播时间,那么大多数心跳包还没到达follower时,follower就会因为超时而转变为candidate状态。
选举超时时间应比平均故障间隔时间小上几个数量级,只有这样我们的整个系统才会在发生故障后只有一小段选举超时时间不可用。
Part3:一些关键点
这个部分是Raft的一些关键点,也包含了一些不容易想清楚的地方
脑裂问题
Raft解决脑裂问题的关键在于majority,在Raft的分布式系统中,节点数量为2n+1个,我们每次的操作都需要至少n+1个节点的同意,n+1被称为majority。因此,当两个节点都想成为leader时,他们总的需要至少2n+2个节点的同意,这是不可能的,这也就解决了脑裂问题。除此之外,majority还保证了新任leader中保存着完好的日志副本,因为如果前一任leader宕机了,在此之前,前一任leader必然会将日志复制到majority中,而宕机之后新任leader的选举也需要征得majority的同意,这两次的majority至少有一个节点是重合的,它即保存着前一任节点的日志副本,也会对新任leader是否包含这些日志进行判断,因此,Raft能保证leader中的日志必然是完善的。
Log的作用
- 给每个日志条目分配一个序号,这个序号可以保证宕机重启之后,再加入到系统中,日志能被正确地同步
- 由于follower无法确定leader追加的日志是否已经被提交,因此需要将这些unknown状态的日志保存下来
Follower应用状态机的时机
当leader收到来自client的请求时,它首先会对其他follower节点调用追加日志RPC,收到大多数节点的确认后leader才能将日志应用到自己的状态机上,但对于follower节点,它并不知道复制的日志是否已被提交,因此,leader在发送心跳或者下一次追加日志的时候,需要将commitIndex作为参数附加在RPC中,以便follower能够知道现在提交到哪一条日志了。
Part4:改进点
对于Raft的实现可以改进的地方
合并请求/批处理
如果client频繁地向leader发送请求,可以将来自client的请求先置于一个队列中,当队列达到一定长度再提交给consensus module进行处理,然后再追加日志,而不是来一个请求就直接交给consensus module,然后写本地日志,调用RPC,等待结果返回。合并请求可以省去大量RPC网络传输的时间。
多节点变更
由于多节点变更实现和理解都比较复杂,因此在实际生产中,常采用多次应用单节点变更的方式来代替多节点变更
Part5:缺点
- Raft不依靠硬件,使用逻辑时间来保障一致性,但也因此,Raft强依赖于网络,如果网络传输时延过大,那么Raft的性能也会遭受影响
- Raft的leader必须具有完整日志,不能有日志空洞,因此Raft的leader在追加日志时需要检查之前日志的一致性,这就导致了性能消耗