MIT 6.5840 Lab2B - log replication of Raft

1. 任务

论文


执行领导者和追随者代码,添加新的日志条目,以便 go test -run 2B 测试通过。

其实在LAB2A中,我们就已经实现了大部分的结构体定义了,2B就得额外添加上RequestVote RPC的选举条件和AppendEntries RPC的选举条件。另外,如果说2A是领导选举和心跳,那么2B的心跳就得加上日志条目信息了。这个任务官方给出的难度是Hard,但如果2A按照我那样做出来了,那我觉得这部分要做的就并不多了,也就Simple水平,所以感谢前面为我们未来铺的路吧,无论是项目还是人生。 我何德何能,敢在几天前说出这种话令后人耻笑,我已经debug到快要崩溃了。

在这里插入图片描述


我们本次要完成的任务就是:

  1. 完善AppendEntries()
  2. 完善Ticker()
  3. 完善Make()
  4. 实现Start()

2. log replication of Raft

2.1 Raft结构体

我觉得这里要重点理解一下几个变量,这几个变量我们在2A没有用到,在2B要用到了。

commitIndex
已知可以提交的日志条目下标,会和lastApplied搭配使用

lastApplied
已经提交了的日志条目下标

commitIndexlastApplied组成了 follower 的日志提交方式

nextIndex[]
leader 专有,nextIndex为乐观估计,指代 leader 保留的对应 follower 的下一个需要传输的日志条目,应该初始化为len(rf.log)

matchIndex[]
leader 专有,matchIndex为悲观估计,初始化为-1,指代 leader 已经传输给对应 follower 的日志条目下标,即follower 目前所拥有的的总日志条目,通常为nextIndex - 1

commitIndex,lastApplied,nextIndex[],matchIndex[]共同组成了 leader 的提交规则,并且 leader 总是最先提交的,可以认为 leader 为这个集群的代表,leader 提交后,follower 才会提交

applyChan chan ApplyMsg
将日志写入channel,测试的时候就会验证该通道

2.2 Start函数

这是外部接口函数,该函数是接受一个command,如果当前节点是leader,则将该command加入到日志中。

func (rf *Raft) Start(command interface{}) (int, int, bool) {
	index := -1
	term := -1

	rf.mu.Lock()
	defer rf.mu.Unlock()
	if rf.state != Leader {
		return index, term, false
	}
	// 添加新日志
	e := logEntry{command, rf.currentTerm}
	rf.log = append(rf.log, e)

	index = len(rf.log)
	term = rf.currentTerm
	return index, term, true
}

2.3 初始化

这部分和lab2a一样,没什么变化。当时在做lab2a的时候就已经实现了。需要注意的一点是,这里我们将lastApplied初始化为了-1,其实也能理解嘛,这个参数是可commit的index,如果为0,那岂不是一开始就能commit了。

func Make(peers []*labrpc.ClientEnd, me int,
	persister *Persister, applyCh chan ApplyMsg) *Raft {
	rf := &Raft{}
	rf.peers = peers
	rf.persister = persister
	rf.me = me

	// Your initialization code here (2A, 2B, 2C).
	rf.currentTerm = 0
	rf.votedFor = -1
	rf.log = make([]logEntry, 0)

	rf.commitIndex = -1
	rf.lastApplied = -1

	rf.nextIndex = make([]int, len(peers))
	rf.matchIndex = make([]int, len(peers))

	rf.state = Follower
	rf.voteCount = 0
	rf.timer = Timer{timer: time.NewTicker(time.Duration(150 + rand.Intn(200))*time.Millisecond)}

	
	// initialize from state persisted before a crash
	rf.readPersist(persister.ReadRaftState())

	// start ticker goroutine to start elections
	go rf.ticker()


	return rf
}

2.4 ticker

这一部分主要是我们在执行sendRequestVotesendAppendEntries的一些参数初始化。我这几天大部分时间都是在这个参数初始化上面折腾,搞得我血压真的高了(可能也是我太菜了吧)。PrevLogIndex都是初始化为rf.nextIndex[i] - 1,这个nextIndex会在后续不断的心跳检测中变换,以便将日志提交给follower。

func (rf *Raft) ticker() {
	for rf.killed() == false {
		select {
		case <-rf.timer.timer.C:
			rf.mu.Lock()
			switch rf.state {
			case Follower: 	// follower->candidate
				rf.state = Candidate
				// fmt.Println(rf.me, "进入candidate状态")
				fallthrough
			case Candidate:	// 成为候选人,开始拉票
				rf.currentTerm++
				rf.voteCount = 1
				rf.timer.reset()
				rf.votedFor = rf.me

				// 开始拉票选举
				for i := 0; i < len(rf.peers); i++ {
					if rf.me == i {	// 排除自己
						continue
					}
					args := RequestVoteArgs{Term: rf.currentTerm, CandidateId: rf.me, LastLogIndex:  len(rf.log)-1}
					if len(rf.log) > 0 {
						args.LastLogTerm = rf.log[len(rf.log)-1].Term
					}
					reply := RequestVoteReply{}
					go rf.sendRequestVote(i, &args, &reply)
				}
			case Leader:
				rf.timer.resetHeartBeat()
				for i := 0; i < len(rf.peers); i++ {
					if i == rf.me {
						continue
					}
					args := AppendEntriesArgs{Term: rf.currentTerm, LeaderId: rf.me, PrevLogIndex: rf.nextIndex[i] - 1,
						PrevLogTerm: 0, Entries: nil, LeaderCommit: rf.commitIndex}
					if args.PrevLogIndex >= 0 {
						args.PrevLogTerm = rf.log[args.PrevLogIndex].Term
					}

					if rf.nextIndex[i] < len(rf.log) {	// 刚成为leader的时候更新过 所以第一次entry为空
						entries := rf.log[rf.nextIndex[i]:]	//如果日志小于leader的日志的话直接拷贝日志
						args.Entries = make([]logEntry, len(entries))
						copy(args.Entries, entries)
					}
					
					// fmt.Println("写入的主机是:", i,"len(rf.log):", len(rf.log), "PrevLogIndex:", args.PrevLogIndex, "rf.nextIndex[i]:", rf.nextIndex[i], "rf.currentTerm:", rf.currentTerm, "rf.commitIndex:", rf.commitIndex)

					reply := AppendEntriesReply{}
					go rf.sendAppendEntries(i, &args, &reply)
				}
				
			}
			rf.mu.Unlock()
		}
	}
}

2.5 AppendEntries

关于何时附加日志,首先明确,follower 只会在收到AppendEntries rpc 请求后执行提交。
结构体

type AppendEntriesArgs struct {
	Term 			int	// leader's term
	LeaderId 		int	// Leader的id
	PrevLogIndex	int	// 前一个日志的日志号
	PrevLogTerm 	int	// 前一个日志的任期号
	Entries			[]logEntry	// 当前日志体
	LeaderCommit	int	// leader的已提交日志号	若leadercommit>commitIndex,那么把commitIndex设为min(若leadercommit, index of last new entry)
}

type AppendEntriesReply struct {
	Term	int	// 自己当前的任期号
	Success	bool	// 如果follower包括前一个日志,则true
	CommitIndex  int	// 用于返回与Leader.Term的匹配项,方便同步日志
}

follower的具体流程如下:

  1. 判断自己的TermLeaderTerm大小,若自己更大的话,拒绝,reply里面有自己的term,所以leader会变为follower;
  2. 进行conflict判断,Leader保存的nextIndex一开始为节点的日志总长度,但是Follower节点的日志数目肯定不大于nextIndex,原因有很多,比如这个follower之前是leader,一部分数据没有提交,亦或是单纯有一些数据丢失了,所以这个时候就需要依靠PrevLogIndex来寻找尽可能多相同的日志。这一点在论文5.3
    • preLogIndex大于当前日志最大下标,说明缺失日志,拒绝附加;利用CommitIndex定位到日志长度的位置,这就是下一次的位置;
    • 在相同index下日志的Term不同,说明日志冲突,拒绝附加;这里用到了课上说的快重传加速,加速找到follower需要写入的位置,这样就不用一个一个地去回溯了。
  3. 若到了第三步,说明不存在冲突问题了,现在要做的就是将传来的日志进行判断:
    • 将args中后面的日志一次性全部添加进log,并对比LeaderCommit,执行Commit操作;注意,由于我们之前就将PrevLogIndex一步到位了,所以没什么额外的操作了;
    • 若日志为空,说明这个请求只是一个HeartBeat,只需要判断一下是否有可提交的新日志(对比LeaderCommit

RPC函数

func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
	rf.mu.Lock()
	defer rf.mu.Unlock()

	reply.Term = rf.currentTerm
	reply.Success = false
	reply.CommitIndex  = 0
	// fmt.Println("收到心跳")
	// 收到rpc的term比自己的小 (§5.1)
	if args.Term < rf.currentTerm {	// 并通知leader变为follower
		return 
	} 
	
	if args.Term > rf.currentTerm {	// 承认对方是leader
		rf.convert2Follower(args.Term)
	}
	
	if args.PrevLogIndex >= 0 &&	// 首先leader要有日志
			(len(rf.log)-1 < args.PrevLogIndex ||	// 1. preLogIndex大于当前日志最大下标,说明缺失日志,拒绝附加
			rf.log[args.PrevLogIndex].Term != args.PrevLogTerm) {	// 2. 在相同index下日志不同
		reply.CommitIndex  = len(rf.log) - 1
		if reply.CommitIndex  > args.PrevLogIndex {
			reply.CommitIndex  = args.PrevLogIndex	// 多出来的日志会被舍弃掉,需要和leader同步
		}
		curTerm := rf.log[reply.CommitIndex].Term
		for reply.CommitIndex  >= 0 {
			if rf.log[reply.CommitIndex].Term == curTerm {	// speed up,更快找到下标
				reply.CommitIndex--
			} else {
				break
			}
		}
		reply.Success = false	// 返回false说明此节点日志没有跟上leader,或者有多余日志,或者日志有冲突
	} else if args.Entries == nil {	// heartbeat 用于更新状态
		if rf.lastApplied < args.LeaderCommit {
			rf.commitIndex = args.LeaderCommit
			go rf.applyLogs()	// 提交日志
		}
		reply.CommitIndex  = len(rf.log) - 1	// 用于leader更新对应主机的nextIndex
		reply.Success = true
	} else {	// 需要同步日志
		// fmt.Println("同步日志开始, lastApplied:", rf.lastApplied, "LeaderCommit: ", args.LeaderCommit)
		rf.log = rf.log[:args.PrevLogIndex + 1]	// 第一次调用的时候prevlogIndex为-1
		rf.log = append(rf.log, args.Entries...)	// 将args中后面的日志一次性全部添加进log

		if rf.lastApplied < args.LeaderCommit {
			rf.commitIndex = args.LeaderCommit	// 与leader同步信息
			go rf.applyLogs()
		}

		reply.CommitIndex  = len(rf.log) - 1	// 用于leader更新对应主机的nextIndex
		if args.LeaderCommit > rf.commitIndex && args.LeaderCommit < len(rf.log) - 1{
			reply.CommitIndex  = args.LeaderCommit	// 令 commitIndex 等于 leaderCommit 和 新日志条目索引值中较小的一个
		}
	}	
	rf.timer.reset()	
}

上面的心跳执行完了后,就要在sendAppendEntries处理我们Leader的逻辑了:

  1. 如果返回值中的Term大于leader的Term,证明出现了分区,节点状态转换为follower;
  2. 如果RPC成功的话更新leader对于各个服务器的状态;
  3. 如果RPC失败的话证明两边日志不一样,使用前面提到的reply。CommitIndex作为nextIndex,用于请求参数中的PrevLogIndex。
func (rf *Raft) sendAppendEntries(server int, args *AppendEntriesArgs, reply *AppendEntriesReply) {
	// 一直请求rpc,直到成功
	if ok := rf.peers[server].Call("Raft.AppendEntries", args, reply); !ok {
		// fmt.Println("RPC心跳连接失败,他的id是:", server)
		return 
	}
	rf.mu.Lock()
	defer rf.mu.Unlock()

	if rf.state != Leader || args.Term < rf.currentTerm || rf.currentTerm != args.Term {
		return 
	}

	// 自己的term没别人的大,变为follower
	if rf.currentTerm < reply.Term {
		rf.convert2Follower(reply.Term)
		return
	}

	if reply.Success {
		rf.nextIndex[server] = reply.CommitIndex + 1	// CommitIndex为对端确定两边相同的index 加上1就是下一个需要发送的日志
		rf.matchIndex[server] = rf.nextIndex[server] - 1
		if rf.nextIndex[server]  > len(rf.log) {	
			rf.nextIndex[server] = len(rf.log)
			rf.matchIndex[server] = rf.nextIndex[server] - 1
		}

		commitCount := 1	// 自己
		for i := 0; i < len(rf.peers); i++ {
			if i == rf.me {
				continue
			}
			if rf.matchIndex[i] >= rf.matchIndex[server] {
				// fmt.Println("机器数量为:", len(rf.peers), "commitCount:", commitCount)
				commitCount++
			}
		}
		// fmt.Printf("commitCount:%d\ncommitIndex: %d, rf.matchIndex[server]: %d\nrf.log[rf.matchIndex[server]].Term: %d, rf.currentTerm: %d", commitCount,rf.commitIndex,rf.matchIndex[server],rf.log[rf.matchIndex[server]].Term,rf.currentTerm )
		if commitCount >= len(rf.peers)/2+1 && // 超过一半的数量接收日志了
			rf.commitIndex < rf.matchIndex[server] && 	// 保证幂等性 即同一条日志正常只会commit一次
			rf.log[rf.matchIndex[server]].Term == rf.currentTerm {
				rf.commitIndex = rf.matchIndex[server]
				go rf.applyLogs()	// 提交日志
			}
	} else {
		rf.nextIndex[server] = reply.CommitIndex + 1
		if rf.nextIndex[server] > len(rf.log) {
			rf.nextIndex[server] = len(rf.log)
		}
	}
}

2.5 另外一些辅助函数

// 将日志写入管道
func (rf *Raft) applyLogs() {
	rf.mu.Lock()
	defer rf.mu.Unlock()
	if rf.commitIndex > len(rf.log)-1 {
		fmt.Println("出现错误 : raft.go commitlogs()")
	}
	// fmt.Println("开始写入管道")
	// 初始化是-1
	for i := rf.lastApplied + 1; i <= rf.commitIndex; i++ {	
		rf.applyChan <- ApplyMsg{
			CommandValid: true,
			Command: rf.log[i].Command,
			CommandIndex: i + 1,
		}
	}
	rf.lastApplied = rf.commitIndex
}
func (rf *Raft) convert2Follower(term int) {
	rf.currentTerm = term
	rf.state = Follower
	rf.voteCount = 0
	rf.votedFor = -1
	rf.timer.reset()
}

3. 测试

TestBasicAgree2B:最基础的追加日志测试。发送日志并查看提交情况
TestRPCBytes2B:基于RPC的字节数检查保证每个cmd都只对每个peer发送一次。空log不算在字节数里
For2023TestFollowerFailure2B:断联leader后剩余节点能否正常提交,断联所有节点后再追加log能否保证不提交
For2023TestLeaderFailure2B:3个peer断联第一个leader后保证提交,断联第二个leader后保证不提交
TestFailAgree2B:断连小部分,不影响整体Raft集群的情况检测追加日志。
TestFailNoAgree2B:断连过半数节点,保证无日志可以正常追加。然后又重新恢复节点,检测追加日志情况。
TestConcurrentStarts2B:模拟客户端并发发送多个命令
TestRejoin2B:Leader 1断连,再让旧leader 1接受日志,再给新Leader 2发送日志,2断连,再重连Leader 1,提交日志,再让2重连,再提交日志。
TestBackup2B:先给Leader 1发送日志,然后断连3个Follower(总共1Ledaer 4Follower),网络分区。提交大量命令给1。然后让leader 1和其Follower下线,之前的3个Follower上线,向它们发送日志。然后在对剩下的仅有3个节点的Raft集群重复上面网络分区的过程。
TestCount2B:检查无效的RPC个数,不能过多。
在这里插入图片描述
终于!!!我激动地在实验室打拳跳了起来!!!

4. 总结

这个LabB是我目前为止花时间最多的一个Lab,好吧,其实之前也就做了2个。这几天真的是茶饭不思,午休的时候也一直在想,晚上睡觉失眠也在想,健身也在想。一开始脑子混乱,到昨天晚上突然茅塞顿开,然后今天又花了一天肝了出来,激动之心难以言表。昨天还在感慨马上8月中旬了,我Lab2都还没做完一半,心里那个急啊,想着马上做完这个就得开始复习了,不过好在今天把这个给做出来了,据说Lab2c很简单(希望不要骗我了)。这个测试我也只跑了一遍,可能还有很多概率性错误,但我不想想了暂时,这几天真的累死了。就这样吧,今天早点回去休息一下,弹下吉他,早点休息了。


给个建议,还是得细品论文,如果你不想看论文,可以看官网的动画演示,自己模拟,兴许就有思路了。我当时就是这样做的,这个动画真的能解答你很多的疑惑。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值