MIT6.824 spring21 Lab2D总结记录
写在前面
lab2D是今年新添加的部分,网上很难找到博客资源。
这一部分要求我们为raft添加log compaction功能:在运行一段时间后,raft的上层service可以生成一个snapshot,并通知raft。在这之后,raft就可以丢弃snapshot包含的log entries,起到节约空间的作用。
这部分难度不大,但是细节略多。
(测试结果:Lab2D的testcase跑50次,全部PASS)
代码见:https://github.com/sun-lingyu/MIT6.824-spring21/tree/Raft-2D
关于CondInstallSnapshot
看过Lab2D实验指导的人都会发现,如果follower收到了一个InstallSnapshot RPC,其处理逻辑是非常扭曲的:
首先,在InstallSnapshot Handler中,follower需要将收到的snapshot通过applyCh发送给上层service。此时follower并不会安装这个snapshot。
在一段时间后,上层service会调用CondInstallSnapshot函数,询问raft是否应该安装此snapshot。若在follower执行InstallSnapshot Handler到执行CondInstallSnapshot的这段时间里,raft没有因为收到applyentries RPC导致其commitID超过该snapshot。
在什么情况下CondInstallSnapshot会拒绝安装snapshot?
当然,出现“CondInstallSnapshot拒绝安装snapshot”这种情况,是可以理解的。下面给出一种可能的情况:
leader的当前状态如下。图中的直线代表leader的log,且假设所有的log entry都已commit。
leader向其中一个落后的follower发送appendEntries RPC。其中包含了从nextIndex直到log末尾的所有entry。
由于种种原因(不稳定的网络或follower fail),这个包并没有及时被follower接受。
接下来,leader的上层应用调用Snapshot(),对leader的log进行压缩:
如图,这个snapshot可能超过了nextIndex。因此,当leader试图重发刚刚的appendEntries时,它将只能发送整个snapshot给follower。
若由于网络波动,follower首先收到了snapshot,它将执行InstallSnapshot RPC。
若在它还未执行CondInstallSnapshot时,它收到了先前发送的appendEntries,并commit,那么在执行CondInstallSnapshot时,它将拒绝安装此snapshot。
为什么InstallSnapshot RPC的处理逻辑这么扭曲?
论文中对InstallSnapshot 的描述远比实验指导中简单:论文中甚至根本没有提及过CondInstallSnapshot。
那么我们为什么需要CondInstallSnapshot?为什么不可以在InstallSnapshot RPC handler中直接安装snapshot?
实验指导中给出的答案是:这样可以确保service与raft安装snapshot的原子性。
如果你对Lab2B的实现还有印象,就会发现:这个问题的答案与我们的实现高度相关。
让我从“如何向applyCh发送log entry”讲起:
在我的Lab2B实现中,向applyCh发送log entry的时机有两个:
1. appendEntries RPC handler中,发现commitIndex需要更新之后
2. leader appendEntries RPC receiver中,commit新的log entry之后
在这两个发送时机,执行发送的goroutine都是持有锁的。
如果在向applyCh发送任何信息时,总持有锁,那么可以保证raft与上层service的通信是严格串行的。(没有任何不确定性)
那么如果在InstallSnapshot RPC handler中,向applyCh发送snapshot时也持有锁,就也可以保证service与raft安装snapshot的原子性。
即:raft向上传递log entry和snapshot是有严格顺序的。service和raft是严格同步的(虽然service可能滞后raft一段时间),其正确性可以保证。
这样一来,似乎可以在InstallSnapshot RPC handler中直接安装snapshot。
但是:这个过程涉及了一把锁与一个blocking channel。一旦处理不当,将导致死锁。
虽然在2B/2C中,这样的实现不会产生问题。这是因为raft的上层service一定会及时读取applyCh。
但是在2D中引入Snapshot函数后,这种机制将导致死锁!
在实验提供的service代码中:当上层service从applyCh中收到了一定数量的log entry后,它将执行Snapshot函数,使raft压缩其log。在Snapshot函数返回前,上层service不会继续执行。
执行Snapshot函数需要获取锁。若此时有goroutine正在持有锁并试图向applyCh中发送log entry,则会发生死锁。
因此,Frans Kaasoek教授在讲解2A/2B时,特意提到不要在向applyCh发送log entry时持有锁。在他的实现中,向applyCh发送是采用一个专门goroutine执行来确保串行的。(我发现2B/2C的testcase都能过,就没当回事😭😭😭)
在把实现改为用专门goroutine(以下称为applier)向applyCh发送后,我发现为了避免复制导致使用额外空间,applier最好直接在rf.log上操作。但是这样一来,applier就只能串行发送log entry,而很难把发送snapshot这件事情插入到log entry的串行发送过程中。(这里可能没有讲清楚。可以看我Lab2D中applier的代码,一看就明白了。)
这时候,按照实验指导的方式操作就变成了一件自然的事情:
我们不要求 发送snapshot和发送log entry这两件事在同一个串行事件队列中发生。即:
logEntry--logEntry--snapshot--logEntry--snapshot--logEntry
而是选择退而求其次,允许发送log entry与发送snapshot并发执行。即:
logEntry--logEntry--logEntry--logEntry
snapshot--snapshot
也就是说,applier不负责发送snapshot。
这样一来,service什么时候能接收到snapshot成了一件不确定的事情。我们必须引入CondInstallSnapshot函数,确定service接收到snapshot的时间。
进一步,我们就可以确保service和raft同时安装snapshot,避免了不确定性。
一些实现细节
在理解了上面的内容后,按照实验指导和论文的figure13,一步一步实现即可。
为了方便对之前的代码进行改动,我建议将log由先前简单的logEntry slice改为较复杂的struct,封装其结构。如下:
这个struct封装了log与snapshot的信息,并提供两个函数:
1. 提供从log index到log.entries index的转换方法。
2. 提供log最后一项的log index。
我建议在index函数中,在发生
index < l.LastIncludedIndex
这种情况(即:请求的index已经被包含在snapshot中)时panic。可以很快地找出潜在的bug。
还有一个需要注意的点是:在InstallSnapshot Handler中,若决定将此snapshot发送给applyCh,需要reset election timer。
也不要忘记在Make函数(初始化)中把rf.lastApplied 赋值为 rf.log.LastIncludedIndex。
写在最后
总的来说,Lab2D难度不大,但是需要修改之前2B的很多逻辑和细节。只要足够细心,就可以很快通过。
我觉得,2D最重要的事情是:理解为什么需要ConInstallSnapshot函数。
知其然,更应知其所以然。不能因为实验指导这样说了,就盲目服从。这样对学习是没有帮助的。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 零经验选手,Compose 一天开发一款小游戏!
· 通过 API 将Deepseek响应流式内容输出到前端
· AI Agent开发,如何调用三方的API Function,是通过提示词来发起调用的吗