本文将从Raft共识算法中的日志开始,介绍和分析etcd的Raft中Raft Log模块的设计和实现。目的是帮助读者更好地理解etcd的Raft实现,并为实现类似场景提供一种可能的方法。
Raft 共识算法本质上是一个复制状态机,其目标是在服务器集群中以相同的方式复制一系列日志。这些日志使集群中的服务器能够达到一致的状态。
在这种情况下,日志指的是Raft Log。集群中的每个节点都有自己的Raft Log,Raft Log由一系列日志条目组成。日志条目通常包含三个字段:
需要注意的是,Raft Log 的索引从 1 开始,只有 Leader 节点才能创建 Raft Log 并将其复制到 Follower 节点。
当日志条目持久存储在集群中的大多数节点(例如 2/3、3/5、4/7)上时,它被视为已提交。
当日志条目应用于状态机时,它被视为已应用。
etcd raft 是一个用 Go 编写的 Raft 算法库,广泛应用于 etcd、Kubernetes、CockroachDB 等系统。
etcd raft 的首要特点是它只实现了 Raft 算法的核心部分。用户必须自己实现网络传输、磁盘存储以及Raft流程中涉及的其他组件(尽管etcd提供了默认实现)。
与 etcd raft 库的交互有些简单:它告诉您哪些数据需要持久化以及哪些消息需要发送到其他节点。您的责任是处理存储和网络传输过程并相应地通知它。它不关心如何实现这些操作的细节;它只是处理您提交的数据,并根据 Raft 算法告诉您接下来的步骤。
在etcd raft的代码实现中,这种交互模型与Go独特的通道特性无缝结合,使得etcd raft库真正与众不同。
在etcd raft中,Raft Log的主要实现位于log.go和log_unstable.go文件中,主要结构是raftLog和unstable。不稳定结构也是 raftLog 中的一个领域。
etcd raft通过协调raftLog和unstable来管理算法内的日志。
为了简化讨论,本文将仅关注日志条目的处理逻辑,而不涉及 etcd raft 中的快照处理。
type raftLog struct { storage Storage unstable unstable committed uint64 applying uint64 applied uint64 }
raftLog的核心字段:
type unstable struct { entries []pb.Entry offset uint64 offsetInProgress uint64 }
unstable 的核心领域:
raftLog 中的核心字段很简单,可以很容易地与 Raft 论文中的实现相关联。然而,unstable 中的字段可能看起来更抽象。以下示例旨在帮助阐明这些概念。
假设我们的 Raft 日志中已经保存了 5 个日志条目。现在,我们在unstable中存储了3个日志条目,并且这3个日志条目当前正在被持久化。情况如下图:
offset=6表示unstable.entries中位置0、1、2的日志条目分别对应实际Raft Log中的位置6(0 6)、7(1 6)、8(2 6)。当offsetInProgress=9时,我们知道unstable.entries[:9-6],包括位置0、1和2的三个日志条目,都被持久化了。
在unstable中使用offset和offsetInProgress的原因是unstable并没有存储所有的Raft Log条目。
由于我们只关注 Raft 日志处理逻辑,所以这里的“何时交互”是指 etcd raft 何时传递需要用户持久化的日志条目。
etcd raft 主要通过 Node 接口中的方法与用户交互。 Ready 方法返回一个通道,允许用户从 etcd raft 接收数据或指令。
type raftLog struct { storage Storage unstable unstable committed uint64 applying uint64 applied uint64 }
从此通道接收到的 Ready 结构包含需要处理的日志条目、应发送到其他节点的消息、节点的当前状态等等。
对于Raft Log的讨论,我们只需要关注Entries和ComfilledEntries字段:
type unstable struct { entries []pb.Entry offset uint64 offsetInProgress uint64 }
处理完Ready传递过来的日志、消息等数据后,我们可以调用Node接口中的Advance方法来通知etcd raft我们已经完成了它的指令,让它可以接收并处理下一个Ready。
etcd raft 提供了 AsyncStorageWrites 选项,可以在一定程度上增强节点性能。然而,我们在这里不考虑这个选项。
在用户端,重点是处理接收到的 Ready 结构体中的数据。在 etcd raft 方面,重点是确定何时将 Ready 结构传递给用户以及之后要采取的操作。
我在下图中总结了这个过程中涉及到的主要方法,图中展示了方法调用的大致顺序(注意,这仅代表大概的调用顺序):
可以看到整个过程是一个循环。这里我们先概述一下这些方法的大致功能,在后续的写流分析中,我们会深入研究这些方法是如何作用于raftLog和unstable等核心字段的。
这里有两点需要考虑:
1。坚持≠承诺
正如最初所定义的,只有当日志条目被 Raft 集群中的大多数节点持久化时,该日志条目才被视为已提交。因此,即使我们通过 Ready 持久化 etcd raft 返回的条目,这些条目仍然无法被标记为已提交。
但是,当我们调用 Advance 方法通知 etcd raft 我们已经完成持久化步骤时,etcd raft 将评估集群中其他节点的持久化状态,并将一些日志条目标记为已提交。然后,这些条目通过 Ready 结构的 CommiedEntries 字段提供给我们,以便我们可以将它们应用到状态机。
因此,在使用 etcd raft 时,将条目标记为已提交的时间由内部管理,用户只需满足持久性先决条件即可。
内部通过调用 raftLog.commitTo 方法实现承诺,该方法会更新 raftLog.comfilled,对应 Raft 论文中的 commitIndex。
2。承诺≠应用
在 etcd raft 中调用 raftLog.commitTo 方法后,直到 raft.comfilled 索引的日志条目都被视为已提交。然而,索引在lastApplied
将条目标记为已应用的时间也在 etcd raft 内部处理;用户只需要将Ready中提交的条目应用到状态机即可。
另一个微妙之处是,在 Raft 中,只有 Leader 可以提交条目,但所有节点都可以应用它们。
在这里,我们将通过分析 etcd raft 处理写入请求时的流程来连接之前讨论的所有概念。
为了讨论更一般的场景,我们将从一个 已经提交并应用了三个日志条目的 Raft 日志开始。
图中,绿色代表raftLog字段和存储在Storage中的日志条目,而红色代表不稳定字段和存储在entry中的未持久化日志条目。
由于我们已经提交并应用了三个日志条目,因此已提交和已应用的日志条目都设置为 3。applying 字段保存前一个应用程序的最高日志条目的索引,在本例中也是 3。
此时,还没有发起任何请求,因此unstable.entries为空。 Raft Log 中的下一个日志索引是 4,偏移量为 4。由于当前没有日志被持久化,所以 offsetInProgress 也设置为 4。
现在,我们发起一个请求,将两个日志条目追加到 Raft Log 中。
如图所示,附加的日志条目存储在unstable.entries中。在此阶段,核心字段中记录的索引值不会发生任何更改。
还记得HasReady方法吗? HasReady 检查是否有未持久化的日志条目,如果有,则返回 true。
判断是否存在未持久化日志条目的逻辑是基于unstable.entries[offsetInProgress-offset:]的长度是否大于0。显然,在我们的例子中:
type raftLog struct { storage Storage unstable unstable committed uint64 applying uint64 applied uint64 }
表示有两个未持久化的日志条目,因此 HasReady 返回 true。
readyWithoutAccept 的目的是创建要返回给用户的 Ready 结构体。由于我们有两个未持久化的日志条目,readyWithoutAccept 会将这两个日志条目包含在返回的 Ready 的 Entries 字段中。
acceptReady 在 Ready 结构传递给用户后被调用。
acceptReady 将正在持久化的日志条目的索引更新为 6,这意味着 [4, 6) 范围内的日志条目现在被标记为正在持久化。
用户将Entries持久化为Ready后,调用Node.Advance来通知etcd raft。然后,etcd raft 就可以执行在acceptReady 中创建的“回调”了。
这个“回调”会清除unstable.entries中已经持久化的日志条目,然后将偏移量设置为Storage.LastIndex 1,即6。
我们假设这两个日志条目已经被 Raft 集群中的大多数节点持久化,因此我们可以将这两个日志条目标记为已提交。
继续我们的循环,HasReady 检测到是否存在已提交但尚未应用的日志条目,因此返回 true。
readyWithoutAccept 返回一个 Ready,其中包含已提交但尚未应用于状态机的日志条目 (4, 5)。
这些条目的计算方式为:低、高:= 在左开、右闭区间内应用 1、提交 1。
acceptReady 然后将 Ready 中返回的日志条目 [4, 5] 标记为已应用于状态机。
用户调用 Node.Advance 后,etcd raft 执行“回调”并将更新应用于 5,表明索引 5 及之前的日志条目已全部应用于状态机。
这样就完成了写请求的处理流程。最终状态如下图,可以和初始状态进行对比。
我们首先概述了 Raft Log,了解其基本概念,然后初步了解了 etcd raft 实现。然后我们深入研究了 etcd raft 中 Raft Log 的核心模块,并考虑了重要的问题。最后,我们通过对写入请求流的完整分析将所有内容联系在一起。
我希望这种方法可以帮助您清楚地了解 etcd Raft 实现,并形成您自己对 Raft Log 的见解。
本文到此结束。如有错误或疑问,欢迎私信或留言。
顺便说一句,raft-foiver 是我实现的 etcd raft 的简化版本,保留了 Raft 的所有核心逻辑,并根据 Raft 论文中的流程进行了优化。以后我会单独发一篇文章介绍这个库。如果您有兴趣,请随时 Star、Fork 或 PR!
以上是了解 etcd 的 Raft 实现:深入研究 Raft 日志的详细内容。更多信息请关注PHP中文网其他相关文章!