0 前言
经过前面三个lab我们实现了raft算法的选举(leader election)、日志复制(log replication)与持久化(persist)的功能,在最后一个lab将实现日志压缩(log compaction)。日志压缩的诞生出于这样的考虑:在我们前面所有的代码中,日志均存在内存里,然而如果一台服务器是24 × \times × 7地工作,某些时刻日志将会把内存撑爆,而且日志很多的话,持久化的时候也需要很多的IO操作时间,所以要引入日志压缩技术让服务器丢弃一些日志节省内存。所以使用什么技术丢弃日志是这一节讨论的话题。
1 Snapshot快照
raft使用的是Snapshot快照技术(论文第七章)。服务器在完成日志的apply之后,可以将快照信息写入快照(最后一条日志的Index和Term等),然后将此日志和之前的所有日志删除,节省内存的目的也就达到了。
由于快照是服务器自己独立进行的,由上层service命令raft执行。有时候会引出一些问题,比如leader在调用快照之后,删除了一些日志,但是某一Follower落后leader太多,需要向其发送的日志已经不在leader的内存里了。对于这种情况,raft的解决方案是leader直接发送自己的快照过去,让follower根据快照修改自己的状态。
快照技术简洁有效,需要注意的是创建快照的时机,创建频率太高(浪费磁盘带宽)或者太低(占用内存高)都不好。一种方法是日志到达一定数目的时候创建快照。当然这个问题在本lab里不会涉及。
2 接口设计
Raft论文比较简洁,没有对实现快照的函数接口做一个规定,lab2d为我们提供了几个接口,让我们分析一下。
2.1 Snapshot()接口
Snapshot接口是由上层service调用让raft节点来创建一个快照,接口有两个参数:
- index int,这个参数是快照的截止index
- snapshot []byte,上层service传来的快照字节流,将会包含所有截止到index的信息
如果说raft节点上次创建快照的时刻lastSnapshotIndex大于index,会拒绝创建快照,否则创建快照。
2.2 RPC调用接口
之前说过当一个follower落后leader太多时,leader会发快照过去。在我的实现中,当leader发现一个follower的nextIndex[follower]小于leader节点的快照时刻时,就会通过RPC调用发快照过去。所以还要设计两个调用接口:
- sendSnapshotToPeer(server int) 这个接口由leader调用发送快照
- InstallSnapshot(args *InstallSnapshotArgs,reply *InstallSnapshotReply),这个是RPC调用接口,参数在论文中有详细讲述。当follower决定接受快照之后,会像applyCh写入一条消息根据快照修改自己的状态。
有些读者对这个过程不太清楚,我这里举个例子
读者提问:有些follower落后太多,但是需要的日志leader已经删除了,这种情况怎么办?
答:这种情况leader发送将自己的 状态 拍成快照发送给follower(快照不是日志,是状态)。举个例子(lab3中会涉及),在一个key/value存储系统中,leader收到了多条日志,x = 3,x=4,x=5,x=6,leader向follower复制日志,然后不停commit修改了x的值,每次修改后删去日志。但是某个follower网络有故障,一直没复制到日志,等到leader提交x=6之后才上线。leader发送心跳后发现此follower落后太多,于是直接将x=6这个状态写成快照,发送给follower,让它安装(需要额外RPC),follower收到快照,在自己的状态中直接修改x=6,并且将自己与leader的日志匹配位置修改到x=6的日志index处。
2.3 CondInstallSnapshot()接口
在向applyCh写入一条消息后,会调用CondInstallSnapshot()接口是用来判断是否可以实施快照的,比如判断一下快照的index是否小于节点的commitIndex。在实现CondInstallSnapshot接口时最好看一看config.go,了解它是如何被调用的。
3 结果
下图是2D的运行结果。2D其实写码不太难,关键是理解快照技术的含义,并且对前面代码做好修改。常见的坑就是log坐标的修改,因为某些日志已经被抛弃了,所以从前代码里直接访问rf.logs[index]的地方可能会有错,要转换为rf.logs[index - rf.lastSnapshot]。
最后展示一下所有测试通过的截图,希望大家做lab愉快: