前言
通过约一个月的努力,笔者终于实现了 2000 次测试不出错的 Raft 算法。受课程组的保密要求,无法公开源码,故撰写本文以记录实现思路和需要考虑的问题,旨在助力正在实现 Raft 的读者。
主节点选举
在这个部分里,我们需要搭建起整个 Raft 算法的框架并完成主节点的选举。一个 Raft 集群由若干个节点组成,它们有三种状态,分别是:Leader(主节点)、Candidate(候选者)、Follower(跟随者),其中主节点负责与客户端进行通信,而跟随者只被动地响应主节点和候选者的 RPC 请求。Raft 算法中的 RPC 请求只有三种,分别是 RequestVote(请求选票)、AppendEntries(日志复制)、InstallSnapshot(传输快照)。
每个跟随者都有一个选举定时器(Election Timer),它们在一个时间范围中随机地选取一个时间并开始倒计时(在我的实现中,时间范围取 320ms~520ms),如果它们在倒计时结束时都没有收到主节点发来的 AppendEntries 请求,就会开启一个新的 Term 并成为候选者。Term 是 Raft 算法中的一个逻辑时间单位,一个 Term 中至多只能有一个主节点,这样有利于维护集群的一致性。集群初始启动时,所有节点均为 Follower 状态,因此待到有一个定时器超时后,即进入选举阶段。待选举出主节点后,集群即可对外提供服务了,主节点需要定时向其它节点发送心跳(AppendEntries 请求)来防止它们开启下一轮选举,在我的实现中为每 100 ms一次。
Raft 论文中含有大量的细节,给代码实现带来了不少难度。如前所述,一个 Raft 节点只可能处于 Leader、Candidate、Follower 三种状态 ,它们只会处理 RequestVote 请求、AppendEntries 请求、InstallSnapshot 请求、选举超时这四种事件,因此笔者采用表格的形式将不同状态下对各个事件的处理方式进行了总结。
首先,如果收到 RPC 请求中的 Term
大于本节点的 currentTerm
,则一律将本节点 currentTerm
置为 Term
,状态转为跟随者,重置 votedFor
;如果收到 RPC 请求中的 Term
小于本节点的 currentTerm
,则立即返回。接下来按照表格进行处理。
主节点 | 候选者 | 跟随者 | |
---|---|---|---|
选举超时 | - | 进入下一个 Term,继续拉选票 | 进入下一个 Term,成为候选者,开始拉选票 |
RequestVote 请求 | 忽略 | 忽略 | 若对方日志比自己更新且尚未投票:投票,重置定时器 |
AppendEntries 请求 | - | 转为跟随者,重置定时器 | 重置定时器 |
InstallSnapshot 请求 | - | 若发来快照新于自己的:替换 | 若发来快照新于自己的:替换 |
表格可以帮助你快速熟悉 Raft 的大致结构,其实 Raft 论文也提供了对算法复杂机制的概括,即图 2。图 2 包括了节点需要保存的状态,以及各种重要的处理细节。如果你的实现中出现了 bug,可以对照图 2 检查代码。
在实现过程中,还要注意几个问题:
一旦收到超半数投票,立即转为主节点 :一旦出现网络分区,RequestVote 请求可能数秒都无法响应,这时如果等待所有投票结果到来再统一计票,好几个选举周期都过去了。可以使用 Go 语言的 sync.Cond
来实现等待-唤醒机制,每当收到 RequestVote 响应则进行计票。如图所示,绿色代表被投票,红色代表未被投票,灰色代表未响应,此时虽然还有 RequestVote 响应未收到,但是得票已超半数,应立即转为主节点并开始广播心跳。
正确实现定时器 :在实验说明中,建议大家采用 time.Sleep()
而非 time.Timer
的方式来实现定时器。定时器的需求为:当触发了重置定时器的条件,就从 320ms~520ms 中随机选择一个时间并重新开始倒计时。然而,time.Sleep()
并不支持在睡眠中途停止,我们可以维护一个 lastHeartBeat
变量,每次重置定时器都保存重置时的时间戳,下次睡眠的时间为随机选取的时间减去已在上次倒计时中睡眠的这部分。一旦出现超时,则进入下一个 Term,开启一个协程来广播 RequestVote 请求,定时器则立即开始下一个倒计时。
正确使用锁 :在 Raft 代码中,可能会有这样几类操作:读写内存中的值、发送 RPC 请求、提交日志至 applyCh 通道,对于后两类操作,我们无法确保接收 RPC 请求的节点网络是否通畅,也无法保证通道另一端的 goroutine 已准备就绪。因此,在持有锁的时候,应只进行读写内存值的操作,不应在持有锁的时候发送 RPC 请求或发送数据给通道,否则可能会导致该节点长达数秒的等待。 如下图所示,持锁发送 RPC 请求还可能导致死锁 。
日志复制
一旦主节点被选举出来,它就可以开始处理客户端发来的请求,Raft 层连接一个上层应用,假如上层应用是个键值数据库,则客户端的操作为SET x=5
或SET y=7
等,这些操作按到达的先后顺序保存在主节点的日志(log)中。尽管已经保存到了主节点的日志中,这些指令还不能被执行,Raft 需保证在每一个节点上执行的指令相同,从而达成各节点状态一致性,因此还需要在当前 Term 中把这些日志复制到超过半数节点上才可以提交。如图所示,一旦这些日志被复制到了超过半数节点上,那么以后选出的主节点也一定含有这些日志,这些日志永远不会被覆盖掉(例如,索引号为9,Term 为6以及它们前面的日志),尽管 (f) 节点曾是 Term2 和 Term3 的主节点,但由于它接收的日志没有及时复制到超过半数节点上,则这些日志最终会被覆盖掉。
以 (f) 节点为例,我们来看看日志复制是如何进行的。主节点当选后,把每个节点的 nextIndex 置为 11,这个变量表示下一个要同步的日志索引号,然后向 (f) 发送 11 号日志前一个日志的索引和 Term 号,(f) 节点会进行比对,如果前一个日志的索引和 Term 号相同,则说明前面所有日志都是相同的(Raft 论文利用反证法证明了这一结论)。显然,(f) 节点 10 号日志的 Term 为 3,不与发来的日志匹配,此时需要对主节点的 nextIndex 进行回退,回退到 (f) 与主节点匹配的地方再开始复制。如果每次回退仅仅是 nextIndex-1
,那么需要 7 次 RPC 请求才能成功复制,对此我们可以跳过 (f) 上所有 Term3 的日志,回退到 nextIndex=7
,再跳过所有 Term2 的日志,回退到 nextIndex=4
,即可仅用 2 次 RPC 请求完成复制。这种回退方式可能会跳过已经匹配的日志,但是大大减少了日志复制的耗时。
为了尽快达成共识,每个新生成的日志都要立即复制到其它节点上。如图所示,如果每次有新日志生成,都根据 nextIndex 来发送 AppendEntries 请求,既会大大增加 RPC 请求的数量,也会使某些日志被反复发送。由此可以采用等待-唤醒机制实现一个日志复制协程(replicator),每当有新日志生成,就发送唤醒信号,此时 replicator 从等待队列中被唤醒,与生成日志的操作抢占锁和运行权。若生成日志的操作较密集,当 replicator 抢占到运行权时,可以一次性复制多个日志。
当主节点上的某个日志被复制到超过半数节点上时,这条指令就可以提交给上层应用来执行。Raft 论文中写道:"如果存在一个 N,使得 N > commitIndex
,并满足大多数的 matchIndex[i] ≥ N
,以及 log[N].term == currentTerm
:则令 commitIndex = N
"。如下图所示,将 matchIndex 排序后,检查中位数索引号所在的日志是否为当前 Term,如果是则可以提交新的日志了。日志应用协程(applier)也是采用等待-唤醒机制来实现,这样不必在每个 AppendEntries 响应都检查一遍是否应该提交日志,使用 applier 的好处同样可以防止日志被重复应用到上层应用中。主节点计算出新的 commitIndex 后通过 AppendEntries 发给跟随者,跟随者更新该值并应用日志给上层应用。
在实现过程中,还有几个需要注意的问题:
RPC 请求前后节点状态发生变化 :在 Raft 实现中,典型的 RPC 请求模型如下图所示。由主节点或候选者发起,首先加锁检查自己的角色是不是能够发送这一请求,然后准备请求数据,它们发送的 RPC 请求主要为广播,需要保证广播的同一批请求参数一致,不能前一半为 Term=19
,后一半为 Term=20
(发给不同节点的日志等可不一致),由于发送 RPC 请求需要释放锁,因此可以将需要保持一致的参数复制一份。待对方节点响应完毕后,本节点处理响应信息,再次持有锁,此时不能保证节点状态与发送 RPC 之前一致,例如:作为 Term19 的主节点发送 AppendEntries 请求,收到响应时已是 Term20 的跟随者,此时应将它视为过期请求而废弃,不应再正常处理。
Raft 论文图 8:图 8 告诉我们:(1) 如果在当前 Term 把之前 Term 的日志复制到超过半数的节点(图(c) 中索引为 2 的日志),在未来仍可能被覆盖掉,只有把当前 Term 的日志复制到超过半数的节点才可以提交;(2) 如图(d) 所示,日志覆盖后需要删掉原日志中多出来的尾部。
RPC 乱序问题 :两个先后发出的 RPC 请求不一定按相同的顺序到达,也不一定按相同的顺序发回响应。因此我们需充分考虑旧的 RPC 比新的 RPC 晚处理的情况,以两个乱序的 AppendEntries 请求为例,它们分别负责复制 log[9:11]
和 log[9:14]
:(1) 上面提到日志覆盖后需要删掉原日志中多出来的尾部,实现时要保证只有当主节点发来的日志与跟随者冲突时,才删除多出来的尾部,否则复制 log[9:11]
的请求会把 log[11:14]
这些正确的日志删除掉;(2) 若 AppendEntries 请求成功,则 nextIndex 只会递增;若 AppendEntries 请求失败,则 nextIndex 只会递减。根据这一发现,可以防止旧的 AppendEntires 虽然成功,却把 nextIndex 改小了。
持久化
当我们正确实现主节点选举和日志复制后,就已经完成了 Raft 的大部分工作,我们可以保证即使个别节点故障、网络出现分区、甚至是主节点故障都不会影响到集群正常运行。然而,若整个集群出现断电事故呢?我们希望先前用户们的数据、操作、日志等仍可以被恢复,就要把它们保存在非易失性存储上面,这就是持久化。
根据论文图 2,我们需要对 currentTerm
、votedFor
、log[]
持久保存,每当它们被修改,调用 persister 保存它们即可。
日志压缩
当我们的 Raft 集群如此运行了几周、几月后,必然积攒下了大量的日志,占满内存和磁盘,节点重启后也要从头运行这几个月的日志来恢复服务。因此,我们可以用状态快照的方式来压缩日志:当保存了上层应用在某个索引下的状态后,就可以把这个索引及以前的日志全部删除掉。
上层应用主动触发 Raft 层保存当前快照,主节点保存快照后通过 InstallSnapshot 发送给所有节点,若主节点发来的快照较新,则其它节点将其替换。若某个跟随者落后的日志已经被主节点舍弃掉,则先用 InstallSnapshot 请求将快照传输给该节点。对于 applier 协程,如果 lastApplied < lastIncludedIndex
,则提交快照给上层应用。