该项目是MIT6.824 的课程 Lab,该 Lab 是在单机下模拟多节点通信的框架上实现 raft 分布式共识的基础功能。
In Search of an Understandable Consensus Algorithm (Extended Version) 这篇 2014 年的 paper 介绍了一种号称比 Paxos 更简单易懂的共识算法,能够实现多节点之间的容错服务。
单机的数据库,文件系统... 如果发生了崩溃,或者出现了磁盘错误,在程序员主动修复之前这段期间,整个系统是不可用的,客户端长时间的等待恢复可以说是不可允许的。所以人们可能想到了使用主从备份的方式来进行容错,主节点拷贝数据或者状态给从节点,从节点在主节点崩溃的时候可以承担起主节点的作用,还不会导致数据丢失。但是很重要的一个问题是这个主节点每收到一份数据就要同步给其他所有从节点,在所有节点回复复制成功之后才能回复客户端请求成功,我们可以说主节点需要和所有从节点之间进行同步,这个开销是很大的,当然潜在的好处就是容错节点数大,如果总共有 N 个节点,则容许 N-1 个节点崩溃。而 raft 或者 paxos 这些共识算法,则可以在容许 (N-1)/2
个节点崩溃的前提下,只需要半数以上从节点回复复制成功,就可以将请求成功的消息及时的回复给客户端。
- 状态
- Leader(领导者),负责接受客户端请求,并将请求同步到其他节点。
- Candidate(选举者),负责在初始化或者怀疑 Leader 崩溃的情况下,参与选举,并选举出一个新的 Leader。
- Follower(跟随者),接受 Leader 的同步数据,并判断选举人是否更新来决定是否投票给选举人。
raft 的所有节点都处在这三种状态之中,不时可能会发生下面所述的状态上的转移:
- 转移
Follower -> Candidate
Follower 的选举计时器超时,怀疑 Leader 发生崩溃,发起选举,成为 Candidate。Candidate -> Leader
Candidate 会发送投票请求给所有节点,如果收到了半数以上节点的投票,则成为 Leader。Leader -> Follower
Leader 发现有更高任期的 Leader 或者 Candidate,则让贤成为 Follower。Candidate -> Follower
同理 Candidate 发现有更高任期的 Leader 或者 Candidate,则成为 Follower。
raft 的状态转移是通过消息传递来实现的,两个核心 远程过程调用 (RPC):
- RequestVote RPC 主要用来选举出 Leader 节点。
- AppendEntries RPC 主要用来日志复制,同时作为 Leader 的心跳。
也就是对应我们之前所说的 选举 和 日志复制 两个环节。
在 raft 中两个很重要的概念:
- 任期 Term 一个全局逐渐递增的序列号,作为 raft 集群的逻辑时钟。每次选举将会递增任期。
- 日志 Log 用来为 raft 客户端请求定序。
Quorum 相交定理 :一个集合中两个含有多数节点的子集合相交。
- 选举人必须有半数以上节点投票才可以成为领导者 -> 这意味着选举人的日志肯定新于半数节点。
- 领导者必须有半数以上节点返回成功才可以提交日志 -> 日志复制到半数以上节点。
上述两个子集合必有相交,也就是一定有一个节点拥有已提交的日志,并且给选举人投票了。
下一个选举人想要成为领导者则必须日志新于半数节点,而这半数节点(加上它自己)中肯定至少有一个节点拥有上一轮提交的所有日志。
结论:领导者一定有所有已经提交的日志。
为什么 2F + 1 个节点可以容许 F 个节点戎机? 因为当 F 个节点戎机时,剩下 F + 1 节点仍然可以形成一个 Quorum,仍然可以完成选举投票和日志复制,领导者一定有所有已经提交的日志。 当然如果 F + 1 及以上个节点崩溃那整个 raft 集群就没办法应对了。我们应当采用一些安全性更强的一致性算法去做到拜占庭容错。
原理相对容易理解一些,然而真正实现下来却是细节满满,MIT6.824 的 lab 的测试可以说非常细致,一些刁钻的问题:分区,丢包,网络延迟等等,测试中都有所涉及。
-
Lab 2A 领导者选举
在这个小节,我们需要实现领导者选举的功能。
- 首先所有节点的初始状态都是 Follower,我们需要实现一个定时器,每隔一段时间,就会主动发起一次选举。所以这里我采用了一个ticker goroutine 专门做选举超时的检测工作,timer 超时会向 ch 写入内容,select 进行检测。另外,有时我们需要重置定时器,因此我们使用一个 appendEntriesRpcCh 专门发送重置定时器信号,并重置定时器。
- 等到选举超时发生时,执行选举回调函数,节点状态从
Follower
变为Candidate
,自增任期,对每个节点开一个 goroutine 广播投票RequestVoteRPC
,投票的信息,也就是RequestVoteArg
:type RequestVoteArgs struct { Term int /* 选举人的任期 */ CandidateId int /* 选举人的 ID */ //LastLogIndex int /* 选举人最后一条日志的 索引 */ //LastLogTerm int /* 选举人最后一条日志的 任期 */ }
选举人对所有其他节点发送
RequestVoteRPC
,选举人每个发送 RPC 此时会阻塞并等待回复,而其他节点则会执行RequestVote
,也就是根据自己的状态和广播信息来确定是否为选举人投票。具体是什么条件呢?- 自己的任期比选举人的任期大,不投票,并将自己的任期返回通知选举人;
- 任期相同,已经投票给别人,不投票。
- 自己的任期比选举人的任期小,投票并更新自己的任期为选举人的任期。
此时还没有发送日志项,因此之后在有了日志后还需要补充一些拒绝投票的条件。 选举人投票如果收到了回复,首先判断自己的身份有没有发生变化(可能在其他协程中做了改变),发生了变化则直接返回,然后如果发现回复中的任期比自己大,则会更新自己的任期,并重置为跟随者。选举人收集所有投票总数,如果超过半数所有节点给它投票,则它可以宣布自己为领导者了;如果没有收集到半数节点投票,则会等待新一轮选举超时;如果有多个选举人,可能会出现平票现象,这会导致新一轮的选举,开销很大,所以我们需要随机化选举超时时间,减少选举冲突。成为领导者后,领导者需要定期(0.5 毫秒到 20 毫秒)给所有节点发送心跳(也是通过一个 ticker goroutine),来证明自己的领导人身份,因此我们通过发送没有日志的
AppendEntriesRPC
来代表心跳,AppendEntriesArgs
的结构:type AppendEntriesArgs struct { Term int /* 领导者人的任期 */ LeaderId int /* 领导者人的 ID */ // PrevLogIndex int /* 领导者人发送的新日志的前一条日志索引 */ // PrevLogTerm int /* 领导者人发送的新日志的前一条日志任期 */ // Entries []RaftLog /* 领导者人发送的新日志 */ // LeaderCommit int /* 领导者人的最后一条提交日志的索引 */ }
接收者节点接受了
AppendEntriesRPC
后,如果领导者的任期比它大,则认同领导者的身份;否则返回给领导者它的任期,领导者同样也会重置为跟随者,并更新它的任期。 -
Lab 2B 日志
在之前 Lab 2A 中实现了简单的领导者选举步骤,而在 Lab2B 部分则需要实现日志复制的步骤。首先什么是日志?这里的日志不是我们平时用来 debug 的日志,而是用来记录客户端的所有操作的日志,可以说 raft 中的日志承担了数据存储的功能以及用来保障集群的一致性。在 raft 中,客户端的所有请求都会路由到领导者,由领导者负责将日志复制到其他节点上,使得所有节点的日志都趋向于一致。leader 是如何知道需要给其他节点发送多少日志项呢?在 raft 中,leader 维护着一些日志项的索引值,例如
nextIndex[i]
代表的是领导者需要从该索引的日志项开始向节点i
发送日志,matchIndex[i]
代表的是领导者认为节点i
已经匹配的日志项坐标。当整个集群初始化的时候,leader 会将nextIndex[i]
设置为当前日志的长度len
加上 1,此时是向其他节点发送日志的,之后会慢慢调低到合适的位置。而这个调低的过程是通过AppendEntriesRPC
完成的,上文中的AppendEntriesArgs
结构多个几个新的成员:PrevLogIndex
用来用来表示接收者已经和领导者匹配的日志项索引,PrevLogTerm
表示领导者上索引为PrevLogIndex
的日志项的上的任期,Entries[]
代表领导者从索引PrevLogIndex + 1
开始的所有日志。LeaderCommit
代表领导者已经 提交 的日志项索引。一般来说在发送AppendEntriesRPC
的时候,PrevLogIndex
会设置成nextIndex - 1
,而Entries
则是空的,接收者在接收到AppendEntriesRPC
时会去校验这个PrevLogIndex
和PrevLogTerm
是否匹配,如果不匹配,则会返回错误,并让领导者递减其nextIndex
(也可以一次减小很大幅度),直到了领导者和接收者最终在日志的某个位置开始匹配,随后领导者将PrevLogIndex
后面的日志全部发送给接收者。type AppendEntriesArgs struct { Term int /* 领导者人的任期 */ LeaderId int /* 领导者人的 ID */ PrevLogIndex int /* 领导者人发送的新日志的前一条日志索引 */ PrevLogTerm int /* 领导者人发送的新日志的前一条日志任期 */ Entries []RaftLog /* 领导者人发送的新日志 */ LeaderCommit int /* 领导者人的最后一条提交日志的索引 */ }
接收者处理
AppendEntries
的逻辑中同样也会将任期是否过时的情况回复给领导者,领导者可能会因此重置为跟随者并更新任期。在校验完PrevLogIndex
和PrevLogTerm
之后,跟随者会将PrevLogIndex
后面多余的日志截断,并追加发送来的所有新日志项。跟随者发送其认为匹配成功的日志项坐标,并回复给领导者,领导者看到后会更新matchIndex
和nextIndex
,然后领导者会对日志进行提交,但是提交却需要满足一定的安全性约束:如果发现某个日志项内的任期恰好是当前任期,且没提交过(通过比较matchIndex[i] 和 commitIndex
),而且已经复制到多数节点(通过看matchIndex[]
),则会对该日志项之前所有日志进行提交,更新commitIndex
;而接收者则会根据LeaderCommit
和当前日志长度的最小值作为其commitIndex
进行日志 提交,LeaderCommit
的值是领导者维护的commitIndex
,也就是说跟随者会将上一轮领导者提交的内容进行提交。当然这里很重要的一点就是接收者因为成功接收了领导者的AppendEntriesRPC
,所以需要重置选举超时。注意到我们这里只是更新了
commitIndex
,代表该索引之前所有日志已经提交成功,事实上 raft 节点还会维护一个lastApply
用来表明已经最后一个被 应用 的日志项索引。每次commitIndex
发生了变化,我们都会提交所有[lastApply,commitIndex]
的日志项。姑且不用知道应用层是如何使用该日志项的,可能是用来构建 K/V,也可能用来做其他分布式服务。回到 Lab2A 中的选举部分,我们也应当做出一些修改,
RequestVoteArg
:type RequestVoteArgs struct { Term int /* 选举人的任期 */ CandidateId int /* 选举人的 ID */ LastLogIndex int /* 选举人最后一条日志的 索引 */ LastLogTerm int /* 选举人最后一条日志的 任期 */ }
受到投票请求的节点会额外比较其最后的日志任期是否小于等于选举人的最后的日志任期,如果满足则比较其最后的日志索引是否小于等于选举人的最后的日志索引,都满足的情况下才会给对方投票。
-
Lab 2C 持久化
需要持久化哪些数据呢?论文中给出了几个必要的数据:
currentTerm 服务器已知最新的任期 votedFor 当前任期内收到选票的 candidateId,如果没有投给任何候选人 则为空 log[] 日志条目
持久化日志是必然的,这可以帮助该节点在崩溃恢复后重建状态机。 持久化任期可以帮助节点在崩溃恢复时感知自己是否发生了崩溃并落后于其他节点。 持久化投票可以防止在投票给了一个节点后崩溃而恢复后在同任期又投给另一个节点。
理论上还可以持久化一些其他信息,例如 commitIndex...
MIT6.824 的假持久化经常受到大家的诟病。但是作为一个课程的 Lab,它这个假的持久化可以减小一些持久化的 IO 开销,让我们能有更多的精力花在整体逻辑的实现上。
整体上就是在每次修改这些变量的时候调用持久化接口。值得注意的一点是如果是真持久化可能需要类似
fsync
来保证持久化成功,否则 raft 将不满足线性一致性。在实现完持久化后,我们需要在 raft 重启时恢复这些变量,之后会将日志中的客户端请求重新应用到应用层中。
-
Lab 2D 快照
有了日志 log,我们的确能正确保存客户端提供的所有请求数据,能够在重启服务的时候恢复其状态机,但是长期运行的服务可能会导致日志过长,这会非常占用磁盘资源(不过 MIT6.824 这里是假的持久化,所以占据的是内存资源),另外在恢复日志的时候(1. 重启本机恢复 2. 通过其他节点传递日志恢复)也会很慢。因此我们需要通过一个机制去控制日志的大小,也就是 快照。
在 Lab2D 中,每 10 条日志都会主动触发一次快照生成,丢弃旧的快照,丢弃当前日志项索引之前的所有日志,然后将它们进行持久化。
在实现快照中,需要额外保存快照中最后一条日志的索引
LastIncludedIndex
和其项的任期LastIncludedTerm
,它将会作为接收者是否应当接收快照以及是否截断日志的依据。整体上说快照会用到两个地方。
- 在节点崩溃启动的时候,我们需要首先将快照中的内容重新恢复,之后将异步安装到应用中。 此处的快照崩溃恢复步骤和日志的崩溃恢复步骤类似。
- 当接收者节点请求的数据在领导者快照中,领导者需要给接收者发送快照。
InstallSnapshotRPC 发送的内容也很简单,主要就是快照的数据和
LastIncludedIndex
和LastIncludedTerm
。接收方则会判断领导者是否落伍以及快照是否不够新,如果认为足够新则也会覆盖其现有快照,并持久化,截断快照内的日志。type InstallSnapshotArgs struct { Term int //领导人的任期号 LeaderId int //领导人的 Id,以便于跟随者重定向请求 LastIncludedIndex int //快照中包含的最后日志条目的索引值 LastIncludedTerm int //快照中包含的最后日志条目的任期号 // Offset int //分块在快照中的字节偏移量 Data []byte //从偏移量开始的快照分块的原始字节 // Done bool // 如果这是最后一个分块则为 true }
当我们已经实现好 raft 框架的基本内容之后,我们需要在其之上构建一个 KV 服务,用来理解分布式下的线性一致性。
首先客户端的三个接口 Get()
,Put()
,Append()
,抽象出来其实就是读和写两个操作。
客户端会将请求发送给任意一个节点,如果该节点不是领导者,可以返回给客户端他认为的领导者,并让客户端重新去联系它。等到客户端联系上领导者之后,客户端认为发生了丢包时会将 RPC 重复发送,因此我们需要一个机制去保证客户端的请求会被处理 恰好一次,因此在客户端的请求中我们需要包含客户端 UID 和 客户端单调递增的请求序列号。 而 raft 集群的应用层(KV层)则需要用一个字典去记录客户端的请求记录列表(当然如果客户端所有请求是顺序发送则只用记录最后一次请求)。
当领导者发现之前已经接受了相同请求时则会忽略该请求。
得益于 raft 对操作的定序,KV 的读取一定会读取到最后一次写入的内容。