Log压缩和快照(Log compaction and snapshots)在Lab3b中出现的较多。在Raft中,Log压缩和快照解决的问题是:对于一个长期运行的系统,例如运行了几周,几个月甚至几年,如果我们按照Raft论文图2的规则,那么Log会持续增长。最后可能会有数百万条Log,从而需要大量的内存来存储。如果持久化存储在磁盘上,最终会消耗磁盘的大量空间。如果一个服务器重启了,它需要通过重新从头开始执行这数百万条Log来重建自己的状态。当故障重启之后,遍历并执行整个Log的内容可能要花费几个小时来完成。这在某种程度上来说是浪费,因为在重启之前,服务器已经有了一定的应用程序状态。
为了应对这种场景,Raft有了快照(Snapshots)的概念。快照背后的思想是,要求应用程序将其状态的拷贝作为一种特殊的Log条目存储下来。我们之前几乎都忽略了应用程序,但是事实是,假设我们基于Raft构建一个key-value数据库,Log将会包含一系列的Put/Get或者Read/Write请求。假设一条Log包含了一个Put请求,客户端想要将X设置成1,另一条Log想要将X设置成2,下一条将Y设置成7。
如果Raft一直执行没有故障,Raft之上的将会是应用程序,在这里,应用程序将会是key-value数据库。它将会维护一个表单,当Raft一个接一个的上传命令时,应用程序会更新它的表单。
所以第一个命令之后,应用程序会将表单中的X设置为1。
第二个命令之后,表单中的X会被设置为2。
第三个命令之后,表单中的Y会被设置为7。
这里有个有趣的事实,那就是:对于大多数的应用程序来说,应用程序的状态远小于Log的大小。某种程度上我们知道,在某些时间点,Log和应用程序的状态是可以互换的,它们是用来表示应用程序状态的不同事物。但是Log可能包含大量的重复的记录(例如对于X的重复赋值),这些记录使用了Log中的大量的空间,但是同时却压缩到了key-value表单中的一条记录。这在多副本系统中很常见。在这里,如果存储Log,可能尺寸会非常大,相应的,如果存储key-value表单,这可能比Log尺寸小得多。这就是快照的背后原理。
所以,当Raft认为它的Log将会过于庞大,例如大于1MB,10MB或者任意的限制,Raft会要求应用程序在Log的特定位置,对其状态做一个快照。所以,如果Raft要求应用程序做一个快照,Raft会从Log中选取一个与快照对应的点,然后要求应用程序在那个点的位置做一个快照。这里极其重要,因为我们接下来将会丢弃所有那个点之前的Log记录。如果我们有一个点的快照,那么我们可以安全的将那个点之前的Log丢弃。(在key-value数据库的例子中)快照本质上就是key-value表单。
我们还需要为快照标注Log的槽位号。在这个图里面,这个快照对应的正好是槽位3。
有了快照,并且Raft将它存放在磁盘中之后,Raft将不会再需要这部分Log。只要Raft持久化存储了快照,快照对应的Log槽位号,以及Log槽位号之后的所有Log,那么快照对应槽位号之前的这部分Log可以被丢弃,我们将不再需要这部分Log。
所以这就是Raft快照的工作原理,Raft要求应用程序做快照,得到快照之后将其存储在磁盘中,同时持久化存储快照之后的Log,并丢弃快照之前的Log。所以,Raft的持久化存储实际上是持久化应用程序快照,和快照之后的Log。大家都明白了吗?
学生提问:听不清。
Robert教授:或许可以这样看这些Log,快照之后的Log是实际存在的,而快照之前的Log可以认为是幽灵条目,我们可以认为它们还在那,只是说我们永远不会再去查看它们了, 因为我们现在有快照了。事实上,我们不再存储幽灵条目,但是效果上是等效于有完整的Log。
刚刚的回答可能有些草率。因为如果按照Raft论文的图2,你有时还是需要这些早期的Log(槽位1,2,3)。所以,在知道了有时候某些Log可能不存在的事实之后,你可能需要稍微重新理解一下图2。
所以,重启的时候会发生什么呢?现在,重启的场景比之前只有Log会更加复杂一点。重启的时候,必须让Raft有方法知道磁盘中最近的快照和Log的组合,并将快照传递给应用程序。因为现在我们不能重演所有的Log(部分被删掉了),所以必须要有一种方式来初始化应用程序。所以应用程序不仅需要有能力能生成一个快照,它还需要能够吸纳一个之前创建的快照,并通过它稳定的重建自己的内存。所以,尽管Raft在管理快照,快照的内容实际上是应用程序的属性。Raft并不理解快照中有什么,只有应用程序知道,因为快照里面都是应用程序相关的信息。所以重启之后,应用程序需要能够吸纳Raft能找到的最近的一次快照。到目前为止还算简单。
不幸的是,这里丢弃了快照之前的Log,引入了大量的复杂性。如果有的Follower的Log较短,在Leader的快照之前就结束,那么除非有一种新的机制,否则那个Follower永远也不可能恢复完整的Log。因为,如果一个Follower只有前两个槽位的Log,Leader不再有槽位3的Log可以通过AppendEntries RPC发给Follower,Follower的Log也就不可能补齐至Leader的Log。
我们可以通过这种方式来避免这个问题:如果Leader发现有任何一个Follower的Log落后于Leader要做快照的点,那么Leader就不丢弃快照之前的Log。Leader原则上是可以知道Follower的Log位置,然后Leader可以不丢弃所有Follower中最短Log之后的本地Log。
这或许是一个短暂的好方法,之所以这个方法不完美的原因在于,如果一个Follower关机了一周,它也就不能确认Log条目,同时也意味着Leader不能通过快照来减少自己的内存消耗(因为那个Follower的Log长度一直没有更新)。
所以,Raft选择的方法是,Leader可以丢弃Follower需要的Log。所以,我们需要某种机制让AppendEntries能处理某些Follower Log的结尾到Leader Log开始之间丢失的这一段Log。解决方法是(一个新的消息类型)InstallSnapshot RPC。
当Follower刚刚恢复,如果它的Log短于Leader通过 AppendEntries RPC发给它的内容,那么它首先会强制Leader回退自己的Log。在某个点,Leader将不能再回退,因为它已经到了自己Log的起点。这时,Leader会将自己的快照发给Follower,之后立即通过AppendEntries将后面的Log发给Follower。
不幸的是,这里明显的增加了的复杂度。因为这里需要Raft组件之间的协同,这里还有点违反模块性,因为这里需要组件之间有一些特殊的协商。例如,当Follower收到了InstallSnapshot,这个消息是被Raft收到的,但是Raft实际需要应用程序能吸纳这个快照。所以它们现在需要更多的交互了。
学生提问:快照的创建是否依赖应用程序?
Robert教授:肯定依赖。快照生成函数是应用程序的一部分,如果是一个key-value数据库,那么快照生成就是这个数据库的一部分。Raft会通过某种方式调用到应用程序,通知应用程序生成快照,因为只有应用程序自己才知道自己的状态(进而能生成快照)。而通过快照反向生成应用程序状态的函数,同样也是依赖应用程序的。但是这里又有点纠缠不清,因为每个快照又必须与某个Log槽位号对应。
学生提问:如果RPC消息乱序该怎么处理?
Robert教授:是在说Raft论文图13的规则6吗?这里的问题是,你们会在Lab3遇到这个问题,因为RPC系统不是完全的可靠和有序,RPC可以乱序的到达,甚至不到达。你或许发了一个RPC,但是收不到回复,并认为这个消息丢失了,但是消息实际上送达了,实际上是回复丢失了。所有这些都可能发生,包括发生在InstallSnapshot RPC中。Leader几乎肯定会并发发出大量RPC,其中包含了AppendEntries和InstallSnapshot,因此,Follower有可能受到一条很久以前的InstallSnapshot消息。因此,Follower必须要小心应对InstallSnapshot消息。我认为,你想知道的是,如果Follower收到了一条InstallSnapshot消息,但是这条消息看起来完全是冗余的,这条InstallSnapshot消息包含的信息比当前Follower的信息还要老,这时,Follower该如何做?
Raft论文图13的规则6有相应的说明。我认为正常的响应是,Follower可以忽略明显旧的快照。其实我(Robert教授)看不懂那条规则6。