目录
1 难点及完成思路:
1.1 难点分析:
1.2 完成思路:
1.3 实验心得:
1.4 报告结构:
2 Leader选举模块
2.1 思路概括(代码角度)
2.2 注意问题:
2.3 代码细节(对应第2.1节框架各个部分)
2.4 运行结果
3 日志复制模块
3.1 思路概括(代码角度):
3.2 注意点
3.3 细节代码
3.4 运行结果
4 持久化模块
5 日志压缩/快照模块
6.824 实现报告
1 难点及完成思路:
1.1 难点分析:
本次课程作业为复现论文中Raft算法,推荐语言为go,参考框架也已经给出。Raft算法本身思想感觉并没有难以理解的地方,但因为之前几乎从未接触过这类分布式/并发控制/多线程的代码,所以,第一个难点就是不会写多线程,不了解多线程代码的运行逻辑;另一个难点就是尽管参考框架中有一定的注释,但看完以后感觉还是不太能理解代码各个模块之间的关系,尤其是不能理解自己需要在参考框架定义的那些函数里进行哪些操作,以及这些功能是怎么被调用的也不太明白。
1.2 完成思路:
因此,基于上述分析,作业的完成思路分为如下两步,第一步通过开源代码弄清各个模块间的逻辑关系,第二步尝试自己复现代码。
1.3 实验心得:
因为第二步复现代码前已经知道了开源代码的一些实现思路,所以相比完全从零开始复现,这样做必然从一定程度上间接地少踩了不少坑,不过实现过程中还是遇到了许多问题,写了无数个中间输出来调bug。例如该实验中,有许多地方涉及了多线程/协程的竞态问题,因此就需要合理的使用锁,当然目前来看本次作业中的竞态问题是比较好解决的问题,通过-race参数的报错在相应地方加锁即可。目前感觉代码逻辑的严谨性才是最大的难点,例如发投票/发送心跳/发日志的过程,无论对于信息的接受方还是发送方,都需要通过对接受/反馈得到的信息来判断自己目前所应处的三种节点状态,这个问题在论文的Figure 2中是有提到的,另外自己参考的代码中也有对应实现,但自己复现时就没考虑周全,以至于最后对着参考代码一行一行的找问题,自己写的代码和参考代码还有很多的地方思路不太一样,就导致调bug变的异常困难。
1.4 报告结构:
接下来报告中将在2、3、4、5节中详细介绍选举任务、日志复制任务、持久化任务以及日志压缩任务的实现过程。
2 Leader选举模块
整个选举算法的基本思想是:
初始所有节点处于Follower状态,并随机一个固定区间的[150, 300]或者[500, 600]的选举超时时间,如果在该时间内节点没有收到心跳信息,就变为Candicate状态并发起选举,如果网络中一半以上节点同意它的选举申请,则该节点转为Leader状态,并周期性的向其他节点发送心跳包来保持自己的Leader地位。
2.1 思路概括(代码角度)
首先,从各个函数之间的交互关系来看,Make函数可以看作整个程序的入口,在test.go文件或者config.go中,会通过调用Make函数来生成网络中的N个节点,同时在Make函数中,完成对节点的初始化以及启动相应的选举Goroutine/协程,代码步骤概括如下(具体细节见2.3):
①定义所需数据结构:Raft(表示一个节点)、RequestVoteArgs(Candidate发出的投票申请信息)、RequestVoteReply(投票申请的回应信息)、AppendEntriesArgs(该模块中Leader用一个空的该结构发送心跳,后面也会用该结构发日志)、AppendEntriesReply(心跳/日志回复信息)
②Make函数对节点进行初始化,并开启选举协程GoElection()和心跳协程GoHeartbeat()。
③实现GoElection()函数和GoHeartbeat()函数,前者功能为向其他节点发送投票申请,并根据投票反馈信息决定自己成为Leader状态还是变回Follower状态。后者Leader节点才会执行,用于向其他节点发送心跳信息。
④实现AppendEntries(…)、RequestVote(…),前者用于接收GoHeartbeat()发送的心跳信息/日志信息,后者用于接收GoElection()函数发出的投票申请信息。
2.2 注意问题:
首先是对于锁的使用,当可能会出现多个协程修改同一共享变量(例如节点的rf.Term就在多处被修改)的时候,就需要加锁。而如果一段代码可能会阻塞或者处于等待状态的话就不要加锁,例如自己在写GoElection()函数时出现的一个问题,Candidate状态节点会通过调用sendRequestVote(…)函数向其他节点发送投票申请,根据反馈结果来修改自己得到的票数votes,因为对于每个节点,Candidate都会单独开辟一个协程来处理上述过程,所以在对总票数votes进行判断前要先用time.Sleep()等一会儿,等到各个协程运行完后再处理votes值,此时如果在time.Sleep()前加锁,会导致 votes值无法得到修改,因此也就无法处理正确的votes值,所以一定是先写time.Sleep()语句,再加锁。
其次就是网络中一个节点向其他节点通信时一定不要用串行方法,要单独开一个协程来处理。
2.3 代码细节(对应第2.1节框架各个部分)
因为关键代码部分都加了注释,所以一些小的逻辑细节就不再赘述(主要就是根据接受信息/反馈信息的任期、当前节点/server自己的任期和状态来决定要不要接受信息或者更新自己的状态),这里先给出一个关键函数GoElection(…)的伪码。
算法2.1 GoElection(…)函数
表2.1 代码截图对应表
模块名 | 对应代码截图 |
Raft结构体 | 图2.1 |
RequestVoteArgs结构体 | 图2.2 |
RequestVoteReply结构体 | 图2.3 |
AppendEntriesArgs结构体 | 图2.4 |
AppendEntriesReply结构体 | 图2.5 |
Make(…)函数 | 图2.6 |
GoElection(…)函数 | 图2.7 |
GoHeartbeat(…)函数 | 图2.8 |
AppendEntries(…)函数 | 图2.9 |
RequestVote(…)函数 | 图2.10 |
图2.1 Raft结构体( server/节点的实体类)
图2.2 RequestVoteArgs结构体(封装了投票申请)
图2.3 RequestVoteReply结构(投票反馈信息)
图2.4 AppendEntriesArgs结构(封装心跳信息/日志信息)
图2.5 AppendEntriesArgsReply(心跳反馈)
图2.6 Make函数
图2.7 GoElection函数(开启选举的核心函数)
图2.8 GoHeartbeat函数(用于Leader发送心跳)
图2.9 接受心跳信息
图2.10 RequestVote函数(接受拉票信息,返回投票信息)
2.4 运行结果
图2.11 2A运行结果
3 日志复制模块
3.1 思路概括(代码角度):
日志复制模块的入口可以看成是提供的框架中的rf.Start(…)函数(除了Start函数,在Leader每次向其他servers发送心跳时,如果发现有发送日志的必要,也会进行日志复制操作),本次实验中,还另外定义一个GoCopyLogs(…)函数,在Start中,通过调用GoCopyLogs()来实现向其他server/节点发送日志的主要逻辑,日志信息接受函数和第1节中的心跳接受函数为同一个函数,只不过添加了对日志进行处理的代码。下面分别简要说一下发送日志和接受日志处理过程的思想。
发送方:
在发送日志信息时,一个很重要的变量为nextIndex[i],它表示Leader向Follower[i]发日志时,应该从哪个位置开始复制。其更新的地方有3处,第一个是Leader刚被选举出来时,此时可以更新所有follower的nextIndex值为len(leader.logs)+1;第二个是Follower复制日志成功时;第三个是Follower添加日志失败时,此时需要缩小nextIndex的值到Leader和Follower能匹配的地方。
发送方首先会把nextIndex开始的所有日志发给其他Follower,同时还会带上nextIndex之前一条日志的term和index,以判断之前的日志是否完全匹配。如果Follower第一次就成功复制了日志,那Leader只需要更新一些相应的Index即可,如果没有复制成功,则需要根据Follower反馈的冲突点重新调整nextIndex的值,再重新发送日志过去进行复制,这里冲突点的确定参考了论文里提到的优化策略,大概思想如下:
图 3.1 冲突点确定策略
接收方:
当接受方收到一条日志添加请求时,会进行如下逻辑判断:
图3.2 Follower处理接受日志的逻辑
3.2 注意点
这里在写代码时一直没考虑Make(…)函数中applyCh这个量的作用,以为只把日志信息添加到server的logs属性中就可以了,导致测试没法通过。对于applyCh这个量,目前的理解大概是,server通过applyCh把日志中的命令提交到状态机/执行命令的机器上,然后test.go文件测试时,测试的是执行命令的机器上有没有这条日志记录。
所以,为了实现提交功能,定义了一个新的函数GoApply(…),该函数在发现server的commitIndex大于lastIndex时,就会把相应的日志命令塞到applyCh中。另外和提交功能相关的几个Index的含义及更新时机如下:
matchIndex:每个Follower和Leader日志一致的最高位置。更新的地方有两个:一个是follower添加日志成功时要更新matchIndex,再一个就是通过Start(…)函数接受一个新的命令时,会更新leader自己的matchIndex,方便后面统计多少follower和自己日志一致,以判断要提交哪些命令。
commitIndex:目前已经提交的日志的最高index。在Leader添加日志后,会通过matchIndex[follower[i]]来检测某位置的follower是否超过总节点数的一半,是的话就把该位置的日志标记为提交状态。
lastApplied:和commitIndex的含义一样,lastApplied用于判断是否应该向状态机发送要执行的命令了。
3.3 细节代码
分别依次展示四处代码:GoApply(…)函数(提交命令到状态机)、GoCopyLogs(…)函数(Leader发送日志)、Start(…)函数(日志复制入口)、AppendEntries函数(follower接受日志)
图3.3 GoApply函数
图3.4 GoCopyLogs(…)函数
图3.5 Start(…)函数
图3.6 AppendEntries函数
3.4 运行结果
图3.7 日志复制模块运行结果
4 持久化模块
许多博客把持久化任务看作持久化和日志复制优化两个任务,因为前面在日志复制中已经考虑了优化策略,参考代码帮忙少踩了很多坑,所以这里就只关注哪些量需要被持久化、怎么实现持久化以及哪些地方需要调用持久化操作这三个问题就可以了。
首先,根据论文及其他资料,需要持久化的量有三个,term、votedFor以及log。其次,怎么实现持久话以及读取持久化,框架中已经给出了例子,照着写一下就行了,代码如下:
图4.1
其次,在哪些地方调用这两个函数。对于readPersist函数,仅需要在节点启动/初始化时调用一次就可以了;而对于persist()函数,则所有代码中,修改上述三个量的地方都应该调用一次persist()函数。
5 日志压缩/快照模块
图 5.1 交互图
通过参考上面5.1交互图以及一些资料和代码,将看的内容总结概括一下,在日志压缩模块的代码实现中,我们只需要关注如下三个问题即可:
- 弄清安装一个快照需要包含哪些信息,定义安装快照相关的结构体。
图5.2 安装快照相关的实体类
- 实现框架中给出的Snapshot(函数),上层服务会调用它更新快照及日志。
图5.3 Snapshot(…)函数
- Raft层之间的快照信息传递:编写发送快照的代码、接受并安装快照的代码。
首先,当Leader节点发送给其他节点的日志信息被压缩/删除时,Leader需要发送快照给其他节点,所以,考虑在日志复制的函数中进行这一操作,思路如下(代码要特别注意压入日志压缩功能后对log的索引的使用,不能再直接使用绝对位置了,要做转化):
图5.4 发送快照代码
其次,需要编写接受快照的代码,定义一个InstallSnapshot(…)函数如下,这里需要说明的是,InstallSnapshot(…)函数中只是把要安装的快照信息塞入到applyCh中,提交到了状态机中,我们还需要完成框架中定义的CondInstallSnapshot(…)函数,来最终完成快照的安装。即快照经过的路径为:Leader发送快照-> InstallSnapshot函数->applyCh->CondInstallSnapshot函数,这么做的目的可能是为了保证状态机/上层服务和raft层在安装快照时是一起的,是具有原子性的,也可能有其他原因。当然,也有一些参考代码/博客将CondInstallSnapshot函数的返回值直接设置为true,好像这也是6.824课程所建议的方法,这里就先不考虑原因了。InstallSnapshot(…)函数和CondInstallSnapshot(…)函数代码如下:
图5.5 InstallSnapshot函数代码(Follower接受快照信息)
图5.6 CondInstallSnapshot函数代码(安装快照)
图5.7 运行结果截图