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

InstallSnapshot:
Leader 方面,当$nextIndex[Follower] \leq Leader.lastIncludeIndex$的时候,由于 Leader 已经没有这部分的日志,Leader 会发送 InstallSnapshot RPC 给 Follower,请求 Follower 安装快照(快速追赶),并且在成功收到返回值后,Leader 会更新 nextIndex 和 matchIndex
Follower 方面,如果 Follower 收到 Leader 的任期没有过期的话,只能执行。与论文不同的是:不需要考虑分块发送 snapshot,所以
offset和done都不需要考虑,相应的,在实现的时候也不需要考虑第 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 的InstallSnapshothandler 里做(lastIncludedIndex <= commitIndex直接返回)。

- CondInstallSnapshot:若 $lastIncludedIndex\leq commitIndex$ ,则返回
false,此时 service 不会应用 snapshot;否则剪短自己的 log,并更新 snapshot、lastIncludedTerm 和 lastIncludedIndex,并进行持久化。
tips:
在持久化的时候考虑 lastIncludedIndex 和 lastIncludedTerm,并应用到 commitIndex 和 lastApplied。
因为我们要删掉已经被 snapshot 的 log,所以需要改变 log 的索引方式。
对 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 的InstallSnapshothandler 里。