MIT6.824(2024春)Raft-lab3B代码分析

lab3B--日志复制

在这个实验中,我们需要基于上次实验的Leader选举心跳机制实现日志复制这一内容。还是在这里给出一些参考资料:

任务简介

在论文中,日志复制逻辑如下:

  • Leader:
    1. 客户端向Raft集群的一个节点发送命令,如果发送的节点不是Leader,那么该节点就会通过心跳得知leader并返回给client。但是不是所有的Follower节点都有Leader消息,如果某个Follower因为网络问题暂时没有收到心跳,它不会立即知道谁是Leader。如果这个Follower收到了客户端的请求,它会直接返回错误,告诉客户端自己不是Leader。然后客户端会重试。
    2. Leader收到了命令,在Start()函数中将其构造为一个日志项,添加当前节点的currentTerm为日志项的Term, 并将其追加到自己的log中。一旦将其追加到自己的 log中,那么位于sendHeartbeats()协程中的判断条件就会通过,得知自己可以发送日志。
    3. Leader通过心跳发送函数发送AppendEntries RPC给所有节点,其他节点根据这个消息复制日志,AppendEntries RPC需要增加PrevLogIndexPrevLogTerm以供follower校验, 其中PrevLogIndexPrevLogTermnextIndex确定。
    4. 如果RPC返回了成功, 则更新matchIndexnextIndex, 同时寻找一个满足过半的matchIndex[i] >= N的索引位置N, 将其更新为自己的commitIndex, 并提交直到commitIndex部分的日志项。这里的提交部分是另起一个协程去检测。
    5. 如果RPC返回了失败,且伴随的的Term更大, 表示自己已经不是Leader了,需要退化为Follower并且重置自己的投票,进入新的任期后必须重置投票,因为每个节点在新的任期都可进行一次投票。
    6. 如果RPC返回了失败,,且伴随的的Term和自己的currentTerm相同,将nextIndex自减再重试。如果Follower在PrevLogIndex位置的日志项的Term与PrevLogTerm不匹配,就会出现这种情况。Leader将nextIndex自减,意味着下次会尝试发送更早的日志,这是一个"回退"机制,目的是找到Leader和Follower日志一致的位置。
  • Follower:
    1. follower收到AppendEntries RPC后,currentTerm不匹配直接告知更新的Term,并返回false
    2. follower收到AppendEntries RPC后,通过PrevLogIndexPrevLogTerm可以判断出"leader认为自己log的结尾位置"是否存在并且Term匹配,如果不匹配的话,返回false。
    3. 如果位置信息匹配的话,则需要判断插入位置是否有旧的日志项, 如果有, 则向后将log中冲突的内容清除。
    4. RPC中的日志项追加到log中。
    5. 根据RPC的传入参数更新commitIndex,,并提交直到commitIndex部分的日志项。

官方提示

  • 您的第一个目标是通过 TestBasicAgree3B() 测试。首先实现 Start() 函数,然后编写代码通过 AppendEntries RPC 发送和接收新的日志条目,遵循论文中的图2。在每个节点上,通过 applyCh 发送每个新提交的条目。
  • 需要实现选举限制,就是说如果一个服务器要成为Leader,那么它的日志必须具有权威。
  • 代码可能包含重复检查某些事件的循环。不要让这些循环持续不断地执行而不暂停,因为这会使您的实现变得太慢,导致测试失败。使用 Go 的条件变量,或在每个循环迭代中插入 time.Sleep(10 * time.Millisecond)。

代码设计

根据论文可知,上次实验中实现的心跳实际上就是一种特殊的AppendEntries,其特殊在Entries长度为0。

• If last log index ≥ nextIndex for a follower: send AppendEntries RPC with log entries starting at nextIndex • If successful: update nextIndex and matchIndex for follower (§5.3) • If AppendEntries fails because of log inconsistency: decrement nextIndex and retry (§5.3)

AppendEntries除了RPC失败的情况下,会一直重试,直到返回true,那么如果我们单独创建一个协程用于发送真正的不为心跳的AppendEntries, 需要考虑如下的问题:

  • 重试是应该立即重试,还是设置一个重置超时。
  • 如何触发这个处理AppendEntries的协程,是累计了多个个日志项后再出发处理协程? 还是一旦有一个日志项就触发?
  • 发射心跳处理函数时也会附带PrevLogIndexPrevLogTerm以供follower验证, 心跳函数的这些参数会不会和之前的AppendEntries冲突? follower端如何处理这些重复的内容?

由上述知,如果将AppendEntries和心跳的发射器分开实现,会增加代码的复杂度,同时AppendEntries也具有重复发送的特点,这和心跳的特点完美契合,因此,我们得出如下结论: AppendEntries可以和心跳公用同一个发射器。

首先我们需要按照RPC设计图添加几个Raft结构体的参数:

go 复制代码
// A Go object implementing a single Raft peer.
type Raft struct {
	......
	commitIndex int           // 提交日志的索引
	lastApplied int           // 给上层应用日志的索引
	nextIndex   []int         // 发给 follower[i] 的下一条日志索引
	matchIndex  []int         // follower[i] 已复制的最大日志索引
	applyCh     chan ApplyMsg // 向上层应用传递消息的管道
}

由于我们在Raft结构体中添加了新的参数,所以我们之前的心跳发射器需要更改部分代码:

go 复制代码
// leader定期发送心跳
func (rf *Raft) sendHeartbeats() {
	......
    args := &AppendEntriesArgs{
        Term:         rf.currentTerm,
        LeaderId:     rf.me,
        PrevLogIndex: rf.nextIndex[i] - 1,
        PrevLogTerm:  rf.log[rf.nextIndex[i]-1].Term,
        LeaderCommit: rf.commitIndex,
    }
    if len(rf.log)-1 >= rf.nextIndex[i] {
        // 如果有新的log需要发送,则就是一个真正的AppendEntries而不是心跳
        args.Entries = rf.log[rf.nextIndex[i]:]
        DPrintf("leader %v 开始向 server %v 广播新的AppendEntries\n", rf.me, i)
    } else {
        // 如果没有新的log发送,就发送一个长度为0的切片表示心跳
        args.Entries = make([]Entry, 0)
        DPrintf("leader %v 开始向 server %v 广播新的心跳, args = %+v \n", rf.me, i, args)
    }
    go rf.handleHeartBeat(i, args)
	......
}

我们来解析一下构造args的参数:

  • 其中Term和LeaderId都是帮助Follower知道当前是在哪个任期,哪个Leader负责发送日志,是当前Raft集群的相关信息。
  • PrevLogIndex和PrevLogTerm:PrevLogIndex表示新日志条目之前的那个日志索引,而PrevLogTerm表示PrevLogIndex对应的任期号。这两个参数的作用是确保日志的一致性。当LeaderFollower发送AppendEntries RPC时,会包含这两个参数,Follower会检查自己在这个位置的日志是否与Leader一致。如果一致,Follower会接受新的日志条目;如果不一致,Follower会拒绝,Leader会通过回退nextIndex来找到双方日志一致的位置。这种机制确保了即使在网络分区或节点故障的情况下,系统最终也能达到日志一致的状态。
  • LeaderCommit:它表示Leader当前已提交的日志索引。Follower会根据这个参数来更新自己的commitIndex。具体来说,如果LeaderCommit大于Follower当前的commitIndexFollower会将commitIndex更新为LeaderCommit和自身日志长度中的较小值。这个机制确保了Follower能够及时提交已经被Leader确认的日志条目,从而保持整个集群的日志一致性。

if len(rf.log)-1 >= rf.nextIndex[i]这个判断条件知,当Leader将客户端的请求变为日志追加到自己的log中时,如果具备向其中一个服务器发送log的能力,就向其发送,因为发送AppendEntires是由单独的协程起的,所以可以异步进行。

然后是Leader向每个服务器发送AppendEntires的处理函数handleHeartBeat()

go 复制代码
func (rf *Raft) handleHeartBeat(serverTo int, args *AppendEntriesArgs) {
	......
	if reply.Success {
		// server回复成功
		rf.matchIndex[serverTo] = args.PrevLogIndex + len(args.Entries)
		rf.nextIndex[serverTo] = rf.matchIndex[serverTo] + 1

		// 如果有超过一半的节点都收到某一日志,则可以提交
		n := len(rf.log) - 1

		for n > rf.commitIndex {
			count := 1 // 包括Leader自己
			for i := 0; i < len(rf.peers); i++ {
				if i == rf.me {
					continue
				}
				if rf.matchIndex[i] >= n && rf.log[n].Term == rf.currentTerm {
					count += 1
				}
			}

			if count > len(rf.peers)/2 {
				// 如果至少一半的follower回复了成功, 更新commitIndex
				rf.commitIndex = n
				break
			}
			n -= 1
		}
		rf.mu.Unlock()
		return
	}

	if reply.Term > rf.currentTerm {
		// 回复了更新的term, 表示自己已经不是leader了
		DPrintf("server %v 旧的leader收到了来自 server % v 的心跳函数中更新的term: %v, 转化为Follower\n", rf.me, serverTo, reply.Term)
		rf.currentTerm = reply.Term
		rf.state = Follower
		rf.votedFor = -1
		rf.timeStamp = time.Now()
		rf.mu.Unlock()
		return
	}

	if reply.Term == rf.currentTerm && rf.state == Leader {
		// term仍然相同, 且自己还是leader, 表名对应的follower在prevLogIndex位置没有与prevLogTerm匹配的项
		// 将nextIndex自减再重试
		rf.nextIndex[serverTo] -= 1
		rf.mu.Unlock()
		return
	}
}

注意,当前函数并不需要死循环去轮询对应Follower是否接收成功了AppendEntires,因为心跳重试机制在sendHeartbeats()中已经存在了,对应for !rf.killed()time.Sleep(time.Duration(HeartBeatTimeOut) * time.Millisecond),所以这个函数不需要死循环。

函数在向Follower发送RPC之后还需要处理回复,逻辑如下:

  1. 如果回复成功的话,我就更新matchIndexnextIndex中对应服务器的值,便于我们发送下一次AppendEntires时跟踪对应服务器需要哪些日志。
  2. 接下来的逻辑是判断如果有超过一半的节点都收到某一日志,则可以提交。通过一个循环进行,注意,这个循环在每个向不同Follower发送AppendEntries的协程中都存在,所以commitIndex是不断更新的,直到最后一个协程处理完回复之后才是最新。
  3. if reply.Term > rf.currentTerm这个判断条件是检测到Follower的任期比自己的大,退化为Follower,重置选票之后直接退出。
  4. if reply.Term == rf.currentTerm && rf.state == Leader这个判断条件表示:表名对应的follower在prevLogIndex位置没有与prevLogTerm匹配的项,我们需要将nextIndex自减后重试。

下面修改每个Follower收到AppendEntires的处理函数,先理清楚一下这个函数需要做的事情:

  • 检测是心跳还是AppendEntires
  • 检测当前节点的日志与args参数是否匹配,匹配的话就拓展日志。
  • 不匹配的话就返回信息,便于Leader重试。
  • 有必要的情况下更新commitIndex

下面是对应代码:

go 复制代码
// 处理AppendEntries RPC(心跳)
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
	......
	if len(args.Entries) == 0 {
		//心跳函数
		DPrintf("server %v 接收到 leader &%v 的心跳\n", rf.me, args.LeaderId)
	} else {
		DPrintf("server %v 收到 leader %v 的的AppendEntries: %+v \n", rf.me, args.LeaderId, args)
	}
	if args.PrevLogIndex >= len(rf.log) || rf.log[args.PrevLogIndex].Term != args.PrevLogTerm {
		// PrevLogIndex和PrevLogTerm不合法
		reply.Term = rf.currentTerm
		rf.mu.Unlock()
		reply.Success = false
		DPrintf("server %v 检查到心跳中参数不合法:\n\t args.PrevLogIndex=%v, args.PrevLogTerm=%v, \n\tlen(self.log)=%v, self最后一个位置term为:%v\n", rf.me, args.PrevLogIndex, args.PrevLogTerm, len(rf.log), rf.log[len(rf.log)-1].Term)
		return
	}

	if len(args.Entries) != 0 && len(rf.log) > args.PrevLogIndex+1 && rf.log[args.PrevLogIndex+1].Term != args.Entries[0].Term {
		// 发生了冲突, 移除冲突位置开始后面所有的内容
		DPrintf("server %v 的log与args发生冲突, 进行移除\n", rf.me)
		rf.log = rf.log[:args.PrevLogIndex+1]
	}

	// append逻辑
	rf.log = append(rf.log, args.Entries...)
	if len(args.Entries) != 0 {
		DPrintf("server %v 成功进行apeend\n", rf.me)
	}

	reply.Success = true
	reply.Term = rf.currentTerm

	// 根据Leader中的提交信息更新当前节点的提交信息
	if args.LeaderCommit > rf.commitIndex {
		rf.commitIndex = int(math.Min(float64(args.LeaderCommit), float64(len(rf.log)-1)))
	}
	rf.mu.Unlock()
}

按照官方提示,我们还需要完成Start()函数,我们先来看一下这个函数是做什么的,框架代码中的注释说明了:Start函数只是将command追加到自己的log中,因此其不保证command一定会提交。因此我们在之前的心跳发送器中检测到了log的变更,所以可以进行发送日志的判断,如果成功的话就发送日志。

go 复制代码
func (rf *Raft) Start(command interface{}) (int, int, bool) {
	rf.mu.Lock()
	defer rf.mu.Unlock()
	if rf.role != Leader {
		return -1, -1, false
	}

	newEntry := &Entry{Term: rf.currentTerm, Cmd: command}
	rf.log = append(rf.log, *newEntry)

	return len(rf.log) - 1, rf.currentTerm, true
}

还需要一个将日志应用到状态机的协程,CommitChecker也是一个轮询的协程,其不断检查rf.commitIndex > rf.lastApplied,将rf.lastApplied递增然后发送到管道applyCh

go 复制代码
func (rf *Raft) CommitChecker() {
	// 检查是否有新的commit
	for !rf.killed() {
		rf.mu.Lock()
		for rf.commitIndex > rf.lastApplied {
			rf.lastApplied += 1
			msg := &ApplyMsg{
				CommandValid: true,
				Command:      rf.log[rf.lastApplied].Cmd,
				CommandIndex: rf.lastApplied,
			}
			rf.applyCh <- *msg
			DPrintf("server %v 准备将命令 %v(索引为 %v ) 应用到状态机\n", rf.me, msg.Command, msg.CommandIndex)
		}
		rf.mu.Unlock()
		time.Sleep(CommitCheckTimeInterval)
	}
}

最后一个需要修改的函数就是选举函数,根据论文,nextIndex[]matchIndex[],都是存储在内存中的数据,如果Leader宕机之后需要重新初始化,而这两个变量的初始化,nextIndex[]的初始化是乐观的,它总是认为其他节点的日志与自己的一样,如果发生不匹配的情况就会将nextIndex[]自减然后重试。matchIndex[]是悲观初始化的,它总是认为其他节点没有与Leader已经同步的日志,所以初始化为0,在处理Follower对于Leader的回应时会通过rf.matchIndex[serverTo] = args.PrevLogIndex + len(args.Entries),快速对齐。

go 复制代码
func (rf *Raft) collectVote(serverTo int, args *RequestVoteArgs) {
	...
	if rf.voteCount > len(rf.peers)/2 {
		...
		rf.role = Leader
		// 需要重新初始化nextIndex和matchIndex
		for i := 0; i < len(rf.nextIndex); i++ {
			rf.nextIndex[i] = len(rf.log)
			rf.matchIndex[i] = 0
		}
		...
	}

	rf.muVote.Unlock()
}

执行测试:

相关推荐
爱coding的橙子2 小时前
每日算法刷题Day19 5.31:leetcode二分答案3道题,用时1h
算法·leetcode·职场和发展
地平线开发者3 小时前
征程 6EM 常见 QConfig 配置解读与示例
算法·自动驾驶
GEEK零零七3 小时前
Leetcode 1908. Nim 游戏 II
算法·leetcode·博弈论
sbc-study3 小时前
混沌映射(Chaotic Map)
开发语言·人工智能·python·算法
Magnum Lehar4 小时前
vulkan游戏引擎game_types.h和生成build.bat实现
java·算法·游戏引擎
Christophe Chen4 小时前
strcat及其模拟实现
c语言·算法
独家回忆3645 小时前
每日算法-250531
算法
@我漫长的孤独流浪5 小时前
数据结构测试模拟题(2)
数据结构·c++·算法
秋难降5 小时前
贪心算法:看似精明的 “短视选手”,用好了也能逆袭!💥
java·算法