MIT6.824 Lab2实现与踩坑

Lab2要求实现Raft协议,最主要的就是实现两个RPC方法——RequestVote和AppendEntries,每个RPC方法的参数、返回值和处理流程要完全按照论文来实现,同时要参考课程给出的Raft Structure AdviceStudent Guide。下面对论文中的一些细节和go语言的实现细节做一些记录

一、Lab2A

Lab2A只需要实现leader election这一个功能,测试也只有两个,但也是整个Lab2,3,4的开始,所以并不简单

currentTerm和votedFor的改变: 按照论文中图2 rules for all servers的第二条要求,当一个peer收到了一个term > currentTerm的RPC请求后,不需要做任何判断,直接将自己的currentTerm更新为收到的那个term,并将自己的状态改为Follower。对于RequestVote方法,需要将votedFor重置为-1,以避免无法继续投票

election time怎么监测: Raft需要始终监测是否在electionTimeout时间内收到了leader发来的heartbeat或AppendEntries RPC请求。在课程给出的Raft Structure Advice中提到了最简单的方法就是使用一个单独的go routine一直循环判断距离上一次收到leader的消息是否已经过了electionTimeout时间,而不是使用go语言中的time.Ticker或time.Timer,因为很难用对。我的具体实现如下:

func (rf *Raft) checkElectionTimeout() {
	for rf.killed() == false {
		time.Sleep(rf.electionTimeout)
		rf.mu.Lock()
		// Raft结构体中使用 lastHeartbeatTime 记录上一次收到leader消息的时间
		if time.Now().After(rf.lastHeartbeatTime.Add(rf.electionTimeout)) {
			rf.leaderElection()
		}
		rf.mu.Unlock()
	}
}

重置election time的时间: 有四种情况需要重置自己的election time:

  1. 给别的peer投票
  2. 收到了符合要求的heartbeat
  3. 自己发起了选举
  4. 自己是leader,给别的follower发送heartbeat。遍历到自己时,不需要给自己发送heartbeat,但要记得重置election time

如果测试时出现了warning: term changed even though there were no failures的问题,很可能是因为在某个过程中漏掉了重置election time的步骤

赢得选举后如何执行后面的过程: 可以使用go语言中的sync.Once保证赢得选举后的操作只执行一次。赢得选举后要执行一下操作:

  1. 设置自己的状态为leader
  2. 设置其他peer的nextIndex为自己的index+1,matchIndex为0
  3. 给其他peer发heartbeat
  4. 重置自己的选举时间

为什么多个peer总是同时发起选举: 在设定各个peer的electionTimeout时,直接用rand.Intn()生成随机数,不需要设置seed,设置了seed反而会导致各个peer的electionTimeout相同

二、Lab2B

Lab2B是Raft的核心部分,主要是对于AppendEntries RPC的各种情况的实现

heartbeat和AppendEntries RPC的区别: follower收到leader的heartbeat时也需要和普通的AppendEntries RPC一样进行prevLogIndex和prevLogTerm的检查,并据此返回true或false,这个是肯定的。但论文中说:heartbeat和AppendEntries RPC一样,只不过携带的是空的log entries。

其实我完全没有区分heartbeat和AppendEntries RPC,heartbeat也带上需要的log entries,也是可以的。如果要按照论文中的实现,一定要检查heartbeat的返回值,如果heartbeat的返回值==false,需要再发送AppendEntries RPC发过去对方所需的log entries,否则在最后一个command提交之后,落后的peer再也无法更新自己的log

follower截断自身的log entries: 从Lab2B开始,就要考虑到RPC请求的发送顺序和收到顺序并不相同,一个follower可能会收到过时的AppendEntries RPC请求。因此在收到AppendEntries RPC后,不能直接截断args.prevLogIndex之后的log entries并拼接发来的log entries(因为自己拥有的log entries可能比这条请求携带的log entries更新),而是要找到第一条与发来的log冲突(index相同但term不同)的位置,从这个位置开始截断,并拼接上请求参数中的log的剩余部分。同理leader更新nextIndex和matchIndex也要注意这个问题

如何实现command的apply: Lab2B中需要实现一个apply()函数,这里是第一次考虑Raft层与上层应用交互的问题,在Raft层看来,这里实现的是lastApplied追赶commitIndex的功能,在上层应用看来,这里是Raft层对某个command达成了共识,要提交给上层应用执行

Raft中有lastApplied和commitIndex两个字段,commitIndex是根据大多数节点的matchIndex来更新的,lastApplied是在apply()函数中更新的。leader收到AppendEntries RPC的返回值为true的响应后,要更新matchIndex,进一步更新自己的commitIndex。之后要“通知”apply()函数,让lastApplied+1~commitIndex之间的log提交给上层应用

怎么“通知”一个函数要开始执行呢?Raft Structure Advice也提到了要使用sync.Cond。apply()函数要单独作为一个go routine执行,因为提交的command这一步实际是向channel中发一条消息,可能会阻塞。sync.Cond的作用是调用Wait()方法将自己挂起,等待某个条件满足,直到别的go routine调用Broadcast()方法唤醒自己,才继续执行。在这里,Broadcast()方法是更新commitIndex之后调用的,apply()函数等待的条件是lastApplied + 1 <= commitIndex。具体实现为:

// 使用sync.Cond处理log的apply
// 在Wait()方法之前,一定保证持有锁
// 执行rf.applyCh <- msg时,一定保证没有持有锁
func (rf *Raft) apply() {
	for !rf.killed() {
		rf.applyCond.L.Lock()
		for rf.lastApplied+1 > rf.commitIndex {
			rf.applyCond.Wait()
		}
		DPrintln(rf.me, "apply commands from", rf.lastApplied+1, "to", rf.commitIndex, ", lastIncludedIndex =", rf.lastIncludedIndex)
		for i := rf.lastApplied + 1; i <= rf.commitIndex; i++ {
			if i <= rf.lastIncludedIndex {
				DPrintln(rf.me, "apply", i, "index, while lastIncludedIndex =", rf.lastIncludedIndex)
				continue
			}
			msg := ApplyMsg{
				CommandValid: true,
				Command:      rf.getLog(i).Command,
				CommandIndex: i,
			}
			if msg.Command == nil {
				DPrintln(rf.me, "apply a nil command, applied index =", i)
			}
			rf.mu.Unlock()
			rf.applyCh <- msg
			rf.mu.Lock()
		}
		rf.lastApplied = max(rf.lastApplied, rf.commitIndex)
		rf.applyCond.L.Unlock()
	}
}

sync.Cond还挺难用的。Wait()方法内部会先释放锁,因此在调用Wait()方法前必须保证持有锁。在唤醒之后会自动持有锁,因此Wait()方法之后再不能加锁。rf.applyCh <- msg这条语句可能会阻塞,因此执行这条语句前先释放锁,但构建msg的过程需要持有锁

AppendEntries RPC的优化——加快日志回溯: 论文中这部分说的很简略,但Student Guide中说的很详细,可以直接按照里面所说的三条规则来执行:

  1. follower中没有prevLogIndex的条目,则conflictIndex = len(log)且conflictTerm = nil
  2. follower中有prevLogIndex的条目,但term不匹配,则conflictTerm = log[prevLogIndex].Term,且conflictIndex = log中第一条term = conflictTerm的index
  3. leader收到响应后,找到最后一个term = conflictTerm的位置,将nextIndex设置为这个位置+1。如果找不到term = conflictTerm的log,则将nextIndex设置为conflictIndex
三、Lab2C

Lab2只需要实现Raft属性的持久化,没有太多的代码和问题,但由于节点的重启,会引入Lab2B中没发现的bug,我在TestFigure8Unreliable2C这个test遇到的bug,最后发现是对论文中Figure 8不理解导致的,在此记录。

论文中Figure2 Rules for Servers中Leader的第4条规则,要求leader的commitIndex对应的log entry的term必须等于currentTerm,否则会出现Figure 8的错误情况。这种错误情况是:一个leader提交了之前term的log(假设提交的index=a),之后宕机了。新当选的leader在index=a这个位置拥有更大term的log,新leader将index=a位置的log修改了,即修改了已经提交过的log entry

四、其它提示

加锁的问题: 加锁既不能导致死锁,也不能出现数据访问冲突(可以使用go test -race测试出来)。要想完美实现是很难的,但这个Lab不用考虑加锁导致的性能问题,我想了几个加锁原则:

  1. 由于不要求性能,直接将锁加在很长一段代码上,或加在整个函数上都是可以的,这样可以避免一些bug
  2. 发送RPC请求时不加锁,等收到了RPC响应后再重新加锁执行后面的操作
  3. 给channel中发消息的语句不加锁,这条语句执行完成后重新加锁
  4. go中的mutex是不可重入锁,在函数A调用了函数B这种情况下,要考虑A中调用B的这条语句是否持有锁,如果是持有锁则函数B不能再加锁

通过测试: 想通过一次测试很简单,但有很多bug是偶尔出现的,因此需要写脚本跑多次(几百或几千)测试

log的访问: Raft中log entry的访问(读、写、截断、拼接、获取最大index / term)不要直接下标访问,都封装成getter和setter。因为Lab2C中每次修改log都需要持久化,Lab3B中还会有log的下标转换问题

  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值