ICode9

精准搜索请尝试: 精确搜索
首页 > 编程语言> 文章详细

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

2021-11-27 16:02:32  阅读:180  来源: 互联网

标签:RPC lab rf 一致性 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理论延伸来讲讲分布式一致性

林林林:从CAP理论到分布式一致性协议52 赞同 · 5 评论文章正在上传…重新上传取消​

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

建议先读一遍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有一定的帮助。

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

  • 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时应该能帮到你:

2. 开始lab2实现Raft

6.824 Home Page: Spring 2020​nil.csail.mit.edu/6.824/2020/index.html

务必遵循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

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权限访问。

标签:RPC,lab,rf,一致性,Raft,日志,节点,分布式
来源: https://blog.csdn.net/m0_64374605/article/details/121578549

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有