学习分布式一致性协议:自己实现一个Raft算法

前言

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理论延伸来讲讲分布式一致性,点击查看

1. Lab前的预备工作

1.1 如何检验Raft算法的正确性

感觉这个是大多数人首先都比较关心的问题,这个Raft算法做出来之后我怎么知道它能work呢?lab中首先会给你一个代码大致骨架,骨架中附带了很多单元测试可以测试你的代码的正确性,所以按照一定规则去实现你的算法之后run一遍单元测试就行了。

1.2 编程语言

MIT6.824 中 lab 使用的语言均为Go语言,不会Go语言的同学不要就这么打退堂鼓了,我在做lab之前也不会Go语言,但这个语言简单高效,如果有Java或者C++的基础的话上手会非常快,实际做lab的话只用到了少数并发的Go库函数,所以库函数的学习成本也不会特别高,Go的语法与Java、C++类似,熟悉几天就能上手,关于IDE我个人使用的是GoLand 30天免费体验,也可以使用比较强大的 Vim -> vim使用文档,用熟练之后效率不亚于GoLand。

在Go中使用的一些特定的Go的库函数、一些比如定时器的做法在下面介绍lab的时候会具体涉猎

1.3 阅读论文

做lab之前,首当其冲的当然就是阅读Paper

  • Raft论文:https://raft.github.io/raft.pdf (英文版)
    • https://www.ulunwen.com/archives/229938 (中文版)
    • 有英文底子的都建议看英文版,因为难免英文原著有些意思翻译成中文会丢失了一些味道,直接看中文的话有的地方可能会有点疑惑

建议先读一遍paper,大概了解了解Raft算法的具体构思,看不懂的先跳过,第一遍不求甚解,有个大致思想即可。

1.4 Lecture

此时你大致已经对Raft有一定的想法了,相当于预习了一遍课程,这时候就可以开始上课了,如果只做lab2的话,你需要关注以下几个lecture:

  1. Lecture 5: Go, Threads, and Raft
  2. Lecture 6: Fault Tolerance: Raft (1)
  3. Lecture 7: Fault Tolerance: Raft (2)

其中第一个lecture讲的是在使用Go语言实现Raft时会出现的几个问题,有参考价值,第二个和第三个lecture讲的是Raft算法的一些细节,这几个lecture建议都要看,对实现lab有一定的帮助。

以下是我找到的有三个课程资源:

  • YouTube的全英文无字幕高端玩家版:https://www.youtube.com/channel/UC_7WrbZTCODu1o_kfUMq88g/videos
    • 这个比较适合英文特别好的,门槛比较高不是很推荐哈哈
  • simviso团队中文翻译版:https://www.simtoco.com/#/albums?id=1000019
    • 翻译的不错,但缺点是没翻译完,只翻译了Lecture5、6和Lecture7的前面一点点。不过也翻译了大部分了,前面大半部分可以参考这里
  • 机翻的中英文双字幕:https://www.bilibili.com/video/BV1qk4y197bB?p=7
    • 由于是机翻,很多翻译不到位,需要有一定的英文阅读水平,看英文字幕就可以了,结合上面的中文翻译版的这里就只需要从Lecture7开始看

1.5 回顾论文

可以动手做lab之前我认为有一个指标就是你至少需要懂论文中的Figure2中的每一个字的意思,知道为什么这样子设计,Raft算法由简单易懂著称,其只有两个RPC方法,一个是AppendEntries日志复制,一个是RequestVote请求投票,以及一系列的Raft属性都在Figure2中,同时有一系列Follower、Candidate、Leader、AllServer需要遵循的规则,理解这些规则并且做lab的时候一定要按照论文中的这些规则说的去做。

当你对某个Figure2中的规则产生疑惑,请多回顾多读几遍论文,这是做lab时bug-free的关键。做之前务必保证理解了Figure2。

1.6 参考资料

最后总结几个参考资料,做lab时应该能帮到你:

  • 助教的blog:Students’ Guide to Raft -> https://thesquareplanet.com/blog/students-guide-to-raft/
    • 课程的助教总结了几个做lab时需要注意的几个点,和一些bug经验,做之前可以参考参考
  • 教授写的Lock锁使用建议:http://nil.csail.mit.edu/6.824/2020/labs/raft-locking.txt
  • 教授写的Raft结构建议:http://nil.csail.mit.edu/6.824/2020/labs/raft-structure.txt
  • lecture的讲义:http://nil.csail.mit.edu/6.824/2020/notes/l-raft2.txt

2. 开始lab2实现Raft

课程主页

务必遵循paper中的Figure2的每条规则来实现你的lab

现在就开始着手做lab了,进入课程主页,左边的导航中进入lab2 ,开始动手之前务必保证读一遍教授说的话,以及仔细阅读每个Task下面的Hint提示(我做的时候进的是2018的网页,提示相当少,做完才发现有2020年的网页,提示变多了好几条)

2.1 Lab2A

首先是2A,实现Leader选举,刚开始2A里的两个测试个人认为是最简单的,因为leader选举在下面的2B、2C都会迎来更大的挑战,如果你能pass2A,并不能代表Leader选举的逻辑就一定ok,也就是说在2B、2C中如果出现BUG还是有可能因为你的Leader选举逻辑有问题导致的。

下面就提几个要点帮助你快速上手实现Raft

要点只会设计一些Raft算法无关的东西,比如语言这块,初衷是希望算法之外的东西不要浪费大家太多时间,更多关注算法的实现

2.1.1 加锁建议

一个原则,不要考虑锁性能(锁的粒度)问题,我们更关注的是算法的正确性,有可能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上了。。。)如果打上日志,在程序死锁的时候会比较方便排查问题

2.1.2 定时器实现

节点有一段时间收不到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))

2.1.3 等待RPC建议

一个节点变为Candidate后,会发起投票选举,向其余所有节点发送RPC,此时若获取到大多数选票(3个节点就只需要获取到1票,和自己的一票一共两票)就可以返回并声明自己是Leader,换句话说,3个节点发送2次RPC的情况下,收到其中一个RPC投票OK的响应,主线程就可以继续往下做Leader的逻辑了,不需要等待另一个RPC投票响应。那么这种逻辑怎么做呢?

WaitGroup

我使用了比较取巧的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方法吞掉异常。。比较取巧。个人会比较建议用下面助教推荐的方式来做,看自己喜好了。

Condition

这个方法也是lecture5里助教说的方法,类似Java里的Object#wait()、Object#notify(),主要思路是在主线程for循环一直检查条件,大多数或全部RPC结束,然后调用wait(),每次goroutine的RPC返回后都调用notifyall() 方法唤醒主线程去检查条件。这里不多说,主要看lecture5我记得是第一个助教在说的。

2.1.4 Debug调试建议

做lab的过程中出了问题,我基本都是通过打日志的方式来调试,不断在关键地方打Log,不断Run你的Test,到后面2C的时候有几个测试比较复杂,我建议你在脑子过一下你的实现,review你刚刚写的代码是很重要的,我出的大部分bug都是由于代码粗心,有几个小错误,经过review,在脑子里跑一下自己的代码会比较能看出来。如果问题实在复杂,建议查看test源码,看看test到底以什么方式跑的。所以总结我用的调试方法有如下三点:

  • 在关键地方打日志看数据变化
  • 在脑子里跑一遍自己的代码实现,review你的code
  • 看看 test code 的工作原理,从而明白错误为什么会产生

2.1.5 其他的小Tips

  • 多看看 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)
    

2.2 Lab2B

2.2.1 提交log

在入口方法 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

2.2.2 两个优化建议

加速日志同步

在下面几个测试中节点会大量的宕机,日志会大量的乱序,当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都有时间限制,也出于自己对代码的严格要求,不将就,建议做成批量提交的比较优雅。

2.3 Lab2C

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

3. 最后

如果你完成了lab,请不要将你的lab上传到例如GitHub这样的公开代码库,如果学习lab的同学直接参考源码那学习效果将会大打折扣。同时这也是MIT6.824的教授Morris所要求的,如果要上传到代码库给特定的人参考或是其他用途,最好将仓库设置为Private权限访问。

你可能感兴趣的:(分布式)