实现 Raft 协议
简介
Raft 是一个分布式共识算法,用于保证所有机器对一件事达成一个看法。本文用于记录实现 Raft 选举和日志复制的代码细节。
选举
节点启动时首先是跟随者状态,如果到达选举超时时间就尝试选举,为了预防对称网络分区带来的任期不断增加问题,需要使用预投票机制。
选举超时时间:跟随者在这段时间内没有搜到领导者的消息,就触发选举超时,转变为候选者开始竞选
对称网络分区:以 3 台机器为例,其中一台机器与另外两台机器(这两台中有一个领导者)的网络隔离开了,此时跟随者会触发选举超时,导致其不断增加任期,在网络恢复正常时,领导者会因任期小而下线,集群因此触发重新选举
预投票机制:触发选举超时先询问其他节点是否同意当前节点进行投票,当多数节点同意时再进行投票,即正式投票
上面介绍了选举需要注意的问题,下面说具体流程。
- 节点启动时开启一个选举超时时间检测定时任务,用于在当前节点是跟随者时不断检测是否发生了选举超时,发生超时就开始竞选。每一次定时任务的触发时间都是变化的,以防止所有节点一起选举,所有人都不投票后死循环。
- 任务内容:如果当前节点不是跟随者或选举超时时间内收到了来自领导者的消息就跳过这轮检测,否则就代表领导者可能下线了,开始竞选。
- 竞选的第一阶段是预投票:发起预投票 RPC,内容有想竞选的任期以及当前的最新日志的任期和索引,如果只有少数节点同意投票就结束这轮任务,否则开始正式投票,RPC 内容与上次相同,但这次需要改变当前节点任期号为想竞选的任期号了,同时也要更改状态为候选者,如果只有少数节点同意投票就结束这轮任务(同时回滚状态为跟随者),否则就更改状态成为领导者,开始发送心跳等等(成为领导者的一些事后面再说)。
到这里跟随者如何选举成为候选者以及领导者就大致完成了,还缺少其他节点如何处理投票请求:
- 请求的任期比当前节点小就拒绝。
- 领导者有效(选举超时时间内有收到了心跳)就拒接。
- 该任期已经投过票就拒绝。
- 最新的本地日志任期大就拒绝,日志任期相同但本地最新日志的索引更大也拒绝。
如果上面 4 个条件全通过就投票。投票过程中还有一些节点状态的变更处理,比如收到正式投票的任期比当前节点任期大需要转变为跟随者等等,当然这些不算是重点。
日志复制
日志复制是 Raft 的核心,这里涉及到状态机的执行,也就是共识的关键,比较复杂。
在完成选举后集群有了领导者,由领导者负责与客户端沟通,在领导者收到客户端请求时,领导者将这条待状态机执行的命令和当前任期组合成一条日志写入本地磁盘,并向其他节点发送该条日志,如果多数节点都表示收到了,也就表明达成共识了,那么领导者就会将这个命令放到状态机中执行,那么什么时候集群中的其他跟随者节点的状态机执行该条日志的命令呢?答案是由定时的心跳负责,每次心跳都会携带领导者状态机最后执行的日志索引,当跟随者收到后就会将当前节点状态机最后执行的日志索引和心跳中领导者的日志索引之间的日志放到状态机中执行,也就是说日志中命令的执行是一个二阶段的过程。
选举中我们忽略了一个地方,就是成为领导者后需要询问集群的节点日志复制情况,以此来将当前领导者多的日志复制到其他跟随者,大概过程如下:领导者拿着最新日志的任期和索引和跟随者对比,如果相同,等着领导者新的日志复制就行了,如果不同,说明这个日志是脏的(日志没被复制给大多数),此时领导者拿着该条日志的前一条日志继续对比,直到相同,然后领导者将相同的日志之后的所有日志复制给跟随者,跟随者将相同日志后的日志都删掉,再追加上领导者发来的日志,这样跟随者的日志就正确了。跟随者与领导者日志的对齐后就可以等待领导者发心跳了(即通知跟随者将哪些日志放到状态机中执行)。
关于状态机执行日志还有很重要的一点,就是节点需不需要保存当前状态机执行过的最后一条日志的索引,比如机器重启了,从头执行所有日志对状态机有没有影响。可以思考下,如果是一个 KV 数据库状态机,不保存也没问题,因为日志不管从哪里执行,数据库中的数据也不会变,但如果是 id 生成器,就会出现多执行一次 id 就会变化,多执行很多次甚至可能出现 id 分配完无法继续分配的问题,所以命令执行多次有问题就需要保存,并且需要满足保存执行过的索引和执行状态机命令是一个原子性的操作。
读请求优化(读索引读)
日志复制是需要刷盘的,这个操作非常耗时,写请求只能通过领导者进行日志复制处理,但读请求不同,可以像 ReentrantReadWriteLock 读写锁一样,将读请求负载到跟随者上,也就是实现跨机器的 volatile 语义(和跨进程类似,但实际上只需要实现“内存屏障”就可以了,因为是不存在多线程,也就不需要“MESI”),即读跟随者时确保跟随者的状态机已经和领导者的状态机一样,具体过程如下:跟随者收到读请求,跟随者请求领导者同步日志以及状态机应该执行到那条日志,领导者收到请求后向所有的节点发一个 RPC 确认领导者地位(防止领导者所在的少部分节点分区后还能正常读),确认后同步日志并回复该跟随者,收到回复后的跟随者的状态机再执行读命令。
对于领导者的读请求同样也不需要走日志复制,只需要和其他跟随者确认自己的领导者地位就可以执行读命令了。
最后
coding 时要注意节点任期的变化,刚开始可以先用一个全局锁来回避这个问题,等后面到一定的复杂程度再细化锁。完整的 Raft 还需要考虑很多,比如快照、批量、pipeline、删减节点等等。最后贴上我的实现 raft/README.md 以及相关学习资料: