MIT 6.824 Lab3 KVRaft 笔记
本以为写完 Raft 以后,在 Raft 上搭一个 app 想必是行云流水。事实证明我错了,又是踩了许多坑。 T_T
实现思路
How clients find the cluster leader?
我从这开始的第一步就陷入了纠结。Client 自己要存 leader 是肯定的,但我在考虑当 leader 挂掉以后,怎么快速发现新的 leader,想让 server 直接给 client 返回一个 leader 信息,这样就能让 client 更快地找到 leader,而不需要遍历一遍所有 server。但我的 Raft 实现里并没有存现任 leader。
其实这个问题确实是有意义的,不过我不该一上来就想这个优化,这明显不会是一开始的瓶颈。一开始关注大局,搭起骨架,能跑起来才是最重要的。还是那句老话,premature optimization is the root of all evil。
How do you know when a client operation has completed?
这个问题涉及到可以说是这个 lab 里最重要的问题之一:怎么处理「重复」请求,或者说怎么实现幂等性。
基本的思路,没有 failure 的时候是容易的:请求开始时,向 Raft 发送一个 entry,然后等待至 Raft apply 这条 entry 就完成了。那怎么「等待」呢?kvserver 的代码结构应该也是有一个 background worker 负责不断接收 Raft apply 的 command,然后更新状态机,而创造这条 command 的 RPC handler 需要被它通知 apply 完成。
简单用一个 channel 肯定是不行的,因为顺序会出问题。我们需要对一个特定 index 进行等待/通知。自然的想法是那就用一个 map[int]chan
吧。我的直觉又觉得,创建这么多小对象不太优雅,会不会开销很大?嗯…………总之我又是经过一通尝试以后,最后还是回到了这个简单方案。(我去偷看了一眼 etcd,发现好像就是这么做的)
这个只是细节小问题,关键的大问题还是如何处理重复请求:不能重复 APPEND,以及 PUT 旧值不能覆盖新值。
我一开始在想:GET 是不是也有时机问题?重复的 GET 可以直接返回当前的值吗,还是需要缓存第一次成功时候的值?(甚至想做一个 TCP 的滑动窗口那样的机制)后来没做结果缓存,发现根本没问题。现在想来,其实是每个 client 自己是一次发送一个请求的,而别的 client 的请求和它是并发的,就是没有固定的顺序。虽然两次 GET 在 server 端看来读到了不同的值,但其实 client 只收到了一个值,没有问题。
实现上首先容易想到 client 要自带一个 id 和序列号 seq,然后 server 可以记录每个 client 的最大 seq,要是遇到过就是重复请求了。一开始可能会觉得这个检测放在 RPC handler 里就行了,遇到一个请求就更新一下。但这问题重重:首先收到过请求不代表请求成功;更关键的问题是这样 server 之间没法共享 seq 记录,所以必然会失败。
其实 Raft 会重复 apply 是必然的:假设第一次请求超时,第二次请求又来了,此时并不能确定第一次请求的状态。如果直接放弃了有点不太好,如果提交这个 command 那就会重复 apply 了。加上上面说的要在 server 间共享状态,可以想到「去重」这个功能应该放到「状态机」里(而不是单个 server 的局部状态)。状态机中记录每个 client apply「成功」的最大序列号,而不是 server「收到」的最大序列号,就可以完美解决了。
故事本来到这里就该结束了,但是我在这里又自作多情,困在了一个自己给自己找的问题上,这可能也是这个 lab 花了我最久时间的问题。上面说了判断请求成功需要 RPC handler 等待对应 index 的 command 被 apply,这里 apply 是有可能会失败的,也就是对应的 index 上并不是这个 RPC 提交的 command,所以 RPC 就给 client 返回了一个「失败」的结果,这没有问题。
但是 client 端要怎么处理这次失败呢?可以直接发起相同的请求重试。但我不满足于此,我觉得,在一般的情况下,client 知道请求失败了,可以选择放弃这一请求,做别的事。关键是 client 明确知道了「失败」,这是一个确定的结果,获得了更多的信息,这在语义上和不确定发生了什么的「超时」是不一样的。因此,虽然这个 lab 要求 client 失败重试,我做了一个小 trick:「失败」后重试时 seq+1。
这一处理给我带来了相当大的麻烦,但是也让我的实现不得不更严谨了,本来有一些细节其实直接重试的话是无所谓的。
最简单的情景:
- APPEND seq1 成功,但是 reply 丢包了。
- Client 重试 APPEND seq1 失败,client 收到失败回复,增加 seq 变为2。
- APPEND seq2 成功。出现了重复 APPEND。
这里的问题在于,RPC handler 里并不做去重检测,只有 apply 的时候会去重。这个问题如果 client 不改 seq 无脑重试的话是不存在的,相当于不管之前的请求是否成功 RPC handler 都选择再提交一次 command,反正状态机会做去重。
这个问题修复比较容易,在 RPC handler 的一开始加一个去重检测就可以了,这可能会稍微减少一些重复提交,但是并不能完全避免重复提交。于是问题就又来了:
- APPEND seq1,超时。
- Client 重试 APPEND seq1,RPC handler 里提交完 command 后第一次提交 apply 成功。
- 第二次提交 apply 失败,返回失败。这里返回了错误的结果,可想而知 client 之后会重新提交 seq2,然后出现重复 APPEND。
这个问题和 lab2 里提过的一个问题很像:发送 RPC 请求前后状态可能已经发生了变化。这里是等待 command 被 apply,道理是一样的。有「等待」就需要考虑等待前后的状态。这个问题的解决也很简单,等待唤醒时再检测一次去重就可以了。
到这里,可能会感觉差不多够意思了,然后噩梦就来了:跑几百次测试,又会出现 missing/duplicate APPEND。慢慢地分析 log,发现情景是这样的:
- [server A] index 574 seq60 超时(真实结果:成功)。
- [server B] index 569 seq60 超时。
- [server B] index 570 seq60 失败 -> seq61。
- [server A] index 575 seq61 成功。重复 APPEND。
可以发现,出现问题的根本原因是:中途更换 leader。对 leader A 的结果仍然不确定(最终成功了)的情况下,换了个假 leader B,然后被它的结果误导了。换 leader 的行为是必须存在的,否则由于假 leader 无法 commit,对它请求会永远超时。
事情发展到这里,我就不想再继续修问题了,决定回归淳朴,不要让 client 增加 seq,放弃对「失败」语义的执着。我觉得有一个根本的问题,就是状态机里只记录了「apply 成功的最大序列号」,状态机并不知道「失败」,也不存在请求和回复的概念。处理 RPC 请求和回复完全是单个 server 的局部逻辑。在这样的情况下,要让请求有复杂的语义(如果是可以做到的)需要非常小心,处理各种边界情况。因此,我觉得这种设计可能本身就是不太合适的,如果有复杂请求处理的需求,那可能需要考虑再增加一个处理请求的状态机了。
(P.S. 上面这个换 leader 的小问题应该还是容易解决的,因为假 leader 不能 commit,之所以会失败肯定是已经收到真 leader commit 的消息,变回了 follower,所以在 RPC 返回的时候再检查一次是否是 leader 应该就可以了)
Key/Value index
其实这本应该是状态机的大头。但是这个 lab 好像并不太关心存储引擎,做一个纯内存的 hash index 就够了。存储引擎这个话题还是等以后看实际系统再慢慢研究吧。
Snapshot
Raft paper 里提到了它们使用了基于 fork()
的 copy-on-write 来实现高效的 snapshot,不过 lab 里没提这回事儿,暴力锁住 copy 就行了。我也没深究下去。
虽然总体来说实现 snapshot 还是不太困难的,但是要考虑的细节依然不少。诸如更新 commitIndex,是否要删除多余 log 等等情况,一不小心就写错了。这种东西可能还是最好要先想清楚各种情况然后再实现,可以在纸上先画画。
踩坑记录 & 心得体会
Raft 没测出来的问题
TestSnapshotSize3B 非常慢
这个问题在 lab 2 的笔记里已经说过了,原因在于这个测试的 pattern 是每次先请求指令,等完成了再发下一条,所以对 latency 有要求,要在请求来时主动额外多发一次心跳就可以了。
这个问题拖到这么后面才出现,其实还是有点难排查的,稍微有点不合理。
TestCount2B: only 2 decided for index 6; wanted 3
lab 3 写着写着也重跑了一下 lab 2 的测试,结果出现这个问题,有点吓人。看日志发现明明达成了一致,却没有 apply 是怎么回事。仔细看是有一个 server 更新 commitIndex 之后没有 apply,是 applier 里的 Cond 没有被唤醒。原因是我写出了这样的代码:
for {
mu.Lock()
cond.Wait()
toApply = copy(...)
mu.Unlock()
for _, cmd := toApply {
applyCh <- cmd
}
}
又是一个有点小聪明的优化:觉得 apply 开销比较大,甚至可能 block,所以先复制再 apply,apply 的时候不需要锁。其实还是有点道理的,但是这就带来了 Cond 丢失唤醒的问题。解决的话要么就老老实实带锁 apply,要么需要再加一个定时唤醒 Cond 的 worker。其实我在做 kvserver 的时候也有担心过类似的问题,没想到在这里遇到了。总之丢失唤醒是一个使用 Cond 需要考虑的问题。通常的提醒是:不持有锁的时候唤醒可能会丢失。但我这里主动释放了 Cond 醒来后带的锁,所以就算持有锁唤醒,这儿也可能丢。
之所以这个测试会出现问题,是因为这里 cfg.one()
要求「成功」,而且参数 retry
是 false。看注释就知道了:
if retry==true, may submit the command multiple times, in case a leader fails just after Start(). if retry==false, calls Start() only once, in order to simplify the early Lab 2B tests.
其他测试要么不要求「成功」,只需要达成「一致」,要么是会重试的。
AppendEntries
一个 3A 早期卡了很久的大问题。现象是出现 missing append。看 log 追踪到如下情况:
- Follower(4,4,4,4,4),括号内为 entry 的 term。
- Leader (4,4,7,7)
- Follower 收到 AppendEntries,变为(4,4,7,7,4)
- 下次选举,follower 投票给了 outdate candidate(term < 7)。
问题很明显了。原因是我一开始的 AppendEntries 将 PrevLogIndex 之后的原有 entries 一概删除,后来(上次说了踩坑之后)改成了仅当 PrevLogIndex 有冲突的时候才删除。但这还不够,没有冲突也不能简单覆盖,还需要逐项检查。看 paper 中其实说的很清楚:if an existing entry conflicts,并不仅仅是 prev entry conflicts。
又是一个在 Raft 的测试里没测出来的问题,分布式系统真的处处是坑,真需要咬文嚼字啊。
日志的重要性
在分布式系统里不能单步执行,不能依赖 debugger 了(虽然我也不会用),log 对排查问题非常重要。以前,我已经感受到 log 相比无脑 print 的优越性了,在 lab 2 中我也会尽量多打印一些有用的信息。但是在 lab 3,我发现做的还是并不够。
我一度觉得出了问题,看问题周围几十上百行 log 就差不多够意思了。但并发量大的时候,有海量的无关信息。因此,需要日志可搜索、可追踪。我一开始在 lab 2 里的日志很随意,例如 DPrintf("%v: var1: %v, var2:%v, do something", rf.me, v1, v2)
,这首先有个大问题就是我没法追踪一个固定 server 的日志了,因为只打印了一个数字,没办法搜索。其次,因为没有固定格式,在搜索一些东西的时候可能要写很复杂的正则表达式(凭我蹩脚的正则表达式水平勉强拼出来)。在 lab 3 中,我就更注意了一点,例如会打成 DPrintf("[kvserver][me: %v] balabala ...", kv.me, ...)
。由于我到挺后面才被难看的日志恶心到,所以也没有全面改造(还是不得不改了一些)。更进一步,其实还可以使用结构化日志,更加规范要求;也可以对对象实现自己的 log 方法,固定打印一些常用的变量。总之,不管具体实践如何,日志应该是个从一开始就要考虑,不能草率对待的东西。
另外,在排查问题的时候,尤其是性能问题,我有时候还想统计一下 RPC 调用的次数。我通过打日志的方式实现了一下,但这会让日志膨胀得很厉害。这个问题让我感受到了对监控系统的需求(Prometheus & Grafana?)。
last 是上一个还是最后一个?
其实 Raft 里好像没有这个问题,上一个就明确是 prev,last 只表示最后,而且我自己也没写错遇到问题被坑到。只是我自己看代码还是会产生迷惑,想到这个问题。总之,尽量要区分,甚至避免使用容易出现歧义的命名,虽然凭语境可能很容易区分清楚,但是不要给自己带来处理额外信息的心智负担。
goroutine leak
我在测试中遇到了这样的情况:测试超过 10 分钟,强制结束,显示有两百多个 goroutine,很多等待了超过 8 分钟。
有一个卡住的位置是在 Raft 的 applier 里 applyCh<-
。这个会卡住应该是因为 kvserver 被 Kill()
了,不再消费消息了。我一开始 Kill
里没有处理逻辑,但是所有 background worker 的循环都加了 !rf.killed()
的条件,本以为这已经足够了。但虽然循环次数不会无限了,循环内部还有无限等待的操作,可能会卡住。因此 Kill()
内需要做一些回收操作。除了 applier,还有几个地方类似。
当然,测试卡住并不是因为这个,而是其他地方的逻辑错误,这里是顺手解决一下问题。
真实的一个导致卡死的逻辑错误:kvserver 里,我传消息的方式是每个 index 对应的 channel 大小为 1,因为不会重复 apply 相同的 index。但是在 InstallSnapshot 之后,有可能 lastApplied 会变小,于是就重复 apply 了相同的 index,就有可能在相应的 channel 上卡住了。
解决这个问题,要么可以拒绝 install 太老的 snapshot,要么可以在 install 之后重建一个新的 map[int]chan
。
小结
看了一下写完 6.824 的 Lab 1-3 做了一个月,一共花了 95 个小时(WakaTime 数据),一开始实现其实也还好,到最后 debug 真的是花了几十小时,有点体力活的感觉。尤其是跑几百次测试可能才出现一次的 bug,看了半天日志,修好了,结果过了几个小时跑了几百次又出了新的 bug,真叫人有点绝望。分布式系统 debug 确实不容易,出现问题难以复现,所以需要各种手段的帮助,比如良好的日志,测试框架支持(如果在网络正常的情况下测试,那得多久才能发现问题?混沌工程真是意义重大)。
收获
-
领域知识:Raft、single-leader replication
- 编程实践练习:
- 多线程(协程/任务)编程:
- 等待与通知
chan
和select
Mutex
和Cond
- Background worker
- Bash script
- 计时器
- 多线程(协程/任务)编程:
- 经历了一些常见的问题:
- 阻塞操作(RPC 调用、channel)前后状态可能不一致
- 改代码要 find all usage 检查多处一致
- 如果抽掉一切技术细节,剩下的 takeaway 我想可能是:
- 设计好了再开始实现,要想清楚不同情况下的行为规则。让代码忠实地遵守规则,并通过一些手段来检验和保证。
- 在实现的一开始就要考虑如何为排查问题设置辅助手段(这里是 log),不要轻敌,不要过于自信。
- Debug 要有耐心,也要动脑子,对问题敏感。