实现要求

对于一个长时间运行的 Raft 系统,如果持续收到日志,会遇到以下问题:

  1. 空间不够:如果日志无限追加下去,本地硬盘空间可能存不下。
  2. 重启过慢:因为重启时需要重放( replay)所有日志,如果日志过长,重放过程将会持续很久不能正常对外提供服务。

一个经典的解决办法是,定期对日志做快照(snapshot)。针对某个日志 entry 做了快照之后,该 entry 以及以前的日志变都可以被截断truncate)。当然,这种方法能解决的我们上面两个问题的本质原因在于:相比日志,快照的存储更为紧凑。日志记录的是事件(比如 update k1 = v2),而快照通常记录的是数据条目(比如 {k1: v1, k2: v2}),而一个数据条目通常会在事件中出现多次(写入-更新-删除等等),因此从日志到快照通常会有压缩空间。

但这样同时会引出另一个问题:如果 Leader 想给从节点发送日志时,发现相关日志条目已经被截断怎么办?这就需要引入一个新的 RPC:InstallSnapshot。通过此 RPC,先将 Leader 的 Snapshot 无脑同步给 Follower,再做之后日志同步。Raft 论文中第七章提到了该 RPC,可以进行参考,但具体是细节还需要你来设计。

最终,你的 Raft 实现需要对外提供以下接口:

1
Snapshot(index int, snapshot []byte)

在 PartD 的测试中,测试框架会定期调用该接口,其含义大致是:应用层表示我在 index 处做了一个 snapshot,raft 层可以记下该 snapshot,并且把 index 以及以前的日志给删掉了。

需要注意的是,测试框架会在每个 Peer (而非只在 Leader) 上,调用该接口。

我们重新梳理一遍你需要实现的内容:

  1. 实现一个 Snapshot() 接口,承接应用层来的快照请求
  2. 实现一套 InstallSnapshot RPC,当日志同步时发现日志被截断时先同步 Snapshot

在 1 中,你需要将应用层传下来的 snapshot 存储下来,以备 2 使用。此外,在 2 中,如果 Follower 接受到 snapshot 时,也需要将其存下来,以备重启或者将来变成 Leader 使用。在这两种情况下,你都可以使用 persister.Save() 来存。

在 Raft 因为宕机重启时,如果有 snapshot,那么第一步就是要先应用 snapshot。你可以往 applyCh 中发一个 ApplyMsg 来应用 snapshot。当然,此处的 ApplyMsg 要用上之前 lab 没有使用的其他字段了,就是下面代码中的 For PartD 部分:

1
2
3
4
5
6
7
8
9
10
11
type ApplyMsg struct {
CommandValid bool
Command interface{}
CommandIndex int

// For PartD:
SnapshotValid bool
Snapshot []byte
SnapshotTerm int
SnapshotIndex int
}

实现要点

  1. 作为基础,可以先改造下日志的实现,让其支持从某个下标(比如 X )开始。在 PartB/PartC 中,X 可以设置为 0,以跑过测试;在 PartD 中,在收到 Snapshot(index, snapshot) 请求时,可以将 X 设置为 index,并截断 index 以及以前的日志。如果你这一步做对了,就可以跑过 PartD 的第一个测试:TestSnapshotBasicPartD
  2. 注意使用 Golang 中的切片进行截断时,底层可能并没有真正的丢弃数据,因此需要使用:比如新建+拷贝替代切片,以保证 GC 真正会回收相应空间。
  3. 不需要实现论文中增量发送 Snapshot(因为实践中 Snapshot 可能过大) ,我们实验中只需一次全量发送即可。因此论文中图 13 的 offset 是不需要的。
  4. 尽管在调用 Snapshot(index, snapshot) 时,index 处的日志被丢掉了。但在发送 AppendEntries 时,可能会用到该 index(即当前存留日志的前一条、也是 snapshot 的最后一条)term,因此你最好将其保存下来:lastIncludeTerm/lastIncludeIndex。另外需要考虑下,他们是否需要进行持久化,以应对宕机重启。
  5. Snapshot(index, snapshot) 只能针对已提交的日志做快照,因此最好检查下 index 和 CommitIndex 的关系。