前言
本文将从 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)。
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.go
和 log_unstable.go
两个文件中,主要的结构体为 raftLog
和 unstable
,unstable
同时也是是 raftLog
的一个字段。
- raftLog 负责 Raft Log 的主要逻辑,
raftLog
可以通过提供给用户的Storage
接口来获取节点中的日志存储情况; - unstable 如其名字所示,包含了那些还没有持久化的日志条目,也就是还没有提交的日志(uncommitted);
etcd raft 通过 raftLog
和 unstable
的配合来对算法中的日志进行管理。
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 个日志条目正在被持久化,则情况如图所示:
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
中使用offset
和offsetInProgress
的原因就是因为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 来说,只需要关注 Entries
和 CommittedEntries
两个字段:
- 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
结构体,以及传递后应该做什么。
我将这个过程的涉及到的主要方法流程总结成下面的图,注意这里只代表方法大致的调用顺序:
可以看到整个过程是一个循环,我们先在这里将这些方法的大致作用进行梳理,在后面的写流程处理流程分析部分再来深入这些方法对 raftLog
和 unstable
核心字段的操作。
- 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 会把这些日志条目作为 Ready
的 CommittedEntries
返回给我们,然后我们就可以把这些日志条目应用到状态机,调用 Advance
方法后,etcd raft 就会把这些条目标记为已提交(applied)。
etcd raft 界定已应用(applied)的时间点也在其内部,用户只需要把 Ready
中已提交(committed)的条目应用到状态机即可。
另外一个容易被忽略的点是:在 Raft 中只有 Leader 才能 commit,但是所有人都能 apply。
一次写请求的处理流程
在这里我们将通过分析 etcd raft 处理一次写请求的流程来将之前的所有的内容串联起来。
初始状态
为了讨论更一般的情况,我们将从已经提交并应用了三个日志条目的 Raft Log 开始。
如图所示,绿色的代表 raftLog
的字段以及 Storage
中日志条目的存储情况,红色代表 unstable
的字段以及 entries
中存储的未持久化的日志条目。
由于我们已经提交并应用了三个日志条目,所以 committed
和 applied
都为 3,applying
的值是上一次应用中的最高日志条目的索引,这里也是 3。
现在我们还没有发起请求,所以 unstable.entries
为空,Raft Log 的下一个日志索引为 4,所以这里 offset
为 4,没有持久化中的日志条目,所以 offsetInProgress
也为 4。
发起请求
现在我们发起请求,想要为 Raft Log append 两个日志条目。
如图所示,追加的日志条目已经存储进 unstable.entries
中,这一步并不会对核心字段记录的索引值进行任何修改。
HasReady
还记得 HasReady
方法吗,HasReady
判断存在未持久化的日志条目后,返回 true
。
判断是否存在未持久化的日志条目的逻辑是 unstable.entries[offsetInProgress-offset:]
的长度是否大于 0,显然在我们的情况下
len(unstable.entries[4-4:]) == 2
也就是存在两个未持久化的日志条目,HasReady
因此返回 true
。
readyWithoutAccept
readyWithoutAccept
的作用是创建返回给用户的 Ready
。这里我们有两个为持久化的日志条目,所以 readyWithoutAccept
会在返回的 Ready
的 Entries
字段包含这两个日志条目。
acceptReady
acceptReady
在 Ready
传递给用户后被调用。
acceptReady
将正在进行持久化的日志条目索引修改为 6,即索引 [4, 6)
范围内的日志条目正在进行持久化。
Advance
用户将 Ready
中的 Entries
持久化后调用 Node.Advance
通知 etcd raft,然后 etcd raft 就可以执行 acceptReady
中创建的 “回调” 了。
这个 “回调” 会将清空 unstable.entries
中已经持久化了的日志条目,然后将 offset
设置为 Storage.LastIndex+1
也就是 6。
提交日志条目
我们这里假设这两个日志条目已经被 Raft 集群中的大多数节点持久化了,我们就可以将这两个日志条目标记为已提交(committed)了。
HasReady
这里继续我们的循环,HasReady
判断存在提交了(committed)但未应用的日志条目,所以返回 true
。
readyWithoutAccept
readyWithoutAccept
返回一个 Ready
,包含了已经提交(committed)但是没有应用到状态机的日志条目(4,5)。
这个条目是通过 low, high := applying+1, committed+1
计算出来的,左开右闭。
acceptReady
acceptReady
会将 Ready
中返回的日志条目[4, 5]
标记为正在应用到状态机。
Advance
etcd raft 在用户调用 Node.Advance
后执行 “回调”,更新 applied
为 5,表明索引为 5 以及之前的日志条目都被状态机应用了(applied)。
最终状态
至此一个写请求的处理流程结束,最终状态如下所示,你可以与初始状态进行对照。
总结
我们从 Raft Log 的概述开始,了解到 Raft Log 的基本概念,然后对 etcd raft 的实现有了一个初步了了解,继续深入到 etcd raft 中 Raft Log 的实现的各个核心模块和需要注意思考的问题,最终通过一个完整的写请求流程分析来将所有的内容串联起来。
希望这种方式可以帮助你深入浅出 etcd raft 的实现,并对 Raft Log 有自己的理解。
以上就是本篇文章的所有内容了,如果哪里写错了或者有任何问题,欢迎私聊或评论指出,以上。
BTW,raft-foiver 是我仿照 etcd raft 实现的一个简化版,保留了 Raft 中的所有核心逻辑并根据论文中的流程进行了优化,之后我也会出一篇单独的文章对这个库进行介绍,如果你感兴趣,欢迎 Star, Fork, PR!