3051 字
15 分钟
我遇见的 Raft 面试题与其背后的真实问题

首先说一下 6.824 在我找暑期实习的过程中的共十场面试,一半问到了这个项目,其中只有两场问的比较深入,可能确实是烂大街了。还被问到过“你的 raft 和 etcd 的实现相比有什么优势?”,我打 etcd?真的假的(x

建议做6.824的同学完整读一下 Raft 作者的博士论文,并阅读一下工业 Raft 实现的代码,有时间可以尝试自己实施一下这些改进。

本文记录我遇到的一些 Raft 面试题及其背后的工程语境,从实际问题出发。也借此整理一下 Raft 在真实系统中如何落地,以及有哪些被教学项目忽略的复杂度。

如有错误,欢迎指出~

*有看过工业上 Raft 的实现吗?比如 etcd、Ratis …#

这是我在两场面试中遇到的问题。回头来看,这类问题其实很合理:毕竟教学项目只是协议的简化实现,工业级系统往往在性能、可用性和工程复杂度上都有更高要求。Raft 官网(raft.github.io)上列出了多个知名的开源实现,比较有代表性的包括 etcd/raft、Ratis、Dragonboat、HashiCorp 等。

etcd/raft#

看一下etcd的raft实现,和 MIT6.5840 给出的框架相比,最显著的差异是 etcd 的状态机是没有加锁的,而是采用了单线程事件循环,这种设计避免了并发访问状态带来的竞态与锁复杂度。由此,在事件驱动上,使得所有事件(包括状态推进、消息解析、日志提交等)都能串行执行,从而简化了并发场景下的一致性处理。

说回 Raft 协议本身,其作者在他的博士论文中也坦率地指出:

Unless we felt confident that a particular problem would affect a large fraction of Raft deployments, we did not address it in Raft. As a result, parts of Raft may appear naive.

也就是说,在实际环境中,根据具体场景,Raft 有大量需拓展改进的空间,而 etcd 等实现就是这些演进的优秀实例。下文我会继续整理几个典型的问题场景。

*我们想要往集群内添加新的节点,该怎么做?#

最直接的方法是停掉系统,更新配置,再重新上线。但这样对可用性影响太大,不太可取。

节点的动态变更#

那动态添加如何添加呢?

流程如下:

我们对 Leader 发起添加/删除节点的命令 » 利用 Raft 机制,Leader 将变更封装为 Entry,传播给其他节点 » 过半节点获取到 Entry » Leader commit » a 节点 commit » b 节点 commit » …

这个过程中国,配置切换存在不可避免的状态不一致,我们称更改前后的配置分别为 Cold{C_{old}}Cnew{C_{new}}

动态变更的隐患#

如果我们向有三个节点的集群 a,b,c{a, b, c} 添加两个新节点 e,f{e, f}。在某一时刻,可能出现:

  • 拥有Cold{C_{old}} 节点为{a,b}{\{a,b\}}

  • 拥有Cnew{C_{new}} 的节点为{c,e,f} {\{c,e,f\}}

这时候,Cold{C_{old}}Cnew{C_{new}}的节点个数均大于配置记录的半数,若此时发生选举,可能会导致两个独立子集都认为自己合法,造成脑裂。

单服务器方法#

论文中提出了一种策略:每次只修改一个节点,确保 ColdC_{old}CnewC_{new} 的多数始终重叠,从而规避脑裂风险。 4_3.png

新节点同步滞后带来的可用性问题#

enter image description here

  1. Leader 将新节点先添加为无投票权成员;

  2. 新节点开始接受日志复制,追上 Leader 的 lastIndex;

  3. 追上以后,领导者可能已经收到了新的日志条目,开始下一轮复制。轮持续时间随轮数缩短,最后一轮的持续时间少于选举超时时间;

  4. 新节点顺利完成“几轮”这样的同步过程后,就可以将新节点提权为正式节点;反之,则认为配置更改失败。

    round.jpg

联合共识#

论文提出了一种联合共识的方法来快速配置而不是逐个删减,这种方式有着更大的灵活性,但它引入了较高的复杂实现度。

核心思想:在配置从旧的 ColdC_{old} 转换为新的 CnewC_{new} 的过程中,Raft 会先进入一个中间状态 Cold,new{C_{old, new}} ,这个过渡配置要求新老配置节点都占大多数才算成功 commit。比如从 3 个服务器的集群更改为 9 个服务器的不同集群时,中间配置应用后继续 commit 既需要 Cold{C_{old}} 的 3 个节点中的 2 个,也需要 Cold,new{C_{old, new}} 的 9 个节点中的 5 个。

一旦联合配置稳定运行,可以将配置更新为 CnewC_{new},此后提交只需获得 CnewC_{new} 的多数。

惊群效应#

当节点断线或从集群中被删除后,会不断自增 Term, 重新连接后过高的 Term 会让原来的 Leader 退位并重新开始选举,这个过程中的损耗明显是没有意义且可避免的。这种情况叫做惊群效应。 此外,有时某些特别的网络分区状况也会因 Term 的不断自增发生更严重的问题,例如: 集群中有 a,b,c,d{a, b, c, d} 四个节点,其中 aa 为 Leader,节点 dd 无法与 a,b{a, b} 通信但与 c{c} 的通信正常。这种情况下,由于 dd 无法收到 Leader 的日志,永远不可能当选, 会自增 Term。此时 cc 节点会跟随 dd 更新自身的 Term,从而又导致 aa 节点下台,整个集群受 dd 的影响无法固定一个 Term 进行正常工作,进而导致系统完全不可用

PreVote#

为避免上述问题,etcd 和许多生产级 Raft 实现引入了 PreVote(预投票)机制。也就是每次 Candidate 发起选举时,不再自增 Term,而是尝试 Term + 1 进行一次预选举,这次选举不会改变任何节点的参数,避免离群节点的 Term 不断递增;并且,为了防止这个投票过程对正常节点的工作进行干扰,在至少一个基础选举超时时间范围内没有收到有效 Leader 的心跳的节点才能回复投票。如果能通过预选举,才自增 Term 并进行真正选举,缺点就是会拉长正常选主的时间,但由于 Leader 失效本就是低频事件,这一开销在大多数场景下可以接受。

活锁#

prevote在一些情况下会造成活锁问题。例如:集群中有 a,b,c,d{a, b, c, d} 四个节点,节点 aa 是原 Leader,但如果节点 bb, cc 无法与 aa 正常通信,会开始 PreVote 阶段,由于节点 dd 仍然能获取到来自 aa 的心跳,因此不会为 bb, cc 投票。整个系统进入完全不可用状态。

针对这个问题,可以引入 check quorum 方法。

check quorum#

如果 Leader 无法从半数节点中获取回复,则 Leader 要主动退位来打破这个僵局,这样保证了 Leader 永远和多数节点相连接,避免 Leader 被网络隔离时出现的问题。

何时进行快照?#

在 6.824 中,我们之间写死了一个阈值,当日志大小超过这个值以后就进行快照。 更好的处理方式是计算快照后的大小,如果远小于当前日志大小,那就说明值得进行快照,但这样需要频繁计算快照大小。所以,我们可以使用上次的快照大小直接对比,当快照大小超出日志大小*扩展因子后进行快照。

写入快照太慢?#

  • 并发进行:把快照的生成放在后台任务中

  • 写时复制:快照过程中冻结当前数据状态,允许主线程继续写入新的状态副本,避免因快照阻塞后续操作。

  • Scale-out 有钱加设备!

Raft 在工程中还有哪些优化?#

  • Batch:Leader 可以一次收集多个客户端请求,然后批发送给 Follower,降低网络传输开销。

  • Pipeline:Leader 给 Follower 发送了一批日志之后,它可以直接更新 nextIndex,并且立刻发送后面的日志,不需要等待 Follower 的返回。如果网络出现了错误,或者 Follower 返回错误,Leader 就回滚 nextIndex,然后重新发送日志。

  • 并行 append:Leader 可以先并行的将日志发送给 Follower的过程和日志落盘的过程可以并行进行,减少落盘开销带来的影响。

  • Multi-Raft:每台机器可以同时运行多个raft实例,机器之间组建多 Raft 组,客户端请求路由到不同的group上,从而实现多主读写,提高并发性能。

其他补充#

领导权主动转移#

有些情况下 Leader 需要主动下台或转移给其他节点

  1. 当前 Leader 不在接收请求
  2. 更新目标服务器的日志使其匹配最新日志
  3. 发送请求,让目标服务器先于其他服务器开始选举

删除当前 Leader#

要求 Leader 把自己从集群中删除,一种简单的实现就是上面讲的领导权主动转移方法,转移领导权后再让新 Leader 删除自己。

另一种方式是直接让 Leader 更改配置删除自己。提交 CnewC_{new} 条目,从配置中删除的领导者将会下台。之后,CnewC_{new} 中的节点进入正常选举流程,选出新的 Leader;不过,如果 Leader 在 CnewC_{new} 被提交前崩溃,有可能在恢复后再次当选 Leader,延迟配置生效。

请求路由#

在 6.824 中,Raft 应用客户端记录下之前访问过的 Leader 节点,每次发送新请求时首先尝试访问这个节点。而首次访问或记录的节点已不再是 Leader(也就是不知道谁是 Leader)的时候,需要随机选择服务器,遍历发起请求找到 Leader。

这里我们其实可以直接让服务器返回 Leader 的地址,避免客户端再次寻找 Leader 的网络开销。或者让服务端主动路由到 Leader 服务器。

高效查询只读命令#

6.824中,Raft 的读写操作都必须经过完整的日志提交流程,即:客户端请求 → Leader 生成日志 → 复制到多数 → commit → 应用到状态机 → 返回结果。但这显然对只读查询是不必要的浪费 —— 毕竟查询不会修改状态,不需要写入日志。那么,我们是否可以“绕过 Raft 日志”来加速读请求?关键问题是如何保证绕过日志的方式不会破坏线性一致性。

ReadIndex#

为了保证线性一致性,Leader 在处理只读请求前,执行如下步骤:

  1. 确保自己是合法 Leader:如果当前 Term 中还没有提交过任何条目,Leader 会先提交一个空操作。

  2. 记录当前 commitIndex 作为 readIndex。readIndex 代表状态机中可安全读取的最小索引。

  3. 发出新一轮的心跳,如果可以获取到多数节点的确认证明没有出现更高 Term 的新 Leader。

  4. 等待状态机应用到 readIndex。

  5. 最后,Leader 向其状态机发出查询,并向客户端回复结果。

    进一步优化:基于租约的只读查询#

上面的方法虽然避免了日志写入,但每批只读请求仍需一次网络交互。为了进一步降低延迟,可以使用租约(Lease)机制:

Leader 在 election timeout 内,假设自己不会被取代,于是可以在这段时间内“直接响应读请求”而无需发送心跳。

这种机制假设假设跨服务器的时钟漂移没超过界限。

以上!

参考资料#

  1. Consensus: Bridging Theory and Practice - Diego Ongaro

  2. TiKV 源码解析系列 - Raft 的优化

  3. 聊聊Raft的性能优化

  4. Raft KV 与 Snapshot

我遇见的 Raft 面试题与其背后的真实问题
https://yomi.moe/posts/raft-implementation/
作者
藍々
发布于
2025-06-16
许可协议
CC BY-NC-SA 4.0