前置芝士

Task

完成 Raft 的快照功能(涉及到较多的与 Service 层的交互。

  • 为什么要有 snapshot?
    1. snapshot 不是「日志去重」——它不是把 10 条 log 压成 9 条,而是拿状态机在某个 index 的镜像,整体替换掉这个 index 之前的全部日志前缀。10 条日志做完快照剩下的是 0 条日志加 1 份状态,跟这些日志改的是不是同一个 key 无关。
    2. snapshot 可以减少 Raft 层 log 的长度,帮助进度较慢的 Raft 节点快速恢复状态机(减短 raft 中 log 的长度)。snapshot 区别于持久化 log,后者主要是不让宕机的 raft 丢失太多日志。
  • 在 2D 中,snapshot 会涉及到 raft 层与 service 层的多次交互,看这个 diagram of Raft interactions  或许可以帮助理解 Raft 协议不同层次的功能与特性。
  • snapshot 作用于每一个 Raft 节点,我们需要记录 snapshot 最后一个 index 和 term,用于一致性检查。

Step

  1. Snapshot() 被 service 层调用,约莫 10 次 commit 调用一次,用于保存快照。需要注意的是快照是 service 传给 raft 层的,而不是我们在 raft 层写入日志创建的,我们不需要创建快照,仅需要处理 Service 层传进来的 snapshot,进行日志截断和快照持久化。
func (rf *Raft) Snapshot(index int, snapshot []byte) {
	rf.mu.Lock()
	defer rf.mu.Unlock()
	if rf.lastIncludedIndex() >= index {
		return
	}
	term := rf.logAt(index).Term
	rf.log = rf.log[index-rf.lastIncludedIndex():]
	rf.setLastIncludedIndex(index)
	rf.setLastIncludedTerm(term)
	rf.persister.SaveStateAndSnapshot(rf.getEncodeStateL(), snapshot)
	DPrintf(dSnap, "S%v last: %v", rf.me, index)
}

上面这句 rf.log = rf.log[index-rf.lastIncludedIndex():] 有两个隐患:

  • 底层数组不释放。reslice 只是移动起始指针,被丢掉的那段 Entry 仍被同一个底层数组引用着,Command 里的 interface{} 跟着活下去。做快照本来就是为了省内存,结果内存没降。
  • 别名风险。2B 里截断日志用的是 rf.log = append(rf.log[:k], args.Entries...),会原地写同一个底层数组。如果某处还留着旧切片的引用(比如已经组好准备发出去的 args.Entries),就会被悄悄改写。

稳妥的做法是新建切片拷贝一份,并把哨兵位的 Command 清空,让被压缩掉的命令能被 GC:

newLog := make([]Entry, len(rf.log)-(index-rf.lastIncludedIndex()))
copy(newLog, rf.log[index-rf.lastIncludedIndex():])
rf.log = newLog
rf.log[0].Command = nil // log[0] 只当哨兵,存 lastIncludedIndex/Term
Raft 日志在索引 2 处压缩为快照,剩余日志从索引 3 继续,并记录 lastIncludedIndex 的示意图
Raft 日志压缩后的快照与剩余日志布局。
  1. InstallSnapshot:

  2. Leader 方面,当$nextIndex[Follower] \leq Leader.lastIncludeIndex$的时候,由于 Leader 已经没有这部分的日志,Leader 会发送 InstallSnapshot RPC 给 Follower,请求 Follower 安装快照(快速追赶),并且在成功收到返回值后,Leader 会更新 nextIndex 和 matchIndex

  3. Follower 方面,如果 Follower 收到 Leader 的任期没有过期的话,只能执行。与论文不同的是:不需要考虑分块发送 snapshot,所以offsetdone都不需要考虑,相应的,在实现的时候也不需要考虑第 2,3,4 点 implementation。最后要把 snapshot 以及其他必要参数交给 applyCh 让 service 更新状态机——注意这一步不能另起一个 go routine 去发,必须交给那个唯一的 applier goroutine 串行发送。快照和普通命令走的是同一条 applyCh,两个 goroutine 各发一半,上层收到的 index 顺序就乱了(甚至先收到 index 更小的命令、再收到覆盖它的快照)。apply 命令和 apply 快照必须在同一条串行路径上。

2026-08 更新(版本差异):本文写于 2021 版课程,当时 raft.go 骨架里有一个 CondInstallSnapshot(),Raft 把快照发给上层后,上层要先回头问一次「这期间有没有新的 commit」,得到 true 才真正安装。

这个函数从 2022 版起已经从骨架中移除了,现行课程不再需要它——只要求把快照经 applyCh 交给上层,上层直接安装即可。下面几处关于 CondInstallSnapshot 的讨论只在读 2021 版代码时有参考价值;如果你做的是新版,跳过即可,对应的「迟到快照要丢弃」的判断放在 Raft 的 InstallSnapshot handler 里做(lastIncludedIndex <= commitIndex 直接返回)。

InstallSnapshot RPC 的 term、leaderId、lastIncludedIndex、lastIncludedTerm、offset、data 和 done 参数及接收端处理步骤
Raft 论文中的 InstallSnapshot RPC 参数与接收流程。
  • CondInstallSnapshot:若 $lastIncludedIndex\leq commitIndex$ ,则返回false,此时 service 不会应用 snapshot;否则剪短自己的 log,并更新 snapshot、lastIncludedTerm 和 lastIncludedIndex,并进行持久化。

tips:

  1. 在持久化的时候考虑 lastIncludedIndex 和 lastIncludedTerm,并应用到 commitIndex 和 lastApplied。

  2. 因为我们要删掉已经被 snapshot 的 log,所以需要改变 log 的索引方式。

  3. 对 condInstallsnapshot 的解释:Follower 收到 InstallSnapshot,向上层请求应用 snapshot 的时候,如果上层调用 condInstallSnapshot 的那一刻 commitIndex 还没被推进,就相当于「发快照」和「安装快照」这两步之间没有插入别的 apply,达到了原子的效果。

    为什么需要这层保护?因为 Raft 通过 applyCh 向 service 通信的情况有两种:一种是 apply 命令到状态机,另一种是 apply 快照到状态机。这两条路径共享同一条 channel、作用于同一份状态机,彼此是会互相干扰的(原文写成「两者是并行的,互不干扰」,正好说反了——如果真的互不干扰,也就不需要这道判断了)。只要在 Raft 发出 snapshot 到 service 收到它之间夹进了 commit,也就是当 $lastIncludedIndex\leq commitIndex$ 的时候,这份快照就已经过时,装上去等于让状态机回退并重复 apply。

    结论是:apply 命令和 apply 快照必须走同一条串行路径(同一个 applier goroutine),并在安装前判断 $lastIncludedIndex > commitIndex$。新版课程去掉了 condInstallSnapshot,这个判断就直接落在 Raft 的 InstallSnapshot handler 里。