从 Raft Log 深入浅出 etcd raft 实现

44 阅读12分钟

前言

本文将从 Raft 一致性算法中的日志出发,对 etcd raft 中 Raft Log 模块的设计与实现进行介绍与分析,希望帮助读者更好的理解 etcd raft 的实现并在实现类似的场景方面提供一种思路。

Raft Log 概述

Raft 一致性算法本质上是一个复制状态机(replicated state machine),算法的目标是在服务器集群中以相同的方式复制一系列日志,通过这些日志让集群中的服务器达成一致。

这里的日志就是 Raft Log。集群中的每个节点都有属于自己的 Raft Log,Raft Log 由一系列日志条目组成,一个日志条目通常由拥有三个字段:

  • Index: 日志条目的索引
  • Term: 创建日志条目时 Leader(集群)的任期
  • 数据: 日志条目所包含的数据,可能是具体的命令等

需要注意的是,Raft Log 的索引是从 1 开始的,Raft Log 只能由 Leader 节点创建并复制给 Follower 节点。

当一个日志条目被持久化存储到集群中大多数(e.g. 2/3, 3/5, 4/7)节点上时,我们认为这个日志条目已提交(committed)

当一个日志条目被应用到状态机时,我们认为这个日志条目已应用(applied)

image-20241029213545928.png

etcd raft 实现概述

etcd raft 是一个使用 go 语言编写的 Raft 算法库,被广泛用于 etcd,Kubernetes,CockroachDB 等系统中。

etcd raft 最主要的特点是他只实现了 Raft 算法最核心的部分,Raft 算法过程中涉及到的网络传输,磁盘存储等都需要用户来自己实现(不过 etcd 提供了默认的实现)。

与 etcd raft 库交互的方式类似于,它会告诉你哪些数据需要持久化,哪些消息需要发送给其他节点,而你只需要自己来完成这些存储和网络传输过程并告诉它即可,它不关心你实现这些操作的细节,它只负责处理你提交给它的数据并根据 Raft 算法来告诉你下一步需要做什么。

同时在 etcd raft 的代码实现中,它很好的将这种交互方式和 go 语言中特有的 channel 结合起来,这让 etcd raft 库变的独一无二。

如何实现 Raft Log

log 与 log_unstable

在 etcd raft 中 Raft Log 的主要实现在 log.golog_unstable.go 两个文件中,主要的结构体为 raftLogunstableunstable 同时也是是 raftLog 的一个字段。

  • raftLog 负责 Raft Log 的主要逻辑,raftLog 可以通过提供给用户的 Storage 接口来获取节点中的日志存储情况;
  • unstable 如其名字所示,包含了那些还没有持久化的日志条目,也就是还没有提交的日志(uncommitted);

etcd raft 通过 raftLogunstable 的配合来对算法中的日志进行管理。

raftLog 和 unstable 的核心字段

为了简化整个过程,我们在这篇文章中只考虑日志条目的处理逻辑,不考虑 etcd raft 中的快照处理。

type raftLog struct {
	storage Storage
	unstable unstable
	committed uint64
	applying uint64
	applied uint64
}

raftLog 的核心字段:

  • storage: 提供给用户实现的存储接口,用来获取已经持久化的日志条目;
  • unstable: 未持久化的日志,例如当 Leader 接收到来自客户端的请求,Leader 会创建一条他的 Term 的日志条目并 append 到 unstable 日志中;
  • committed: 论文中的 commitIndedx,已知的最后一个已提交(comitted)的日志条目的 Index;
  • applying: 正在应用的最高的日志 Index;
  • applied: 论文中的 lastApplied,已经被状态机应用最高的日志 Index;
type unstable struct {
	entries []pb.Entry
	offset uint64
	offsetInProgress uint64
}

unstable 的核心字段:

  • entries: 未持久化的日志条目,以切片的形式保存在内存中;
  • offset: 用于将 entries 中的日志条目映射到 Raft Log 中,entries[i] = Raft Log[i+offset]
  • offsetInProgress: 用于指示正在持久化中的 entries,持久化中的条目为 entries[:offsetInProgress-offset]

raftLog 中的这些核心字段都很好理解,很容易得将其与论文中的实现联系起来,但 unstable 中的这些字段可能还比较抽象,希望通过下面这个例子可以帮助你理解。

假设我们的 Raft Log 中已经持久化了 5 个日志条目了,现在我们有 3 个日志条目存储在 unstable 中,并且这 3 个日志条目正在被持久化,则情况如图所示:

image-20241103001949263.png

offset=6 代表位于 unstable.entries 切片 0,1,2 位置的日志条目对应的是实际 Raft Log 中 6(0+6),7(1+6),8(1+2)的位置,通过 offsetInProgress=9 我们就可以知道 unstable.entries[:9-6] 也就是 0,1,2 三个日志条目都正在被持久化。

所以需要在 unstable 中使用 offsetoffsetInProgress 的原因就是因为 unstable 并不保存所有的 Raft Log 日志条目。

什么时候交互

由于我们只考虑 Raft Log 的处理逻辑,所以这里的什么时候交互指的是 etcd raft 会在什么时候将这些需要进行持久化的日志条目传递给用户。

用户侧

etcd raft 与用户的交互基本使用的是 Node 接口的方法,其中 Ready 方法返回一个用于从 etcd raft 接收数据或者说接收 etcd raft 指令的 channel。

type Node interface {
    ...
    Ready() <-chan Ready
    ...
}

从 channel 中接收到的 Ready 结构体中包含了我们需要进行操作的日志条目,需要发送到其他节点的消息,当前节点的状态等。

对于我们这里讨论的 Raft Log 来说,只需要关注 EntriesCommittedEntries 两个字段:

  • Entries: 需要我们进行持久化的日志条目,持久化之后的条目就可以通过 Storage 接口的方法获取到;
  • CommittedEntries: 需要我们提交到状态机的日志条目,当我们提交成功后,这些日志也将被视为已经应用(applied)到状态机了;
type Ready struct {
	*SoftState
	pb.HardState
	ReadStates []ReadState
	Entries []pb.Entry // persist it
	Snapshot pb.Snapshot
	CommittedEntries []pb.Entry // commit it
	Messages []pb.Message
	MustSync bool
}

在我们处理完日志,消息等 etcd raft 通过 Ready 传递的数据后,我们就可以通过调用 Node 接口的 Advance 方法来告知 etcd raft 我们已经完成了他的所有命令可以进行接收并处理下一个 Ready 了。

etcd raft 提供了 AsyncStorageWrites 选项,开启后可以一定程度上提高节点的性能,但是我们在这里不考虑这个选项。

etcd raft 侧

用户侧需要考虑的事情是如何处理接收的 Ready 结构体中的数据,而在 etcd raft 侧需要考虑的事情则是什么时候传递给用户一个 Ready 结构体,以及传递后应该做什么。

我将这个过程的涉及到的主要方法流程总结成下面的图,注意这里只代表方法大致的调用顺序:

image-20241103231625237.png

可以看到整个过程是一个循环,我们先在这里将这些方法的大致作用进行梳理,在后面的写流程处理流程分析部分再来深入这些方法对 raftLogunstable 核心字段的操作。

  • HasReady: 如其名字所示,判断是否存在需要传递给用户的 Ready,例如只要在 unstable 中存在未持久化的并且不在持久化过程中的日志条目 HasReady 就会返回 true
  • readyWithoutAccept: HasReady 返回 true 后被调用,创建返回给用户的 Ready,包括需要持久化的日志条目,标记为已提交(committed)的日志条目;
  • acceptReady: etcd raft 把 readyWithoutAccept 创建的 Ready 传递给用户后被调用,把在 Ready 中返回给用户的日志条目和已提交条目标记为正在进行持久化和正在应用,并创建用户调用 Node.Advance 后被调用的 “回调”,用于把日志条目标记为已经持久化和已应用(applied)。
  • Advance: 用于在用户调用 Node.Advance 后执行 acceptReady 中创建的 “回调”。

如何界定 committed 和 applied

在这里一共有两点需要注意:

1. 已经持久化 != 已提交(committed)

正如我们在一开始定义的那样,界定已提交(committed)的条件是被 Raft 集群中的大多数节点都持久化了。所以即使我们把 etcd raft 通过 Ready 返回给我们的 Entries 持久化了,这些条目也不能被标记为已提交(committed)。

不过在我们调用 Advance 方法告诉 etcd raft 我们已经完成了持久化的命令后,etcd raft 会结合集群中其他节点的日志持久化的情况来将一些日志标记为已提交(committed),然后通过 Ready 结构体的 CommittedEntries 字段传递给我们,让我们将这些已提交的日志条目应用到状态机。

所以使用 etcd raft 时,界定已提交(committed)的时间点是在其内部,用户只需要完成持久化的前提条件即可。

内部提交的方式是调用 raftLog.commitTo 方法,这个方法会更新 raftLog.committed,也就是论文中的 commitIndex

2. 已提交(committed)!= 已应用(applied)

在 etcd raft 内部调用 raftLog.commitTo 方法后,raft.committed 这个索引以及之前的日志条目就被认为是已提交(committed)了,但是索引为 lastApplied < index <= committedIndex 的日志条目还没有被应用到状态机,etcd raft 会把这些日志条目作为 ReadyCommittedEntries 返回给我们,然后我们就可以把这些日志条目应用到状态机,调用 Advance 方法后,etcd raft 就会把这些条目标记为已提交(applied)。

etcd raft 界定已应用(applied)的时间点也在其内部,用户只需要把 Ready 中已提交(committed)的条目应用到状态机即可。

另外一个容易被忽略的点是:在 Raft 中只有 Leader 才能 commit,但是所有人都能 apply。

一次写请求的处理流程

在这里我们将通过分析 etcd raft 处理一次写请求的流程来将之前的所有的内容串联起来。

初始状态

为了讨论更一般的情况,我们将从已经提交并应用了三个日志条目的 Raft Log 开始。

image-20241108154706243.png

如图所示,绿色的代表 raftLog 的字段以及 Storage 中日志条目的存储情况,红色代表 unstable 的字段以及 entries 中存储的未持久化的日志条目。

由于我们已经提交并应用了三个日志条目,所以 committedapplied 都为 3,applying 的值是上一次应用中的最高日志条目的索引,这里也是 3。

现在我们还没有发起请求,所以 unstable.entries 为空,Raft Log 的下一个日志索引为 4,所以这里 offset 为 4,没有持久化中的日志条目,所以 offsetInProgress 也为 4。

发起请求

现在我们发起请求,想要为 Raft Log append 两个日志条目。

image-20241108155806702.png

如图所示,追加的日志条目已经存储进 unstable.entries 中,这一步并不会对核心字段记录的索引值进行任何修改。

HasReady

还记得 HasReady 方法吗,HasReady 判断存在未持久化的日志条目后,返回 true

判断是否存在未持久化的日志条目的逻辑是 unstable.entries[offsetInProgress-offset:] 的长度是否大于 0,显然在我们的情况下

len(unstable.entries[4-4:]) == 2

也就是存在两个未持久化的日志条目,HasReady 因此返回 true

image-20241108161148816.png

readyWithoutAccept

readyWithoutAccept 的作用是创建返回给用户的 Ready。这里我们有两个为持久化的日志条目,所以 readyWithoutAccept 会在返回的 ReadyEntries 字段包含这两个日志条目。

image-20241108161603388.png

acceptReady

acceptReadyReady 传递给用户后被调用。

image-20241108162518021.png

acceptReady 将正在进行持久化的日志条目索引修改为 6,即索引 [4, 6) 范围内的日志条目正在进行持久化。

Advance

用户将 Ready 中的 Entries 持久化后调用 Node.Advance 通知 etcd raft,然后 etcd raft 就可以执行 acceptReady 中创建的 “回调” 了。

image-20241108163525597.png

这个 “回调” 会将清空 unstable.entries 中已经持久化了的日志条目,然后将 offset 设置为 Storage.LastIndex+1 也就是 6。

提交日志条目

我们这里假设这两个日志条目已经被 Raft 集群中的大多数节点持久化了,我们就可以将这两个日志条目标记为已提交(committed)了。

image-20241108164622285.png

HasReady

这里继续我们的循环,HasReady 判断存在提交了(committed)但未应用的日志条目,所以返回 true

image-20241108165300528.png

readyWithoutAccept

readyWithoutAccept 返回一个 Ready,包含了已经提交(committed)但是没有应用到状态机的日志条目(4,5)。

这个条目是通过 low, high := applying+1, committed+1 计算出来的,左开右闭。

image-20241108170020863.png

acceptReady

acceptReady 会将 Ready 中返回的日志条目[4, 5]标记为正在应用到状态机。

image-20241108170832791.png

Advance

etcd raft 在用户调用 Node.Advance 后执行 “回调”,更新 applied 为 5,表明索引为 5 以及之前的日志条目都被状态机应用了(applied)。

image-20241108171505102.png

最终状态

至此一个写请求的处理流程结束,最终状态如下所示,你可以与初始状态进行对照。

image-20241108171818483.png

总结

我们从 Raft Log 的概述开始,了解到 Raft Log 的基本概念,然后对 etcd raft 的实现有了一个初步了了解,继续深入到 etcd raft 中 Raft Log 的实现的各个核心模块和需要注意思考的问题,最终通过一个完整的写请求流程分析来将所有的内容串联起来。

希望这种方式可以帮助你深入浅出 etcd raft 的实现,并对 Raft Log 有自己的理解。

以上就是本篇文章的所有内容了,如果哪里写错了或者有任何问题,欢迎私聊或评论指出,以上。

BTW,raft-foiver 是我仿照 etcd raft 实现的一个简化版,保留了 Raft 中的所有核心逻辑并根据论文中的流程进行了优化,之后我也会出一篇单独的文章对这个库进行介绍,如果你感兴趣,欢迎 Star, Fork, PR!

参考列表