17. Raft PartD 日志压缩
实现要求
对于一个长时间运行的 Raft 系统,如果持续收到日志,会遇到以下问题:
- 空间不够:如果日志无限追加下去,本地硬盘空间可能存不下。
- 重启过慢:因为重启时需要重放( 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) 上,调用该接口。
我们重新梳理一遍你需要实现的内容:
- 实现一个
Snapshot()接口,承接应用层来的快照请求 - 实现一套
InstallSnapshotRPC,当日志同步时发现日志被截断时先同步 Snapshot
在 1 中,你需要将应用层传下来的 snapshot 存储下来,以备 2 使用。此外,在 2 中,如果 Follower 接受到 snapshot 时,也需要将其存下来,以备重启或者将来变成 Leader 使用。在这两种情况下,你都可以使用 persister.Save() 来存。
在 Raft 因为宕机重启时,如果有 snapshot,那么第一步就是要先应用 snapshot。你可以往 applyCh 中发一个 ApplyMsg 来应用 snapshot。当然,此处的 ApplyMsg 要用上之前 lab 没有使用的其他字段了,就是下面代码中的 For PartD 部分:
1 | type ApplyMsg struct { |
实现要点
- 作为基础,可以先改造下日志的实现,让其支持从某个下标(比如 X )开始。在 PartB/PartC 中,X 可以设置为 0,以跑过测试;在 PartD 中,在收到
Snapshot(index, snapshot)请求时,可以将 X 设置为index,并截断index以及以前的日志。如果你这一步做对了,就可以跑过 PartD 的第一个测试:TestSnapshotBasicPartD。 - 注意使用 Golang 中的切片进行截断时,底层可能并没有真正的丢弃数据,因此需要使用:比如新建+拷贝替代切片,以保证 GC 真正会回收相应空间。
- 不需要实现论文中增量发送 Snapshot(因为实践中 Snapshot 可能过大) ,我们实验中只需一次全量发送即可。因此论文中图 13 的
offset是不需要的。 - 尽管在调用
Snapshot(index, snapshot)时,index 处的日志被丢掉了。但在发送 AppendEntries 时,可能会用到该 index(即当前存留日志的前一条、也是 snapshot 的最后一条)term,因此你最好将其保存下来:lastIncludeTerm/lastIncludeIndex。另外需要考虑下,他们是否需要进行持久化,以应对宕机重启。 Snapshot(index, snapshot)只能针对已提交的日志做快照,因此最好检查下 index 和CommitIndex的关系。
