芙蓉花又栖满了枝头 奈何蝶难留
漂泊如江水向东流
望断门前隔岸的杨柳 寂寞仍不休
我无言让眼泪长流
——山外小楼夜听雨
完整代码见: https://github.com/SnowLegend-star/6.824
在完成Lab之前,务必把论文多读几遍,力求完全理解Leader选举、log日志等过程。
Part 3B: log (hard)
我开始以为完成PartA后面的三个部分就好办了,后来才发现过于too young too naïve。PartB的hard难度不是白标的,真正实现起来太恶心了。在着手解决问题之前,我们要建立一种思想:由于Leader是会不断发送heartbeat的,这从宏观看来就是一种for循环,所以对于未成功接收到heartbeat的Server不需要用for循环重复发送直至成功。其次,调用for循环可能使进程或者goroutine长时间持有lock,不利于并发性。sendVoteRequest()的内部实现同样如此。
其次,我们可以发现hearbeat和appendEntry本质上没什么区别。只需要把普通heartbeat看成是附加了一个空的entry就行。
在阅读完TA关于lock的建议之后,我们要注意到一点,及时把可能会改变的变量保存起来。例如rf.currentTerm这个参数,可能在刚开始调用sendHeartBeat()时这个参数还是1,等到了设置args的时候这个参数就变成2了。可以用currenTermConst=rf.currentTerm来保存,这样如果在sendHeartBeat()的执行中rf.currenTerm发生了改变我们也可以及时发现。
下面来讲讲figure 2中几个需要理解透彻的变量。
- commitIndex:注意,所有Server都会有自己的commitIndex,这就可以抽象出一个applier()函数完成entry提交工作。对于Leader来说,有半数Server(包括Leader本身)在自己的log中存储了某个entry后,Leader就可以视作这个entry是时候被提交了。而对于普通的Follower来说,则是用到#5:commitIndex = min(leaderCommit, index of last new entry)。这里可能有人会有疑问,什么时候会出现leaderCommit和index of last new entry不等的情况呢?假设A是Leader,B是Follower。
- leaderCommit>index of last new entry。B作为Follower发生了宕机,导致B的log要远远落后于A。LogA的index=7,LogB的index=4。B恢复后重新加入集群,A会根据nextIndex[B]来实现两者的同步。假设两者之前的log已经达成了同步,那A会在nextIndex[B]=5(即PrevLogIndex=4)处发现两者同步了。此时A会给B发送一个index=5的entry来帮助B补充自己的log。在B看来,leaderCommit=7,但是自己的index of last new entry=5,那它的commitIndex=5。
- leaderCommit<index of last new entry。假设A和B的log已经同步了,且两者的index=8。现在Client向Leader添加了新的entry,Leader在发送heartbeat时就会顺便把这条新的entry发送给B。如果这个时候大多数Server还没有接收到这条entry,那在B看来LeaderCommit=8,但是自己的index of last new entry=9。
- lastApplied:很明显一条entry要先可以commit,再进行apply。把commit看做是apply的先决条件。
- nextIndex[]:重中之重。这条属性我感觉特别绕,自己在纸上模拟了几次才完全理解它的含义。BTW,不要感觉自己看懂了就直接开始代码实现,一定要尝试在纸上模拟具体情况确保自己真正理解后才动手写代码。nextIndex[]难在使用和实现上。
最开始我是在sendHeartBeat()中对Leader的nextIndex[i]进行初始化的,但这样就产生了一个问题。每次发送heartbeat时,nextIndex[]原有的内容都被重新初始化了。如果所有Server的日志都已经对齐还好,万一出现日志未对齐的情况,这就导致不得不在sendHeartBeat()内部一次性完成所有Follower日志的对齐工作,导致goroutine长时间占有lock从而严重影响了并发。所以可以考虑在某个Server当选Leader的时候才进行nextIndex[]的初始化。在这个Leader在位期间,它的nextIndex[]一直由heartbeat进行同步状态的维护。
还有,根据TA在《Students' Guide to Raft》中提到的,用len(rf.log)给nextIndex[]赋值的时候一定要慎重,因为rf.log的长度是极易发生变化的。
最后一点,不能在完成PartB时nextIndex[i]—是不可行的,会挂在“leader backs up quickly over incorrect follower logs”这个测试点中。用课上提到的根据Term快速回退才行。
- matchIndex[]:它的作用乍一看也是让人摸不着脑袋。对于如何判断大多数Server接收到了某个entry,我最开始的想法是和vote一样,为每个Leader维护一个applyEntrySuccess。如果某个Follower成功接收entry那applyEntrySuccess就加1。
但是,由于我们处于一个分布式系统中,无法确保每个Follower接收entry的进度是一致的。可能Follower B在接收index=4的entryX,Follower C却在接收index=6的entryY,那不就得维护一个record[服务器数量][Leader日志长度]的二维数组了。慢着,这个二维数组好像可以从record[M][N]=true/false压缩为record[i]=接收到日志index为i的Server数量,那这不就是matchIndex[]数组嘛。
除此之外,还要注意log[N].term == currentTerm的重要性。可以进行commit的entry一定是在当前Term中产生的,当前Term的Leader不可以随意提交老旧Term中生的entry。《raft-extended》的5.4.2就是在说明这一点。
- prevLogIndex:它的值为 nextIndex[i]-1,相当于nextIndex[]的附属。那为什么要同时存在nextIndex[]和PrevLogIndex呢?直接用其中一个来维护日志对齐不好吗?
从宏观上来看,nextIndex[]用来维护Leader和Follower之间日志复制的进度,而PrevLogIndex则是确保Leader和Follower日志的一致性。
这里有一个小技巧,令heartbeat携带的entries[]比PrevLogIndex慢一步。例如Leader的index=5,Follower1的index=4。初始化nextIndex[1]=6,我们现在检查Follower1中PrevLogIndex=5的情况。此时heartbeat携带的entries[]应该为空,如果Follower1中PrevLogIndex=5的entry存在,那直接匹配成功。如果匹配失败,那nextIndex[1]=5,PrevLogIndex会减少为4,此时heartbeat携带的entries[]包含Index=5的entry。如果现在匹配成功了,那直接把entries添加到Follower1.Log的末尾就行,匹配失败则继续递减。
- prevLogTerm也类似,就不在赘述。
- success:我们要注意到如果Follower的Term要大于Leader的Term,Success应该是false。这种情况出现在旧的Leader宕机后又重新加入集群,确保旧的Leader会及时结束自己的统治生涯。如果Follower和Leader的日志不匹配,Success也是false。
- lastLogTerm: 这里也是我发现自己在PartA遗漏的一个小问题。日志更长的Server不一定更新。在《raft-extended》的5.4.1中提到,“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”切不可忽略这一点。
最后是我在完成lab时遇到的各种bug。
最麻烦的bug就是死锁问题。我在尝试使用更细粒度的锁时,发现“basic agreement”直接超时了。尝试了几次依然如此,我初步判断是什么地方出现死锁了。把sendHeartBeat()看了好几遍也没发现什么错误。直到偶然看了眼Start(),发现这里调用sendHeartBeat()之前就获取了lock,而我在sendHeartBeat尝试再次获取lock就必然会死锁。怪不得打印的日志看不到给Leader添加新的entry。
还有一个问题是注意goroutine的异步性。可能当前Server已经不是Leader了,但是这个Server在Leader状态发出的rpc调用还没结束。
nextIndex的快速回退算是最好解决的问题了。主要是在实现之前我就考虑要不要采用快速回退的方法,由于偷懒就没用。想着等所有测试点都过了再自己优化下,倒是在这里得提前进行这一步优化了。
最后贴张通过的图
Part 3C: persistence (hard)
完成part2有点懈怠了,就摆烂了好几天。Part C说实话并没有什么特别的任务点,简单完成persist()和readPersist()后再选择合适的位置对状态进行保存即可。
从figure2中我们可以知道需要保存的就三种状态,currentTerm、voteFor和log[]。如果Part B实现得比较完美的话,那么Part C是可以直接通过的。Part C就是加强版的PartB。我就是完成了持久化函数后直接通过了。
通过后我还以为自己真是个天才,Part B实现得过于完美了。于是神便治下傲慢之罪,让我在50次测试中接受拷打。
仔细阅读了问题日志之后,我发现自己第一个问题是nextIndex的快速回退执行有问题,有时候nextIndex来不及回退完成测试就结束了。出现这种问题的原因应该是我只在每次心跳中执行nextIndex的快速回退。
这时摆在我眼前的路有两条:一是发现日志匹配失败后就立即处理nextIndex直至匹配成功,而不是等到下一次心跳才接着处理nextIndex。二是适当调快心跳频率,同时考虑将conflictIndex设置为Follower的commitIndex。如果我可以接收Leader向Follower进行了过度重传,那在Follower的commitIndex不等于0的情况下,发生冲突直接将conflictIndex设置为commitIndex是不是可行的呢?我打算牺牲Leader进行重传的日志条目数量来换取日志的快速对齐,一番权衡之下我选择了第二种方法。
选择了方法二后,最棘手的bug出现了。我发现服务器居然在相同index、相同term的情况下内容不同。
************************************************
Server A在term=2当选为Leader,BCD三个服务器的term=2。三个服务器的日志均为[{-1 -1} {1 1} {22 2}]此时A 断开连接了。
B在term=3的时候当选为Leader,同时向B的日志中添加一个新entry{121 3},此时B的日志为[{-1 -1} {1 1} {22 2} {121 3}]。B还没来得及向CD两个服务器发送这个新entry时,B断开连接。
同时A再次加入连接,ACD的日志均为[{-1 -1} {1 1} {22 2}]。现在A加入连接后,通过发送心跳得知自己的Term=2过时了,更改为Term=3。由于此时集群中还没有Leader,所以会选出Term=4的新Leader。
旧的Leader被停止从而断开连接后,由于持久化状态不会保存ServerType,所以它再次加入连接后状态被更新为Follower。现在A加入连接后,状态是Follower而不是Leader,所以无法通过发送心跳得知自己的Term=2过时。由于此时集群中还没有Leader,如果A率先超时的话,那它就会在Term=3成为新的Leader是吗?此时如果再给A添加新的entry时,就会发现A的日志为[{-1 -1} {1 1} {22 2} {242 3}]。
等B再次加入集群的时候,集群中就会出现这种奇怪的日志冲突。
按理说上述情况时不存在的,因为每个Server只会在一个Term中投一次票。但是通过观察,我却发现群众里有坏人。
我真是没想到这里选举居然出了问题,遂加强条件判断。
后来发现加强条件判断还是有漏洞,原来问题出在Heartbeat()里面。如果某个Server已经在Term=X中投票了,于是Term=X一般会选举出一个Leader。但是这些投票的Server由于收到了来自Leader的心跳,就又会重新重置自己的voteFor。此时如果Leader挂掉了,重新连接了一个新的Server,这个Server就可以在Term=X再次获得投票。
照例贴张通过的图嘻嘻