etcd 的核心术语
- Raft:etcd 所采用的保证分布式系统数据强一致性的算法。
- Node:一个 Raft 状态机实例。
- Member:一个 etcd 实例,它管理着一个 Node,并且可以为客户端请求提供服务。
- Cluster:由多个 Member 构成可以协同工作的 etcd 集群。
- Peer:对同一个 etcd 集群中另外一个 Member 的称呼。
- Client:向 etcd 集群发送 HTTP 请求的客户端。
- WAL:预写式日志,etcd 用于持久化存储的日志格式。
- Snapshot:etcd 防止 WAL 文件过多而设置的快照,存储 etcd 数据状态。
- Entry:Raft 算法中的日志的一个条目。
- Proxy:etcd 的一种模式,为 etcd 集群提供反向代理服务。
- Leader:Raft 算法中通过竞选而产生的处理所有数据提交的节点。
- Follower:Raft 算法中竞选失败的节点作为从属节点,为算法提供强一致性保证。
- Candidate:当 Follower 超过一定时间接收不到 Leader 的心跳时(认为 Leader 发生了故障)转变为 Candidate 开始竞选。
- Term:某个节点成为 Leader 到下一次竞选时间,称为一个 Term。
- Vote:选举时的一张投票。
- Index:数据项编号,Raft 中通过 Term 和 Index 来定位数据。
- Commit:一个提交,持久化数据写入到日志中。
- Propose:一个提议,请求大部分 Node 同意数据写入。
etcd Server 采用树形的结构来组织储存数据,类似 Linux 的文件系统,也有目录和文件的分层结构,不过一般被称为 nodes。
例如:用户指定的 key 可以为单独的名字,如:testkey,此时 key testkey 实际上存放在根目录 “/” 下面。也可以为指定目录结构,如:/testdir/testkey,则将创建相应的目录结构。
etcdctl set /testdir/testkey "Hello world"
-
HTTP Server:接受客户端发出的 API 请求以及其它 etcd 节点的同步与心跳信息请求。
-
Store:用于处理 etcd 支持的各类功能的事务,包括数据索引、节点状态变更、监控与反馈、事件处理与执行等等,是 etcd 对用户提供的大多数 API 功能的具体实现。
-
Raft:强一致性算法的具体实现,是 etcd 的核心算法。
-
WAL(Write Ahead Log,预写式日志):是 etcd 的数据存储方式,etcd 会在内存中储存所有数据的状态以及节点的索引,此外,etcd 还会通过 WAL 进行持久化存储。WAL 中,所有的数据提交前都会事先记录日志。
- Snapshot 是为了防止数据过多而进行的状态快照;
- Entry 表示存储的具体日志内容。
通常,一个用户的请求发送过来,会经由 HTTP Server 转发给 Store 进行具体的事务处理,如果涉及到节点数据的修改,则交给 Raft 模块进行状态的变更、日志的记录,然后再同步给别的 etcd 节点以确认数据提交,最后进行数据的提交,再次同步。
Raft
新版本的 etcd 实现,Raft 包就是 Raft 一致性算法的具体实现。
Raft 中一个 Term(任期)是什么意思?
Raft 算法中,从时间上,一个 Term(任期)即从一次竞选开始到下一次竞选开始之间。从功能上讲,如果 Follower 接收不到 Leader 的心跳信息,就会结束当前 Term,变为 Candidate 继而发起竞选,继而帮助 Leader 故障时集群的恢复。发起竞选投票时,Term Value 小的 Node 不会竞选成功。如果 Cluster 不出现故障,那么一个 Term 将无限延续下去。另外,投票出现冲突也有可能直接进入下一任再次竞选。
Raft 状态机是怎样切换的?
Raft 刚开始运行时,Node 默认进入 Follower 状态,等待 Leader 发来心跳信息。若等待超时,则状态由 Follower 切换到 Candidate 进入下一轮 Term 发起竞选,等到收到 Cluster 的 “多数节点” 的投票时,该 Node 转变为 Leader。Leader 有可能出现网络等故障,导致别的 Nodes 发起投票成为新 Term 的 Leader,此时原先的 Old Leader 会切换为 Follower。Candidate 在等待其它 Nodes 投票的过程中如果发现已经竞选成功了一个 Leader,那么也会切换为 Follower。
如何保证最短时间内竞选出 Leader,防止竞选冲突?
在 Raft 状态机一图中可以看到,在 Candidate 状态下, 有一个 times out,这里的 times out 时间是个随机值,也就是说,每个 Node 成为 Candidate 以后,times out 发起新一轮竞选的时间是各不相同的,这就会出现一个时间差。在时间差内,如果 Candidate1 收到的竞选信息比自己发起的竞选信息 Term Value 大(即对方为新一轮 Term),并且在新一轮想要成为 Leader 的 Candidate2 包含了所有提交的数据,那么 Candidate1 就会投票给 Candidate2。这样就保证了只有很小的概率会出现竞选冲突。
如何防止别的 Candidate 在遗漏部分数据的情况下发起投票成为 Leader?
Raft 竞选的机制中,使用随机值决定 times out,第一个超时的 Node 就会提升 Term 编号发起新一轮投票,一般情况下别的 Node 收到竞选通知就会投票。但是,如果发起竞选的 Node 在上一个 Term 中保存的已提交数据不完整,Node 就会拒绝投票给它。通过这种机制就可以防止遗漏数据的 Node 成为 Leader。
Raft 某个节点宕机后会如何?
通常情况下,如果是 Follower 宕机,如果剩余可用节点数量超过半数,Cluster 可以几乎没有影响的正常工作。如果宕机的是 Leader,那么 Follower 就收不到心跳而超时,发起竞选获得投票,成为新一轮 Term 的 Leader,继续为 Cluster 提供服务。
需要注意的是:etcd 目前没有任何机制会自动去变化整个 Cluster 的 Instances(总节点数量),即:如果没有人为的调用 API,etcd 宕机后的 Node 仍然被计算为总节点数中,任何请求被确认需要获得的投票数都是这个总数的半数以上。
为什么 Raft 算法在确定可用节点数量时不需要考虑拜占庭将军问题?
拜占庭问题中提出:允许 n 个节点宕机还能提供正常服务的分布式架构,所需要的总节点数量为 3n+1。而 Raft 只需要 2n+1 就可以了,其主要原因在于:拜占庭将军问题中存在数据欺骗的现象,而 etcd 中假设所有的 Node 都是诚实的。etcd 在竞选前需要告诉别的 Node 自身的 Term 编号以及前一轮 Term 最终结束时的 Index 值,这些数据都是准确的,其他 Node 可以根据这些值决定是否投票。另外,etcd 严格限制 Leader 到 Follower 这样的数据流向保证数据一致不会出错。
客户端从集群中的哪个节点读写数据?
为了保证数据的强一致性,etcd Cluster 中的数据流向都是从 Leader 流向 Follower,也就是所有 Follower 的数据必须与 Leader 保持一致,如果不一致则会被覆盖。
即所有用户更新数据的请求都最先由 Leader 获得,然后通知其他节点也进行更新,等到 “大多数节点” 反馈时,再把数据一次性提交。一个已提交的数据项才是 Raft 真正稳定存储下来的数据项,不再被修改,最后再把提交的数据同步给其他 Follower。因为每个 Node 都有 Raft 已提交数据准确的备份(最坏的情况也只是已提交数据还未完全同步),所以读的请求任意一个节点都可以处理。
实际上,用户可以对 etcd Cluster 中的任意 Node 进行读写:
- 读取:可以从任意 Node 进行读取,因为每个节点保存的数据是强一致的。
- 写入:etcd Cluster 首先会选举出 Leader,如果写入请求来自 Leader 即可直接写入,然后 Leader 会把写入分发给所有 Follower;如果写入请求来自其他 Follower 节点那么写入请求会给转发给 Leader 节点,由 Leader 节点写入之后再分发给集群上的所有其他节点。
如何保证数据一致性?
etcd 使用 Raft 协议来维护 Cluster 内各个 Nodes 状态的一致性。简单的说,etcd Cluster 是一个分布式系统,由多个 Nodes 相互通信构成整体对外服务,每个 Node 都存储了完整的数据,并且通过 Raft 协议保证每个 Node 维护的数据是一致的。
etcd Cluster 中的每个 Node 都维护了一个状态机,并且任意时刻,Cluster 中至多存在一个有效的主节点,即:Leader Node。由 Leader 处理所有来自客户端写操作,通过 Raft 协议保证写操作对状态机的改动会可靠的同步到其他 Follower Nodes。
如何选举 Leader 节点?
假设 etcd Cluster 中有 3 个 Node,Cluster 启动之初并没有被选举出的 Leader。此时,Raft 算法使用随机 Timer 来初始化 Leader 选举流程。比如说上面 3 个 Node 上都运行了 Timer(每个 Timer 的持续时间是随机的),而 Node1 率先完成了 Timer,随后它就会向其他两个 Node 发送成为 Leader 的请求,其他 Node 接收到请求后会以投票回应然后第一个节点被选举为 Leader。
成为 Leader 后,该 Node 会以固定时间间隔向其他 Node 发送通知,确保自己仍是 Leader。有些情况下当 Follower 们收不到 Leader 的通知后,比如说 Leader 节点宕机或者失去了连接,其他 Node 就会重复之前的选举流程,重新选举出新的 Leader。
如何判断写入是否成功?
etcd 认为写入请求被 Leader 处理并分发给了其他的 “多数节点” 后,就是一个成功的写入。“多数节点” 的数量的计算公式是 Quorum=N/2+1,N 为总结点数。也就是说,etcd 并发要将数据写入所有节点才算一次写,而是写入 “多数节点” 即可。
如何确定 etcd Cluster 的节点数?
上图左侧给出了集群中 Instances(节点总数)对应的 Quorum(仲裁数)的关系,Instances - Quorom 得到的就是集群中容错节点(允许出故障的节点)的数量。
所以在 etcd Cluster 推荐最少节点数为 3 个,因为 1 和 2 个 Instance 的容错节点数都是 0,一旦有一个节点宕掉整个集群就不能正常工作了。
进一步的说,当我们需要决定 etcd Cluster 中 Instances 的数量时,强烈推荐奇数数量的节点,比如:3、5、7、…,因为 6 个节点的集群的容错能力并没有比 5 个节点的好,他们的容错节点数是一样的,一旦容错节点超过 2 后,由于 Quorum 节点数小于 4,整个集群也就变为不可用的状态了。
etcd 实现的 Raft 算法性能如何?
单实例节点支持每秒 2000 次数据写入。Node 数量越多,由于数据同步涉及到网络延迟,会根据实际情况越来越慢,而读性能会随之变强,因为每个节点都能处理用户请求。
Store
Store,顾名思义,是 etcd 实现的各项底层逻辑,并提供了相应的 API 支持。要理解 Store,只需要从 etcd 的 API 入手。下面列举最常见的 CURD API 调用。
- 为 etcd 存储的键赋值:
curl http://127.0.0.1:2379/v2/keys/message -X PUT -d value="Hello world"
{
"action":"set", # 执行的操作
"node":{
"createdIndex":2, # etcd Node 每次有变化时都会自增的一个值
"key":"/message", # 请求路径
"modifiedIndex":2, # 类似 node.createdIndex,能引起 modifiedIndex 变化的操作包括 set, delete, update, create, compareAndSwap and compareAndDelete
"value":"Hello world" # 存储的内容
}
}
- 查询 etcd 某个键存储的值:
curl http://127.0.0.1:2379/v2/keys/message -X GET
- 修改键值:
curl http://127.0.0.1:2379/v2/keys/message -XPUT -d value="Hello etcd"
- 删除键值:
curl http://127.0.0.1:2379/v2/keys/message -XDELETE
WAL
etcd 的数据存储分为两个部分:
- 内存存储:内存中的存储除了顺序化的记录下所有用户对节点数据变更的记录外,还会对用户数据进行索引、建堆等方便查询的操作。
- 持久化(硬盘)存储:持久化则使用 WAL(Write Ahead Log,预写式日志)进行记录存储。
WAL 日志是二进制的,解析出来后是以上数据结构 LogEntry。其中:
第一个字段 type,只有两种:
- 0 表示 Normal
- 1 表示 ConfChange,ConfChange 表示 etcd 本身的配置变更同步,比如有新的节点加入等。
第二个字段是 term,每个 term 代表一个 Leader 的任期,每次 Leader 变更 term 就会变化。
第三个字段是 index,这个序号是严格有序递增的,代表变更序号。
第四个字段是二进制的 data,将 Raft Request 对象的 pb 结构整个保存下。
etcd 源码下有个 tools/etcd-dump-logs 脚本工具,可以将 WAL 日志 dump 成文本查看,可以协助分析 Raft 协议。
Raft 协议本身不关心应用数据,也就是 data 中的部分,一致性都通过同步 WAL 日志来实现,每个 Node 将从 Leader 收到的 data apply 到本地的存储,Raft 只关心日志的同步状态,如果本地存储实现的有 Bug,比如没有正确的将 data apply 到本地,也可能会导致数据不一致。
在 WAL 的体系中,所有的数据在提交之前都会进行日志记录。在 etcd 的持久化存储目录中,有两个子目录:
- 一个是 WAL:存储着所有事务的变化记录;
- 另一个是 Snapshot:存储着某一个时刻 etcd 所有目录的数据。
通过 WAL 和 Snapshot 相结合的方式,etcd 可以有效的进行数据存储和节点故障恢复等操作。
为什么需要 Snapshot(快照)?
因为随着使用量的增加,WAL 存储的数据会暴增,为了防止磁盘很快就爆满,etcd 默认每 10000 条记录做一次 Snapshot,经过 Snapshot 以后的 WAL 文件就可以删除。所以,通过 API 可以查询的操作历史记录默认为 1000 条。
首次启动时,etcd 会把启动的配置信息存储到 data-dir 配置项指定的目录路径下。配置信息包括 Local Node ID、Cluster ID 和初始时的集群信息。用户需要避免 etcd 从一个过期的数据目录中重新启动,因为使用过期的数据目录启动的 Node 会与 Cluster 中的其他 Nodes 产生不一致性,例如:之前已经记录并同意 Leader Node 存储某个信息,重启后又向 Leader Node 申请这个信息。所以,为了最大化集群的安全性,一旦有任何数据损坏或丢失的可能性,你就应该把这个 Node 从 Cluster 中移除,然后加入一个不带数据目录的 New Node。
WAL(Write Ahead Log)最大的作用是记录了整个数据变化的全部历程。在 etcd 中,所有数据的修改在提交前,都要先写入到 WAL 中。使用 WAL 进行数据的存储使得 etcd 拥有两个重要功能:
- 故障快速恢复: 当你的数据遭到破坏时,就可以通过执行所有 WAL 中记录的修改操作,快速从最原始的数据恢复到数据损坏前的状态。
- 数据回滚(undo)或重做(redo):因为所有的修改操作都被记录在 WAL 中,需要回滚或重做,只需要方向或正向执行日志中的操作即可。
WAL 和 Snapshot 的命名规则?
在 etcd 的数据目录中,WAL 文件以 $seq-$index.wal
的格式存储。最初始的 WAL 文件是 0000000000000000-0000000000000000.wal,表示这是所有 WAL 文件中的第 0 个,初始的 Raft 状态编号为 0。运行一段时间后可能需要进行日志切分,把新的条目放到一个新的 WAL 文件中。
假设,当集群运行到 Raft 状态为 20 时,需要进行 WAL 文件的切分时,下一份 WAL 文件就会变为 0000000000000001-0000000000000021.wal。如果在 10 次操作后又进行了一次日志切分,那么后一次的 WAL 文件名会变为 0000000000000002-0000000000000031.wal。可以看到 “-” 符号前面的数字是每次切分后自增 1,而 “-” 符号后面的数字则是根据实际存储的 Raft 起始状态来定。
而 Snapshot 的存储命名则比较容易理解,以 $term-$index.wal
格式进行命名存储。term 和 index 就表示存储 Snapshot 时数据所在的 Raft 节点状态,当前的任期编号以及数据项位置信息。
etcd 的设计目的是用来存放非频繁更新的数据,提供可靠的 Watch 插件,它暴露了键值对的历史版本,以支持低成本的快照、监控历史事件。这些设计目标要求它使用一个持久化的、多版本的、支持并发的数据数据模型。
当 etcd 键值对的新版本保存后,先前的版本依然存在。从效果上来说,键值对是不可变的,etcd 不会对其进行 in-place 的更新操作,而总是生成一个新的数据结构。为了防止历史版本无限增加,etcd 的存储支持压缩(Compact)以及删除老旧版本。
逻辑视图
从逻辑角度看,etcd 的存储是一个扁平的二进制键空间,键空间有一个针对键(字节字符串)的词典序索引,因此范围查询的成本较低。
键空间维护了多个修订版本(Revisions),每一个原子变动操作(一个事务可由多个子操作组成)都会产生一个新的修订版本。在集群的整个生命周期中,修订版都是单调递增的。修订版同样支持索引,因此基于修订版的范围扫描也是高效的。压缩操作需要指定一个修订版本号,小于它的修订版会被移除。
一个键的一次生命周期(从创建到删除)叫做 “代(Generation)”,每个键可以有多个代。创建一个键时会增加键的版本(Version),如果在当前修订版中键不存在则版本设置为 1。删除一个键会创建一个墓碑(Tombstone),将版本设置为 0,结束当前代。每次对键的值进行修改都会增加其版本号,即:在同一代中版本号是单调递增的。
当压缩时,任何在压缩修订版之前结束的代,都会被移除。值在修订版之前的修改记录(仅仅保留最后一个)都会被移除。
物理视图
etcd 将数据存放在一个持久化的 B+ 树中,出于效率的考虑,每个修订版仅仅存储相对前一个修订版的数据状态变化(Delta)。单个修订版中可能包含了 B+ 树中的多个键。
键值对的键,是三元组(Major,Sub,Type):
- Major:存储键值的修订版。
- Sub:用于区分相同修订版中的不同键。
- Type:用于特殊值的可选后缀,例如 t 表示值包含墓碑
键值对的值,包含从上一个修订版的 Delta。B+ 树,即:键的词法字节序排列,基于修订版的范围扫描速度快,可以方便的从一个修改版到另外一个的值变更情况查找。
etcd 同时在内存中维护了一个 B 树索引,用于加速针对键的范围扫描。索引的键是物理存储的键面向用户的映射,索引的值则是指向 B+ 树修该点的指针。
etcd 的 Proxy 模式Proxy 模式,即:etcd 作为一个反向代理把客户端的请求转发给可用的 etcd Cluster。这样,你就可以在每一台机器上都部署一个 Proxy 模式的 etcd 作为本地服务,如果这些 etcd Proxy 都能正常运行,那么你的服务发现必然是稳定可靠的。
所以 Proxy 模式并不是直接加入到符合强一致性的 etcd Cluster 中,也同样的,Proxy 并没有增加集群的可靠性,当然也没有降低集群的写入性能。
Proxy 模式取代 Standby 模式的原因?
实际上 etcd 每增加一个核心节点(Peer),都会增加 Leader 一定程度的包括网络、CPU 和磁盘的负担,因为每次信息的变化都需要进行同步备份。增加 etcd 的核心节点可以让整个集群具有更高的可靠性,但是当数量达到一定程度以后,增加可靠性带来的好处就变得不那么明显,反倒是降低了集群写入同步的性能。因此,增加一个轻量级的 Proxy 模式 etcd Node 是对直接增加 etcd 核心节点的一个有效代替。
Proxy 模式实际上是取代了原先的 Standby 模式。Standby 模式除了转发代理的功能以外,还会在核心节点因为故障导致数量不足的时候,从 Standby 模式转为正常节点模式。而当那个故障的节点恢复时,发现 etcd 的核心节点数量已经达到的预先设置的值,就会转为 Standby 模式。
但是新版本的 etcd 中,只会在最初启动 etcd Cluster 时,发现核心节点的数量已经满足要求时,自动启用 Proxy 模式,反之则并未实现。主要原因如下:
- etcd 是用来保证高可用的组件,因此它所需要的系统资源,包括:内存、硬盘和 CPU 等,都应该得到充分保障以保证高可用。任由集群的自动变换随意地改变核心节点,无法让机器保证性能。所以 etcd 官方鼓励大家在大型集群中为运行 etcd 准备专有机器集群。
- 因为 etcd 集群是支持高可用的,部分机器故障并不会导致功能失效。所以机器发生故障时,管理员有充分的时间对机器进行检查和修复。
- 自动转换使得 etcd 集群变得复杂,尤其是如今 etcd 支持多种网络环境的监听和交互。在不同网络间进行转换,更容易发生错误,导致集群不稳定。