MIT6.824是麻省理工学院开设的一个很棒的分布式系统公开课程,课程的Schedule在这里 ,这门课程的学习方式主要是通过教授的 lecture 讲解、Paper阅读、FAQ答疑,以及实践lab来完成的,是一个学习理论知识,然后动手实践的过程,个人认为是很好的学习方式,而MIT6.824公开课让更多不是麻省理工的学生也能很好的学习分布式系统知识,免费学习MIT课程学到就是赚到!
MIT6.824主要围绕以下4个lab进行学习
- lab1->MapReduce:实现一个MapReduce系统,其是一个具有Map和Reduce功能的分布式计算系统
- lab2->Raft:实现Raft算法,其是一个分布式一致性协议,分为以下3个部分
- 2A:Leader选举
- 2B:日志复制
- 2C:持久化数据
- lab3->分布式容错的Key/Value存储服务:搭建一个容错的Key-Value分布式服务,其是建立在lab2-Raft的一个上层建筑,需要在lab2的基础上实现日志快照等功能,对外可以提供 K-V 存储服务
- lab4->Shared Key/Value服务:一个分片的存储服务
而本篇文章讨论的是如何学习lab2的部分,也就是实现一个Raft算法,本文会指出学习方式,以及你需要做到的一些要点、常见的坑、资料等等。你可以将本文作为一个lab2的Guide来进行阅读。
如果读者对其他lab有兴趣,也可以参照本文差不多的方式进行其他lab的学习。
首先放一张lab2A、2B、3C,3pass图(做完还是有满满的成就感的)
前段时间花了一周左右的时间动手写代码完成了MIT6.824课程中的lab2,达到 bug-free 属实不易,在做的过程中踩过许多坑,发现做lab的时候交流、沟通代码中的一些问题很重要,交流会开拓了我们的思路、解决方法,如果没人交流,就比较容易出现一个疑难杂症会卡好几个小时甚至几天的情况,比较容易产生气馁、想放弃的情绪,我在做lab2C部分的最后一个具有挑战性的unreliable test的时候有一个bug硬找了快两天,中途有几次想过放弃,但意志力和对技术的热情驱使我不能将就,所以坚持下来,最终会找到解决方法的思路的。
学习MIT6.824课程,我们不像MIT学生那样,学生之间可以进行讨论,有问题可以询问助教、教授,我们在做的时候只是一个人,你最多可以找到MIT6.824的交流群,但群里真正能帮助你解决一些问题的人不多,最终靠自己的比重还是比较大的,所以一些学习资料就显得比较重要,这也是本文创作的初衷,想让更多人学习到MIT6.824这门课程,学习Raft算法不止是阅读paper和一些理论知识,没有什么比直接实现一个Raft还能够深刻学习分布式一致性协议的了。其次自己实现一个Raft,想想就很有意思。
学习lab2,我希望至少需要有CAP和分布式一致性相关知识基础,起码要了解他们,知道Raft是干嘛用的,为什么需要使用Raft。这里推荐自己的一篇文章,从CAP理论延伸来讲讲分布式一致性,点击查看
感觉这个是大多数人首先都比较关心的问题,这个Raft算法做出来之后我怎么知道它能work呢?lab中首先会给你一个代码大致骨架,骨架中附带了很多单元测试可以测试你的代码的正确性,所以按照一定规则去实现你的算法之后run一遍单元测试就行了。
MIT6.824 中 lab 使用的语言均为Go语言,不会Go语言的同学不要就这么打退堂鼓了,我在做lab之前也不会Go语言,但这个语言简单高效,如果有Java或者C++的基础的话上手会非常快,实际做lab的话只用到了少数并发的Go库函数,所以库函数的学习成本也不会特别高,Go的语法与Java、C++类似,熟悉几天就能上手,关于IDE我个人使用的是GoLand 30天免费体验,也可以使用比较强大的 Vim -> vim使用文档,用熟练之后效率不亚于GoLand。
在Go中使用的一些特定的Go的库函数、一些比如定时器的做法在下面介绍lab的时候会具体涉猎
做lab之前,首当其冲的当然就是阅读Paper
建议先读一遍paper,大概了解了解Raft算法的具体构思,看不懂的先跳过,第一遍不求甚解,有个大致思想即可。
此时你大致已经对Raft有一定的想法了,相当于预习了一遍课程,这时候就可以开始上课了,如果只做lab2的话,你需要关注以下几个lecture:
其中第一个lecture讲的是在使用Go语言实现Raft时会出现的几个问题,有参考价值,第二个和第三个lecture讲的是Raft算法的一些细节,这几个lecture建议都要看,对实现lab有一定的帮助。
以下是我找到的有三个课程资源:
可以动手做lab之前我认为有一个指标就是你至少需要懂论文中的Figure2中的每一个字的意思,知道为什么这样子设计,Raft算法由简单易懂著称,其只有两个RPC方法,一个是AppendEntries日志复制,一个是RequestVote请求投票,以及一系列的Raft属性都在Figure2中,同时有一系列Follower、Candidate、Leader、AllServer需要遵循的规则,理解这些规则并且做lab的时候一定要按照论文中的这些规则说的去做。
当你对某个Figure2中的规则产生疑惑,请多回顾多读几遍论文,这是做lab时bug-free的关键。做之前务必保证理解了Figure2。
最后总结几个参考资料,做lab时应该能帮到你:
课程主页
务必遵循paper中的Figure2的每条规则来实现你的lab
现在就开始着手做lab了,进入课程主页,左边的导航中进入lab2 ,开始动手之前务必保证读一遍教授说的话,以及仔细阅读每个Task下面的Hint提示(我做的时候进的是2018的网页,提示相当少,做完才发现有2020年的网页,提示变多了好几条)
首先是2A,实现Leader选举,刚开始2A里的两个测试个人认为是最简单的,因为leader选举在下面的2B、2C都会迎来更大的挑战,如果你能pass2A,并不能代表Leader选举的逻辑就一定ok,也就是说在2B、2C中如果出现BUG还是有可能因为你的Leader选举逻辑有问题导致的。
下面就提几个要点帮助你快速上手实现Raft
要点只会设计一些Raft算法无关的东西,比如语言这块,初衷是希望算法之外的东西不要浪费大家太多时间,更多关注算法的实现
一个原则,不要考虑锁性能(锁的粒度)问题,我们更关注的是算法的正确性,有可能data-race的时候请毫不犹豫加上一把大锁
由于算法中很多地方都需要并发编程,比如Candidate发起投票请求RPC,要同时给所有节点发送RPC,此时就开多个goroutine进行RPC,一旦涉及并发编程,就会有data-race、数据可见性的问题,参照Happen-before原则,在所有有data-race的地方都加一把锁,为了可见性也为了原子性。
func (rf *Raft) GetState() (int, bool) {
// 为了可见性
rf.mu.Lock()
defer rf.mu.Unlock()
return rf.currentTerm, rf.state == Leader
}
这里获取节点中的当前Term和节点的state属性的时候加锁是为了可见性,currentTerm、state这两个属性明显会有data-race,所以这里一定注意可见性,不然Agoroutine修改了currentTerm,Bgoroutine调用上面的GetState方法有可能看不到最新的currentTerm值
同时有些方法需要加一把大锁,有些方法需要你读取currentTerm,然后又要根据某个值去修改currentTerm,请毫不犹豫加上一把大锁。
如何避免死锁?大部分死锁是由于锁获取顺序问题,比如有两把锁X和Y,同时有两个线程A和B,A先获取X锁后再去请求Y锁,B先获取Y锁后再去请求X锁造成死锁。这里锁获取顺序一个是先X后Y,一个是先Y后X,有这种锁获取顺序的时候务必注意死锁问题。
也就是说,我们避免锁上加锁的问题就可以避免死锁,所以一个原则,在RPC调用的时候不要持有锁,为什么呢?举一个例子:
func (rf *Raft) TimeoutAndVote() {
rf.mu.Lock()
// 节点的选举计时器超时,开始发起选举投票RPC
for i := 0; i < peersCount; i++ {
go func(server int) {
// 发送RPC投票请求
rf.sendRequestVote(server, &request, reply)
}
}
rf.mu.Unlock()
}
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
rf.mu.Lock()
// 节点收到请求投票RPC后的处理函数
rf.mu.Unlock()
}
每一个节点都是不同的Raft实例,也就是说每个节点都是不同的锁,集群3个节点就总共有3把锁
假设集群两个节点A和B,A、B同时选举超时,发起选举投票,所以A、B同时进入TimeoutAndVote函数发起选举投票,A获取了A锁,B获取了B锁,此时A发送RPC给B,进入RequestVote函数,需要获取到B锁,同时B发送RPC给A,进入RequestVote函数,需要获取到A锁,锁获取顺序一个是先A后B,一个是先B后A,所以发生了死锁。
如果我在调用RPC之前释放了锁,然后RPC结束之后重新获取锁,这样的话就避免了锁上加锁的情况,没有了多锁场景自然就没有死锁问题。所以一个原则,调用RPC过程不要持有锁。个人在做lab的时候遵循这个原则死锁就不会出现。
为了死锁能方便调试,你可以选择性把加锁函数封装起来,打上日志
func (rf *Raft) lock(where string) {
// DPrintf是src/raft/util.go的一个日志工具函数,通过修改其Debug值方便选择是否开启日志
DPrintf("%s lock", where)
rf.mu.Lock()
}
func (rf *Raft) unlock(where string) {
DPrintf("%s unlock", where)
rf.mu.Lock()
}
当然我是没用到这种技巧,如果你遵循上面原则,并且在Lock的地方都记得Unlock了,基本不会有死锁(我在做的时候死锁都是出现在忘记unlock上了。。。)如果打上日志,在程序死锁的时候会比较方便排查问题
节点有一段时间收不到Leader的心跳或AE(AppendEntries,下文称AE)的时候,就会变为Candidate并发起投票选举,这是2A中需要实现的,实现这个功能就需要一个定时器,那么你可以这样做:
// 设置一个时间值
const CandidateDurationMin = time.Duration(time.Millisecond * 200)
// 初始化定时器
rf.electionTimer = time.NewTimer(CandidateDuration)
// 另外开一个线程进行不断循环
for !rf.isKilled {
// 阻塞
<-rf.electionTimer.C
// timeout之后往下走
if rf.isKilled {
break
}
// here 2A...
// 重新倒计时
rf.electionTimer.Reset(time.Duration(CandidateDuration))
}
timer的实现是依靠Go中的channel管道来做的,可以理解为一个阻塞队列,等你设置的timeout之后就会往阻塞队列里面放值, <-rf.electionTimer.C 这行代码在timeout之前会被阻塞,这样就实现了定时器的功能。在收到心跳或者AE的时候就像最后一行调用Reset函数那样重置定时器,这样就能保证Follower收到RPC就永远不会发起一个投票选举。若想马上开始走定时器逻辑:
rf.electionTimer.Reset(time.Duration(0))
一个节点变为Candidate后,会发起投票选举,向其余所有节点发送RPC,此时若获取到大多数选票(3个节点就只需要获取到1票,和自己的一票一共两票)就可以返回并声明自己是Leader,换句话说,3个节点发送2次RPC的情况下,收到其中一个RPC投票OK的响应,主线程就可以继续往下做Leader的逻辑了,不需要等待另一个RPC投票响应。那么这种逻辑怎么做呢?
我使用了比较取巧的waitGroup的方式(个人浅显理解感觉很像Java的CountDownLatch,就直接拿来当CountDownLatch来用了)
var wg sync.WaitGroup
wg.Add(1)
for i := 0; i < peersCount; i++ {
go func(server int) {
// RPC
rf.sendRequestVote(server, &request, &reply);
// if 大多数ok 或 全部节点RPC都结束
if reply.xxx {
defer func() {
if err := recover(); err != nil {
}
}()
// 如果满足了大多数,唤醒主线程
wg.Done()
}
}
}(i)
}
// 阻塞主线程,直到得到大多数节点的选票或者全部节点
wg.Wait()
因为调用Done方法的时候有可能被调用两次(一次是满足了大多数,一次是全部RPC return),所以这里我使用recover方法吞掉异常。。比较取巧。个人会比较建议用下面助教推荐的方式来做,看自己喜好了。
这个方法也是lecture5里助教说的方法,类似Java里的Object#wait()、Object#notify(),主要思路是在主线程for循环一直检查条件,大多数或全部RPC结束,然后调用wait(),每次goroutine的RPC返回后都调用notifyall() 方法唤醒主线程去检查条件。这里不多说,主要看lecture5我记得是第一个助教在说的。
做lab的过程中出了问题,我基本都是通过打日志的方式来调试,不断在关键地方打Log,不断Run你的Test,到后面2C的时候有几个测试比较复杂,我建议你在脑子过一下你的实现,review你刚刚写的代码是很重要的,我出的大部分bug都是由于代码粗心,有几个小错误,经过review,在脑子里跑一下自己的代码会比较能看出来。如果问题实在复杂,建议查看test源码,看看test到底以什么方式跑的。所以总结我用的调试方法有如下三点:
多看看 http://nil.csail.mit.edu/6.824/2020/labs/lab-raft.html 教授写的Hint
RPC时方法参数的对象struct中的字段要大写,如果小写就相当于Java的private,访问限定会报错
RPC怎么写?
func (rf *Raft) sendAppendEntries(server int, args *AppendEntriesArgs, reply *AppendEntriesReply) bool {
// Call方法第一个参数是RPC接收方会被调用的方法
ok := rf.peers[server].Call("Raft.AppendEntries", args, reply)
return ok
}
// RPC会被调用到这里来
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
// ...
}
test会实现RPC超时的逻辑,一般来说你不需要去实现超时判断,除非你会想要精确控制RPC超时时间,那么你可以利用channel和select去做:
// channel,相当于阻塞队列里面是布尔值
rstChan := make(chan bool)
ok := false
// 不阻塞,在下面select中阻塞
go func() {
rst := rf.peers[server].Call("Raft.AppendEntries", args, reply)
// 返回的结果给到channel阻塞队列
rstChan <- rst
}()
// select会轮询(应该是?不是特别了解)
// 直到两个channel哪个ok就return,如果timer.C的channel先return了,就是超时了
// 同时返回ok的默认值false,表示RPC超时,请求失败
select {
case ok = <-rstChan:
case <-time.After(TimeoutDuration):
}
return ok
}
随机数怎么做?
// 以rf.me做随机种子,保证每个节点种子不一样,足够随机
rf.random = rand.New(rand.NewSource(time.Now().UnixNano() + int64(rf.me)))
// 获取随机值
randomVal := rf.random.Intn(200)
在入口方法 Make方法中有一个channel参数为 applyCh chan ApplyMsg ,将被视作已提交的日志放到这个channel中
msg := ApplyMsg{
CommandValid: true,
Command: logEntry.Command,
CommandIndex: logEntry.Index,
}
// 提交log
applyCh <- msg
new一个ApplyMsg对象然后put到channel中,这样test才会知道这个节点的这段日志被提交了。
其中Leader会确保日志在半数以上节点被复制完成,才会提交这条log到channel,然后更新自己的commitIndex,表示这条日志被提交,随着AE心跳或者日志复制,leader会告诉follower这条日志被提交,然后follower也需要做一个提交动作,将leader告诉自己的这条log提交到channel中。
我曾经以为只需要在leader中put channel就可以了,但这样test会认为你的follower这条日志没有被提交,某些test需要检测日志在所有节点已经被提交,从而无法pass test。所以注意follower也需要put log到channel
在下面几个测试中节点会大量的宕机,日志会大量的乱序,当follower从宕机中恢复,需要与leader通信日志Index,此时就需要同步日志,将follower日志与leader日志同步起来,此时follower需要找到最后一个与leader一样的日志(相同Index处的log的Term也相同被视作相同的日志),从这条日志往后开始进行复制,也就是paper中提到的prevLogIndex、prevLogTerm的作用,笨方法是leader与follower一条一条从最后往前开始对比日志哪条一样,但如果日志比较长,会造成有一条不同的日志就需要一次RPC,非常耗时,你需要优化加速这一过程,不是每条日志都进行比较,而是会跨过整个Term进行比较。
至于优化方式为了lab效果这里不多聊。可以参考lecture7教授会讲到3个case,和助教的blog中的 An aside on optimizations 也有提到。
这条的必要性有待考究,我在做lab的时候潜意识就将实现做成批量提交的方式,所以不知道这项优化是否会影响test,个人建议还是做成批量提交的比较好。下面的某些test的log有可能会达到几百几千条,如果一条一条日志慢慢提交,慢慢check大多数条件然后apply,个人感觉会比较慢,而每个test都有时间限制,也出于自己对代码的严格要求,不将就,建议做成批量提交的比较优雅。
lab中持久化不是持久化到硬盘,而是将数据Encode之后变为byte数组存在内存中。服务器重启之后会读取这部分内存中的byte数组到Raft实例这部分内存中使用。code骨架中有持久化例子,在persist和readPersist方法中,分别有Decode与Encode的方式与事先准备好的持久化方法 rf.persister.SaveRaftState(data)
这个lab的目标外表看上去是持久化(我做到2C时已经觉得做完了lab,觉得2C不会花多少时间,实际上这部分出现的BUG是我调试最久的。。),但其实重点难点并不在持久化怎么做,而是在什么时机持久化,以及更有挑战性的test,大概率会导致你的leader选举与AE日志复制出现BUG,所以2C在我看来是对日志复制与Leader选举更大的挑战,如果你没做好上面一节说的优化建议,很有可能无法PASS 2C的test。所以这里我没什么好建议给你,加油干就完事了,坚持不懈不要被BUG击退。
分布式一致性算法中相对于Paxos,Raft还是比较简单易懂的,实现一个Raft对于学习分布式一致性还是很有帮助的,Raft真正的难点在于工业级的优化,论文中只是教你如何实现,但粗略的实现在生产环境上性能并不是那么的理想,所以优化是难点也是一个重点。对优化感兴趣的读者可以参考Raft的工业级实现比如etcd
如果你完成了lab,请不要将你的lab上传到例如GitHub这样的公开代码库,如果学习lab的同学直接参考源码那学习效果将会大打折扣。同时这也是MIT6.824的教授Morris所要求的,如果要上传到代码库给特定的人参考或是其他用途,最好将仓库设置为Private权限访问。