这是2021年国庆期望做的练习,随后整理完善了下。希望能对Raft感兴趣的同学一些启发。
做这个作业是为了熟悉Raft一致算法。基本的准备工作就不说了,可以对照着[https://pdos.csail.mit.edu/6.824/labs/lab-raft.html] 指导文档。
也是为了练习Go的编码。重复1000小时以上才能熟悉,1w小时以上才能精通。
0x01 2A部分
2A是适中的难度,也是第一个作业。实现Raft算法中的选Leader过程。
基础框架中已经给出出,需要在有2A注释的位置,加上实现的代码。保证最终的代码可以通过如下的测试用例。
go test -run 2A -race
一开始因为没有写什么代码,所以这个测试中3个用例全部失败。
先熟悉下用例的运行过程。
1.1 TestInitialElection2A
这个用例是用来初始化选举的,也就是说初始化完成后会出现一个Leader。
- 使用make_config生成一个集群配置。
- 调用配置的begin方法。这个时候应该已经开始选举了。
- 之后就是检查选举的状态了。
make_config时已经将各个Raft实例初始化,并连接到了一个虚拟的RPC网络上。现实中应该是一个个的进程,课程中是集成在了一个进程中,因为Go支持协程,很适合这种场景。
1.2 需要做哪些工作?
- 完成Raft类型的定义。
除了论文中说的状态,还要保存当前的状态。状态有Candidate,Leader,Follower三个状态。 - 完成RequestVoteArgs和RequestVoteReply的定义,这个可以直接照搬论文里的。
- 实现RequestVote处理收到的投票请求。
RequestVote中处理收到的选举请求。根据论文中的逻辑实现就行了。 - 发起投票前开始前要随机等待一段时间。在ticker中发起投票,向所有的peer发送投票RPC请求。
- 在Make中初始化raft实例。
初始化时,需要设置初始的状态。term和votedFor属性,包含当前的状态。
1.3 理清选举的细节
-
Leader如何维持自己的状态?
Leader通过周期性的发心跳请求给各个peer,一旦有follower没有在指定的时间内收到心跳,就认为Leader异常了,就会在超时时间之后开始新一轮的选举。 -
如何保证已经投票给了A的peer,不会投票给B?
这个要实现方案自己保证。并且如果已经投票了,但在指定的超时时间内不能转换为Follower状态,需要重新发起选举。
0x02 2B部分
事务提交的过程。
按照论文5.3中的描述,Leader收到客户端的请求后,leader会将请求entry附加到log列表之后,然后并发向所有其他的节点发送AppendEntries RPC请求。当这个entry被安全地复制了,leader将entry应用到自己的state machine上,并响应客户端。论文中提到了,如果没有并安全地复制,leader将一直会重复发送AppendEntries请求。即使是客户端已经被告知超时了,请求失败了。
通过梳理发现,要想搞清楚这部分编码要如何进行,就要理清楚这个操作中涉及到的几个关键动作和概念。
2.1 entry列表和状态机
figure2中,每个节点有3个持久化的属性。
- currentTerm,指代当前的term轮次。
- votedFor,当前term下被选出的节点。
- log[],这个是个状态机的entry列表。
其实这3个属性就决定了状态机的状态。2B中需要关注的是,状态机中跟业务直接相关的部分就是log[],当节点启动时,可以初始化出一个字典来。通过将log[]中所有的条目应用到字典上,就得到了启动后的字典值。这个字典可以提供给客户端进行查询。
现在可以说明commitIndex和lastApplied这两个运行时属性了,其实就是log[]中的索引。commitIndex就是达成共识的标引,lastApplied是最新应用到状态机中的索引。
2.2 收到AppendEntries时Follower如何处理?
按照论文中的流程,是要做一些判断才能应用log。必要时候是要丢弃掉不符合的日志。当一个leader的log还复制给多数节点时集群崩溃恢复了,旧leader在随后的时间里加入到集群中时,之前未复制的log就要丢弃掉,否则无法同步集群中新的日志。
2.3 初始化时如何向follower复制日志?
想理清leader的log是如何复制给follower的,就要从leader的初始化说起。
- 从持久化中加载Log列表,初始化commitIndex和lastApplied属性。
- 成为leader后,初始化nextIndex和matchIndex。
根据figure2中的描述,nextIndex将被初始化为log列表中最后一个条目的Index+1。而matchIndex被初始化为0。当触发心跳向所有主机发送AppendEntriesRPC请求时,假如有一个长时间离线的follower恢复在线,它的currentTerm小于当前的leader的currentTerm,将响应success为false,即告诉leader,它的数据和leader的不一致。然后leader会减小对应的nextIndex进行重试。
重试时,leader因为nextIndex与最后一个log的Index相等了,会把最后一个log条目附加到AppendEntriesRPC请求中,发给follower,如果还不成功。再次减小nextIndex,直到匹配上follower的最后一个log条目。至此follower就能同步到leader的数据了。
那么通过以上的分析可知:
- leader需要保留所有的log,不能清除历史的,防止有新的follower加入。 工程实现上可能不太好,所以有了快照传送的数据同步方式。
- follower与leader的差别较大时,可能需要很长时间才能同步成功。 可以用二分法快速找到差别点。
同样可以知道nextIndex和matchIndex属性的作用。
nextIndex: 保存下一个要向follower同步的Log索引,它规定了从哪个位置向follower拷贝Log。
matchIndex: 记录了已经成功复制的最大Index,通过它来确认某个Index是否已经达成多数复制了。
为什么需要两个Index列表描述呢?
是的,按照figure2中的说明,matchIndex和nextIndex是一起更新的。好像是可以只用一个来表示。不过通过重新回顾初始化过程,就能明白只有一个是不行的。nextIndex会在同步协商过程中变动,而matchIndex的更新是严格要求的,它用来指示follower实际的复制索引。
0x03 阶段总结
2B已经完成。代码在[https://gitee.com/linmaolin/mit6.824] 中。感兴趣的同学可以看看,一起交流学习。
快照传输部分还没有做。
学到了如下:
- Go中单元测试要开启-race选项,用来检查竞争读写。
- 实打实地熟悉了Raft协议的选举和日志复制过程。