07. Raft PartA 调试和小结
经过调试发现,视频代码主要有几个问题:
startReplication 落了一个返回值
日志打印有点问题
GetState 函数没有实现。
becomeCandidateLocked 没有调用 resetElectionTimerLocked(视频里没说)
Raft 的细节是非常多的,即使我实现过很多次,也很容易因为粗心或者忘记细节造成问题。所以有错不可怕,关键是掌握科学的调试方法,可以参考 0x02. [附录2分布式调试]。
实现关键点
下面我罗列一些 PartA 实现的关键点,如果出错,可能会造成测试通不过:
重置时钟本质上是认可对方权威,且承诺自己之后一段时间内不在发起选举。在代码中有两处:
接收到心跳 RPC,并且认可其为 Leader
接受到选举 RPC,并且给出自己的选票
在收到 RPC(回调函数)和收到 RPC 返回值时,第一件事就是要对齐 term。在 Raft 中,term 是一个非常关键的设定,只有在相同 term 内,一切对话才能展开。对齐 term 的逻辑就是:
你 term 大,我无条件转 Follower
你 term 小,不理会你的请求 ...
11. Raft PartB 选举日志比较
在有了日志之后,在进行投票时,就需要进行日志比较了。
因为 Raft 算法是强 Leader 算法,因此会要求 Leader 一定要包含所有已经提交日志。因此,在进行选举时,我们确保只有具有比大多数 Peer 更新日志的候选人才能当选 Leader。
日志新旧比较
关于两个 Peer 所存日志谁更 up-to-date 的问题,论文中是这样描述的:
Raft determines which of two logs is more up-to-date by comparing the index and term of the last entries in the logs.
If the logs have last entries with different terms, then the log with the later term is more up-to-date.
If the logs end with the same term, then whichever log is longer is more up-to-date
总结下,比较对象是最后一个 ...
10. Raft PartB 日志复制
PartB 需要定义日志格式,然后在 PartA 心跳逻辑的基础上,补全日志同步的逻辑。总体上来说,Leader 需要维护一个各个 Peer 的进度视图(nextIndex 和 matchIndex 数组)。其中 nextIndex 用于进行日志同步时的匹配点试探,matchIndex 用于日志同步成功后的匹配点记录。依据全局匹配点分布,我们可以计算出当前全局的 commitIndex,然后再通过之后轮次的日志复制 RPC 下发给各个 Follower。
每个 Follower 收到 commitIndex 之后,再去 apply 本地的已提交日志到状态机。但这个 apply 的流程,我们留到之后一章来专门做,本章就暂时留待 TODO 了。
因此,本章就只实现逻辑:匹配点的试探和匹配后的更新。
结构体完善
AppendEntries RPC 结构体
根据 ApplyMsg 所需字段,来定义 LogEntry 。然后在此基础上,依照 Raft 论文中的[图 2]来补全 RPC 涉及到的结构体:AppendEntriesArgs ,AppendEntriesReply 并不需要添加额外字段 ...
09. Raft PartB 结构调整
从这一部分开始,我们的代码量开始陡然增大。为了避免所有代码都挤在一个 raft.go 文件中,我们将其按逻辑进行拆分:
领导选举逻辑从raft.go 中拆出来,命名为 raft_election.go
日志复制逻辑从 rat.go 中拆出来,命名为 raft_replication.go
所有其他公用的逻辑剩下在 raft.go 中
注:在 golang 中,同一个类的实现拆到多个文件中时,其他文件通常使用该类名作为前缀,在我们这,就是 raft_XX.go
选举拆分
选举部分主要包括我们在 PartA 中提到的几部分:RPC 发送方的三个层次以及 RPC 接收方的回调函数。
1234567// functions in Candidate(RPC sender)func (rf *Raft) sendRequestVote(server int, args *RequestVoteArgs, reply *RequestVoteReply) boolfunc (rf *Raft) startElection(term int)func (rf *Raft) electionTic ...
13. Raft PartB 调试和小结
经过调试,我们有几个地方没有实现对:日志同步的 reply 处理前没有检查 context、Start 没有实现、AppendEntries 日志判断。
Context 检查
需要 Context 检查的主要有四个地方:
startReplication 前,检查自己仍然是给定 term 的 Leader
replicateToPeer 处理 reply 时,检查自己仍然是给定 term 的 Leader
startElection 前,检查自己仍然是给定 term 的 Candidate
askVoteFromPeer 处理 reply 时,检查自己仍然是给定 term 的 Candidate
由于我们 replication 和 election 实现的对称性,可以发现前两个和后两个是对称的,因此很好记忆。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748replicateToPeer := func(peer int, args *AppendEntr ...
12. Raft PartB 日志应用
本章我们要增加在代码组织(02.Raft 代码总览)一章中提到的三个工作流中的最后一个:日志应用工作流, applyTicker。
由于 Apply 只有在 commitIndex 变大的时候才会触发,因此我们可以使用 golang 中的条件变量 sync.Cond ,使用唤醒机制,只有在 commitIndex 增大后才唤醒 applyTicker。
字段补全
根据论文图 2 我们需要给 apply 逻辑补上两个字段:
commitIndex:全局日志提交进度
lastApplied:本 Peer 日志 apply 进度
由于我们想在实现时,使用 sync.Cond 唤醒 apply 的工作流,因此需要增加:applyCond。
最后,在我们 Raft 实现的设定中,apply 的过程,就是将 applyMsg 通过构造 Peer 时传进来的 channel 返回给应用层。因此还需要保存下这个 applyCh。
123456789101112// A Go object implementing a single Raft peer.type Raft struct { ...
14. Raft PartC 状态持久化
实现要求
PartA 和 PartB 基本实现了不宕机情况下的领导者选举、日志同步和日志应用逻辑。但是在当某个 Peer 异常重启后,是不能正常重新加入集群的。为此,需要将 Raft 的关键信息定时持久化,重启后加载,以保证正常加入集群。
至于哪些状态需要持久化,论文图 2 都有标记。这些需要持久化的属性集中任何一个属性发生变化,都需要进行持久化,因为 Peer 任意时刻都有可能宕机。在本课程中,我们不会真将状态持久化到硬盘上,而是使用测试框架中的 persistor.go 中的 Persister 类对数据进行“持久化”,主要涉及两个接口:
12func (ps *Persister) ReadRaftState() []byte {}func (ps *Persister) Save(raftstate []byte, snapshot []byte) {}
ReadRaftState 是反序列化接口,可以认为是从磁盘上读出一个字节数组;Save 是序列化接口,负责将字节数组形式的快照(PartD 才用)和 raft 状态写入磁盘。
写完后在 ...
16. Raft PartC 调试和小结
工欲善其事,必先利其器。这次在调试之前,我们首先再加一些之前没有加到位的调试日志。有了这些关键环节的日志,我们就可以追踪日志试探匹配过程中冲突以及后撤的 index 变迁轨迹和当时 Leader、Follower 两方日志构成。有了这些,即使我们一开始实现出错,也可以根据这些信息来修正我们的优化算法。
加些日志
由于大部分时候我们并不需要特别详细的信息,因此我们将这些日志信息大多都加为 Debug 级别,可以通过设置 VERBOSE = 0 来打印,VERBOSE = 1 来关闭对 Debug 日志的打印。
RPC 收发信息
每个 RPC 发送出去、收到的时候,都可以打印下关键参数信息。为了复用,我们可以给 RPC 用到的 XXXArgs 各自构造一个 format 好的 String() 函数。且尽量的简洁的打印信息。
领导选举模块收发信息:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253func (args *RequestVoteA ...
15. Raft PartC 实现和优化
PartC 最主要的逻辑包括两部分:
实现 Raft 的序列化和反序列化函数
将上述函数插入到代码流程中合适位置
但由于测试时加入了不同 Peer 的宕机重启,对我们之前的选主逻辑和复制逻辑的正确性和性能又提出了更高的要求。也就是说,同样的代码,即使能过 PartB 的测试,也不一定能过 PartC 的测试。
因此,我们本节的大部分时间,反而会花在于对之前代码逻辑的性能提升和查漏补缺上。
逻辑实现
序列化和反序列化
为了使代码模块清晰,我们将这部分也单独拆出一个文件,起名为 raft_persistence.go,主要包括对关键字段的序列化和反序列化两个函数:
123456789101112131415161718192021222324252627282930313233343536373839404142func (rf *Raft) persistLocked() { w := new(bytes.Buffer) e := labgob.NewEncoder(w) e.Encode(rf.currentTerm) ...
17. Raft PartD 日志压缩
实现要求
对于一个长时间运行的 Raft 系统,如果持续收到日志,会遇到以下问题:
空间不够:如果日志无限追加下去,本地硬盘空间可能存不下。
重启过慢:因为重启时需要重放( replay)所有日志,如果日志过长,重放过程将会持续很久不能正常对外提供服务。
一个经典的解决办法是,定期对日志做快照(snapshot)。针对某个日志 entry 做了快照之后,该 entry 以及以前的日志变都可以被截断(truncate)。当然,这种方法能解决的我们上面两个问题的本质原因在于:相比日志,快照的存储更为紧凑。日志记录的是事件(比如 update k1 = v2),而快照通常记录的是数据条目(比如 {k1: v1, k2: v2}),而一个数据条目通常会在事件中出现多次(写入-更新-删除等等),因此从日志到快照通常会有压缩空间。
但这样同时会引出另一个问题:如果 Leader 想给从节点发送日志时,发现相关日志条目已经被截断怎么办?这就需要引入一个新的 RPC:InstallSnapshot。通过此 RPC,先将 Leader 的 Snapshot 无脑同步给 Follo ...