MIT6.824 Lab2 实现记录

准备工作

在做该实验前,需要熟读论文《In Search of an Understandable Consensus Algorithm (Extended Version)》,主要根据 Figure 2 来实现代码。

此外,在做实验的过程中,如果遇到了问题可以去看一下助教写的raft指南:Students' Guide to Raft,里面写了一些建议和许多常见的错误。

关于Debug,实验中给你写好了DPrintf函数,在 util.go 文件中我们可以设置开启或关闭。

关于测试,每个部分都有多个测试函数,例如 Lab 2A 通过命令 go test -run 2A 按顺序依次测试,如果只想测试某个函数,则只需要修改后面 2A 即可,它会根据正则表达式匹配。

 

Raft

相比于 Paxos,Raft 最大的特性就是易于理解,它主要做了两方面的事情:

  1. 问题分解:把共识算法分为三个子问题,分别是领导者选举日志复制安全性三部分。
  2. 状态简化:对算法做出一些限制,减少状态数量和可能产生的变动。

在 Raft 中, leader 将客户端请求封装到一个个 log entry 中,将这些 log entries 复制到所有 follower 节点,然后大家按相同顺序应用 log entries 中的 command,从而使得各个节点的最终状态是一致的。

在任何时刻,每一个服务器节点都处于 leader、follower 或 candidate 这三个状态之一。

 

Lab 2A:领导者选举

在 Raft 中,有两种超时机制,分别是选举超时心跳超时,当节点选举超时后,它会增加自己当前的任期号并成为 candidate 竞选 leader,如果能获得超过半数的选票即赢得了选举,从而成为 leader。节点的选举超时是随机的,以避免节点同时发起选举。

另一种心跳是心跳超时,是 leader 专有的,leader 会周期性的向其它节点发送心跳,已告知其它节点现在已经存在 leader 了,在后面的实验中,心跳也会包含日志同步信息。如果 follower 一段时间没有收到心跳,那么它就会认为系统中没有可用的 leader,当选举超时后就会发起选举。

在实验 lab2A 中需要实现的部分就是框起来的部分,按照图中所写的实现即可:

  • 首先启动 Raft, 在 Make 中初始化,为每个节点分配一个随机的选举超时时间,同时所有的节点都具有相同的心跳发送间隔时间。在论文中选举超时时间在 150ms — 300ms 之间,并且领导者发送心跳的频率远远超过150ms 一次。但是在实验中限制了每秒不超过 10 次的心跳包,所以需要使用大于 150ms —300ms 的选举超时时间
  • 在 Raft 算法中,服务器节点间的沟通联络采用的是远程过程调用(RPC),在领导者选举中,需要用到两类RPC:
    • 请求投票(RequestVote)RPC:candidate 在选举期间发起,通知各节点进行投票。
    • 日志赋值(AppendEntries)RPC:leader 发起,用来复制日志和提供心跳消息。
    • 需要注意的是,服务器之间通信的时候会交换当前任期号,如果一个服务器上的当前任期号比其他的小,该服务器会将自己的任期号更新为较大的那个值。如果一个 candidate 或者 leader 发现自己的任期号过期了,它会立即回到 follower 状态。如果一个节点收到一个包含过期的任期号的请求,它会直接拒绝这个请求。
  • 当 candidate 发起请求投票时,其余节点会发生以下情况
    • 如果该候选者的任期小于我,说明该节点落后了,拒绝投票。
    • 如果任期一样,如果我还没有投票的话,就给他投票。
    • 如果任期大于我,更新任期值,并重置为 follower,重置之后必处于未投票状态,所以此时必定会给他投票。
  • 当投票结束后,节点需要将自己的任期是否投票返回给 candidate,这里为什么还要返回任期号呢?是因为 candidate 的任期可能落后了,从而可以更新 candidate 的任期并使其退出选举。每次 candidate 接收到回复后,如果当前票数已经超过半数,则可以成为 leader 了。
  • 当成为 leader 之后,需要每隔一段时间就发送心跳。
  • 什么时候重置选举超时时间
    • leader 处收到一个 RPC,且 leader 的任期必须大于等于节点的任期。
    • 当前节点选举超时。

对于选举超时和心跳超时,我的实现是开启两个 goroutine 不断检测节点是否超时:

go rf.timerElection()
go rf.timerHeartbeat()

一旦超时则通过 channel 进行通知:

func (rf *Raft) ticker() {
	for rf.killed() == false {

		// Your code here to check if a leader election should
		// be started and to randomize sleeping time using
		// time.Sleep().

		// loop
		for {
			select {
			case <-rf.timerElectionChan:
				rf.startElection()
			case <-rf.timerHeartbeatChan:
				rf.braodcastHeartbeat()
			}
		}
	}
}

  

Lab 2B:日志复制

该部分是整个实验中最麻烦的一部分,细节非常多。

Leader 被选举出来后,开始为客户端请求提供服务,那么客户端怎么知道哪个节点是 leader 呢?客户端随机向一个节点发送请求,① 正好为 leader 节点; ② 为 follower 节点,可以通过心跳得知 leader 的 ID;③ 节点宕机,客户端只好寻找新的节点。

Leader 接收到客户端的指令后,会把指令作为一个新的条目追加到日志中去,每一个日志条目存储一条状态机指令和从 leader 手上收到这条指令的任期号,日志中的任期号用来检查是否出现不一致的情况,同时也用来保证某些性质。同时每一条日志都有一个整数索引值来表明它在日志中的位置。

具体实现需要注意的点:

  • 日志一致性检查
    • 在 Raft 中,leader 崩溃可能会使日志处于不一致的状态,leader 通过强制 follower 直接复制自己的日志来处理不一致问题,这意味着在 follower 中的冲突日志条目会被 leader 的日志覆盖。

      要使得 follower 的日志进入和自己一致的状态,leader 必须找到最后两者达成一致的地方,然后删除 follower 从那个点之后的所有日志条目,并发送自己在那个点之后的日志给 follower 。所有的这些操作都在进行 AppendEntries RPC 的一致性检查时完成。leader 为每一个 follower 维护了一个 nextIndex,这是 leader 下一个要发送给 follower 的日志条目索引。

      当一个节点成为 leader 时,它初始化所有 nextIndex 为它日志中最后一个索引 + 1。如果一致性检查失败,那么 leader 就会递减 nextIndex 并重发 AppendEntries RPC,最终 leader 和 follower 一定能找到日志上相匹配的点。需要注意的是,leader 从来不会覆盖或删除自己的日志

      for i := 0; i < len(rf.peers); i++ {
            rf.nextIndex[i] = len(rf.log)
            rf.matchIndex[i] = 0
      }
      

      matchIndex 记录 follower 已经同步的日志,在刚成为 leader 时,节点还未与任何 follower 同步成功,所以设为 0 。

  • leader 什么时候提交日志?
    • 当日志已被复制到大多数节点上时,leader 通过更新 commitIndex 将日志条目置为提交状态,同时 leader 中之前的所有日志条目也都会被提交,包括由其他领导者创建的条目。一种写法是将 leader 的 matchIndex 按照升序排列,然后取中位数位置的 Index,并且该日志必须是该 leader 任期内的日志。
      // if there exists an N such that N > commitIndex, a majority of matchIndex[i] >= N, and log[N].term == currentTerm: set commitIndex = N
      match := make([]int, len(rf.peers))
      copy(match, rf.matchIndex)
      sort.Ints(match)
      majority := match[(len(rf.peers)-1)/2]
      if majority > rf.commitIndex && rf.log[majority].Term == rf.currentTerm {
      	rf.commitIndex = majority
      	go rf.applyEntries()
      }

      被复制到半数以上节点的日志将被标记为 Commit 状态,并由另一个 goroutine 负责提交到状态机中,被提交到状态机的日志被标记为 Applied 状态。

      func (rf *Raft) applyEntries() {
      	rf.mu.Lock()
      	defer rf.mu.Unlock()
      
      	for i := rf.lastApplied + 1; i <= rf.commitIndex; i++ {
      		applyMsg := ApplyMsg{
      			CommandValid: true,
      			Command:      rf.log[i].Command,
      			CommandIndex: i,
      		}
      		rf.applyCh <- applyMsg
      		rf.lastApplied += 1
      }
  • follower 什么时候提交日志?
    • leader 通过 AppendEntries将自己的 commitIndex通知给其他节点,其他节点进行提交操作。当大多数节点提交完成后,leader 更新自己的 lastApplied并将日志条目中的命令应用到状态机中。

  • 安全性问题
    • 一个 follower 可能会进入不可用状态在此期间 leader 已经提交了若干日志,然而这个 follower 可能会被选举为 leader 并且覆盖这些日志。因此,不同的状态机可能会执行不同的指令序列。

      所以 raft 在投票的时候,候选人只有包含了所有已经提交的日志条目才可以获得其它节点的投票。在 RequestVoteArgs 结构体中,定义了 LastLogIndexLastLogTerm 这两个值,分别表示候选人最后一个日志号和候选人最后一个日志的任期。raft 通过比较节点中的这两个值来判断谁的日志更新,如果两份日志最后的任期号不同,那么任期号大的日志更新;如果两份日志的任期号相同,那么日志更长的就更新

 

Lab 2C:持久化

Lab 2C 通过封装 gob 对象 persister 来模拟数据持久化到磁盘的过程,需要持久的状态有 3 个currentTermvotedForlog[]votedFor持久化是为了保证一个节点在一个 term 中,最多只给一个 candidate 投票,从而保证在每个 term 中最多只有一个 leader 能被选举出来。

由于节点随时可能 crash, 更新这三个状态后要持久化。这部分实现相对容易,只需要在对象被修改后调用 persist() 函数即可。

 

在 Part B 中,对于日志一致性检查失败的情况,让 nextIndex - 1 后再重试,这样效率较慢。

Students Guide to Raft 中提到了优化的方法:

  • 如果 follower 的日志中没有 prevLogIndex,则设置 conflictIndex = len(log)conflictTerm = None。因为 prevLogIndex 超前了,nextIndex 直接回退到 follower 的日志条目末尾处。
  • 如果 follower 有 prevLogIndex 处的日志, 但是 Term 不匹配, 则设置 conflictTerm = rf.log[prevLogIndex].Term,并找到日志中该 Term 出现的第一个日志条目的下标,并置 conflictIndex = firstIndexWithTerm
  • leader 在收到 response 后,leader 寻找 conflictTerm 的日志:
    • 如果存在对应 conflictTerm 的日志:从任期号为 conflictTerm 的最后一个日志的下一个日志开始同步,即设置 nextIndex = lastIndexWithTerm + 1
    • 如果不存在对应 conflictTerm 的日志:说明 leader 和 follower 在 conflictIndex 处以及之后的日志都有冲突,设置 nextIndex = conflictIndex
  • // nextIndex optimization
    if len(rf.log) <= args.PrevLogIndex {
    	reply.ConflictIndex = len(rf.log)
    	reply.ConflictTerm = -1
    } else {
    	reply.ConflictTerm = rf.log[args.PrevLogIndex].Term
    	for i := 1; i <= args.PrevLogIndex; i++ {
    		if rf.log[i].Term == reply.ConflictTerm {
    			reply.ConflictIndex = i
    			break
    		}
    	}
    }
  • // if AppendEntries fails because of log inconsistency: decrement nextIndex and retry
    if reply.ConflictTerm == -1 {
    	rf.nextIndex[peer] = reply.ConflictIndex
    } else {
    	conflictIndex := -1
    	for i := args.PrevLogIndex; i > 0; i-- {
    		if rf.log[i].Term == reply.ConflictTerm {
    			conflictIndex = i
    			break
    		}
    	}
    	if conflictIndex != -1 {
    		rf.nextIndex[peer] = conflictIndex + 1
    	} else {
    		rf.nextIndex[peer] = reply.ConflictIndex
    	}
    }
    

      

Lab 2D:日志压缩

在前面的代码中,日志都存储在内存中,但是我们不可能让存储的日志无限增长。对于已经应用到状态机的的日志,其实已经不需要维护在 Raft 中了。

Raft 使用快照技术对日志进行压缩,lastIncludeIndexlastIncludeTerm 分别表示快照中最后一个 logindexTermstate 是状态机的数据,由上层应用处理。保留这些数据是为了支持快照后紧接着的第一个日志条目的一致性检查。

Lab2D 主要是要实现3个函数:

  • Snapshot(index int, snapshot []byte):Raft 将 index 及之前的日志进行删除,并将快照保存起来。

  • CondInstallSnapshot(lastIncludedTerm int, lastIncludedIndex int, snapshot []byte) bool:一个节点接受到一个快照后,提交给上层应用,上层应用需要判断快照是否是有用的。

  • InstallSnapshot(index int, snapshot []byte):节点在该函数中处理来自 leader 的快照,判断是否是过期的快照,往 applyCh 写入 snapshot 就是将快照传给了应用。

 

踩坑记录

1. defer

在函数中使用了 defer,但是在函数 A 中又调用了函数 B,由于 A 中此时并没有释放锁,在 B 中又需要加锁的话就会发生死锁。

我一开始在锁住了整个 Start 函数,但是没注意 GetState 中也会进行上锁,这样导致的结果就是日志没有添加进 leader 节点。所以需要在调用完 GetState 后进行加锁。 

 

2. 日志索引

因为日志索引的原因,2B中第一个测试就盯着日志看了好久,发现日志索引从 0开始的话处理起来会比较麻烦,实际在论文中索引就是从 1 开始的,所以为了处理更简单,每个节点在初始化的时候可以都加入一个日志,这样日志索引就可以从 1 开始了。

rf.log = append(rf.log, LogEntry{Term: 0})

  

3. 什么情况下会出现 votedFor = candidateId?

网络环境的原因有概率会导致一个 candidate 的 requestVote 请求在同一个任期内重复发送,而且重复发送的两个包在发送链路和接受链路的速率不同,如果单纯的判断votedFor==-1,会导致这两个包的结果不同。

 

4. figure 8 (unreliable) test 

模拟网络混乱,RPC 调用者和处理者都要 RPC 乱序、丢失、重复和延迟的因素,节点会频繁的崩溃和重启。对于每个 RPC 回复,我都会先做一下检查,检查当前节点和

在该测试中,我几乎每次都会失败,偶尔会成功。

在日志一致性检查成功时,需要更新 nextIndex 和 matchIndex 的值,我一开始的写法是:

rf.nextIndex[peer] += len(args.Entries)
rf.matchIndex[peer] = rf.nextIndex[peer] - 1

但是在考虑网络延迟的情况下,可能会收到多个心跳请求的回复,并且它们都可以工作,所以会造成 nextIndex 多次更新。

所以需要用当时发送 RPC 时的状态进行更新:

rf.matchIndex[peer] = args.PrevLogIndex + len(args.Entries)
rf.nextIndex[peer] = rf.matchIndex[peer] + 1

在更改之后我就能顺利通过 figure 8(unreliable) 的测试。

 

5. apply error

在我修改完 nextIndex 和 matchIndex 后,多次测试时有一次突然出现了 apply error: 

出现 apply error 的原因是某个节点先 apply 了日志 A,而另一个节点在相同的 index 提交了不同的日志 B,也就是说两个 server 同一个 index 上的 log 不一致了。

看到别人的博客说在发送 RPC 请求和处理 RPC 返回体时都需要判断一次自身状态。然后我在发送 RPC 之前先判断一下当前节点是否是 leader。

if _, isLeader := rf.GetState(); !isLeader {
    return
}  

 

 

 

参考:

  1. Raft Part A | MIT 6.824 Lab2A Leader Election
  2. 【MIT 6.824】学习笔记 5: 2021 Raft 实现细节
  3. Students' Guide to Raft
  4. Raft梳理
  5. 欢迎使用 Go 指南
  6. MIT6.824 spring21 Lab2D总结记录
  7. MIT 6.824 Lab2B
posted @ 2022-07-14 17:06  Kayden_Cheung  阅读(689)  评论(1编辑  收藏  举报
//目录