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

134 阅读14分钟

lab3D--快照系统

任务简介

在这个实验中,我们需要实现Raft的快照机制。Raft中需要快照机制主要是因为随着系统运行时间的增长,每个节点都会保存完整的日志记录,这会导致日志无限增长,最终耗尽内存。快照机制通过压缩日志来解决这个问题,它允许节点在创建当前状态的快照后删除该索引之前的所有日志,从而节省存储空间。快照包含当前状态机的状态以及最后一条已提交日志的索引和任期,这样新节点加入时可以直接从快照恢复,而不需要重放所有历史日志。

在Raft中,快照机制通过Snapshot方法实现,当服务层创建快照时,会调用Raft的Snapshot方法,Raft节点会保存快照并删除相应的日志。快照机制的应用场景包括新节点加入集群、节点重启恢复、日志压缩和状态同步。

由于这里会涉及到日志数组的截断,所以需要注意数组越界的边界情况。在Raft中,当节点接收到InstallSnapshot RPC并安装快照后,lastApplied可能已经落后于快照产生时的日志索引,这意味着lastApplied指向的日志项可能已经被快照覆盖,因此是无效的。具体来说,快照包含了当前状态机的状态以及最后一条已提交日志的索引和任期,安装快照后,节点会删除该索引之前的所有日志。如果lastApplied小于快照的索引,那么lastApplied指向的日志项已经被删除,不再存在于日志中,因此不应该被应用。这种情况下,节点需要将lastApplied更新为快照的索引,以确保只应用有效的日志项。这样可以避免应用已经被快照覆盖的无效日志,保证状态机的一致性。

官方提示

下图是Raft共识算法的基础架构:

image.png

我们当前实现的快照机制是由上层执行的,上层调用生成快照的方法之后,在下面的Raft层截断日志,生成快照。

日志复制与应用 具体的service, 如Lab3中将实现的KV存储, 位于raft层之上, 通过Start发送命令给Leader一侧的raft层, Leader raft会将日志项复制给集群内的其他Follower raft, Follower raft通过applyCh这个管道将已经提交的包含命令的日志项向上发送给Follower侧的service

快照请求与传输 某一时刻, service为了减小内存压力,将状态机状态封装成一个SnapShot并将请求发送给Leader一侧的raft(Follower侧的sevice也会会存在快照操作), raft层保存SnapShot并截断自己的log数组, 当通过心跳发现Follower的节点的log落后SnapShot时, 通过InstallSnapshot发送给Follower, Follower保存SnapShot并将快照发送给service

持久化存储 raft之下还存在一个持久化层Persistent Storage, 也就是Make函数提供的Persister, 调用相应的接口实现持久化存储和读取。

代码设计

由于上方服务层向Raft发送快照之后需要截断日志数组,而raft结构体中的字段如commitIndex, lastApplied等, 存储的仍然是全局递增的索引。即使日志被截断,你的实现仍然需要正确地在AppendEntries RPC中发送新条目之前的条目的任期和索引;这可能需要保存和引用最新快照的lastIncludedTerm/lastIncludedIndex。所以我们在Raft结构体中添加以下两个字段:

type Raft struct {
    ...
	snapShot          []byte // 快照
	lastIncludedIndex int    // 日志中的最高索引
	lastIncludedTerm  int    // 日志中的最高Term
}

接下来讨论日志数组在截断之后,数组访问越界的边界情况。截断操作会删除日志数组中的一部分条目,导致数组的长度减少。如果代码中仍然使用原来的索引来访问日志数组,就可能会发生越界错误。我们原来的索引是全局递增的,由commitIndexlastApplied可知,所以我们在日志数组截断之后,不使用这个全局递增的索引去访问日志数组就好了。由于日志数组在截断之后,快照中存在lastIncludedIndexlastIncludedTerm信息,所以我们可以知道现在日志是以lastIncludedIndex为日志数组的起始点,这个起始点标志着在此之前的日志已经被快照替代。

所以我们访问日志时以lastIncludedIndex为起始点访问就好了,我将全局真实递增的索引称为Virtual Index, 将log切片使用的索引称为Real Index, 因此如果SnapShot中包含的最高索引: lastIncludedIndex, 转换的函数应该为:

func (rf *Raft) RealLogIdx(vIdx int) int {
	return vIdx - rf.lastIncludedIndex
}

func (rf *Raft) VirtualLogIdx(rIdx int) int {
	return rIdx + rf.lastIncludedIndex
}

Make函数中,0索引处需要一个空的日志项占位, 截断日志时, 则使用lastIncludedIndex占位。这些占位符可以统一代码中访问数组的索引计算。通过从索引1开始,可以确保所有实际的日志项都有一个有效的索引,便于进行一致性检查。

func Make(peers []*labrpc.ClientEnd, me int,
	...
	rf.log = make([]Entry, 0)
	rf.log = append(rf.log, Entry{Term: 0})
	...
}

有了RealLogIdxVirtualLogIdx, 代码将遵循以下的规则:

  • 访问rf.log一律使用真实的切片索引, 即Real Index
  • 其余情况, 一律使用全局真实递增的索引Virtual Index

因此我们还需要修改之前的代码,在对索引的操作, 调用RealLogIdxVirtual Index转化为Real Index, 或调用VirtualLogIdxReal Index转化为Virtual Index。这里可以参考我的代码仓库。

我们首先完成Snapshot函数,这个函数接收上方服务层的快照请求,并截断自己的log数组,函数基本逻辑:

  1. 先判断这个快照请求是否合法:
    • 创建Snapshot时, 必须保证其index小于等于commitIndex, 如果index大于commitIndex, 则会有包括未提交日志项的风险。快照中只能包含已经提交的日志项。
    • 创建Snapshot时, 必须保证其index小于等于lastIncludedIndex, 因为这可能是一个重复的或者更旧的快照请求RPC, 应当被忽略。
  2. snapshot保存 因为后续Follower可能需要snapshot, 以及持久化时需要找到snapshot进行保存, 因此此时要保存以便后续发送给Follower
  3. 更新Raft结构体中与生成日志有关的字段。
  4. 调用persist持久化。当节点重启时,需要从持久化的快照中恢复状态机的状态,以确保节点能够继续正常工作。
// the service says it has created a snapshot that has
// all info up to and including index. this means the
// service no longer needs the log through (and including)
// that index. Raft should now trim its log as much as possible.
func (rf *Raft) Snapshot(index int, snapshot []byte) {
	// Your code here (3D).
	rf.mu.Lock()
	defer rf.mu.Unlock()

	// 1.快照不能包含未提交的日志 2.重复的快照请求
	if rf.commitIndex < index || index <= rf.lastIncludedIndex {
		DPrintf("server %v 拒绝了 Snapshot 请求, 其index=%v, 自身commitIndex=%v, lastIncludedIndex=%v\n", rf.me, index, rf.commitIndex, rf.lastIncludedIndex)
		return
	}

	DPrintf("server %v 同意了 Snapshot 请求, 其index=%v, 自身commitIndex=%v, 原来的lastIncludedIndex=%v, 快照后的lastIncludedIndex=%v\n", rf.me, index, rf.commitIndex, rf.lastIncludedIndex, index)

	rf.snapShot = snapshot
	rf.lastIncludedTerm = rf.log[rf.RealLogIndex(index)].Term
	// 截断log
	rf.log = rf.log[rf.RealLogIndex(index):]
	rf.lastIncludedIndex = index
	if rf.lastApplied < index {
		// 能被提交快照之后的日志肯定是已经被应用了的
		rf.lastApplied = index
	}
	rf.persist()
}

还需要修改持久化函数。快照需要持久化是因为快照包含了当前状态机的状态,这些状态在节点重启后需要被恢复。

func (rf *Raft) persist() {
	// Your code here (3C).
	// Example:
	// w := new(bytes.Buffer)
	// e := labgob.NewEncoder(w)
	// e.Encode(rf.xxx)
	// e.Encode(rf.yyy)
	// raftstate := w.Bytes()
	// rf.persister.Save(raftstate, nil)

	DPrintf("server %v 开始持久化", rf.me)
	w := new(bytes.Buffer)
	e := labgob.NewEncoder(w)
	e.Encode(rf.votedFor)
	e.Encode(rf.currentTerm)
	e.Encode(rf.log)
	e.Encode(rf.lastIncludedIndex)
	e.Encode(rf.lastIncludedTerm)
	raftstate := w.Bytes()
	rf.persister.Save(raftstate, rf.snapShot)
}

readPersistreadSnapshot分别读取持久化状态和快照,这两个函数都只会在Make函数中进行调用。

func (rf *Raft) readPersist(data []byte) {
	// 目前只在Make中调用, 因此不需要锁
	if len(data) == 0 {
		return
	}
	r := bytes.NewBuffer(data)
	d := labgob.NewDecoder(r)

	var votedFor int
	var currentTerm int
	var log []Entry
	var lastIncludedIndex int
	var lastIncludedTerm int
	if d.Decode(&votedFor) != nil ||
		d.Decode(&currentTerm) != nil ||
		d.Decode(&log) != nil ||
		d.Decode(&lastIncludedIndex) != nil ||
		d.Decode(&lastIncludedTerm) != nil {
		DPrintf("server %v readPersist failed\n", rf.me)
	} else {
		// 2C
		rf.votedFor = votedFor
		rf.currentTerm = currentTerm
		rf.log = log
		// 2D
		rf.lastIncludedIndex = lastIncludedIndex
		rf.lastIncludedTerm = lastIncludedTerm

		rf.commitIndex = lastIncludedIndex
		rf.lastApplied = lastIncludedIndex
		DPrintf("server %v  readPersist 成功\n", rf.me)
	}
}

func (rf *Raft) readSnapshot(data []byte) {
	// 目前只在Make中调用, 因此不需要锁
	if len(data) == 0 {
		DPrintf("server %v 读取快照失败: 无快照\n", rf.me)
		return
	}
	rf.snapShot = data
	DPrintf("server %v 读取快照c成功\n", rf.me)
}

func Make(peers []*labrpc.ClientEnd, me int, persister *Persister, applyCh chan ApplyMsg) *Raft {
	......
	// initialize from state persisted before a crash
	// 调用这两个函数时还没有其他的协程在运行,所以不需要加锁
	rf.readPersist(persister.ReadRaftState())
	rf.readSnapshot(persister.ReadSnapshot())
	......
}

接下来我们先完善快照部分的基本逻辑,在这部分逻辑大致完成之后再修改其他机制,下面给出一张InstallSnapshotRPC结构体相关的论文描述图:

image.png

根据图中的描述可以设计出一下结构体:

type InstallSnapshotArgs struct {
	Term              int         // Leader的任期
	LeaderId          int         // follower根据这个重定向
	LastIncludedIndex int         // 快照中包含的最后一条日志的索引
	LastIncludedTerm  int         // 快照中最后一条日志的任期
	Data              []byte      // 快照
	LastIncludedCmd   interface{} // 快照中最后一条日志的命令内容。在日志截断之后进行占位
}

type InstallSnapshotReply struct {
	Term int // 回复给Leader的Term
}

注意结构体中的LastIncludedCmd这个字段,由于节点中的日志数组索引是从1开始而不是从0开始的,所以我们一开始就在Make函数中在0索引处添加了一个空日志,这个LastIncludedCmd字段也起到了差不多的作用。由于快照会截断日志,日志在截断之后之前的0索引处的空日志就没了,所以此时日志数组第一个有效日志的索引是0而不是1,为了保证第一个有效索引是1,我们在快照截断数组之后同样需要在0索引处添加一个空日志,这个空日志表示之前的日志部分已经被快照替代了。

设计完这个结构体之后,我们讨论快照的发送与响应服务,下面这个函数由Leader节点调用,将快照信息发送到其他Raft节点:

func (rf *Raft) handleInstallSnapshot(serverTo int) {
	reply := &InstallSnapshotReply{}
	rf.mu.Lock()

	if rf.state != Leader {
		rf.mu.Unlock()
		return
	}

	args := &InstallSnapshotArgs{
		Term:              rf.currentTerm,
		LeaderId:          rf.me,
		LastIncludedIndex: rf.lastIncludedIndex,
		LastIncludedTerm:  rf.lastIncludedTerm,
		Data:              rf.snapShot,
		LastIncludedCmd:   rf.log[0].Cmd,
	}

	// 发送RPC时不应该持有锁
	rf.mu.Unlock()
	ok := rf.sendInstallSnapshot(serverTo, args, reply)
	if !ok {
		// RPC发送失败, 下次再触发即可
		return
	}

	rf.mu.Lock()
	defer rf.mu.Unlock()

	if reply.Term > rf.currentTerm {
		// 旧Leader
		rf.currentTerm = reply.Term
		rf.state = Follower
		rf.votedFor = -1
		rf.ResetTimer()
		rf.persist()
		return
	}
	rf.nextIndex[serverTo] = rf.VirtualLogIndex(1)
}

这段函数的基本逻辑如下:

  1. 先判断当前节点是不是Leader,因为只有Leader可以调用这个函数发送快照给别人,这个判断条件可能在Leader发送心跳时发生了网络分区,与其他节点隔绝了,恢复过来之后已经选举出了新的Leader,新Leader给旧Leader发送心跳可知,旧Leader退化为了Follower,所以会进这个分支。
  2. 构造发送快照的结构体,进行RPC调用。
  3. 和心跳一样,需要根据回复检查自己是不是旧Leader,发送成功后,需要将nextIndex设置为VirtualLogIdx(1),因为0索引处是占位,其余的部分已经不需要再发送了。

InstallSnapshot响应函数比发送函数更加困难,因为需要考虑一些边界情况:

func (rf *Raft) InstallSnapshot(args *InstallSnapshotArgs, reply *InstallSnapshotReply) {
	rf.mu.Lock()
	defer func() {
		rf.ResetTimer()
		rf.mu.Unlock()
	}()

	// 如果当前节点的Term比Leader的Term更大(旧Leader),直接拒绝
	if args.Term < rf.currentTerm {
		reply.Term = rf.currentTerm
		return
	}

    // Term更大,证明这是新的Leader,需要更改自身状态, 但不影响继续接收快照
	if args.Term > rf.currentTerm {
		rf.currentTerm = args.Term
		rf.votedFor = -1
	}

	rf.state = Follower

	// 如果已有的日志条目与快照中最后包含的条目的索引和任期相同,则保留其后的日志条目并进行回复。
	hasEntry := false
	rIdx := 0
	for ; rIdx < len(rf.log); rIdx++ {
		if rf.VirtualLogIndex(rIdx) == args.LastIncludedIndex && rf.log[rIdx].Term == args.LastIncludedTerm {
			hasEntry = true
			break
		}
	}

	msg := &ApplyMsg{
		SnapshotValid: true,
		Snapshot:      args.Data,
		SnapshotTerm:  args.LastIncludedTerm,
		SnapshotIndex: args.LastIncludedIndex,
	}

	if hasEntry {
		rf.log = rf.log[rIdx:]
	} else {
		rf.log = make([]Entry, 0)
		// 索引为0处占位,表示之前的内容以及被快照替代
		rf.log = append(rf.log, Entry{Term: rf.lastIncludedTerm, Cmd: args.LastIncludedCmd})
	}

	// 使用快照内容重置状态机(并加载快照中的集群配置信息)
	rf.snapShot = args.Data
	rf.lastIncludedIndex = args.LastIncludedIndex
	rf.lastIncludedTerm = args.LastIncludedTerm

    // 需要检查lastApplied和commitIndex 是否小于LastIncludedIndex, 如果是, 更新为LastIncludedIndex
	if rf.commitIndex < args.LastIncludedIndex {
		rf.commitIndex = args.LastIncludedIndex
	}
	if rf.lastApplied < args.LastIncludedIndex {
		rf.lastApplied = args.LastIncludedIndex
	}

	reply.Term = rf.currentTerm
	rf.applyCh <- *msg
	rf.persist()
}

这个函数在验证完能接收快照之后,通过hasEntry判断条件进行判断是否在快照对应日志处有相同的日志项:

  1. 有的话,直接截断之后的日志数组,因为该日志之前的部分都已经被快照替代了。
  2. 没有的话,需要清空切片, 并将0位置构造LastIncludedIndex位置的日志项进行占位,因为此时日志数组已经完全被快照替代了,所以需要重新构造一个新的日志数组。

注意,在完成上述操作之后,需要将快照信息发送到上方服务层,让服务器应用快照。由于InstallSnapshot可能是替代了一次心跳函数,因此需要重设定时器。

设计完快照的发送与接收函数之后,我们来讨论何时发送快照?

由论文可知,当Leader发现Follower要求回退的日志已经由于快照被截断时,需要发送快照给Follower,具体在代码中,就是SendHeartBeats发现PrevLogIndex < lastIncludedIndex,表示其要求的日志项已经被截断,需要将发送心跳改为发送InstallSnapshot,代码如下:

func (rf *Raft) sendHeartbeats() {
	...
	for !rf.killed() {
		...
		for i := 0; i < len(rf.peers); i++ {
			...
			args := &AppendEntriesArgs{
				Term:         rf.currentTerm,
				LeaderId:     rf.me,
				PrevLogIndex: rf.nextIndex[i] - 1,
				LeaderCommit: rf.commitIndex,
			}

			sendInstallSnapshot := false

			if args.PrevLogIndex < rf.lastIncludedIndex {
				// 表示Follower有落后的部分且被截断, 改为发送同步心跳
				DPrintf("leader %v 取消向 server %v 广播新的心跳, 改为发送sendInstallSnapshot, lastIncludedIndex=%v, nextIndex[%v]=%v, args = %+v \n", rf.me, i, rf.lastIncludedIndex, i, rf.nextIndex[i], args)
				sendInstallSnapshot = true
			} else if rf.VirtualLogIdx(len(rf.log)-1) > args.PrevLogIndex {
				// 如果有新的log需要发送, 则就是一个真正的AppendEntries而不是心跳
				args.Entries = rf.log[rf.RealLogIdx(args.PrevLogIndex+1):]
				DPrintf("leader %v 开始向 server %v 广播新的AppendEntries, lastIncludedIndex=%v, nextIndex[%v]=%v, args = %+v\n", rf.me, i, rf.lastIncludedIndex, i, rf.nextIndex[i], args)
			} else {
				// 如果没有新的log发送, 就发送一个长度为0的切片, 表示心跳
				DPrintf("leader %v 开始向 server %v 广播新的心跳, lastIncludedIndex=%v, nextIndex[%v]=%v, args = %+v \n", rf.me, i, rf.lastIncludedIndex, i, rf.nextIndex[i], args)
				args.Entries = make([]Entry, 0)
			}

			if sendInstallSnapshot {
				go rf.handleInstallSnapshot(i)
			} else {
				args.PrevLogTerm = rf.log[rf.RealLogIdx(args.PrevLogIndex)].Term
				go rf.handleAppendEntries(i, args)
			}
		}
		...
	}
}

还有一种情况,如果心跳回复函数handleHeartBeat检查心跳回复之后发现需要进行回退,但是已经回退不到Follower要求的那个位置了,说明此时日志已经被快照截断,需要发送InstallSnapshot

func (rf *Raft) handleHeartBeat(serverTo int, args *AppendEntriesArgs) {
	......
	if reply.Term == rf.currentTerm && rf.state == Leader {
		// term仍然相同, 且自己还是leader, 表名对应的follower在prevLogIndex位置没有与prevLogTerm匹配的项
		if reply.XTerm == -1 {
			// Follower中在PrevLogIndex位置不存在日志
			DPrintf("leader %v 收到 server %v 的回退请求, 原因是log过短, 回退前的nextIndex[%v]=%v, 回退后的nextIndex[%v]=%v\n", rf.me, serverTo, serverTo, rf.nextIndex[serverTo], serverTo, reply.XLen)
			if rf.lastIncludedIndex >= reply.XLen {
				// 由于Snapshot被截断,Leader应该发送快照
				go rf.handleInstallSnapshot(serverTo)
			} else {
				rf.nextIndex[serverTo] = reply.XLen
			}
			return
		}
		i := rf.nextIndex[serverTo] - 1
		// 防止数组越界
		if i < rf.lastIncludedIndex {
			i = rf.lastIncludedIndex
		}
		for i > rf.lastIncludedIndex && rf.log[rf.RealLogIndex(i)].Term > reply.XTerm {
			i -= 1
		}

		if i == rf.lastIncludedIndex && rf.log[rf.RealLogIndex(i)].Term > reply.XTerm {
			// 要找的日志已经被截断,必须发送快照
			go rf.handleInstallSnapshot(serverTo)
		} else if rf.log[rf.RealLogIndex(i)].Term == reply.XTerm {
			// 之前PrevLogIndex发生冲突位置时, Follower的Term自己也有
			DPrintf("leader %v 收到 server %v 的回退请求, 冲突位置的Term为%v, server的这个Term从索引%v开始, 而leader对应的最后一个XTerm索引为%v, 回退前的nextIndex[%v]=%v, 回退后的nextIndex[%v]=%v\n", rf.me, serverTo, reply.XTerm, reply.XIndex, i, serverTo, rf.nextIndex[serverTo], serverTo, i+1)
			rf.nextIndex[serverTo] = i + 1 // i + 1是确保没有被截断的
		} else {
			// 之前PrevLogIndex发生冲突位置时, Follower的Term自己没有
			DPrintf("leader %v 收到 server %v 的回退请求, 冲突位置的Term为%v, server的这个Term从索引%v开始, 而leader对应的XTerm不存在, 回退前的nextIndex[%v]=%v, 回退后的nextIndex[%v]=%v\n", rf.me, serverTo, reply.XTerm, reply.XIndex, serverTo, rf.nextIndex[serverTo], serverTo, reply.XIndex)
			if reply.XIndex <= rf.lastIncludedIndex {
				// XIndex位置也被截断了
				go rf.handleInstallSnapshot(serverTo)
			} else {
				rf.nextIndex[serverTo] = reply.XIndex
			}
		}
		return
	}
}

这个函数中有三种情况会触发发送快照:

  • Follower在PrevLogIndex处不存在日志,甚至短于lastIncludedIndex
  • Follower的日志在PrevLogIndex这个位置发生了冲突,回退时发现即使到了lastIncludedIndex也找不到匹配项。
  • nextIndex中记录的索引本身就小于lastIncludedIndex

接下来是一些边界情况,这里我参考的是这篇博客,写的很好,推荐去看。

运行测试:

image-20250603112033207