2.核心概念与架构设计
etcd 的数据模型是理解其工作原理的基石。与许多其他键值存储系统不同,etcd 采用了一种持久化的多版本并发控制(Multi-Version Concurrency Control, MVCC)模型。这意味着 etcd 不会就地更新数据,而是保留数据的历史版本,这对于实现可靠的监视(Watch)、历史查询和快照至关重要。
逻辑视图
从逻辑上看,etcd 的键空间是一个扁平的二进制键空间。这意味着所有键都存储在一个单一的层级中,而不是像文件系统那样具有目录结构。键空间通过键的字典顺序进行索引,这使得范围查询非常高效。
键空间维护着多个修订版本(Revisions)。当 etcd 集群启动时,初始修订版本号为 1。每一次修改键空间的原子操作(例如一次 Put 或 Delete 操作,或者一个包含多个操作的事务)都会创建一个新的修订版本。所有之前修订版本的数据都保持不变,因此旧版本的键仍然可以通过指定历史修订版本来访问。
一个键的生命周期跨越一个或多个代(Generations)。创建一个键会将其版本(Version)从 0 增加到 1。对该键的后续修改会递增其版本号。删除一个键会生成一个墓碑(Tombstone),将其版本号重置为 0,从而结束当前代。一旦发生压缩(Compaction),在压缩修订版本之前结束的代以及除最新版本之外的所有值都将被移除。
物理视图
在物理存储层面,etcd 使用一个基于 B+树 的持久化存储引擎(具体为 bbolt)。每个修订版本的键空间状态只包含与前一个版本相比的增量变化,以提高效率。一个修订版本可能对应 B+树中的多个键。
B+树中的键是一个三元组 (major, sub, type):
major是持有该键的存储修订版本。sub用于区分同一修订版本内的不同键。type是一个可选的后缀,用于特殊值(例如,如果值包含墓碑,则为t)。
B+树的值则包含了相对于前一个修订版本的修改(即增量)。B+树按照键的字典字节顺序排列。对修订版本增量的范围查找非常快,这使得能够快速找到从一个特定修订版本到另一个修订版本的修改。压缩操作会移除过时的键值对。
为了加速对键的范围查询,etcd 还维护了一个内存中的 B树 索引。B树索引中的键是用户可见的存储键,其值是指向持久化 B+树中相应修改的指针。压缩操作同样会移除失效的指针。
总的来说,etcd 从 B树获取修订版本信息,然后使用该修订版本作为键从 B+树中获取对应的值。
版本号与修订机制
理解 etcd 中的版本号和修订机制对于正确使用其 API 和构建分布式应用至关重要。
修订版本 (Revision)
修订版本是 etcd 集群范围内的一个 64 位单调递增计数器。每当键空间被修改时,就会分配一个新的修订版本。它充当了集群的全局逻辑时钟,为所有更新提供了顺序。
修订版本的核心作用体现在以下几个方面:
- 全局排序:所有对键空间的修改都通过修订版本进行排序,确保了操作的因果关系。
- 时间旅行查询:由于 etcd 保留了历史数据,客户端可以通过指定一个修订版本号来读取过去某个时间点的键空间状态。
- 监视(Watch):客户端可以从一个特定的历史修订版本开始监视键的变化,确保不会错过任何事件。如果客户端断开连接,它可以从上次收到的修订版本重新连接,继续接收事件。
- 并发控制:许多分布式协调原语(如分布式锁、领导选举)都依赖于修订版本来判断操作的顺序和状态。
键版本 (Version)
键版本是针对单个键的版本号。它与集群范围的修订版本不同:
- 当一个键被创建时,其版本号从 0 变为 1。
- 每次修改该键(更新或删除后重新创建),其版本号都会递增。
- 删除键会将版本号重置为 0。
键版本主要用于客户端判断一个键是否被修改过,或者是否是新创建的。
生成 (Generation)
一个键的生命周期被划分为多个生成。从创建到删除(版本号重置为 0)为一个生成。在一个生成内,键的版本号是单调递增的。理解生成有助于理解 etcd 如何管理键的完整历史。
Raft 共识算法原理
etcd 的高可用性和一致性是通过实现 Raft 共识算法来保证的。Raft 是一种比 Paxos 更易于理解的分布式一致性算法。
核心概念
Raft 将问题分解为几个关键的子问题:
- 领导选举 (Leader Election):在任何时刻,集群中只能有一个领导者(Leader)。所有客户端请求都由领导者处理。如果领导者失效,剩余的节点(Follower)会通过选举产生一个新的领导者。
- 日志复制 (Log Replication):领导者接收客户端的请求,将请求作为日志条目(Log Entry)追加到自己的日志中,然后并行地将该日志条目复制给所有跟随者。一旦日志条目被安全地复制到大多数(Quorum)节点上,领导者就会将其提交(Commit),并应用到自己的状态机(State Machine)中,然后通知客户端操作成功。
- 安全性 (Safety):Raft 算法通过一些机制保证了安全性,例如选举限制(只有拥有最新日志的节点才能成为领导者),确保了已提交的日志条目不会被丢失或覆盖。
etcd 中的 Raft
etcd 使用 Raft 库来管理集群状态。当客户端发起一个写请求(如 put)时,流程如下:
- 请求被发送到 etcd 集群的任意一个节点。
- 如果请求发送到了 Follower 节点,该 Follower 会将请求转发给 Leader。
- Leader 将请求转化为一个 Raft 日志条目,并追加到自己的日志中。
- Leader 将该日志条目发送给所有 Follower。
- Follower 接收到日志条目后,将其写入本地日志,并向 Leader 回复确认。
- Leader 在收到大多数节点的确认后,将该日志条目标记为已提交。
- Leader 将该日志条目应用到其状态机(即 MVCC 键值存储),并返回结果给客户端。
- 同时,Leader 会通知所有 Follower 该条目已提交,Follower 也将其应用到各自的状态机。
这个过程保证了即使部分节点失效,集群依然能够正常工作,并且所有操作都是一致的。
集群架构组件
一个 etcd 集群由多个成员(Member)组成。每个成员是一个运行 etcd 服务器进程的节点。
成员角色
在 Raft 算法中,成员有两种角色:
- 领导者 (Leader):处理所有客户端请求,管理日志复制。一个集群在同一时刻只有一个领导者。
- 跟随者 (Follower):被动接收领导者的日志复制请求,并响应领导者的心跳。如果跟随者在一段时间内没有收到领导者的心跳,它会转变为候选者 (Candidate) 并发起选举。
通信端点
每个 etcd 成员暴露两种主要的通信端点:
- 客户端端点 (Client Endpoint):用于客户端与 etcd 集群通信,通常监听在 2379 端口。客户端通过此端点进行读写操作。
- 对等端点 (Peer Endpoint):用于 etcd 集群成员之间的内部通信(Raft 复制和领导选举),通常监听在 2380 端口。
状态机与存储
每个 etcd 成员内部都包含:
- Raft 模块:负责处理共识逻辑,维护日志。
- MVCC 存储引擎:作为状态机,存储键值数据及其历史版本。它基于 B+树(bbolt)实现。
- WAL (Write-Ahead Log):在将操作应用到状态机之前,Raft 日志会先持久化到 WAL 文件中,以保证崩溃后的数据恢复。
核心术语词汇表
为了更好地理解 etcd,以下是一些核心术语的定义:
- 键 (Key):用户定义的标识符,用于在 etcd 中存储和检索值。
- 键值对 (Key-Value Pair):etcd 中存储的基本单元,包含键、值、版本、修订版本等元数据。
- 键空间 (Keyspace):etcd 集群中所有键的集合。
- 修订版本 (Revision):一个 64 位的集群范围计数器,每次键空间修改时递增,作为全局逻辑时钟。
- 键版本 (Version):单个键的修改次数,从创建开始递增,删除后重置为 0。
- 租约 (Lease):一个具有生存时间(TTL)的契约。键可以附加到租约上,当租约过期或被撤销时,所有附加的键都会被自动删除。
- 事务 (Transaction):一组原子性执行的操作。事务中的所有操作要么全部成功,要么全部失败。
- 比较并交换 (Compare-and-Swap, CAS):一种原子操作,仅当键的当前状态满足特定条件时才执行更新。
- 压缩 (Compaction):丢弃指定修订版本之前的所有历史数据和被覆盖的键,以回收存储空间。
- 快照 (Snapshot):etcd 集群状态在某个时间点的备份。
- 仲裁 (Quorum):为了使集群状态达成一致所需的大多数成员。对于一个包含 N 个成员的集群,仲裁大小为
(N/2) + 1。 - 任期 (Term):Raft 算法中的一个逻辑时钟,是一个单调递增的整数。每次领导选举开始时,任期都会增加。
API 一致性保证
etcd 通过 Raft 算法为其 API 提供了强大的一致性保证,这对于构建可靠的分布式系统至关重要。
持久性 (Durability)
任何已完成的操作都是持久的。所有可访问的数据也都是持久化的。读取操作永远不会返回尚未持久化的数据。
严格可串行化 (Strict Serializability)
KV 服务的操作是原子的,并且按照与真实时间顺序一致的总顺序发生。这是分布式事务性数据库系统中最强的隔离保证。
原子性 (Atomicity)
所有 API 请求都是原子的;一个操作要么完全完成,要么完全不发生。例如,在一个事务中,如果某个条件不满足,整个事务中的所有写操作都不会生效。
线性一致性 (Linearizability)
从客户端的角度看,线性一致性提供了一种“即时生效”的错觉。一个写操作在时间点 t1 完成,那么在 t1 之后的任何时间点 t2 发起的读操作,都应该能读到这个写操作的结果或更新的数据。
etcd 默认对所有操作保证线性一致性。线性一致性需要通过 Raft 共识过程,因此会带来一定的性能开销。为了获得更低的读取延迟和更高的吞吐量,客户端可以将读请求的模式配置为 Serializable(可串行化)。这种模式可能会读到过期的数据(相对于集群的最新状态),但因为它不需要经过 Raft 共识过程,所以性能更高。
Watch API 的保证
Watch API 对事件流提供以下保证:
- 有序性 (Ordered):事件按修订版本排序。
- 唯一性 (Unique):一个事件不会在监视流中出现两次。
- 可靠性 (Reliable):事件序列不会丢失任何可用的历史事件子序列。
- 原子性 (Atomic):一个事件列表保证涵盖完整的修订版本。同一修订版本中对多个键的更新不会被拆分到多个事件列表中。
- 可恢复性 (Resumable):断开的监视可以通过在上次收到的事件修订版本之后建立新的监视来恢复。
- 可标记性 (Bookmarkable):进度通知事件保证在某个修订版本之前的所有事件都已被传递。
需要注意的是,etcd 不保证 Watch 操作的线性一致性。用户需要通过验证 Watch 事件的修订版本来确保与其他操作的正确排序。
Lease API 的保证
Lease 机制本身很简单:可以创建一个租约,将其附加到键上,撤销租约,并根据墙钟时间(TTL)过期。然而,用户需要了解其重要属性才能正确实现分布式协调机制。例如,由于 TTL 是基于物理时间的,并且服务器和客户端使用各自的时钟,可能会出现服务器已经撤销租约但客户端仍然认为其有效的情况。因此,基于租约的锁通常需要结合版本号验证(Compare-and-Swap)来实现真正的互斥。