
首先说一下 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 » …
这个过程中国,配置切换存在不可避免的状态不一致,我们称更改前后的配置分别为 和 。
动态变更的隐患
如果我们向有三个节点的集群 添加两个新节点 。在某一时刻,可能出现:
-
拥有 节点为
-
拥有 的节点为
这时候, 和 的节点个数均大于配置记录的半数,若此时发生选举,可能会导致两个独立子集都认为自己合法,造成脑裂。
单服务器方法
论文中提出了一种策略:每次只修改一个节点,确保 和 的多数始终重叠,从而规避脑裂风险。
新节点同步滞后带来的可用性问题
-
Leader 将新节点先添加为无投票权成员;
-
新节点开始接受日志复制,追上 Leader 的 lastIndex;
-
追上以后,领导者可能已经收到了新的日志条目,开始下一轮复制。轮持续时间随轮数缩短,最后一轮的持续时间少于选举超时时间;
-
新节点顺利完成“几轮”这样的同步过程后,就可以将新节点提权为正式节点;反之,则认为配置更改失败。
联合共识
论文提出了一种联合共识的方法来快速配置而不是逐个删减,这种方式有着更大的灵活性,但它引入了较高的复杂实现度。
核心思想:在配置从旧的 转换为新的 的过程中,Raft 会先进入一个中间状态 ,这个过渡配置要求新老配置节点都占大多数才算成功 commit。比如从 3 个服务器的集群更改为 9 个服务器的不同集群时,中间配置应用后继续 commit 既需要 的 3 个节点中的 2 个,也需要 的 9 个节点中的 5 个。
一旦联合配置稳定运行,可以将配置更新为 ,此后提交只需获得 的多数。
惊群效应
当节点断线或从集群中被删除后,会不断自增 Term, 重新连接后过高的 Term 会让原来的 Leader 退位并重新开始选举,这个过程中的损耗明显是没有意义且可避免的。这种情况叫做惊群效应。 此外,有时某些特别的网络分区状况也会因 Term 的不断自增发生更严重的问题,例如: 集群中有 四个节点,其中 为 Leader,节点 无法与 通信但与 的通信正常。这种情况下,由于 无法收到 Leader 的日志,永远不可能当选, 会自增 Term。此时 节点会跟随 更新自身的 Term,从而又导致 节点下台,整个集群受 的影响无法固定一个 Term 进行正常工作,进而导致系统完全不可用。
PreVote
为避免上述问题,etcd 和许多生产级 Raft 实现引入了 PreVote(预投票)机制。也就是每次 Candidate 发起选举时,不再自增 Term,而是尝试 Term + 1 进行一次预选举,这次选举不会改变任何节点的参数,避免离群节点的 Term 不断递增;并且,为了防止这个投票过程对正常节点的工作进行干扰,在至少一个基础选举超时时间范围内没有收到有效 Leader 的心跳的节点才能回复投票。如果能通过预选举,才自增 Term 并进行真正选举,缺点就是会拉长正常选主的时间,但由于 Leader 失效本就是低频事件,这一开销在大多数场景下可以接受。
活锁
prevote在一些情况下会造成活锁问题。例如:集群中有 四个节点,节点 是原 Leader,但如果节点 , 无法与 正常通信,会开始 PreVote 阶段,由于节点 仍然能获取到来自 的心跳,因此不会为 , 投票。整个系统进入完全不可用状态。
针对这个问题,可以引入 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 需要主动下台或转移给其他节点
- 当前 Leader 不在接收请求
- 更新目标服务器的日志使其匹配最新日志
- 发送请求,让目标服务器先于其他服务器开始选举
删除当前 Leader
要求 Leader 把自己从集群中删除,一种简单的实现就是上面讲的领导权主动转移方法,转移领导权后再让新 Leader 删除自己。
另一种方式是直接让 Leader 更改配置删除自己。提交 条目,从配置中删除的领导者将会下台。之后, 中的节点进入正常选举流程,选出新的 Leader;不过,如果 Leader 在 被提交前崩溃,有可能在恢复后再次当选 Leader,延迟配置生效。
请求路由
在 6.824 中,Raft 应用客户端记录下之前访问过的 Leader 节点,每次发送新请求时首先尝试访问这个节点。而首次访问或记录的节点已不再是 Leader(也就是不知道谁是 Leader)的时候,需要随机选择服务器,遍历发起请求找到 Leader。
这里我们其实可以直接让服务器返回 Leader 的地址,避免客户端再次寻找 Leader 的网络开销。或者让服务端主动路由到 Leader 服务器。
高效查询只读命令
6.824中,Raft 的读写操作都必须经过完整的日志提交流程,即:客户端请求 → Leader 生成日志 → 复制到多数 → commit → 应用到状态机 → 返回结果。但这显然对只读查询是不必要的浪费 —— 毕竟查询不会修改状态,不需要写入日志。那么,我们是否可以“绕过 Raft 日志”来加速读请求?关键问题是如何保证绕过日志的方式不会破坏线性一致性。
ReadIndex
为了保证线性一致性,Leader 在处理只读请求前,执行如下步骤:
-
确保自己是合法 Leader:如果当前 Term 中还没有提交过任何条目,Leader 会先提交一个空操作。
-
记录当前 commitIndex 作为 readIndex。readIndex 代表状态机中可安全读取的最小索引。
-
发出新一轮的心跳,如果可以获取到多数节点的确认证明没有出现更高 Term 的新 Leader。
-
等待状态机应用到 readIndex。
-
最后,Leader 向其状态机发出查询,并向客户端回复结果。
进一步优化:基于租约的只读查询
上面的方法虽然避免了日志写入,但每批只读请求仍需一次网络交互。为了进一步降低延迟,可以使用租约(Lease)机制:
Leader 在 election timeout 内,假设自己不会被取代,于是可以在这段时间内“直接响应读请求”而无需发送心跳。
这种机制假设假设跨服务器的时钟漂移没超过界限。
以上!