206 KiB
etcd v3.7 学习手册 · 01 核心概念篇
基于 https://etcd.io/docs/v3.7/ 整理 文档版本:v3.7.0(源仓库 front matter
git_version_tag: v3.7.0) 整理日期:2026-09-18 本册定位:核心概念篇(认知地图)。兄弟分册:《02-开发实战篇》《03-运维运营篇》。 源文获取:GitHub 源站 etcd-io/website main 分支 content/en/docs/v3.7,逐字保真;与 sitemap.xml 交叉验证,共 79 页纳入、0 页失败。
目录
- 1. 本册导读
- 2. 概述
- 3. 术语表
- 4. 快速上手
- 5. 数据模型
- 6. etcd API
- 7. API 保证
- 8. 持久化存储文件
- 9. 系统限制
- 10. etcd 特性清单
- 11. 设计原理
- 12. etcdctl v3 交互入门
- 13. 附录
1. 本册导读
本册是 etcd v3.7 学习手册的第一册,目标是给读者一张认知地图:先知道 etcd 是什么、能做什么、不能做什么,再理解它的数据模型与 API 语义,最后读懂四份核心设计文档(客户端、Learner、认证、运行时重配置)。读完本册,你应当能够回答三个问题:
- 一条
put从客户端发出到落盘,中间经过了哪些数据结构(keyspace → revision → MVCC → bbolt / WAL)? - etcd 给你的保证边界在哪里(哪些操作是线性一致的、watch 不保证什么、lease 不保证什么)?
- 集群成员变更为什么必须"显式两阶段"、learner 为什么存在?
1.1 本册覆盖的官方页面
本册共纳入并展开 20 个官方页面(含 2 个作为方法清单补充来源的 API 参考页)。逐条清单见 13.1 官方页面与章节映射表。
按主题分组如下:
| 主题 | 官方页面(源文件) | 本册章节 |
|---|---|---|
| 版本入口 | _index.md |
第 1 章、第 2 章 |
| 定位与选型 | learning/why.md |
第 2 章 |
| 术语 | learning/glossary.md |
第 3 章 |
| 安装与起步 | install.md、quickstart.md、demo.md、dev-guide/local_cluster.md |
第 4 章 |
| 数据模型 | learning/data_model.md |
第 5 章 |
| API | learning/api.md、dev-guide/api_reference_v3.md、dev-guide/api_concurrency_reference_v3.md |
第 6 章 |
| API 保证 | learning/api_guarantees.md |
第 7 章 |
| 持久化 | learning/persistent-storage-files.md |
第 8 章 |
| 限制 | dev-guide/limit.md |
第 9 章 |
| 特性阶段 | dev-guide/features.md |
第 10 章 |
| 设计文档 | learning/design-client.md、learning/design-learner.md、learning/design-auth-v3.md、op-guide/runtime-reconf-design.md |
第 11 章 |
| 命令行交互 | dev-guide/interacting_v3.md |
第 12 章 |
1.2 怎么读这册
| 读者类型 | 建议路径 | 说明 |
|---|---|---|
| 第一次接触 etcd | 1 → 2 → 3 → 4 → 5 → 12 | 先建立概念与手感,命令能跑通再说 |
| 应用开发者 | 1 → 3 → 5 → 6 → 7 → 11.1 → 12 | 数据模型与 API 保证是写对代码的前提 |
| 运维 / SRE | 1 → 2 → 4 → 8 → 9 → 10 → 11.2 → 11.4 | 落盘文件、限制、成员变更是日常运维主战场 |
| 要选型的人 | 2(全章,尤其是 2.6–2.12) | 对比表与 lock/lease 注意事项直接决定选型 |
| 要排查故障的人 | 5 → 7 → 8 → 11.1 | revision 语义 + WAL/snapshot 结构 + 客户端 failover |
阅读时的三条硬建议:
- 第 5 章(数据模型)与第 7 章(API 保证)不要跳。etcd 绝大多数"反直觉"行为都能在这两章找到解释,例如"为什么
get --rev=N报required revision has been compacted"、为什么 watch 会收到compact_revision。 - 第 11 章是设计文档,不是操作手册。原文明确写了"是否 promote 由集群运维做最终决定""etcd 决定不支持强制移除成员"这类立场,读的时候要区分"etcd 保证了什么"与"etcd 建议你怎么做"。
- 凡是标注
> 💡 补充:的内容都不是官方文档原文,是基于工程经验的补充,请以官方文档为准做最终判断。
1.3 学习路径图
etcd v3.7 认知地图
┌──────────────────────────────────────────────────────────┐
│ 第 1 层 是什么 / 为什么 │
│ ├─ 第 2 章 概述:定位、用例、竞品对比(why.md) │
│ └─ 第 3 章 术语表(glossary.md) │
└───────────────────────┬──────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ 第 2 层 跑起来 │
│ ├─ 第 4 章 安装(4 种方式)+ 单节点 / 三节点本地集群 │
│ └─ 第 12 章 etcdctl v3 交互入门 │
└───────────────────────┬──────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ 第 3 层 数据是怎么组织的 │
│ ├─ 第 5 章 数据模型:keyspace / revision / MVCC / │
│ │ triple(major,sub,type) / b+tree │
│ └─ 第 8 章 持久化文件:snap/db、WAL、*.snap │
└───────────────────────┬──────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ 第 4 层 对外契约 │
│ ├─ 第 6 章 API:KV / Watch / Lease / Cluster / │
│ │ Maintenance / Auth / Lock / Election │
│ └─ 第 7 章 API 保证:durability、strict serializability │
│ linearizability、watch 六条保证 │
└───────────────────────┬──────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ 第 5 层 边界与内功 │
│ ├─ 第 9 章 系统限制(1.5 MiB / 2 GiB / 8 GiB) │
│ ├─ 第 10 章 特性阶段(experimental / stable / unsafe) │
│ └─ 第 11 章 设计原理:客户端 / Learner / 认证 / 重配置 │
└──────────────────────────────────────────────────────────┘
1.4 本册的写作约定
| 约定 | 说明 |
|---|---|
| 语言 | 正文中文;命令、参数名、字段名、proto 消息名、Go 标识符一律保留英文原文,不翻译 |
| 表格优先 | 参数说明、对比、映射一律用表格,不用段落罗列 |
| 代码块 | 每个代码块都标注语言(bash / go / protobuf / json / text / console 等) |
| 引用官方原文 | 用 > 引自:[页面标题](URL) 标注出处 |
| 个人补充 | 用 > 💡 补充: 显式标注,绝不与原文陈述混写 |
| 官方未提供 | 素材标注"源文未提供/未包含"的,如实写"官方文档未提供" |
| 数字与默认值 | 原样保留,不做换算、不做四舍五入 |
💡 补充:本册所有
etcdctl示例默认已执行export ETCDCTL_API=3,且默认 endpoint 为localhost:2379。如果你的etcdctl来自 v3.3 或更早版本,默认 API 是 v2,必须显式设置该环境变量(见 12.1)。
2. 概述
2.1 etcd 是什么
etcd 的名字来自两个概念:unix 的 /etc 目录和 "d"istributed systems。
引自:etcd versus other key-value stores
/etc是单机存放配置数据的地方,而 etcd 存放的是大规模分布式系统的配置信息,因此"分布式的/etc"就是 "etcd"。
官方定位原话要点:
| 维度 | 官方表述 |
|---|---|
| 设计目标 | 大规模分布式系统的通用底座(general purpose substrate) |
| 核心取舍 | 这类系统绝不容忍 split-brain(脑裂),并愿意为此牺牲可用性 |
| 存储内容 | 以一致且容错的方式存储元数据 |
| 集群目标 | 在稳定性(stability)、可靠性(reliability)、可扩展性(scalability)、性能(performance) 四方面都做到 best of class 的键值存储 |
| 典型用途 | 配置管理(configuration management)、服务发现(service discovery)、协调分布式工作(coordinating distributed work) |
2.2 etcd 解决什么问题
一句话:在不可靠的网络与机器之上,提供一个不会脑裂、顺序可推理、变更可订阅的元数据存储。
拆成四个具体问题:
| 问题 | etcd 的解答 |
|---|---|
| 多个节点如何就"某个配置的值"达成一致? | 单一复制组 + raft 共识;每次修改分配全局唯一且递增的 revision |
| 如何知道配置"刚才变了"? | watch API:从给定 revision(当前或历史)持续流式推送事件,绝不静默丢弃事件 |
| 如何知道某个进程/机器还活着? | lease:短期的、可续约的契约,到期自动删除关联 key |
| 如何让多个进程互斥地干活? | Lock / Election RPC + 基于 revision number / version number 校验的互斥 |
2.3 核心特点
以下特点均可在官方页面中找到原文依据(出处标注在"官方依据"列):
| 特点 | 说明 | 官方依据 |
|---|---|---|
| 强一致,不容忍脑裂 | 愿意为一致性牺牲可用性 | learning/why.md |
| MVCC 多版本数据模型 | 保留 key 的历史版本,支持 time travel queries | learning/data_model.md |
| 可靠的变更通知 | watch 绝不静默丢弃事件;事件有序、唯一、可靠、原子、可恢复、可打书签 | learning/api_guarantees.md |
| lease 原语把连接与会话解耦 | client 断连不立即丢锁,由 TTL 决定 | learning/why.md |
| 安全的分布式共享锁 API | Lock / Election RPC,基于版本号校验(compare and swap) | learning/why.md |
| 动态集群成员重配置 | 运行时重配置 API + learner 机制 | learning/why.md、op-guide/runtime-reconf-design.md |
| gRPC 协议 + HTTP/JSON | 客户端协议基于 gRPC,可序列化为 JSON over HTTP,连 curl 也能通信 |
learning/why.md |
| 扁平二进制键空间 + 区间查询 | 不是目录树,range 比目录更强大 | learning/api.md、learning/data_model.md |
2.4 典型用例
官方给出的两个真实用例:
用例一:Container Linux by CoreOS
| 项 | 内容 |
|---|---|
| 场景 | 运行其上的应用可获得自动、零停机的 Linux 内核更新 |
| etcd 的角色 | Container Linux 用 locksmith 协调更新 |
| 机制 | locksmith 在 etcd 之上实现分布式信号量,确保任意时刻集群中只有一部分节点在重启 |
用例二:Kubernetes
| 项 | 内容 |
|---|---|
| 存什么 | 把配置数据存入 etcd,用于服务发现和集群管理 |
| 为什么必须一致 | etcd 的一致性对正确调度和运行服务至关重要 |
| API server 怎么用 | 把集群状态持久化到 etcd |
| 变更如何扩散 | 使用 etcd 的 watch API 监控集群、滚动推出关键配置变更 |
除这两个之外,官方列出的常见使用模式(common patterns)包括:
- leader election(领导者选举)
- distributed locks(分布式锁)
- monitoring machine liveness(监控机器存活)
2.5 适用场景与不适用场景
下表左侧为官方原文结论,右侧标注出处;凡属"由原文选型建议推导"的行,已用 💡 标注为整理而非原文原句。
| 场景 | 是否适合 | 官方依据 / 说明 |
|---|---|---|
| 存储元数据(配置、服务发现、集群状态) | ✅ 适合 | why.md:etcd 在单个一致复制组内复制全部数据,对"最多几 GB 且需要一致排序的数据"最高效 |
| 需要全局强排序的协调(如协调进程) | ✅ 适合 | why.md:应用主要处理元数据或元数据顺序 → 选 etcd |
| 分布式协调原语(watch、lease、election、lock) | ✅ 适合 | why.md:etcd 开箱即用提供,由 etcd 开发者维护和支持 |
| 需要跨数据中心的大型数据存储 | ❌ 不适合 | why.md:选 NewSQL;且 etcd 复制无法水平扩展(无 data sharding) |
| 数据超过几 GB | ❌ 不适合 | why.md 原句:"若数据超过几 GB,或需要完整 SQL 查询,选 NewSQL 数据库" |
| 需要完整 SQL 查询 | ❌ 不适合 | 同上;etcd 是 KVS,不是 SQL 数据库 |
| 端到端集群服务发现框架(含健康检查/DNS) | ❌ 不够 | why.md:etcd 特性不够,应选 Kubernetes、Consul 或 SmartStack |
| 保护 etcd 之外的外部资源的互斥 | ❌ 不适合 | why.md:etcd 的 lock 特性不能用于保护外部资源,那些资源必须自己提供版本号校验与副本一致性 |
💡 补充:把上表浓缩成一句工程口诀——"etcd 存的是'关于系统的信息',不是'系统的业务数据'"。一旦发现自己想把业务表塞进 etcd、或者想对 etcd 里的数据做 JOIN,就是选错工具的信号。
2.6 竞品对比表
作者声明(原文保留):本文档由 etcd 团队撰写,作者的专长与偏见明显偏向 etcd,仅供参考。
表头四列:etcd / ZooKeeper / Consul / NewSQL (Cloud Spanner, CockroachDB, TiDB)。以下逐项照录,一个不漏:
| 维度 | etcd | ZooKeeper | Consul | NewSQL |
|---|---|---|---|---|
| Concurrency Primitives | Lock RPCs、Election RPCs、命令行 locks、命令行 elections、Go 的 recipes | Java 编写的外部 curator recipes | Native lock API | Rare(极少),if any |
| Linearizable Reads | Yes | No | Yes | Sometimes |
| Multi-version Concurrency Control | Yes | No | No | Sometimes |
| Transactions | Field compares, Read, Write | Version checks, Write | Field compare, Lock, Read, Write | SQL-style |
| Change Notification | Historical and current key intervals | Current keys and directories | Current keys and prefixes | Triggers (sometimes) |
| User permissions | Role based | ACLs | ACLs | Varies(per-table GRANT、per-database roles) |
| HTTP/JSON API | Yes | No | Yes | Rarely |
| Membership Reconfiguration | Yes | >3.5.0 | Yes | Yes |
| Maximum reliable database size | Several gigabytes | Hundreds of megabytes(有时 several gigabytes) | Hundreds of MBs | Terabytes+ |
| Minimum read linearization latency | Network RTT | No read linearization | RTT + fsync | Clock barriers (atomic, NTP) |
逐行解读(解读为整理,原句见上表):
| 维度 | 怎么读这一行 |
|---|---|
| Concurrency Primitives | etcd 把锁与选举做成 RPC + 命令行 + Go recipes 三层;ZooKeeper 依赖外部 Java 库 curator;NewSQL 基本没有 |
| Linearizable Reads | ZooKeeper 不提供线性化读,这是它与 etcd 最实质的差异之一 |
| Multi-version Concurrency Control | 只有 etcd 明确 Yes;这是 time travel query 与 STM 的基础 |
| Transactions | etcd 是"字段比较 + 读 + 写";ZooKeeper 是"版本检查 + 写";NewSQL 是 SQL 风格 |
| Change Notification | etcd 能订阅历史区间和当前;ZooKeeper 只能看当前 key 与目录;Consul 是当前 key 与前缀 |
| User permissions | etcd 是 Role based(基于角色,见 11.3);ZooKeeper / Consul 是 ACL |
| HTTP/JSON API | etcd 有(gRPC 可序列化为 JSON over HTTP,因此 curl 也能用);ZooKeeper 没有 |
| Membership Reconfiguration | ZooKeeper 要 >3.5.0 才支持 |
| Maximum reliable database size | etcd 是 several gigabytes;Consul 是 hundreds of MBs;NewSQL 是 Terabytes+ |
| Minimum read linearization latency | etcd = Network RTT;ZooKeeper 无读线性化;Consul = RTT + fsync;NewSQL 依赖 Clock barriers(atomic、NTP) |
2.7 ZooKeeper
关系:ZooKeeper 与 etcd 解决同一问题(分布式系统协调与元数据存储)。但 etcd 有后见之明,吸取了 ZooKeeper 在设计与实现上的工程和运维经验,这些教训帮助 etcd 支撑起 Kubernetes 这样的大规模系统。
etcd 相对 Zookeeper 的六项改进(原文列举,逐条保留):
| # | 改进项 | 原文英文 |
|---|---|---|
| ① | 动态集群成员重配置 | Dynamic cluster membership reconfiguration |
| ② | 高负载下稳定的读写 | Stable read/write under high load |
| ③ | 多版本并发控制数据模型 | A multi-version concurrency control data model |
| ④ | 可靠的 key 监控,绝不静默丢弃事件 | Reliable key monitoring which never silently drop events |
| ⑤ | 把连接与会话解耦的 lease 原语 | Lease primitives decoupling connections from sessions |
| ⑥ | 安全的分布式共享锁 API | APIs for safe distributed shared locks |
协议与生态差异:
| 项 | ZooKeeper | etcd |
|---|---|---|
| RPC 协议 | 自有的 Jute 协议(Zookeeper 独有,限制了其 language bindings) | 基于 gRPC |
| 语言绑定 | 受 Jute 限制 | 有 go、C++、Java 等语言绑定;且 gRPC 可序列化为 JSON over HTTP |
| 命令行可达性 | — | 连 curl 这样的通用命令行工具也能与之通信 |
| 开箱即用支持 | — | 开箱即用地支持大量语言和框架 |
结论(原文):综合考虑特性、支持与稳定性,新应用若打算用 Zookeeper 做一致性键值存储,最好选择 etcd。
2.8 Consul
Consul 是什么:一个端到端的服务发现框架,内置健康检查、故障检测和 DNS 服务,另外暴露一个带 RESTful HTTP API 的键值存储。
以 Consul 1.0 的情况为准,官方给出的评价:
| 评价项 | 原文结论 |
|---|---|
| 扩展性 | 其存储系统在键值操作上的扩展性不如 etcd 或 Zookeeper |
| 规模问题 | 需要数百万 key 的系统会遭受高延迟和内存压力 |
| 键值 API 最明显的三项缺失 | ① multi-version keys(多版本 key);② conditional transactions(条件事务);③ reliable streaming watches(可靠的流式 watch) |
结论(原文,两句分开记):
- 要分布式一致键值存储 → 选 etcd 优于 Consul。
- 要端到端的集群服务发现 → etcd 特性不够,应选 Kubernetes、Consul 或 SmartStack。
2.9 NewSQL
代表产品(原文点名):Cockroach、TiDB、Google Spanner(表头写作 Cloud Spanner, CockroachDB, TiDB)。
共同点与差异:
| 项 | 内容 |
|---|---|
| 共同点 | etcd 与 NewSQL 都提供强数据一致性保证与高可用性 |
| 差异根源 | 系统设计参数显著不同 → 客户端 API 与性能特征也显著不同 |
| NewSQL 的设计取向 | 面向跨数据中心水平扩展:把数据分区到多个一致的复制组(shards,可能相距很远),存储 TB 级及以上数据集 |
| 为何不适合做分布式协调 | 等待时钟带来长延迟;且假定更新具有 mostly localized dependency graphs |
| 数据组织 | 组织成 tables,提供SQL 风格查询能力,语义比 etcd 更丰富 |
| 代价 | 额外的查询处理、规划与优化复杂度 |
结论(原文):存储元数据或协调分布式应用选 etcd;若数据超过几 GB,或需要完整 SQL 查询,选 NewSQL 数据库。
2.10 用 etcd 存元数据
引自:etcd versus other key-value stores — "Using etcd for metadata"
这一段是理解 etcd 性能特征的关键,逐条拆开:
| 机制 | 说明 |
|---|---|
| 复制范围 | etcd 在单个一致复制组内复制全部数据 |
| 高效场景 | 对"存储最多几 GB 且需要一致排序的数据",这是最高效的做法 |
| 全局唯一 ID | 集群状态的每次修改(可能改动多个 key)都会被赋予一个全局唯一 ID,称为 revision,来自一个单调递增计数器,用于推导顺序 |
| 提交路径 | 因为只有一个复制组,修改请求只需经过 raft 协议即可提交 |
| 收益 | 把共识限制在单一复制组内,etcd 用简单协议获得了分布式一致性,同时实现低延迟与高吞吐 |
| 代价 | etcd 的复制无法水平扩展,因为它没有数据分片(data sharding) |
与 NewSQL 的对照:
| 项 | etcd | NewSQL |
|---|---|---|
| 数据分片 | 无(单复制组) | 通常把数据分片到多个一致复制组 |
| 容量 | 最多几 GB | TB 级 |
| 全局 ID 分配成本 | 由单一复制组的单调递增计数器直接给出 | 每个请求都必须额外经过复制组之间的协调协议 |
| 冲突后果 | 无 | 这一步可能在全局 ID 上发生冲突,迫使有序请求重试 |
| 结果 | 简单、低延迟、高吞吐 | 在严格排序场景上方案更复杂、性能通常比 etcd 更差 |
选型建议(原文):应用主要处理元数据或元数据顺序(如协调进程)→ 选 etcd;需要跨多数据中心的大型数据存储、且不强依赖全局强排序属性 → 选 NewSQL。
2.11 用 etcd 做分布式协调
引自:etcd versus other key-value stores — "Using etcd for distributed coordination"
开箱即用提供的四个原语:
- event watches
- leases
- elections
- distributed shared locks(注意:分布式共享锁有一些非显而易见的性质,见 2.12)
为什么不该把这些原语交给外部库(原文论证链):
| 论据 | 原文表述 |
|---|---|
| 责任归属 | 把这些原语交给外部库,等于回避了开发基础分布式软件的责任,本质上使系统不完整 |
| NewSQL 的现状 | NewSQL 数据库通常期待这些原语由第三方编写 |
| ZooKeeper 的现状 | ZooKeeper 著名地有一个独立分离的协调 recipes 库 —— curator |
| Consul 的现状 | Consul 虽有原生 lock API,却要为其 "not a bulletproof method" 道歉 |
| 算法风险 | 理论上可以在任何强一致存储之上构建这些原语,但算法往往很微妙,很容易写出一个看起来能用、却因 thundering herd(惊群)和 timing skew(时序偏移)突然失效的锁算法 |
| 不止强一致 | etcd 支持的其他原语(如 transactional memory)依赖 etcd 的 MVCC 数据模型,仅有强一致性是不够的 |
结论(原文):分布式协调选 etcd 可避免运维麻烦并节省工程投入。
2.12 lock 与 lease 的使用注意
引自:etcd versus other key-value stores — "Notes on the usage of lock and lease"
这一节是全篇最容易被忽略、也最容易踩坑的一节。
lease 机制的基本思想
| 步骤 | 内容 |
|---|---|
| 1 | 服务端向请求方客户端授予一个称为 lease 的 token |
| 2 | 授予时关联一个 TTL |
| 3 | 当服务端检测到经过的时间超过 TTL,就**撤销(revoke)**该 lease |
| 4 | 客户端持有未被撤销的 lease 期间,可以宣称自己拥有与该 lease 关联的资源的访问权(在 etcd 中该资源就是 keyspace 中的一个 key) |
三条必须记住的限制
| # | 限制 | 原文依据 |
|---|---|---|
| 1 | lock APIs 本身不能当作互斥机制使用;之所以叫 lock 只是出于历史原因(Chubby);lock APIs 只能作为互斥的优化机制 | 原文明确 |
| 2 | lease 机制本身并不保证互斥,持有 lease 不能保证持有资源的锁 | 原文明确;根因:TTL 被定义为物理时间间隔,服务端与客户端各自用自己的时钟度量时间流逝,允许出现"服务端已撤销 lease、客户端仍宣称自己持有该 lease"的情况 |
| 3 | etcd 的 lock 特性不能用于保护外部资源 | 原文明确;若用户要保护与 etcd 无关的外部资源,那些资源必须自己提供版本号校验机制和副本一致性 |
真正的互斥是怎么实现的
etcd 中控制对自身 key 的互斥访问时,互斥是基于**版本号校验(version number validation)**实现的(在 Consul 等系统中也称为 compare and swap):
| 机制 | 说明 |
|---|---|
| 做法 | 在 Put、Txn 这类 etcd RPC 中,可以为操作指定关于 revision number 和 lease ID 的必要条件 |
| 失败语义 | 条件不满足则操作失败 |
| 如何知道拿到锁 | 借助该机制 etcd 为客户端提供分布式锁 —— 即客户端的请求被 etcd 集群成功完成时,它才知道自己获得了某个 key 的锁 |
与分布式锁文献中三个经典概念的对应
| 文献 / 概念 | 出处 | 与 etcd 的对应关系(作者观点) |
|---|---|---|
| sequencer | Chubby 论文 | 作者认为它几乎等同于 etcd 的 revision number + lease ID 的组合 |
| fencing token | Martin Kleppmann,How to do distributed locking | 作者认为在 etcd 场景下它就是 revision number |
| Thor | Practical Uses of Synchronized Clocks in Distributed Systems | 基于版本号校验与 lease 实现分布式锁 |
既然有版本号校验,为什么还要 lease?
原文答案:lease 是一种优化机制,用于减少被中止(aborted)的请求数量。
💡 补充:这一点在实践中的含义是——不要把"我持有 lease"当作"我持有锁"来写业务代码。正确的写法是:把 lease 当作"保活 + 自动清理"(进程挂了锁能自动释放),把
Txn的 revision 比较当作"我是否真的拿到锁"的判据。etcd 官方 Go 客户端的concurrency.Session/concurrency.Mutex就是按这个思路封装的。
3. 术语表
引自:Glossary
本页定义 etcd 文档、命令行和源码中使用的术语。术语名保留英文原文,中文为解释。
3.1 全量术语表
下表按官方文档出现顺序逐条列出(共 24 条术语名):
| # | 术语(英文原文) | 中文解释(依据官方定义) |
|---|---|---|
| 1 | Alarm | 告警。当集群需要运维人员介入才能保持可靠时,etcd server 会触发一个 alarm |
| 2 | Authentication | 鉴权。管理用户对 etcd 资源的访问权限 |
| 3 | Client | 客户端。连接到 etcd 集群并发起服务请求,例如获取键值对、写入数据、或 watch 更新 |
| 4 | Cluster | 集群。由若干 member 组成;每个 member 中的节点遵循 raft 共识协议复制日志;集群接收来自 member 的 proposal,提交(commit)后应用到本地 store |
| 5 | Compaction | 压缩。丢弃给定 revision 之前的全部 etcd 事件历史与被取代的 key,用于回收 etcd 后端数据库的存储空间 |
| 6 | Election | 选举。etcd 集群在成员间举行选举以选出 leader,这是 raft 共识协议的一部分 |
| 7 | Endpoint | 一个指向 etcd 服务或资源的 URL |
| 8 | Key | 用户定义的标识符,用于在 etcd 中存储和检索用户定义的值 |
| 9 | Key range | key 范围。一组 key,可以是单个 key、一个字典序区间(所有满足 a < x <= b 的 x),或大于某个给定 key 的所有 key |
| 10 | Keyspace | 键空间。一个 etcd 集群中所有 key 的集合 |
| 11 | Lease | 租约。一个短期的、可续约的契约(short-lived renewable contract),在其到期时删除与之关联的 key |
| 12 | Member | 成员。一个参与为 etcd 集群提供服务的逻辑 etcd server |
| 13 | Modification Revision | 修改版本号。即最后一次写入某个 key 的第一个 revision(the first revision to hold the last write to a given key) |
| 14 | Peer | 同一集群中的另一个 member |
| 15 | Proposal | 提案。一种需要经过 raft 协议的请求,例如写请求、配置变更请求 |
| 16 | Quorum | 法定人数。修改集群状态达成共识所需的活跃 member 数量;etcd 要求**成员多数派(member majority)**才能达成 quorum |
| 17 | Revision | 版本号。一个 64 位的集群范围计数器,从 1 开始,每次 keyspace 被修改时递增 |
| 18 | Role | 角色。一组 key range 上的权限单元,可授予一组用户用于访问控制 |
| 19 | Snapshot | 快照。etcd 集群状态的某个**时间点(point-in-time)**备份 |
| 20 | Store | 存储。支撑集群 keyspace 的物理存储 |
| 21 | Term | 任期。raft 算法中与每次 leader 选举关联的一个单调递增整数;一个 term 内只能有一个被选出的 leader,且 leader 变更时 term 递增 |
| 22 | Transaction | 事务。原子执行的一组操作;事务中所有被修改的 key 共享同一个 modification revision |
| 23 | Key Version | key 版本。某个 key 自创建以来的写入次数,从 1 开始;不存在或已删除的 key 其 version 为 0 |
| 24 | Watcher | 观察者。客户端打开一个 watcher 以观察给定 key range 上的更新 |
📌 计数说明:官方
learning/glossary.md源文共出现 24 个术语条目(上表 24 行),而素材批注中写为"术语条目共 23 条"。本册以源文实际条目数 24 为准,全量收录,不做删减。
3.2 容易混淆的术语辨析
这几组术语在排查问题时最容易搞混,用表格对照:
| 组 | 术语 | 作用域 | 从几开始 | 何时递增 / 重置 | 典型误用 |
|---|---|---|---|---|---|
| A | Revision | 集群范围(cluster-wide),64 位 | 1 | 每次 keyspace 被修改时递增 | 把它当成"某个 key 的版本" |
| A | Key Version | 单个 key | 1 | 每次写入递增;删除重置为 0 | 以为删除后 version 保留 |
| A | Modification Revision | 单个 key | — | 最后一次写入该 key 的第一个 revision | 与 mod_revision 字段混淆(见下) |
| B | Member | 逻辑 etcd server | — | — | 与"进程/机器"画等号 |
| B | Peer | 同集群中的另一个 member | — | — | 用于指客户端 |
| B | Endpoint | 一个 URL | — | — | 与 member ID 混用 |
| C | Quorum | 集群 | — | 成员多数派 | 以为"半数"即可(多数派 = 超过一半) |
| C | Term | raft 选举周期 | — | leader 变更时递增 | 与 revision 混为一谈 |
| D | Compaction | 回收空间 | — | 按给定 revision 丢弃历史 | 以为压缩后还能读旧 revision |
| D | Snapshot | 时间点备份 | — | — | 与 bbolt snap/db 文件混淆 |
| E | Proposal | 需走 raft 的请求 | — | — | 以为读请求也是 proposal |
| E | Transaction | 原子操作集合 | — | 事务内所有 key 共享同一 modification revision | 以为事务内每个操作各有 revision |
💡 补充:术语表里的 Modification Revision("最后一次写入的第一个 revision")与 API 字段
mod_revision("最后一次修改时的 revision")在语义上对应同一个量,前者是术语定义、后者是 proto 字段名。读代码时看到mod_revision,就按 Modification Revision 的定义理解。
4. 快速上手
4.1 环境要求
| 要求 | 值 / 说明 | 出处 |
|---|---|---|
| 前置阅读一 | Supported platforms(op-guide/supported-platform) |
Install 页 Requirements |
| 前置阅读二 | Hardware recommendations(op-guide/hardware) |
Install 页 Requirements |
| 源码构建所需 Go 版本 | Go 1.21+(front matter minGoVers: 1.21) |
Install 页 front matter |
| 客户端端口 | 2379 | Quickstart / Install / local_cluster |
| peer 端口 | 2380 | Install / local_cluster |
| 版本标签 | v3.7.0(git_version_tag) |
_index.md front matter |
💡 补充:
minGoVers: 1.21只是源码构建的下限,用预编译二进制或 Docker 镜像时不需要本地安装 Go。
4.2 安装方式一 预编译二进制
共 4 步:
| 步骤 | 操作 |
|---|---|
| 1 | 从 Releases 页面下载对应平台的压缩包,选择 release v3.7.0 或更高版本 |
| 2 | 解压缩,得到一个包含二进制文件的目录 |
| 3 | 把可执行二进制加入 PATH(重命名/移动到已在 PATH 中的目录,如 /usr/local/bin;或把上一步生成的目录加入 PATH) |
| 4 | 在 shell 中验证(见下) |
相关链接(原文给出):
- Releases 页面:https://github.com/etcd-io/etcd/releases/
- tagged-release 链接:https://github.com/etcd-io/etcd/releases/tag/v3.7.0
验证:
$ etcd --version
etcd Version: 3.7.0
...
4.3 安装方式二 源码构建
需要 Go 1.21+,共 5 步:
| 步骤 | 命令 / 操作 |
|---|---|
| 1 | 下载 zip 包(https://github.com/etcd-io/etcd/archive/v3.7.0.zip )并解压,或克隆仓库 |
| 2 | cd etcd |
| 3 | ./scripts/build.sh(产物位于 bin 目录) |
| 4 | export PATH="$PATH:pwd/bin" |
| 5 | etcd --version 验证 |
$ git clone -b v3.7.0 https://github.com/etcd-io/etcd.git
$ cd etcd
$ ./scripts/build.sh
$ export PATH="$PATH:`pwd`/bin"
$ etcd --version
etcd Version: 3.7.0
原文注解:若要基于开发分支
github_branch@HEAD构建,则省略-b v3.7.0这个 flag。
4.4 安装方式三 Docker
主容器镜像仓库是 gcr.io/etcd-development/etcd,备用(secondary)是 quay.io/coreos/etcd。
官方给出的完整运行脚本(端口映射 2379 客户端 / 2380 peer):
ETCD_VER=v3.7.0
rm -rf /tmp/etcd-data.tmp && mkdir -p /tmp/etcd-data.tmp && \
docker rmi gcr.io/etcd-development/etcd:${ETCD_VER} || true && \
docker run \
-p 2379:2379 \
-p 2380:2380 \
--mount type=bind,source=/tmp/etcd-data.tmp,destination=/etcd-data \
--name etcd-gcr-${ETCD_VER} \
gcr.io/etcd-development/etcd:${ETCD_VER} \
/usr/local/bin/etcd \
--name s1 \
--data-dir /etcd-data \
--listen-client-urls http://0.0.0.0:2379 \
--advertise-client-urls http://0.0.0.0:2379 \
--listen-peer-urls http://0.0.0.0:2380 \
--initial-advertise-peer-urls http://0.0.0.0:2380 \
--initial-cluster s1=http://0.0.0.0:2380 \
--initial-cluster-token tkn \
--initial-cluster-state new \
--log-level info \
--logger zap \
--log-outputs stderr
docker exec etcd-gcr-${ETCD_VER} /usr/local/bin/etcd --version
docker exec etcd-gcr-${ETCD_VER} /usr/local/bin/etcdctl version
docker exec etcd-gcr-${ETCD_VER} /usr/local/bin/etcdutl version
docker exec etcd-gcr-${ETCD_VER} /usr/local/bin/etcdctl endpoint health
docker exec etcd-gcr-${ETCD_VER} /usr/local/bin/etcdctl put foo bar
docker exec etcd-gcr-${ETCD_VER} /usr/local/bin/etcdctl get foo
脚本中出现的配置字段一览表:
| 类别 | 字段 / 命令 | 值 |
|---|---|---|
| 容器 | 容器名 | etcd-gcr-${ETCD_VER} |
| 镜像 | 主仓库 | gcr.io/etcd-development/etcd:${ETCD_VER} |
| 镜像 | 备用仓库 | quay.io/coreos/etcd |
| 端口 | 客户端 / peer | 2379 / 2380 |
| flag | --name |
s1 |
| flag | --data-dir |
/etcd-data |
| flag | --listen-client-urls |
http://0.0.0.0:2379 |
| flag | --advertise-client-urls |
http://0.0.0.0:2379 |
| flag | --listen-peer-urls |
http://0.0.0.0:2380 |
| flag | --initial-advertise-peer-urls |
http://0.0.0.0:2380 |
| flag | --initial-cluster |
s1=http://0.0.0.0:2380 |
| flag | --initial-cluster-token |
tkn |
| flag | --initial-cluster-state |
new |
| flag | --log-level |
info |
| flag | --logger |
zap |
| flag | --log-outputs |
stderr |
| 验证 | 可执行文件 | etcd、etcdctl、etcdutl 三个 |
| 验证 | 命令 | etcd --version、etcdctl version、etcdutl version、etcdctl endpoint health、etcdctl put foo bar、etcdctl get foo |
4.5 安装方式四 操作系统包管理器
官方免责声明(原文保留):通过 OS 包管理器安装的 etcd 可能版本过时,因为这类包既不由 etcd 项目自动维护、也不被官方支持,需谨慎使用。
| 平台 | 前置条件 | 过程 | 结果 / 结论 |
|---|---|---|---|
| MacOS(Homebrew) | — | brew update → brew install etcd |
etcd --version 验证 |
| Linux(发行版仓库) | — | 多数主流发行版的官方仓库和包管理器都能装 | 发布的版本可能显著过时,因此强烈不建议这种方式 |
| Linux 上的推荐做法 | — | 「预编译二进制」或「使用 Homebrew」 | — |
| Homebrew on Linux | brew update |
brew install etcd |
执行 etcd --version,输出 etcd Version: 3.7.0 |
原文注解:Homebrew 可运行在 Linux 上,能提供较新的软件版本。
第五种方式(官方指向):作为 Kubernetes 安装的一部分 —— 指向 op-guide/kubernetes,即 Running etcd as a Kubernetes StatefulSet。
4.6 安装检查
引自:Install — "Installation check"
想做稍微更完整的健全性检查,参见 Quickstart 页面(即本章 4.7)。
4.7 五分钟启动单节点
引自:Quickstart
本页目标:5 分钟内在本地安装、运行并测试一个单成员(single-member) etcd 集群。
步骤 1 安装 etcd(预编译二进制或源码),务必执行安装说明的最后一步,确认 etcd 已在 PATH 中
步骤 2 启动:etcd
步骤 3 另一个终端写入:etcdctl put greeting "Hello, etcd"
步骤 4 同一终端读回:etcdctl get greeting
步骤 2 启动:
$ etcd
{"level":"info","ts":"2021-09-17T09:19:32.783-0400","caller":"etcdmain/etcd.go:72","msg":... }
⋮
原文注意:
etcd的输出是日志(logs,参见op-guide/configuration/#logging),info 级别的日志可以忽略。
步骤 3 写入(在另一个终端):
$ etcdctl put greeting "Hello, etcd"
OK
步骤 4 读回:
$ etcdctl get greeting
greeting
Hello, etcd
What's next?(路径中的 {{< param version >}} 即 v3.7):
| 读者 | 后续页面 |
|---|---|
| 开发者 | 探索 gRPC API(/docs/v3.7/learning/api);查找语言绑定与工具(/docs/v3.7/integrations) |
| 运维 / 管理员 | 搭建多机集群(/docs/v3.7/op-guide/clustering);学习如何配置 etcd(/docs/v3.7/op-guide/configuration);用 TLS 保护 etcd 集群(/docs/v3.7/op-guide/security);调优 etcd(/docs/v3.7/tuning) |
4.8 启动三节点本地集群
测试与开发部署最快最简单的方式是配置本地集群;生产部署请参考 clustering 章节(op-guide/clustering/)。
一、Local standalone cluster(本地单节点集群)
$ ./etcd
原文注解:若当前工作目录下没有
etcd二进制,它可能在$GOPATH/bin/etcd或/usr/local/bin/etcd,按实际位置执行命令。运行起来的 etcd member 在localhost:2379上监听客户端请求。
交互:
$ ./etcdctl put foo bar
OK
$ ./etcdctl get foo
bar
二、Local multi-member cluster(本地多成员集群)
etcd git 仓库根目录下提供一个 Procfile,用于方便地配置本地多成员集群。操作前需先进入 etcd 源码树根目录。
$ go install github.com/mattn/goreman@latest
$ goreman -f Procfile start
成员启动后,客户端请求监听端口:
| 成员 | client 端口 | peer 端口 |
|---|---|---|
infra1 |
2379 | 2380 |
infra2 |
22379 | 22380 |
infra3 |
32379 | 32380 |
打印成员列表:
$ etcdctl --write-out=table --endpoints=localhost:2379 member list
+------------------+---------+--------+------------------------+------------------------+
| ID | STATUS | NAME | PEER ADDRS | CLIENT ADDRS |
+------------------+---------+--------+------------------------+------------------------+
| 8211f1d0f64f3269 | started | infra1 | http://127.0.0.1:2380 | http://127.0.0.1:2379 |
| 91bc3c398fb3c146 | started | infra2 | http://127.0.0.1:22380 | http://127.0.0.1:22379 |
| fd422379fda50e48 | started | infra3 | http://127.0.0.1:32380 | http://127.0.0.1:32379 |
+------------------+---------+--------+------------------------+------------------------+
表格列:ID、STATUS、NAME、PEER ADDRS、CLIENT ADDRS。三个成员状态均为 started。
存入示例键值对:
$ etcdctl put foo bar
OK
4.9 测试容错
| 步骤 | 命令 | 说明 |
|---|---|---|
| 1 | (查看 Procfile 中列出的进程名,示例取名为 etcd2 的成员) |
确定要停掉的成员的进程名 |
| 2 | goreman run stop etcd2 |
停止该成员 |
| 3 | etcdctl put key hello → OK |
存一个 key(集群仍可写) |
| 4 | etcdctl get key → hello |
读回该 key |
| 5 | etcdctl --endpoints=localhost:22379 get key |
从已停止的成员读,应显示连接失败错误 |
| 6 | goreman run restart etcd2 |
重启被停掉的成员 |
| 7 | etcdctl --endpoints=localhost:22379 get key → hello |
再从重启后的成员读,应成功 |
步骤 5 的预期错误输出(原文照录):
2017/06/18 23:07:35 grpc: Conn.resetTransport failed to create client transport: connection error: desc = "transport: dial tcp 127.0.0.1:22379: getsockopt: connection refused"; Reconnecting to "localhost:22379"
Error: grpc: timed out trying to connect
原文注解:重启成员会重新建立连接,
etcdctl此时能成功读到 key。想进一步了解与 etcd 的交互,可阅读 interacting with etcd 章节(dev-guide/interacting_v3/),即本册 第 12 章。
4.10 第一个 put 与 get 示例
引自:Demo
Demo 页当前正文只有一个小节:Auth,演示 auth、user、role 三个子命令做鉴权。先设置环境变量:
export ETCDCTL_API=3
ENDPOINTS=localhost:2379
完整脚本(原文照录,注释为原文自带):
etcdctl --endpoints=${ENDPOINTS} role add root
etcdctl --endpoints=${ENDPOINTS} role get root
etcdctl --endpoints=${ENDPOINTS} user add root
etcdctl --endpoints=${ENDPOINTS} user grant-role root root
etcdctl --endpoints=${ENDPOINTS} user get root
etcdctl --endpoints=${ENDPOINTS} role add role0
etcdctl --endpoints=${ENDPOINTS} role grant-permission role0 readwrite foo
etcdctl --endpoints=${ENDPOINTS} user add user0
etcdctl --endpoints=${ENDPOINTS} user grant-role user0 role0
etcdctl --endpoints=${ENDPOINTS} auth enable
# now all client requests go through auth
etcdctl --endpoints=${ENDPOINTS} --user=user0:123 put foo bar
etcdctl --endpoints=${ENDPOINTS} get foo
# permission denied, user name is empty because the request does not issue an authentication request
etcdctl --endpoints=${ENDPOINTS} --user=user0:123 get foo
# user0 can read the key foo
etcdctl --endpoints=${ENDPOINTS} --user=user0:123 get foo1
流程含义拆解:
| 阶段 | 命令 | 含义 |
|---|---|---|
| 建 root 角色 | role add root、role get root |
创建并查看角色 root |
| 建 root 用户并授权 | user add root → user grant-role root root → user get root |
创建用户 root,把 root 角色授予该用户 |
| 建受限角色 | role add role0 → role grant-permission role0 readwrite foo |
创建 role0,授予它对 key foo 的 readwrite 权限 |
| 建受限用户 | user add user0 → user grant-role user0 role0 |
创建 user0,把 role0 授予它 |
| 开启鉴权 | auth enable |
此后所有客户端请求都要经过鉴权 |
| 验证 1 | --user=user0:123 put foo bar |
带用户写 foo 成功 |
| 验证 2 | get foo(不带 --user) |
permission denied;原因(原文注释):请求没有发起认证,用户名为空 |
| 验证 3 | --user=user0:123 get foo |
可以读到(原文注释:user0 can read the key foo) |
| 验证 4 | --user=user0:123 get foo1 |
foo1 不在 role0 被授权的 key 上,用于验证越权访问被拒 |
💡 补充:这个 demo 同时演示了 11.3 里最重要的两个概念——role 是 key range 上的权限单元(
role grant-permission role0 readwrite foo),以及开启 auth 后未认证请求一律被拒。注意readwrite是 etcdctl 命令行层的权限名写法,对应 proto 层的Permission消息(permType/key/range_end,见 6.8)。
5. 数据模型
5.1 设计目标
| 设计目标 | 原文表述 |
|---|---|
| 存什么数据 | 可靠存储不频繁更新的数据 |
| 提供什么能力 | 提供可靠的 watch 查询 |
| 对外暴露什么 | 暴露 key-value 对的历史版本,从而支持低成本的快照与 watch 历史事件("time travel queries",时间旅行查询) |
| 模型特征 | 持久化(persistent)、多版本(multi-version)、并发控制(concurrency-control) 的数据模型正好契合这些场景 |
immutable(不可变)特性:
| 要点 | 说明 |
|---|---|
| 存储形态 | 数据存放在多版本持久化 key-value 存储中 |
| 取代行为 | 当某个 key-value 对被新数据取代时,存储会保留其旧版本 |
| 不可变性 | 该 key-value 存储实际上是不可变(immutable)的:操作不会原地(in-place)更新结构,而是始终生成一个更新后的新结构 |
| 修改之后 | 所有历史版本的 key 依然可访问、可 watch |
| 防止无限增长 | 可以对存储执行 compact(压缩) 来丢弃被取代数据的最旧版本 |
5.2 逻辑视图
存储的逻辑视图是一个扁平的二进制 key 空间(flat binary key space)。key 空间在字节串 key 上维护字典序(lexically sorted)索引,因此 range 查询开销很低。
5.2.1 revision(修订号)
| 要点 | 说明 |
|---|---|
| 初始 revision | 存储创建时初始 revision 为 1 |
| 何时新建 revision | 每一个原子性变更操作(例如一个事务操作,其中可能包含多个操作)都会在 key 空间上创建一个新的 revision |
| 旧数据 | 之前 revision 所持有的全部数据保持不变 |
| 旧版本访问 | 旧版本的 key 仍可通过先前的 revision 访问 |
| 索引 | revision 也被索引了,因此结合 watcher 对 revision 做范围扫描是高效的 |
| compaction 的影响 | 若存储被 compact 以节省空间,则 compact revision 之前的 revision 会被移除 |
| 单调性 | revision 在集群的整个生命周期内单调递增 |
5.2.2 generation(代)与 version(版本号)
| 要点 | 说明 |
|---|---|
| generation 定义 | 一个 key 的生命周期横跨一个 generation,从创建到删除 |
| 多个 generation | 每个 key 可以有一个或多个 generation |
| 创建 | 创建 key 会递增该 key 的 version;若该 key 在当前 revision 不存在则从 1 开始 |
| 删除 | 删除 key 会生成一个 key tombstone(墓碑),通过把 version 重置为 0 来终结该 key 当前的 generation |
| 修改 | 对 key 的每次修改都会递增其 version,因此 version 在一个 key 的 generation 内单调递增 |
5.2.3 compaction 对逻辑视图的影响
| 影响 | 说明 |
|---|---|
| 对 generation | 一旦发生 compaction,任何在 compaction revision 之前结束的 generation 都会被移除 |
| 对 value | 在 compaction revision 之前设置的值,除最新一个之外都会被移除 |
用一个具体例子理解(示例为整理,规则来自原文):
revision: 1 2 3 4 5
key foo: [创建 bar] [改为 bar_new]
key foo1: [创建 bar1] [改为 bar1_new]
若在 revision 5 之后执行 compact 4:
- revision 1..4 中被取代的值被移除
- 但每个 key 的"最新一个值"保留
- 此后 get --rev=3 会失败(required revision has been compacted)
5.3 物理视图
etcd 把物理数据作为 key-value 对存放在持久化的 b+tree 中。
| 要点 | 说明 |
|---|---|
| 存储结构 | 持久化的 b+tree |
| delta 存储 | 存储的每个 revision 只包含相对上一个 revision 的 delta(增量),以提高效率 |
| 一对多 | 单个 revision 可能对应树中的多个 key |
5.3.1 key 的 3 元组(triple)
key-value 对的 key 是一个三元组 (major, sub, type):
| 分量 | 含义 |
|---|---|
| major | 持有该 key 的存储 revision |
| sub | 区分同一 revision 内的不同 key |
| type | 可选后缀,用于特殊值(例如值包含 tombstone 时为 t) |
5.3.2 value
key-value 对的 value 包含相对上一 revision 的修改,即一个 delta(而不是完整值)。
5.3.3 排序与范围查找
| 要点 | 说明 |
|---|---|
| 排序 | b+tree 按 key 的字节字典序排序 |
| 高效原因 | 对 revision delta 做范围查找很快,这使人能快速找出从一个特定 revision 到另一个 revision 之间的修改 |
| compaction | 会移除过期的 key-value 对 |
5.3.4 二级内存 btree 索引
| 要点 | 说明 |
|---|---|
| 结构 | etcd 另外维护一个内存中的 btree 索引,用于加速对 key 的 range 查询 |
| 索引 key | btree 索引中的 key 就是暴露给用户的存储 key |
| 索引 value | 是指向持久化 b+tree 中该次修改的指针 |
| compaction | 会移除失效(dead)指针 |
5.3.5 整体读取路径
1. etcd 先从(内存)btree 取得 revision 信息
2. 然后用该 revision 作为 key 从(持久化)b+tree 中取 value
原文引用了图示
data-model-figure-01.png说明该读取路径。
5.4 扁平二进制键空间
etcd 数据模型在扁平二进制 key space 上索引所有 key,而非层级目录。
| 要点 | 说明 |
|---|---|
| 为何扁平 | 扁平二进制 key space 上索引所有 key,而不是目录树 |
| 表达力 | key 按区间 [a, b) 列出,etcd 中称为 "ranges";区间比目录更强大 |
| 示例文件 | 原文引用 data-model-figure-01.png |
5.5 range 与前缀
5.5.1 range 的表示约定
range 由 key 与 range_end 两个字段表示:
| 情形 | 含义 |
|---|---|
key |
区间的第一个 key,必须非空 |
range_end |
区间中最后一个 key 的后一个 key |
range_end 未给出或为空 |
区间只包含 key 本身 |
range_end 是 key 加一(如 "aa"+1 == "ab"、"a\xff"+1 == "b") |
区间表示所有以 key 为前缀的 key |
key 与 range_end 都是 \0 |
区间表示所有 key |
range_end 是 \0 |
区间表示所有大于等于 key 的 key |
5.5.2 range 的三种典型用法
| 用法 | 编码方式 | 例子 |
|---|---|---|
| 单 key 查找 | [a, a+1) |
['a', 'a\x00') 查找 'a' |
| 目录查找 | 按目录深度编码 | — |
| 前缀查找 | [key, key+1) / 边界编码 |
['a', 'b') 查找所有以 'a' 为前缀的 key |
5.5.3 示例:前缀 range
$ etcdctl put foo bar
OK
$ etcdctl put foo1 bar1
OK
$ etcdctl put foo2 bar2
OK
$ etcdctl get --prefix foo
foo
bar
foo1
bar1
foo2
bar2
$ etcdctl get foo foo2
foo
bar
foo1
bar1
原文注解(interacting_v3):
etcdctl get foo foo3中foo3被排除,因为区间是半开区间[foo, foo3),不含foo3。
5.6 lease 与 key 过期
| 要点 | 说明 |
|---|---|
| lease 是什么 | 检测客户端**存活(liveness)**的机制 |
| TTL | 集群授予 lease 一个 TTL;若在给定 TTL 周期内 etcd 集群未收到 keepAlive,lease 即过期 |
| 附加限制 | 每个 key 最多只能附加一个 lease |
| 过期/撤销时 | 附加到该 lease 的所有 key 都会被删除 |
| 事件 | 每个过期的 key 都会在事件历史中生成一个 delete 事件 |
| TTL 单位 | LeaseGrantRequest.TTL 是建议的存活时间(秒) |
| ID | LeaseGrantRequest.ID 是请求的 lease ID,若设为 0,则由 etcd 选择 ID |
最小示例:
# 授予一个 60 秒 TTL 的 lease
$ etcdctl lease grant 60
lease 32695410dcc0ca06 granted with TTL(60s)
# 把 key foo 挂到该 lease 上
$ etcdctl put --lease=32695410dcc0ca06 foo bar
OK
# 60 秒后 foo 自动消失
$ etcdctl get foo
# empty response
💡 补充:lease 过期不是精确的"第 60.000 秒删除"。服务端按墙上时钟推进 TTL,且
LeaseCheckpoint只扩展RemainingTTL(见 8.3)。实际工程中不要依赖 lease 做秒级精确的定时删除,而应把它当作"最多存活 N 秒"的保活契约。
5.7 watch 机制
| 要点 | 说明 |
|---|---|
| 接口性质 | Watch API 提供基于事件的接口,用于异步监控 key 的变化 |
| 工作方式 | etcd watch 从给定 revision(当前或历史)持续等待变化,并把 key 更新流式返回给客户端 |
| 历史范围 | 整个可用事件历史可从最后一次 compaction revision 开始 watch |
| stream 性质 | watch 是长连接请求,使用 gRPC stream 流式传输事件数据 |
| 双向 | watch stream 是双向的:客户端写流以建立 watch,读流以接收事件 |
| 复用 | 单个 watch stream 可通过给事件打上 per-watch 标识符来复用多个不同的 watch,有助于降低核心 etcd 集群的内存占用与连接开销 |
5.7.1 Event 消息
message Event {
enum EventType { PUT = 0; DELETE = 1; }
EventType type = 1;
KeyValue kv = 2;
KeyValue prev_kv = 3;
}
| 字段 | 说明 |
|---|---|
Type |
事件类型:PUT 表示有新数据存入 key;DELETE 表示 key 被删除 |
KV |
事件关联的 KeyValue:PUT 事件包含当前 kv 对,kv.Version=1 的 PUT 事件表示 key 的创建;DELETE 事件包含被删除的 key,其 modification revision 被设为删除时的 revision |
Prev_KV |
事件发生前一个 revision 的 key-value 对;为节省带宽,仅当 watch 显式开启时才填充 |
5.7.2 示例:watch 一个 key
# 终端 A
$ etcdctl watch foo
# 终端 B:etcdctl put foo bar
PUT
foo
bar
# 交互式会话同时 watch 多个 key
$ etcdctl watch -i
$ watch foo
$ watch zoo
# 终端 B:etcdctl put foo bar
PUT
foo
bar
# 终端 B:etcdctl put zoo val
PUT
zoo
val
5.8 最小示例
最小闭环:写 → 读 → 删
export ETCDCTL_API=3
$ etcdctl put foo bar
OK
$ etcdctl get foo
foo
bar
$ etcdctl del foo
1 # one key is deleted
最小闭环:带 revision 的历史读
$ etcdctl put foo bar # revision = 2
OK
$ etcdctl put foo bar_new # revision = 4
OK
$ etcdctl get --rev=2 foo
foo
bar
最小闭环:watch
$ etcdctl watch --rev=2 foo
PUT
foo
bar
PUT
foo
bar_new
💡 补充(Go 客户端最小示例,非官方文档原文):
package main
import (
"context"
"fmt"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
)
func main() {
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"},
DialTimeout: 5 * time.Second,
})
if err != nil {
panic(err)
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := cli.Put(ctx, "foo", "bar"); err != nil {
panic(err)
}
resp, err := cli.Get(ctx, "foo")
if err != nil {
panic(err)
}
for _, kv := range resp.Kvs {
fmt.Printf("key=%s value=%s create_rev=%d mod_rev=%d version=%d lease=%d\n",
kv.Key, kv.Value, kv.CreateRevision, kv.ModRevision, kv.Version, kv.Lease)
}
fmt.Printf("header.revision=%d raft_term=%d\n", resp.Header.Revision, resp.Header.RaftTerm)
}
5.9 进阶示例
5.9.1 用 revision 做"一致的多 key 视图"
# 先制造历史
$ etcdctl put foo bar # revision = 2
OK
$ etcdctl put foo1 bar1 # revision = 3
OK
$ etcdctl put foo bar_new # revision = 4
OK
$ etcdctl put foo1 bar1_new # revision = 5
OK
$ etcdctl get --prefix foo # 最新版本
foo
bar_new
foo1
bar1_new
$ etcdctl get --prefix --rev=4 foo # revision 4 时刻的视图
foo
bar_new
foo1
bar1
$ etcdctl get --prefix --rev=3 foo # revision 3 时刻的视图
foo
bar
foo1
bar1
$ etcdctl get --prefix --rev=2 foo # revision 2 时刻的视图
foo
bar
$ etcdctl get --prefix --rev=1 foo # revision 1 时刻(空)
这正是 5.1 里说的 time travel queries:因为 revision 是集群级逻辑时钟,"在某个 revision 读一批 key"天然得到一致快照,无需额外加锁。
5.9.2 用 Txn 做 compare-and-swap(进阶:分布式互斥的基础)
Txn 的语义见 6.3。这里给出一个最小的"仅当值等于旧值时才写入":
// 💡 补充(工程示例,非官方文档原文):基于 mod_revision 的 CAS
resp, err := cli.Txn(ctx).
If(clientv3.Compare(clientv3.ModRevision("foo"), "=", 4)).
Then(clientv3.OpPut("foo", "bar_cas")).
Else(clientv3.OpGet("foo")).
Commit()
if err != nil {
panic(err)
}
fmt.Println("succeeded:", resp.Succeeded, "header.revision:", resp.Header.Revision)
对应的 etcdctl 层等价操作(官方文档未提供 etcdctl txn 的示例,见 12.14):
📌 官方文档未提供:
dev-guide/interacting_v3.md未包含TXN(事务)命令的示例与输出。上例为本册基于 API 语义整理的工程示例。
5.9.3 lease + keepalive 的存活检测
$ etcdctl lease grant 60
lease 32695410dcc0ca06 granted with TTL(60s)
$ etcdctl lease keep-alive 32695410dcc0ca06
lease 32695410dcc0ca06 keepalived with TTL(60)
lease 32695410dcc0ca06 keepalived with TTL(60)
lease 32695410dcc0ca06 keepalived with TTL(60)
...
5.9.4 一个"服务注册"的最小模型
💡 补充:把上面四个原语组合起来就是最经典的 etcd 用法——服务注册 + 存活检测 + 变更订阅。
# 服务实例注册:key = /services/order/10.0.0.1:8080,挂 30 秒 lease
$ etcdctl lease grant 30
lease 694d5765fc71500b granted with TTL(30s)
$ etcdctl put /services/order/10.0.0.1:8080 '{"weight":10}' --lease=694d5765fc71500b
OK
# 调用方订阅整个服务目录
$ etcdctl watch --prefix /services/order/
# 调用方列举当前实例
$ etcdctl get --prefix /services/order/
进程只要周期性
lease keep-alive就"在线";进程挂掉不再续约,30 秒后 key 自动删除,watcher 收到 DELETE 事件。这就是"monitoring machine liveness"的实现骨架。
6. etcd API
6.1 gRPC services 总览
所有 etcd API 都定义在 gRPC services 中,按功能对 etcd server 能理解的 RPC 进行分类。注意这是 v3 API,不要与 etcd v3.5 中已废弃的 v2 API 混淆。
处理 key space 的服务(3 个):
| service | 用途 |
|---|---|
KV |
创建、更新、读取、删除 key-value 对 |
Watch |
监控 key 的变化 |
Lease |
消费客户端 keep-alive 消息的原语 |
管理集群自身的服务(3 个):
| service | 用途 |
|---|---|
Auth |
基于角色的认证机制,用于认证用户 |
Cluster |
提供成员信息与配置能力 |
Maintenance |
生成恢复用快照、整理存储碎片(defragment)、返回每个成员的状态信息 |
并发原语服务(2 个,来自 api_concurrency_reference_v3):
| service | 用途 |
|---|---|
Lock |
把客户端侧的加锁能力以 gRPC 接口形式暴露(client-side locking facilities) |
Election |
把客户端侧的选举能力以 gRPC 接口形式暴露(client-side election facilities) |
6.2 统一请求响应格式
每个 RPC 都遵循同一格式:函数 Name 接受 NameRequest 参数并返回 NameResponse。例如 Range(RangeRequest) returns (RangeResponse)。
6.2.1 ResponseHeader(所有响应都附带)
message ResponseHeader {
uint64 cluster_id = 1;
uint64 member_id = 2;
int64 revision = 3;
uint64 raft_term = 4;
}
| 字段 | 类型 | 说明 |
|---|---|---|
Cluster_ID |
uint64 |
生成该响应的集群 ID(应用可据此确认正在与预期集群通信) |
Member_ID |
uint64 |
生成该响应的成员 ID |
Revision |
int64 |
生成响应时 key-value 存储的 revision(做 time travel query 时可用它知道请求时刻的最新 revision) |
Raft_Term |
uint64 |
生成响应时成员的 Raft term(可用于检测集群是否完成了一次新的 leader 选举) |
补充定义(来自
api_reference_v3的ResponseHeader):revision是 请求被 apply 时 key-value store 的 revision;对于不与 key-value store 交互的调用,该字段不设置(即为 0)。对于 watch progress 响应,header.revision表示进度:该 stream 上后续收到的所有事件,保证 revision 大于header.revision。
6.2.2 KeyValue(key-value API 可操作的最小单位)
message KeyValue {
bytes key = 1;
int64 create_revision = 2;
int64 mod_revision = 3;
int64 version = 4;
bytes value = 5;
int64 lease = 6;
}
| 字段 | 类型 | 说明 |
|---|---|---|
Key |
bytes |
字节形式的 key,不允许空 key |
Value |
bytes |
字节形式的 value |
Version |
int64 |
key 的版本;删除会将其重置为 0,任何修改都会使其递增 |
Create_Revision |
int64 |
该 key 最后一次创建时的 revision |
Mod_Revision |
int64 |
该 key 最后一次修改时的 revision |
Lease |
int64 |
附加到该 key 的 lease ID;若为 0 表示没有附加 lease |
revision 元数据的三个用途(原文列举):
| 用途 | 使用的 revision |
|---|---|
| etcd 客户端的分布式共享锁 | 使用 create revision 来等待锁归属 |
| 软件事务内存(STM) | 用 mod revision 检测读集冲突 |
| leader election | 用 mod revision 等待更新 |
6.2.3 Revisions(修订号)语义
| 要点 | 说明 |
|---|---|
| 计数器 | etcd 维护一个 64 位集群级计数器 —— store revision,每次 key space 被修改时递增 |
| 角色 | 它充当全局逻辑时钟,顺序化所有更新 |
| 增量式 | 新 revision 所代表的变化是增量式的;与某 revision 关联的数据就是改变了存储的那部分数据 |
| 内部实现 | 新 revision 意味着把变更写入后端 B+tree,并以递增后的 revision 作为 key |
| 历史访问 | 结合 MVCC 后端,可以从过去的 revision 观察 key-value 存储 |
| 保留策略 | 历史保留策略可由管理员配置,通常 etcd 按定时器丢弃 key 的旧 revision,典型集群保留数小时 |
| 断连恢复 | 这也为长时间客户端断连提供可靠处理:watcher 只需从最后观测到的历史 revision 恢复 |
| 带 revision 读 | 读请求也可携带 revision,以返回该 revision 提交时刻的 key space 视图 |
6.2.4 Key ranges(key 区间)
约定与示例见 5.5。补充 API 层的原文表述:
| 情形 | 含义 |
|---|---|
range_end 未给出 |
请求只查找 key |
range_end 为 \0 |
range 为所有 >= key 的 key |
range_end 为 key 加一("aa"+1 == "ab"、"a\xff"+1 == "b") |
range 请求获取所有以 key 为前缀的 key |
key 与 range_end 均为 \0 |
range 请求返回所有 key |
6.3 KV service
方法清单(方法 | 请求 | 响应 | 用途):
| 方法 | 请求 | 响应 | 用途 |
|---|---|---|---|
Range |
RangeRequest |
RangeResponse |
取区间内的 key |
Put |
PutRequest |
PutResponse |
写入 key;递增 revision 并在事件历史中生成一个事件 |
DeleteRange |
DeleteRangeRequest |
DeleteRangeResponse |
删除区间;递增 revision 并为每个被删 key 生成一个 delete 事件 |
Txn |
TxnRequest |
TxnResponse |
单事务处理多个请求;递增 revision,并为每个完成的请求生成相同 revision 的事件;不允许在一个 txn 内多次修改同一 key |
Compact |
CompactionRequest |
CompactionResponse |
压缩事件历史;应定期执行,否则历史无限增长 |
6.3.1 RangeRequest
message RangeRequest {
enum SortOrder { NONE = 0; ASCEND = 1; DESCEND = 2; }
enum SortTarget { KEY = 0; VERSION = 1; CREATE = 2; MOD = 3; VALUE = 4; }
bytes key = 1; bytes range_end = 2; int64 limit = 3; int64 revision = 4;
SortOrder sort_order = 5; SortTarget sort_target = 6; bool serializable = 7;
bool keys_only = 8; bool count_only = 9; int64 min_mod_revision = 10;
int64 max_mod_revision = 11; int64 min_create_revision = 12; int64 max_create_revision = 13;
}
| 字段 | 类型 | 说明 |
|---|---|---|
Key, Range_End |
bytes |
要取的 key 区间 |
Limit |
int64 |
返回 key 的最大数量;为 0 时表示无限制 |
Revision |
int64 |
取值的时间点;小于或等于 0 表示对最新的 key-value 存储取范围;若该 revision 已被压缩则返回 ErrCompacted |
Sort_Order |
enum | NONE(默认不排序)、ASCEND(最低目标值优先)、DESCEND(最高目标值优先) |
Sort_Target |
enum | KEY / VERSION / CREATE / MOD / VALUE |
Serializable |
bool |
设为 true 时使用 serializable 的成员本地读;默认 Range 是 linearizable(反映集群当前共识);serializable 无需与其他节点达成共识即可本地服务,以可能的陈旧读换取性能与可用性 |
Keys_Only |
bool |
只返回 key 不返回 value |
Count_Only |
bool |
只返回区间内 key 的数量 |
Min_Mod_Revision / Max_Mod_Revision |
int64 |
mod revision 的下界 / 上界过滤(下界:更小的被过滤掉;上界:更大的被过滤掉) |
Min_Create_Revision / Max_Create_Revision |
int64 |
create revision 的下界 / 上界过滤 |
6.3.2 RangeResponse
message RangeResponse {
ResponseHeader header = 1;
repeated mvccpb.KeyValue kvs = 2;
bool more = 3;
int64 count = 4;
}
| 字段 | 说明 |
|---|---|
Kvs |
匹配到的 key-value 列表(设置 Count_Only 时为空) |
More |
若设置了 limit,指示区间内是否还有更多 key |
Count |
满足区间请求的 key 总数;与 Kvs 不同,它不受 limit 与过滤器影响(Min/Max、Create/Modify、Revisions),反映指定范围内的完整计数 |
6.3.3 RangeStream
RangeStream 返回与 Range 相同的结果集,但服务端把响应切分成一系列 chunk 流式下发给客户端,避免两端在内存中缓冲整个大区间。它接受与 Range 相同的 RangeRequest,客户端收到的是:
message RangeStreamResponse { RangeResponse range_response = 1; }
跨 chunk 的字段填充规则:
| 字段 | 规则 |
|---|---|
Kvs |
每个 chunk 携带结果的一个互不相交切片;按到达顺序拼接所有 chunk 的 kvs,可得到与单次 Range 相同的 key 集合 |
Header, More, Count |
只在最后一个 chunk 上填充,且仅在流无错完成时填充;之前的 chunk 这些字段为零值 |
| 合并方式 | 对所有 chunk 的 range_response 使用 proto.Merge,可得到与 Range 等价的 RangeResponse |
| 错误时 | 若流以错误结束,则没有任何 chunk 携带有效的 header、more 或 count |
| revision 一致性 | 流中每个 chunk 都基于同一 revision 提供服务。若请求未设置 Revision,服务端在流开始时捕获最新已提交 revision 并在整个流中复用 |
限制:
| 限制 | 说明 |
|---|---|
| 不支持排序与过滤器 | RangeStream 不支持自定义排序与 revision 过滤器(min_mod_revision、max_mod_revision、min_create_revision、max_create_revision),使用这些的请求返回 Unimplemented |
| proxy 不支持 | etcd gRPC proxy 也不支持 RangeStream |
两种常见消费方式:
| 方式 | 说明 |
|---|---|
| ① 独立处理每个 chunk | 高性能场景:客户端边到达边解码处理,最后从末尾 chunk 读取 header/more/count |
| ② 组装成单个响应 | 用 proto.Merge 合并;Go 客户端提供 clientv3.GetStreamToGetResponse 辅助函数 |
# etcdctl 侧:--stream 走 RangeStream
$ etcdctl get --stream --prefix foo
foo
bar
foo1
bar1
foo2
bar2
foo3
bar3
原文注解:
--stream不支持--order、--sort-by以及 revision 过滤器。
6.3.4 PutRequest / PutResponse
message PutRequest {
bytes key = 1; bytes value = 2; int64 lease = 3;
bool prev_kv = 4; bool ignore_value = 5; bool ignore_lease = 6;
}
| 字段 | 类型 | 说明 |
|---|---|---|
Key |
bytes |
要写入的 key 名 |
Value |
bytes |
关联的字节值 |
Lease |
int64 |
关联的 lease ID,0 表示无 lease |
Prev_Kv |
bool |
设置时返回本次 Put 更新前的 key-value 数据 |
Ignore_Value |
bool |
设置时更新 key 但不改变其当前 value;key 不存在时返回错误 |
Ignore_Lease |
bool |
设置时更新 key 但不改变其当前 lease;key 不存在时返回错误 |
message PutResponse { ResponseHeader header = 1; mvccpb.KeyValue prev_kv = 2; }
Prev_Kv 为被 Put 覆盖的 key-value 对(仅当请求中设置了 Prev_Kv)。
6.3.5 DeleteRangeRequest / DeleteRangeResponse
message DeleteRangeRequest { bytes key = 1; bytes range_end = 2; bool prev_kv = 3; }
| 字段 | 说明 |
|---|---|
Key, Range_End |
要删除的 key 区间 |
Prev_Kv |
设置时返回被删除的 key-value 内容 |
message DeleteRangeResponse {
ResponseHeader header = 1;
int64 deleted = 2;
repeated mvccpb.KeyValue prev_kvs = 3;
}
| 字段 | 说明 |
|---|---|
Deleted |
删除的 key 数量 |
Prev_Kv |
本次 DeleteRange 删除的所有 key-value 对列表 |
6.3.6 Transaction(Txn)
事务是 key-value 存储上的原子 If/Then/Else 构造,可用于:
- 防止 key 被意外的并发更新;
- 构建 compare-and-swap 操作;
- 开发更高层并发控制。
| 规则 | 说明 |
|---|---|
| 多请求单事务 | 事务可在单个请求中原子处理多个请求 |
| revision 递增次数 | 对于修改操作,存储 revision 只递增一次,事务产生的所有事件都拥有相同的 revision |
| 禁止 | 同一事务内多次修改同一个 key 是被禁止的 |
| 守卫 | 所有事务由**一组比较的合取(conjunction)**守卫,类似 If 语句 |
| 比较对象 | 每个比较检查存储中的单个 key:可检查值存在/不存在、与给定值比较、或检查 key 的 revision / version |
| 比较范围 | 不同比较可作用于相同或不同 key |
| 求值 | 所有比较原子应用:全部为真则事务成功,执行 then / success 请求块;否则失败,执行 else / failure 块 |
Compare 消息:
| 项 | 取值 |
|---|---|
CompareResult 枚举 |
EQUAL = 0、GREATER = 1、LESS = 2、NOT_EQUAL = 3 |
CompareTarget 枚举 |
VERSION = 0、CREATE = 1、MOD = 2、VALUE = 3 |
字段 result |
逻辑比较类型 |
字段 target |
被比较的 key-value 字段 |
字段 key |
比较的目标 key |
字段 target_union |
oneof:version、create_revision、mod_revision、value(用户指定的比较数据) |
来自
api_reference_v3的完整Compare字段:result、target、key、target_union(oneof)、version、create_revision、mod_revision、value、lease、range_end。其中range_end的语义:把给定 target 与[key, range_end)区间内的所有 key 比较。
RequestOp(请求块) oneof:
| 分支 | 类型 | 约束 |
|---|---|---|
request_range |
RangeRequest |
— |
request_put |
PutRequest |
key 必须唯一,不得与其他 Put 或 Delete 共享 key |
request_delete_range |
DeleteRangeRequest |
不得与任何 Put 或 Delete 共享 key |
request_txn |
TxnRequest |
(来自 api_reference_v3,可嵌套) |
message TxnRequest {
repeated Compare compare = 1;
repeated RequestOp success = 2;
repeated RequestOp failure = 3;
}
| 字段 | 说明 |
|---|---|
Compare |
守卫事务的谓词合取列表 |
Success |
全部比较为真时执行的请求列表 |
Failure |
任一比较为假时执行的请求列表 |
message TxnResponse {
ResponseHeader header = 1;
bool succeeded = 2;
repeated ResponseOp responses = 3;
}
| 字段 | 说明 |
|---|---|
Succeeded |
Compare 求值结果 |
Responses |
与所应用的 RequestOp 列表一一对应的响应列表,编码为 ResponseOp(oneof:response_range、response_put、response_delete_range、response_txn) |
重要注意(原文):内层响应中的
ResponseHeader不应以任何方式解读;客户端若需获取最新 revision,应始终检查TxnResponse中顶层的ResponseHeader。
6.3.7 Compact
message CompactionRequest {
int64 revision = 1;
bool physical = 2;
}
| 字段 | 说明 |
|---|---|
revision |
压缩操作所针对的 key-value store revision;所有 revision 小于该压缩 revision 的被取代 key 都会被移除 |
physical |
设置后 RPC 会等待压缩被物理应用到本地数据库,使被压缩条目从后端数据库中彻底移除 |
$ etcdctl compact 5
compacted revision 5
$ etcdctl get --rev=4 foo
Error: rpc error: code = 11 desc = etcdserver: mvcc: required revision has been compacted
6.4 Watch service
方法清单:
| 方法 | 请求 | 响应 | 用途 |
|---|---|---|---|
Watch |
WatchRequest(流) |
WatchResponse(流) |
监控正在发生或已经发生的事件。输入输出均为流;输入流用于创建/取消 watcher,输出流发送事件;一个 Watch RPC 可 watch 多个 key range;整个事件历史可从最后一次 compaction revision 开始 watch |
6.4.1 WatchCreateRequest
message WatchCreateRequest {
bytes key = 1; bytes range_end = 2; int64 start_revision = 3; bool progress_notify = 4;
enum FilterType { NOPUT = 0; NODELETE = 1; }
repeated FilterType filters = 5;
bool prev_kv = 6;
}
| 字段 | 类型 | 说明 |
|---|---|---|
Key, Range_End |
bytes |
要 watch 的 key 区间 |
Start_Revision |
int64 |
可选的起始 revision(含);若不指定则从 watch 创建响应 header 的 revision 之后开始流式发送 |
Progress_Notify |
bool |
设置后,若近期无事件,watch 会周期性收到一个不含事件的 WatchResponse;适用于客户端希望从最近已知 revision 恢复断连的 watcher。发送频率由 etcd server 依据当前服务器负载决定 |
Filters |
repeated FilterType |
服务端要过滤掉的事件类型列表(NOPUT / NODELETE) |
Prev_Kv |
bool |
设置后 watch 会收到事件发生前的 key-value 数据,用于了解被覆盖了什么数据 |
api_reference_v3 中补充的两个字段:
| 字段 | 说明 |
|---|---|
watch_id |
若提供且非 0,将被赋给该 watcher。由于在 etcd 中创建 watcher 不是同步操作,可用它保证在同一 stream 上创建多个 watcher 时的顺序正确。在 stream 上使用已被占用的 ID 创建 watcher 会返回错误 |
fragment |
启用把大 revision 拆分成多个 watch response |
6.4.2 WatchResponse
message WatchResponse {
ResponseHeader header = 1; int64 watch_id = 2; bool created = 3;
bool canceled = 4; int64 compact_revision = 5; repeated mvccpb.Event events = 11;
}
| 字段 | 说明 |
|---|---|
Watch_ID |
该响应对应的 watch ID |
Created |
为 true 表示这是对 create watch 请求的响应;客户端应保存该 ID,并预期在该 stream 上收到该 watch 的事件;发往该 watcher 的所有事件都具有相同 watch_id |
Canceled |
为 true 表示这是对 cancel watch 请求的响应(或因 start_revision 已被压缩);此后不再向该 watcher 发送事件 |
Compact_Revision |
当 watcher 试图在被压缩的 revision 上 watch 时,设为 etcd 可用的最小历史 revision。发生在:① 在被压缩 revision 上创建 watcher;② watcher 无法跟上 key-value 存储的进度。此时 watcher 会被取消,且用相同 start_revision 创建新 watch 会失败 |
Events |
按序对应该 watch ID 的新事件列表 |
api_reference_v3 补充字段:
| 字段 | 说明 |
|---|---|
cancel_reason |
取消 watcher 的原因 |
fragment |
若大的 watch response 被拆分到多个 response,则为 true |
6.4.3 WatchRequest / WatchCancelRequest / WatchProgressRequest
message WatchRequest {
oneof request_union {
WatchCreateRequest create_request = 1;
WatchCancelRequest cancel_request = 2;
WatchProgressRequest progress_request = 3;
}
}
message WatchCancelRequest { int64 watch_id = 1; }
| 消息 | 字段 | 说明 |
|---|---|---|
WatchRequest |
request_union |
一个请求,要么是创建新 watcher,要么是取消已有 watcher |
WatchCancelRequest |
watch_id |
要取消的 watch ID,取消后不再传输事件 |
WatchProgressRequest |
(无字段) | 请求 watch stream 的进度状态尽快在 watch response stream 中发出 |
6.5 Lease service
方法清单:
| 方法 | 请求 | 响应 | 用途 |
|---|---|---|---|
LeaseGrant |
LeaseGrantRequest |
LeaseGrantResponse |
创建 lease;若服务端在给定 TTL 周期内未收到 keepAlive,lease 过期;其上所有 key 过期并被删除;每个过期的 key 生成一个 delete 事件 |
LeaseRevoke |
LeaseRevokeRequest |
LeaseRevokeResponse |
撤销 lease;其上所有 key 过期并被删除 |
LeaseKeepAlive |
LeaseKeepAliveRequest(流) |
LeaseKeepAliveResponse(流) |
通过客户端 → 服务端 keep alive 请求流与服务端 → 客户端响应流保活 |
LeaseTimeToLive |
LeaseTimeToLiveRequest |
LeaseTimeToLiveResponse |
获取 lease 信息 |
LeaseLeases |
LeaseLeasesRequest |
LeaseLeasesResponse |
列出现有全部 lease |
关键消息字段:
| 消息 | 字段 | 类型 | 说明 |
|---|---|---|---|
LeaseGrantRequest |
TTL |
int64 |
建议的存活时间(秒);过期的 lease 返回 -1 |
LeaseGrantRequest |
ID |
int64 |
请求的 lease ID;若为 0,由 lessor 选择 ID |
LeaseGrantResponse |
ID / TTL / error |
int64/int64/string |
授予的 lease ID / 服务端选定的存活秒数 / 错误 |
LeaseRevokeRequest |
ID |
int64 |
要撤销的 lease ID;撤销时所有关联 key 被删除 |
LeaseKeepAliveRequest |
ID |
int64 |
要保活的 lease ID |
LeaseKeepAliveResponse |
ID / TTL |
int64/int64 |
被刷新的 lease / 该 lease 新的剩余存活秒数 |
LeaseTimeToLiveRequest |
ID / keys |
int64/bool |
lease ID / 是否查询挂在该 lease 上的所有 key |
LeaseTimeToLiveResponse |
ID / TTL / grantedTTL / keys |
int64/int64/int64/bytes[] |
lease ID / 剩余 TTL(秒),lease 将在 TTL+1 秒内过期 / 初始授予的秒数(创建或续约时)/ 挂载的 key 列表 |
LeaseLeasesResponse |
leases |
LeaseStatus[] |
现有 lease 列表(LeaseStatus 含 ID) |
LeaseCheckpoint |
ID / remaining_TTL |
int64/int64 |
要 checkpoint 的 lease ID / 到期前的剩余时间 |
6.6 Cluster service
方法清单:
| 方法 | 请求 | 响应 | 用途 |
|---|---|---|---|
MemberAdd |
MemberAddRequest |
MemberAddResponse |
把成员加入集群 |
MemberRemove |
MemberRemoveRequest |
MemberRemoveResponse |
从集群移除已有成员 |
MemberUpdate |
MemberUpdateRequest |
MemberUpdateResponse |
更新成员配置 |
MemberList |
MemberListRequest |
MemberListResponse |
列出集群中所有成员 |
MemberPromote |
MemberPromoteRequest |
MemberPromoteResponse |
把成员从 raft learner(非投票) 提升为 raft 投票成员 |
关键消息字段:
| 消息 | 字段 | 说明 |
|---|---|---|
Member |
ID / name / peerURLs / clientURLs / isLearner |
成员 ID / 人类可读名称(成员未启动时为空字符串)/ 暴露给集群通信的 URL 列表 / 暴露给客户端的 URL 列表(未启动时为空)/ 是否为 raft learner |
MemberAddRequest |
peerURLs / isLearner |
新成员用于与集群通信的 URL 列表 / 是否为 raft learner |
MemberAddResponse |
header / member / members |
响应头 / 新增成员的信息 / 添加后所有成员列表 |
MemberListRequest |
linearizable |
是否线性化 |
MemberListResponse |
members |
集群关联的所有成员列表 |
MemberPromoteRequest |
ID |
要提升的成员 ID |
MemberPromoteResponse |
members |
提升后所有成员列表 |
MemberRemoveRequest |
ID |
要移除的成员 ID |
MemberUpdateRequest |
ID / peerURLs |
成员 ID / 新的 peer URL 列表 |
6.7 Maintenance service
方法清单:
| 方法 | 请求 | 响应 | 用途 |
|---|---|---|---|
Alarm |
AlarmRequest |
AlarmResponse |
激活(ACTIVATE)、停用(DEACTIVATE)、查询(GET)集群健康告警 |
Status |
StatusRequest |
StatusResponse |
获取成员状态 |
Defragment |
DefragmentRequest |
DefragmentResponse |
整理成员后端数据库碎片,回收存储空间 |
Hash |
HashRequest |
HashResponse |
计算整个后端 keyspace 的 hash,包括 key、lease 等 bucket。仅为测试设计(designed for testing ONLY);生产环境有进行中事务时不可依赖,因为 Hash 不持有 MVCC 锁;key bucket 一致性检查应改用 HashKV |
HashKV |
HashKVRequest |
HashKVResponse |
计算给定 revision 之前所有 MVCC key 的 hash;只遍历后端存储中的 key bucket |
Snapshot |
SnapshotRequest |
SnapshotResponse |
通过流把成员的整个后端快照发给客户端 |
MoveLeader |
MoveLeaderRequest |
MoveLeaderResponse |
请求当前 leader 节点把 leadership 转移给 transferee |
Downgrade |
DowngradeRequest |
DowngradeResponse |
请求降级、校验可行性或取消降级(自 etcd 3.5 起支持) |
关键消息字段:
| 消息 | 字段 | 说明 |
|---|---|---|
AlarmRequest |
action / memberID / alarm |
动作(GET / ACTIVATE / DEACTIVATE)/ 成员 ID(为 0 时覆盖所有成员)/ 告警类型 |
AlarmResponse |
alarms |
与该告警请求关联的 AlarmMember 列表 |
AlarmMember |
memberID / alarm |
触发告警的成员 ID / 告警类型 |
StatusResponse |
version、dbSize、leader、raftIndex、raftTerm、raftAppliedIndex、errors、dbSizeInUse、isLearner、storageVersion、dbSizeQuota、downgradeInfo |
见下表 |
HashKVRequest / HashKVResponse |
revision / hash、compact_revision、hash_revision |
请求 revision;响应:hash 值、hash 开始时的 compacted revision、hash 计算所至的 revision |
SnapshotResponse |
header、remaining_bytes、blob、version |
第一个 header 表示快照的时间点;后续待发送 blob 字节数;快照的下一个 chunk;创建快照的服务器本地版本(用于提示恢复时用哪个 etcd 版本) |
MoveLeaderRequest |
targetID |
新 leader 的节点 ID |
DowngradeRequest |
action / version |
动作(VALIDATE / DOWNGRADE / CANCEL)/ 目标版本 |
DowngradeResponse |
version |
当前集群版本 |
DowngradeInfo |
enabled / targetVersion |
集群是否启用降级 / 目标降级版本 |
StatusResponse 字段逐一说明:
| 字段 | 说明 |
|---|---|
version |
响应成员使用的集群协议版本 |
dbSize |
响应成员物理分配的后端数据库大小(字节) |
leader |
响应成员认为的当前 leader 成员 ID |
raftIndex |
响应成员当前的 raft committed index |
raftTerm |
响应成员当前的 raft term |
raftAppliedIndex |
响应成员当前的 raft applied index |
errors |
包含 alarm/health 信息与状态 |
dbSizeInUse |
响应成员逻辑使用中的后端数据库大小(字节) |
isLearner |
是否为 raft learner |
storageVersion |
db 文件的版本(相对目标集群版本可能延迟更新) |
dbSizeQuota |
配置的 etcd 存储配额字节数(即传给 etcd 实例的 --quota-backend-bytes flag 值) |
downgradeInfo |
是否存在降级流程 |
6.8 Auth service
方法清单(17 个方法):
| 方法 | 请求 | 响应 | 用途 |
|---|---|---|---|
AuthEnable |
AuthEnableRequest |
AuthEnableResponse |
启用认证 |
AuthDisable |
AuthDisableRequest |
AuthDisableResponse |
停用认证 |
AuthStatus |
AuthStatusRequest |
AuthStatusResponse |
显示认证状态 |
Authenticate |
AuthenticateRequest |
AuthenticateResponse |
处理认证请求 |
UserAdd |
AuthUserAddRequest |
AuthUserAddResponse |
新增用户;用户名不能为空 |
UserGet |
AuthUserGetRequest |
AuthUserGetResponse |
获取用户详细信息 |
UserList |
AuthUserListRequest |
AuthUserListResponse |
获取所有用户列表 |
UserDelete |
AuthUserDeleteRequest |
AuthUserDeleteResponse |
删除指定用户 |
UserChangePassword |
AuthUserChangePasswordRequest |
AuthUserChangePasswordResponse |
修改指定用户密码 |
UserGrantRole |
AuthUserGrantRoleRequest |
AuthUserGrantRoleResponse |
给指定用户授予角色 |
UserRevokeRole |
AuthUserRevokeRoleRequest |
AuthUserRevokeRoleResponse |
撤销指定用户的角色 |
RoleAdd |
AuthRoleAddRequest |
AuthRoleAddResponse |
新增角色;角色名不能为空 |
RoleGet |
AuthRoleGetRequest |
AuthRoleGetResponse |
获取角色详细信息 |
RoleList |
AuthRoleListRequest |
AuthRoleListResponse |
列出所有角色 |
RoleDelete |
AuthRoleDeleteRequest |
AuthRoleDeleteResponse |
删除指定角色 |
RoleGrantPermission |
AuthRoleGrantPermissionRequest |
AuthRoleGrantPermissionResponse |
给指定角色授予某个 key 或 range 的权限 |
RoleRevokePermission |
AuthRoleRevokePermissionRequest |
AuthRoleRevokePermissionResponse |
撤销指定角色在某个 key 或 range 上的权限 |
关键消息字段:
| 消息 | 字段 | 说明 |
|---|---|---|
AuthenticateRequest |
name / password |
用户名 / 密码 |
AuthenticateResponse |
token |
可用于后续 RPC 的 authorized token |
AuthStatusResponse |
enabled / authRevision |
认证是否启用 / auth store 的当前 revision |
AuthUserAddRequest |
name / password / options / hashedPassword |
用户名 / 密码 / authpb.UserAddOptions / 已哈希密码 |
AuthUserChangePasswordRequest |
name / password / hashedPassword |
用户名 / 新密码(该字段将在 API 层被移除)/ 新密码(该字段将在 API 层被初始化) |
AuthUserGetResponse |
roles |
该用户的角色列表 |
AuthRoleGetResponse |
perm |
authpb.Permission 列表 |
AuthRoleGrantPermissionRequest |
name / perm |
被授权的角色名 / 要授予的 authpb.Permission |
AuthRoleRevokePermissionRequest |
role / key / range_end |
角色 / key / range_end |
AuthUserGrantRoleRequest |
user / role |
用户 / 角色 |
Permission(api/authpb/auth.proto) |
permType / key / range_end |
权限类型 / key / range_end |
Role(api/authpb/auth.proto) |
name / keyPermission |
角色名 / Permission 列表(bucket authRoles 中的单个条目) |
User(api/authpb/auth.proto) |
name / password / roles / options |
用户名 / 密码 / 角色列表 / UserAddOptions(bucket authUsers 中的单个条目) |
UserAddOptions |
no_password |
是否无密码 |
6.9 Lock service
引自:API reference: concurrency —
server/etcdserver/api/v3lock/v3lockpb/v3lock.proto
The lock service exposes client-side locking facilities as a gRPC interface.
| 方法 | 请求 | 响应 | 用途 |
|---|---|---|---|
Lock |
LockRequest |
LockResponse |
在给定的命名锁(named lock)上获取分布式共享锁。成功时返回一个唯一 key,该 key 在调用者持有锁期间一直存在。该 key 可与事务配合,安全确保对 etcd 的更新只在持有锁所有权期间发生。锁一直持有到:对该 key 调用 Unlock,或与属主关联的 lease 过期 |
Unlock |
UnlockRequest |
UnlockResponse |
用 Lock 返回的 key 释放对该锁的持有。下一个等待该锁的 Lock 调用者会被唤醒并获得锁的所有权 |
字段说明:
| 消息 | 字段 | 类型 | 说明 |
|---|---|---|---|
LockRequest |
name |
bytes |
要获取的分布式共享锁的标识符 |
LockRequest |
lease |
int64 |
将附加到锁所有权上的 lease ID。若该 lease 过期或被撤销、且当前持有锁,则锁自动释放。用同一个 lease 调用 Lock 被视为单次获取;用同一个 lease 加锁两次是 no-op |
LockResponse |
header |
etcdserverpb.ResponseHeader |
响应头 |
LockResponse |
key |
bytes |
在 Lock 调用者拥有锁期间存在于 etcd 上的 key。用户不应修改该 key,否则锁可能表现出未定义行为 |
UnlockRequest |
key |
bytes |
由 Lock 授予的锁所有权 key |
UnlockResponse |
header |
etcdserverpb.ResponseHeader |
响应头 |
📌 与 2.12 呼应:
Lock返回的 key 之所以要"与事务配合使用",正是因为 lock APIs 本身不能当作互斥机制,真正的互斥靠Txn里的 revision/lease 条件校验。
6.10 Election service
引自:API reference: concurrency —
server/etcdserver/api/v3election/v3electionpb/v3election.proto
The election service exposes client-side election facilities as a gRPC interface.
| 方法 | 请求 | 响应 | 用途 |
|---|---|---|---|
Campaign |
CampaignRequest |
CampaignResponse |
等待获取选举中的 leadership,成功时返回表示 leadership 的 LeaderKey。该 LeaderKey 可用于:① 在选举上发布新值;② 事务性地为"仍持有 leadership"的 API 请求做守卫;③ 从选举中辞职 |
Proclaim |
ProclaimRequest |
ProclaimResponse |
用新值更新 leader 已发布的值 |
Leader |
LeaderRequest |
LeaderResponse |
返回当前选举的公告(若有) |
Observe |
LeaderRequest |
LeaderResponse |
按选举产生的各 leader 的公告顺序流式推送 |
Resign |
ResignRequest |
ResignResponse |
释放选举 leadership,使其他 campaigner 可以获取该选举的 leadership |
字段说明:
| 消息 | 字段 | 类型 | 说明 |
|---|---|---|---|
CampaignRequest |
name |
bytes |
选举中该 campaign 的标识符 |
CampaignRequest |
lease |
int64 |
附加到选举 leadership 上的 lease ID。若该 lease 在辞职前过期或被撤销,则 leadership 转移给下一个 campaigner(若有) |
CampaignRequest |
value |
bytes |
campaigner 赢得选举时设置的初始公告值 |
CampaignResponse |
header / leader |
ResponseHeader / LeaderKey |
响应头 / 用于持有 leadership 的资源描述 |
LeaderKey |
name |
bytes |
与 leadership key 对应的选举标识符 |
LeaderKey |
key |
bytes |
表示选举所有权的不透明 key。若该 key 被删除,则 leadership 丢失 |
LeaderKey |
rev |
int64 |
key 的 creation revision。可在事务中通过比较 key 的 creation revision 是否等于 rev 来测试选举所有权 |
LeaderKey |
lease |
int64 |
选举 leader 的 lease ID |
LeaderRequest |
name |
bytes |
要查询 leadership 信息的选举标识符 |
LeaderResponse |
header / kv |
ResponseHeader / mvccpb.KeyValue |
响应头 / 表示最新 leader 更新的 key-value 对 |
ProclaimRequest |
leader / value |
LeaderKey / bytes |
在选举上的 leadership 持有 / 用于覆盖 leader 当前值的更新值 |
ResignRequest |
leader |
LeaderKey |
要通过辞职放弃的 leadership |
6.11 方法清单汇总
八个 service 的方法总数:
| service | 方法数 | 方法清单 |
|---|---|---|
KV |
5 | Range、Put、DeleteRange、Txn、Compact |
Watch |
1 | Watch(双向流) |
Lease |
5 | LeaseGrant、LeaseRevoke、LeaseKeepAlive、LeaseTimeToLive、LeaseLeases |
Cluster |
5 | MemberAdd、MemberRemove、MemberUpdate、MemberList、MemberPromote |
Maintenance |
8 | Alarm、Status、Defragment、Hash、HashKV、Snapshot、MoveLeader、Downgrade |
Auth |
17 | AuthEnable、AuthDisable、AuthStatus、Authenticate、UserAdd、UserGet、UserList、UserDelete、UserChangePassword、UserGrantRole、UserRevokeRole、RoleAdd、RoleGet、RoleList、RoleDelete、RoleGrantPermission、RoleRevokePermission |
Lock |
2 | Lock、Unlock |
Election |
5 | Campaign、Proclaim、Leader、Observe、Resign |
| 合计 | 48 | — |
💡 补充:
Compact在learning/api.md正文的 services 描述里没有单独展开,但它是KVservice 的方法之一(来自api_reference_v3)。使用etcdctl compact时走的就是它。
7. API 保证
etcd 是一个一致且持久的 key-value 存储,通过 gRPC Services 暴露,并为分布式系统提供最强的**一致性(consistency)与持久性(durability)**保证。本规格说明枚举 etcd 作出的 API 保证。
本章每条保证都按三段展开:保证内容(原文)+ 对使用者的意义(怎么用它写代码)+ 违反后果(一旦不成立会看到什么现象 / 你必须自己补什么)。
7.1 纳入保证范围的 API
| 类别 | API |
|---|---|
| KV APIs | Range、RangeStream、Put、Delete、Transaction |
| Watch APIs | Watch |
| Lease APIs | Grant、Revoke、Keep alive |
| 类别 | 能力 |
|---|---|
| KV API | 允许直接读取与操作 key-value 存储 |
| Watch API | 允许订阅 key-value 存储的变化 |
| Lease API | 允许给 key 赋予存活时间 |
| KV 与 Watch API 共同 | 都能访问 key 的最新版本,也能在一个连续的历史窗口内访问先前的版本,该窗口由 compaction 操作界定 |
💡 补充:注意"纳入保证范围"的清单里没有
Cluster、Maintenance、Auth、Lock、Election。这些 service 的行为不在这份一致性保证规格内(例如Hash官方明确说"仅为测试设计")。写业务代码时不要把集群管理类 API 当作一致性原语用。
7.2 时延特性
| 项 | 内容 |
|---|---|
| KV API | 调用 KV API 会立即生效 |
| Watch API | 带有无界的延迟返回 |
| 健康集群的经验值 | 在正常工作(correctly working)的 etcd 集群中,watch 事件应在其发生后约 10ms 出现 |
| 上限 | 没有上限;不健康的集群中事件可能永远不到达 |
保证内容:KV 调用立即生效;watch 事件在正常集群约 10ms 出现,但没有上限。
对使用者的意义:不要把 watch 当作"同步通知"来设计流程。任何依赖"我 put 完对方马上就收到"的逻辑都是错的。
违反后果:watch 事件延迟无上界,故障期间事件可能永远不到达 → 因此必须实现 7.8 里的 Resumable(用最后收到的 revision 重建 watch),并在关键路径上用 KV 读做兜底。
7.3 持久性 durability
保证内容:任何已完成的操作都是持久的;所有可访问的数据也就是持久的数据;读永远不会返回尚未被持久化的数据。
对使用者的意义:
| 推论 | 说明 |
|---|---|
| 读到即已落盘 | 只要 get 返回一个值,这个值一定已经被持久化 |
| 收到响应即完成 | 客户端收到 etcd server 响应时即可知道操作已完成(见 7.11) |
| 不需要额外 fsync | 应用侧不需要为"数据会不会丢"再加一层持久化 |
违反后果:如果出现"读到未持久化的数据",意味着发生了 durability 破坏(例如错误的存储配置),这是严重 bug,通常伴随重启后数据丢失。
7.4 严格可串行化
保证内容:etcd 为所有 KV api 调用确保 durability 与 strict serializability(严格可串行化),这是分布式事务数据库系统中最强的隔离保证。KV Service 的操作是原子的,并以与这些操作的**实时顺序一致的全序(total order)**发生;全序由 revision 隐含表达。
嵌套事务的顺序规则:
| 事务类型 | 执行顺序 |
|---|---|
| 不含嵌套 TXN 的事务 | 操作的执行顺序保证与其操作列表中的顺序相同 → 事务内的 GET 响应是稳定的 |
| 含嵌套 TXN 的事务 | 执行顺序不做规定 |
对使用者的意义:
- 可以用 revision 直接推理"谁先谁后",不需要额外加锁或版本号;
- 写
Txn时应避免嵌套 TXN,否则无法依赖操作顺序(也就无法依赖事务内 GET 的稳定性)。
违反后果:若顺序不成立,"事务内读到的值"与"事务内写的值"之间的因果链断裂,compare-and-swap 类逻辑会出现静默的错误结果(不报错但写错值)。
7.5 原子性
保证内容:所有 API 请求都是原子的,一个操作要么完全完成要么完全不发生。对于 watch 请求:一次操作生成的所有事件会在同一个 watch response 中;watch 绝不会观察到单个操作的部分事件。
对使用者的意义:
| 场景 | 含义 |
|---|---|
| 写 | 不存在"写了一半"的中间态 |
| watch | 一次 Txn 改了 3 个 key,你会在同一个 response 里收到这 3 个事件,不会先收到 1 个、再收到 2 个 |
| 消费端 | 可以按 response 为单位做原子地更新本地缓存 |
违反后果:若 watch 能观察到部分事件,消费端会看到"事务执行了一半"的状态,缓存/状态机出现瞬时不一致(例如订单扣了款但没创建记录)。
7.6 线性一致
保证内容(含原文引文):
Linearizability provides the illusion that each operation applied by concurrent processes takes effect instantaneously at some point between its invocation and response. (线性一致提供这样一种假象:并发进程应用的每个操作都在其调用与响应之间的某个时刻瞬时生效。)
原文举例:
| 时间点 | 事件 |
|---|---|
| t1 | 客户端 A 完成一次写 |
| t2(t2 > t1) | 客户端 B 发起读 → 应收到至少与 t1 完成的写一样新的值 |
| t3 | 该读可能直到 t3 才真正完成 |
线性一致保证该读返回最新的值。没有线性一致时,t2 时刻"当前"的返回值到 t3 可能已经"陈旧",因为 t2 与 t3 之间可能发生了并发写。
补充要点:
| 项 | 内容 |
|---|---|
| 默认行为 | etcd 默认为所有其他操作确保线性一致 |
| 代价 | 线性化请求必须走 Raft 共识流程 |
| 可选降级 | 为获得更低的读延迟与更高吞吐,客户端可把请求的一致性模式配置为 serializable |
对使用者的意义:默认就能拿到"最新值",写业务代码不需要自己处理"读到旧值"的问题;代价是每个读都要过一次 Raft。
违反后果:若读不是线性一致的,就会读到"t2 时刻当前、t3 时刻已陈旧"的值——即读到过去。典型症状:刚写入成功,另一个客户端读到的还是旧值。
7.7 serializable 读的语义
保证内容:设置为 serializable 的请求使用成员本地读,无需与其他节点达成共识即可本地服务;相对 quorum 可能访问到陈旧数据,但消除了线性化访问对实时共识的性能惩罚。
| 对比项 | linearizable(默认) | serializable |
|---|---|---|
| 是否走 Raft 共识 | 是 | 否(本地服务) |
| 数据新鲜度 | 反映集群当前共识 | 相对 quorum 可能陈旧 |
| 延迟 | 更高 | 更低 |
| 吞吐 | 更低 | 更高 |
| 可用性 | 依赖 live consensus | 不依赖 |
对使用者的意义:适合"能容忍短暂陈旧、但要高吞吐/低延迟"的读,例如本地缓存刷新、监控采集、大范围列举。
违反后果(你必须自己承担的后果):
- 可能读到旧值(stale read)——不要用它做"写完立刻校验";
- 在分区节点上读,可能读到明显落后的数据;
- 用
serializable做 compare-and-swap 的前置读会放大竞态窗口。
📌 官方文档未提供:
dev-guide/interacting_v3.md未给出serializable读的 etcdctl 命令行示例(见 12.14)。proto 层的开关是RangeRequest.serializable(见 6.3.1)。
💡 补充:用
serializable读时,判断"我读到的值有多旧"的实用方法是比对响应header.revision与etcdctl get <不存在的key> -w=json得到的当前 revision(见 12.11),两者差值就是陈旧程度。
7.8 watch 的六条保证
引自:etcd API guarantees — "Watches make guarantees about events"
| # | 保证 | 原文内容 |
|---|---|---|
| 1 | Ordered(有序) | 事件按 revision 排序。一个事件绝不会在时间上早于某个已投递事件的情况下出现在 watch 上。对不含嵌套 TXN 的事务,生成事件的顺序保证与其操作列表顺序相同;对含嵌套 TXN 的事务,事件顺序不做规定 |
| 2 | Unique(唯一) | 一个事件绝不会在同一 watch 上出现两次 |
| 3 | Reliable(可靠) | 在可用历史窗口内,事件序列绝不会丢弃任何子序列。若事件在时间上排序为 a < b < c,则只要 watch 收到了 a 和 c,就保证也收到 b(前提是 b 在可用历史窗口内) |
| 4 | Atomic(原子) | 事件列表保证涵盖完整的 revision;同一 revision 中对多个 key 的更新不会被拆分到多个事件列表中 |
| 5 | Resumable(可恢复) | 中断的 watch 可通过建立一个新的 watch 来恢复,起点为断连前在 watch 事件中收到的最后一个 revision 之后(只要该 revision 仍在历史窗口内) |
| 6 | Bookmarkable(可打书签) | Progress notification(进度通知)事件保证截止到某一 revision 的所有事件都已投递完毕 |
逐条展开为「保证内容 + 对使用者的意义 + 违反后果」:
7.8.1 Ordered(有序)
- 保证内容:按 revision 排序;不会出现"比已投递事件更早"的事件。
- 对使用者的意义:可以只记录"当前 revision",用它做断点续传的游标;无需自己排序。
- 违反后果:若乱序,消费端状态机会回退(例如把新配置覆盖成旧配置)。
7.8.2 Unique(唯一)
- 保证内容:同一 watch 上事件不重复。
- 对使用者的意义:消费端不需要幂等去重(但重试重建 watch 时仍可能看到重叠区间,见 Resumable)。
- 违反后果:重复事件会导致重复扣减 / 重复触发类业务 bug。
7.8.3 Reliable(可靠)
- 保证内容:可用历史窗口内不丢子序;收到 a 和 c 就必定收到中间的 b。
- 对使用者的意义:这是 etcd 相对其他 KV 存储最关键的差异之一(见 2.6 的 Change Notification 行)。只要你的 watcher 没落后于 compaction,就不会"漏事件"。
- 违反后果:漏事件意味着缓存与集群状态永久分叉,且不会有任何报错。
7.8.4 Atomic(原子)
- 保证内容:一个事件列表涵盖完整 revision,不跨 revision 拆分。
- 对使用者的意义:可以把一个 response 当作"一个事务的效果"来原子地应用。
- 违反后果:拆分开来会让消费端看到事务的中间态。
7.8.5 Resumable 可恢复
- 保证内容:用"断连前最后收到的 revision 之后"作为起点新建 watch 即可恢复。
- 对使用者的意义:重连逻辑的标准写法 —— 保存
WatchResponse.Header.Revision,重连时用它(或 +1 语义的起点)重建。 - 违反后果:如果起点不在历史窗口内(已被 compaction),watch 会被取消并带回
compact_revision,此时必须全量重建状态(先get全量,再从当前 revision 继续 watch),否则状态永久缺失。
7.8.6 Bookmarkable 可打书签
- 保证内容:progress notification 事件保证"截止到某 revision 的所有事件都已投递完毕"。
- 对使用者的意义:即使长时间无写入,也能通过 progress notify 推进"我已同步到哪个 revision"的认知;配合
WatchCreateRequest.progress_notify使用(见 6.4.1)。 - 违反后果:无法区分"没有事件"和"连接假死",故障检测会失效。
# 交互式 watch 会话中用 progress 命令请求进度通知
$ etcdctl watch -i
$ watch a
$ progress
progress notify: 1
# in another terminal: etcdctl put x 0
# in another terminal: etcdctl put y 1
$ progress
progress notify: 3
原文 Note(info):progress notify 响应中的 revision 号来自 watch 流所连接的那个本地 etcd 节点。若该节点被分区、不属于 quorum,此 progress notify revision 可能低于对未分区节点做 quorum read 得到的 revision。
7.9 watch 不保证线性一致
保证内容(这是"不保证",原文明确写出):etcd 不保证 watch 操作的线性一致性。用户应自行校验 watch 事件的 revision,以确保与其他操作的正确排序。
对使用者的意义:
| 场景 | 正确做法 |
|---|---|
| 用 watch 维护缓存 | 记录 header.revision;与 quorum read 得到的 revision 比较,判断是否落后 |
| 把 watch 与其他操作排序 | 显式比较 revision,不要假设"watch 收到 = 当前最新" |
| 判断节点是否落后 | progress notify 的 revision 来自本地节点,可能低于 quorum read 的 revision(见 7.8.6) |
违反后果:把 watch 当成线性一致读来用,会因为事件延迟无上界(7.2)而读到"过期的当前状态",且不会有任何错误提示。
7.10 lease 语义
保证内容(原文完整段落要点):
| 项 | 内容 |
|---|---|
| 主要用例 | lease 机制的主要用例是实现分布式协调机制(如分布式锁) |
| 机制简单性 | lease 可用 grant API 创建、用 put API 附加到 key、用 revoke API 撤销,并会按墙上时钟的 TTL 过期 |
| 使用者义务 | 用户需了解这些 API 与用法的重要属性,才能正确实现分布式协调机制 |
对使用者的意义:lease 只是"带 TTL 的、可续约的、到期删 key 的契约"。它的语义与 2.12 的限制必须一起读:lease 本身不保证互斥,TTL 是物理时间间隔,服务端与客户端各自用自己的时钟度量。
违反后果:
- 把 lease 当互斥锁 → 出现"服务端已撤销、客户端仍以为持有"的双持有;
- 用 etcd 的 lock 保护外部资源 → 无效(外部资源必须自己提供版本号校验与副本一致性);
- keepalive 断连 → TTL 到期后 key 被删,业务误判为"资源已释放"。
📌 官方文档指向:
api_guarantees.md的 Lease APIs 一节明确链接到../why/#notes-on-the-usage-of-lock-and-lease,即本册 2.12。两节必须合并阅读。
7.11 操作完成的定义
保证内容:
| 项 | 内容 |
|---|---|
| 何时算完成 | 一个 etcd 操作在通过共识提交、因而被 etcd 存储引擎"执行"(永久存储)时,被视为完成 |
| 客户端如何知道 | 客户端收到 etcd server 响应时即可知道操作已完成 |
| 不确定情形 | 若客户端超时,或客户端与 etcd 成员之间出现网络中断,客户端可能对操作状态不确定 |
| leader 选举时 | etcd 在发生 leader 选举时也可能中止(abort)操作,并且在这种情况下不会向客户端未完成的请求发送 abort 响应 |
对使用者的意义:
- "收到响应" == "已提交并持久化",可以放心地向下游宣告成功;
- 没有收到响应 ≠ 没有成功。超时/断连时状态未知,必须用带 revision 条件的幂等重试(
Txn+mod_revision比较)来消除不确定性; - leader 选举期间不会收到 abort 响应 —— 所以不要指望靠错误码区分"abort"与"超时"。
违反后果:把超时当作失败直接重试,会产生重复写入;把超时当作成功,会产生丢写。唯一安全的做法是可重试的幂等语义。
7.12 revision 语义
保证内容:
| 项 | 内容 |
|---|---|
| 单个递增 revision | 修改 key-value 存储的 etcd 操作会被赋予单个递增的 revision |
| 事务 | 一个事务操作可能多次修改 key-value 存储,但只被赋予一个 revision |
| key-value 对的 revision | 被该操作修改的 key-value 对的 revision 属性,其值与操作的 revision 相同 |
| 逻辑时钟 | revision 可用作 key-value 存储的逻辑时钟:revision 较大的 key-value 对在 revision 较小的之后被修改 |
| "并发"的定义 | 两个 revision 相同的 key-value 对是被一个操作"并发"修改的 |
对使用者的意义:
| 用法 | 说明 |
|---|---|
| 全序判据 | 比较两个 key 的 mod_revision 即可判断先后,无需外部时钟 |
| 并发判据 | mod_revision 相同 ⇒ 由同一个操作修改 ⇒ 在 etcd 语义下它们"同时"发生 |
| 快照读 | 用同一个 revision 读一批 key,得到一致的快照视图(5.9.1) |
| 锁 / fencing | revision 可充当 fencing token 的等价物(见 2.12) |
违反后果:若 revision 不唯一/不单调,则"用 revision 做游标、做快照、做 fencing"的全部上层机制都会失效,且失效方式通常是静默的。
8. 持久化存储文件
本文说明 etcd 的持久化存储格式:命名(naming)、内容(content) 以及可用于检视它们的工具,面向需要数据恢复的 etcd 开发者。前置阅读:etcd data model 概览(本册 第 5 章)与 Raft 论文(尤其是 "5.3 Log replication" 一节)。
8.1 目录结构与长期存在的文件
| 文件名 | 用途 |
|---|---|
./member/snap/db |
bbolt b+tree,存放全部已应用的数据、成员授权信息与元数据。它知道最后应用的 WAL 日志索引("consistent_index") |
./member/snap/0000000000000002-0000000000049425.snap、./member/snap/0000000000000002-0000000000061ace.snap |
周期性生成的 legacy v2 store 快照,包含:基本成员信息、etcd-version。自 etcd v3 起,其内容与 /snap/db 文件冗余。这些文件周期性(30s)被清理,保留最近的 --max-snapshots=5 个 |
/member/snap/000000000007a178.snap.db |
副本落后太多时从 leader 下载的完整 bbolt 快照,内容与 ./member/snap/db 同类。用于两种场景:① 响应 leader 要求从快照恢复的请求;② 服务器启动期间,发现最后一个快照(.snap.db 文件)的 index 比当前 snap.db 中的 consistent_index 更新时。注意:各副本上周期性生成的快照只以 *.snap 文件形式产生(不是 snap.db),因此不保证最近的快照(WAL 日志中)有对应的 *.snap.db 文件;但这种情况下后端(snap/db)理应比该快照更新。该文件在恢复完成后不会被删除(全部内容会被填充进 ./member/snap/db),并周期性(30s)被清理,同样保留 --max-snapshots=5 个;由于这些文件可能达 O(GBs),存在磁盘空间耗尽风险 |
./member/wal/000000000000000f-00000000000b38c7.wal(及 ...000e-...、...000d-...) |
Raft 的 Write Ahead Logs,包含 Raft 已接受的近期事务、周期性快照或 CRC 记录。保留最近的 --max-wals=5 个文件;每个文件约 ~64*10^6 字节,超过该硬编码大小时切文件,因此文件可能略超该大小(所以预分配的 0.tmp 并不能完全防止磁盘写满)。若快照过于稀少,实际文件数可能多于 --max-wals=5,因为文件系统级锁会保护这些文件、阻止其被过早删除 |
./member/wal/0.tmp(或 .../1.tmp) |
为下一个 WAL 文件预分配的空间,用于避免 Raft 因 WAL 容量不足而卡死且无法发出告警 |
目录结构总览(整理自上表):
default.etcd/
└── member/
├── snap/
│ ├── db # bbolt b+tree(主数据)
│ ├── 0000000000000002-0000000000049425.snap # legacy v2 快照
│ ├── 0000000000000002-0000000000061ace.snap
│ ├── 000000000007a178.snap.db # 从 leader 下载的完整 bbolt 快照
│ ├── *.snap.broken # 无法加载的快照被重命名
│ ├── tmp071677638 # 下载中的临时 bbolt 文件
│ └── db.tmp.071677638 # defragment 过程中的临时后端
└── wal/
├── 000000000000000d-....wal
├── 000000000000000e-....wal
├── 000000000000000f-00000000000b38c7.wal
└── 0.tmp # 预分配的下一个 WAL 文件
8.2 临时文件
| 文件 | 用途 |
|---|---|
./member/snap/0000000000000002-000000000007a178.snap.broken |
快照文件在无法被加载时被重命名为 broken。尝试加载最新文件发生在 etcd 启动时,或 etcdctl 的 backup/migrate 命令期间 |
./member/snap/tmp071677638(随机后缀) |
副本在响应 leader 的 msgSnap 请求(即要求从给定快照恢复存储)时创建的临时(bbolt)文件。内容完整获取成功后被重命名为 /member/snap/[SNAPSHOT-INDEX].snap.db。若服务器在下载中途死亡/被杀,这些文件会留在磁盘上且永不被自动清理,体积可能达 GB 级。见 etcd/issues/12837,已在 etcd 3.5 修复 |
/member/snap/db.tmp.071677638(随机后缀) |
defragmentation(碎片整理)过程中包含后端内容(/member/snap/db)副本的临时文件。成功后被重命名为 /member/snap/db,替换原后端。etcd server 启动时这些文件会被 pruned(清理) |
💡 补充:运维上最常踩的两个坑都在这张表里 —— ①
tmp*残留(3.5 之前)会静默吃掉几十 GB 磁盘;② defrag 需要与 db 等量的空闲磁盘(db.tmp.*是一份完整副本)。做 defrag 前务必检查磁盘余量。
8.3 bbolt db 文件
该文件(member/snap/db)包含 etcd 的主要内容,已应用到 Raft 日志的特定位置(见 consistent_index)。
8.3.1 物理组织
| 要点 | 说明 |
|---|---|
| 结构 | 物理上组织为 b+tree |
| 页面修改 | b-tree 的物理页从不被原地修改(脚注 [^1]:bbolt 文件开头的 metadata 页是原地修改的) |
| 写入方式 | 内容会被复制到新页(从 freepages 列表回收),旧页在没有可能访问它的打开事务时被加入 free-pages 列表 |
| 事务可见性 | 因此一个打开的 RO 事务能看到存储的一致历史状态** |
| RW 事务 | RW 事务是排他的,会阻塞其他所有 RW 事务 |
| 大 value | 大 value 存储在多个连续页上 |
| 碎片化 | 页回收过程加上需要分配不同大小的连续页区域,可能导致 bbolt 存储碎片化不断加剧 |
| 文件大小 | bbolt 文件自身从不收缩;只有在 defragmentation 过程中,文件才会被重写为一个尾部带有一定 free pages 缓冲、并且尺寸被截断的新文件 |
8.3.2 逻辑组织:bucket 与 key(as of version 3.5)
bbolt 存储被划分为 bucket,每个 bucket 内存放按字典序排列的 key(byte[] -> value byte[])。etcd 使用的 bucket 与 key 如下(逐项照录):
| bucket | key | value | 说明 |
|---|---|---|---|
| alarm | rpcpb.Alarm: {MemberID, Alarm: NONE|NOSPACE|CORRUPT} |
示例值 nil |
表示某个成员中被诊断出的问题 |
| auth | "authRevision" |
""(空)或 BigEndian.PutUint64 |
任何 Role 或 User 的变更都会在事务提交时递增该字段;该值仅用于授权过程中的乐观锁 |
| authRoles | [roleName] 字符串 |
authpb.Role 序列化结果 |
角色表 |
| authUsers | [userName] 字符串 |
authpb.User 序列化结果 |
用户表 |
| cluster | "clusterVersion" |
示例值 "3.5.0" 字符串 |
表示共识一致的公共存储版本的 minor 版本 |
| cluster | "downgrade" |
JSON:{ "target-version": "3.4.0", "enabled": true/false } |
持久化最近一次 Downgrade RPC 请求配置的意图,自 v3.5 起 |
| key(核心 bucket) | [revisionId],使用 bytesToRev{main,sub} 编码 |
mvccpb.KeyValue 序列化 proto(key, create_rev, mod_rev, version, value, lease id) |
key-value 的删除以结尾加 't' 的方式序列化(作为 "Tombstone") |
| lease | — | leasepb.Lease 序列化 proto(ID, TTL, RemainingTTL) |
注意:LeaseCheckpoint 只扩展 RemainingTTL,只有 TTL 来自最初的 Grant;TTL 以秒为单位持久化(相对于未定义的 'now'),因此崩溃循环(crash-looping)的服务器不会释放 lease |
| members | 十六进制字符串的 [memberId],如 "8e9e05c52164694d" |
JSON 字符串序列化的 Member 结构:{"id":10276657743932975437,"peerURLs":["http://localhost:2380"],"name":"default","clientURLs":["http://localhost:2379"]} |
表示共识一致的集群成员信息 |
| members_removed | 十六进制 [memberId] |
[]byte("removed") |
记录所有被移除成员的 ID,用于校验被移除成员永不以相同 ID 再次加入。该字段当前(3.4)只从 store V2 读取,从不从 V3 读取(见 PR 12820) |
| meta | "consistent_index" |
uint64 字节(BigEndian) | 最后一个应用到 bolt DB 存储的 WAL entry 的偏移 |
| meta | "scheduledCompactRev" |
bytesToRev{main,sub} 编码(16 字节) |
用于在 compaction 请求后发生崩溃时重新初始化 compaction |
| meta | "finishedCompactRev" |
bytesToRev{main,sub} 编码(16 字节) |
存储最近一次成功 compact 到的 revision |
| meta | "confState" |
— | 自 etcd 3.5 起(PR 12962) |
| meta | "term" |
— | 自 etcd 3.5 起(PR 12964) |
| meta | "storage-version" |
— | 存储版本 |
8.3.3 检视工具
| 工具 | 用途 | 命令 |
|---|---|---|
bbolt 命令行工具 |
列出所有 bucket | go run go.etcd.io/bbolt/cmd/bbolt buckets ./default.etcd/member/snap/db |
bbolt 命令行工具 |
读取特定 key/value | go run go.etcd.io/bbolt/cmd/bbolt get ./default.etcd/member/snap/db cluster clusterVersion |
etcd-dump-db |
列出 v3 etcd 后端(bbolt)内容 | go run go.etcd.io/etcd/v3/tools/etcd-dump-db list-bucket default.etcd(输出 alarm、auth …) |
# 列出 bucket
$ go run go.etcd.io/bbolt/cmd/bbolt buckets ./default.etcd/member/snap/db
# 读取 clusterVersion
$ go run go.etcd.io/bbolt/cmd/bbolt get ./default.etcd/member/snap/db cluster clusterVersion
# 列出 v3 后端内容
$ go run go.etcd.io/etcd/v3/tools/etcd-dump-db list-bucket default.etcd
8.4 WAL 文件
WAL 是 Raft 的持久化存储,用于存放提案(proposals)。leader 先把提案存入自己的日志,然后(并发地)用 Raft 协议复制到 follower;每个 follower 在向 leader 确认复制之前,会先把提案持久化到自己的 WAL 中。
8.4.1 与经典 Raft 模型的两点不同
| # | 差异 | 说明 |
|---|---|---|
| 1 | 不只存 entry | 它不仅持久化带索引的 entry,还持久化 Raft 快照(轻量) 与 hard-state,因此成员的整个 Raft 状态可以仅从 WAL 日志恢复 |
| 2 | append-only | 它是 append-only(只追加) 的:entry 不在原地被覆盖,而是追加到文件后面、具有相同 index 的新 entry 取代先前的那个 |
8.4.2 文件命名
WAL 日志文件按模式 "%016x-%016x.wal", seq, index 命名,例:./member/wal/0000000000000010-00000000000bf1e6.wal。
| 成分 | 含义 |
|---|---|
| ① WAL 日志文件的序号(seq) | 十六进制编码 |
| ② 文件中第一条 entry 或 snapshot 的 index | 十六进制编码 |
特例:第一个文件
0000000000000000-0000000000000000.wal含有 index=0 的初始 snapshot 记录。
8.4.3 物理内容:Frames(帧)
WAL 文件是 Frames(帧) 的序列,每个 frame 包含(逐项照录):
| # | 组成 | 说明 |
|---|---|---|
| 1 | LittleEndian 编码的 uint64 | 含被序列化的 walpb.Record 的长度(脚注 [^2]:这与多数 uint 使用 bigendian 的写法不一致) |
| 2 | Padding | 若干 0 字节,使整个 frame 的尺寸对齐(mod 8) |
| 3 | 序列化后的 walpb.Record 数据 |
见下表 |
walpb.Record 的字段:
| 字段 | 说明 |
|---|---|
type |
int 编码的枚举,决定下面 data 字段的解释方式 |
data |
依 type 而定,通常是序列化后的 proto |
crc |
自 WAL 日志创建以来,该副本上所有日志记录中全部 data 字段(不含 type) 组合的 RC-32 校验和。注意 CRC 会考虑所有记录(即使那些没有被 Raft 提交的) |
文件在当前文件超过
64*10^6字节时被 "cut"(开始写新文件)。
8.4.4 逻辑内容
WAL 文件在逻辑层包含(逐项照录):
| 逻辑记录 | 说明 |
|---|---|
Raftpb.Entry: |
由 Raft leader 复制的近期提案;其中一部分被视为 'committed',其余则可能被逻辑覆盖 |
Raftpb.HardState(term,commit,vote): |
周期性(非常频繁)的信息;指出哪条日志 entry 是 'committed'(已复制到多数服务器),因此保证不会被改变/覆盖,可应用到后端(v2、v3);还包含 "term"(指示是否有选举相关的变化)与 vote(当前 term 内该副本投票给的成员) |
walpb.Snapshot(term, index): |
Raft 状态的周期性快照(不含 DB 内容,只有快照日志 index 与 Raft term)。V2 store 内容存放在单独的 *.store 文件中;V3 store 内容维护在 bbolt 文件中,一旦 entry 被应用进去,它就成为隐式快照 |
| crc32 checksum record | 位于每个文件开头,用于对该文件剩余部分恢复 CRC 校验 |
etcdserverpb.Metadata(node_id, cluster_id) |
标识该日志所属的集群与副本 |
8.4.5 每个 WAL 文件的顺序构成
每个 WAL 文件按顺序由以下内容构成:
| # | 内容 | 适用文件 |
|---|---|---|
| 1 | CRC-32 frame | 来自所有先前文件的 running crc;第一个文件为 0 |
| 2 | Metadata frame | cluster 与 replica ID |
| 3 | Empty Snapshot frame(Index:0, Term:0) |
仅初始 WAL 文件。其作用是维持"所有 entry 都'前接'一个 snapshot"这一不变式 |
| 3' | HardState frame | 非初始(第 2 个及以后)WAL 文件 |
| 4 | entry、hard-state 与 snapshot 记录的混合 | 所有文件 |
8.4.6 重复 index 与覆盖规则
WAL 日志可以对同一 index 包含多条 entry(见 Raft 论文图 7 描述的情况)。
| 规则 | 说明 |
|---|---|
| 只追加 | etcd WAL 只追加,因此 entry 通过追加具有相同 index 的新 entry 被覆盖 |
| 读取时 | 读取 WAL 时,逻辑上会用较新的 entry 覆盖旧的 |
| 最终值 | 只有满足 entry.index <= HardState.commit 的 entry 的最后一个版本可被视为最终值 |
| 未提交 | index > HardState.commit 的 entry 仍可能改变 |
8.4.7 不变式(invariants)
| 项 | 期望 |
|---|---|
| terms | 期望是单调的 |
| indexes ① | 从某个 snapshot 开始 |
| indexes ② | 在该 snapshot 之后,只要处于同一 'term' 就顺序增长 |
| indexes ③ | 若 term 变化,index 可以下降,但下降到的新值必须高于最新的 HardState.commit |
| indexes ④ | 新的 snapshot 可以以任意 >= HardState.commit 的 index 发生,从而开启新的 index 序列 |
8.4.8 检视工具
go install go.etcd.io/etcd/v3/tools/etcd-dump-logs@latest
go run go.etcd.io/etcd/v3/tools/etcd-dump-logs --start-index=0 aname.etcd
注意事项(原文三条):
| # | 注意 |
|---|---|
| ① | 该工具只显示 Entries,不显示 WAL 中的全部记录(Snapshots、HardStates) |
| ② | 工具会自动应用 'overrides' —— 若某 entry 被同一 index 下更新的 entry 覆盖,只打印最终值 |
| ③ | 工具还会打印未提交的 entry(来自 LOG 尾部),且不含 HardState.commitIndex 信息,因此无从判断这些 entry 是否为最终值 |
8.5 snapshot 文件
8.5.1 文件命名
命名模式:member/snap/{term}-{index}.snap,由 ("%016x-%016x.snap") 生成,使用两个十六进制成分:
| 成分 | 含义 |
|---|---|
| term | 快照产生时的 Raft term(两次选举之间的周期) |
| index | 快照产生时最后应用的提案的 index |
8.5.2 创建
*.snap 文件由 Snapshotter.SaveSnap 方法创建,有 2 个触发器:
| # | 触发器 | 说明 |
|---|---|---|
| 1 | 每(约)--snapshotCount=(默认 100'000)个已应用的提案创建一个新文件 |
这是近似值,因为提案可能成批到达,且只在批处理结束时考虑快照,最后快照过程是异步调度的。该 flag 名称(--snapshotCount)相当误导,因为它驱动的是上一次快照 index 与最后应用提案 index 之间的 index 差值 |
| 2 | Raft 要求副本从快照恢复 | 副本通过网络(msgSnap 消息)接收快照时,也会把它作为(轻量的)checkpoint 写入 WAL 日志,这保证了 WAL 日志尾部总是有一个有效 snapshot 后跟 entry,从而抑制 WAL 日志中潜在的连续性缺失 |
原文脚注 [^3]:WAL 日志开头 index:0 的初始快照没有对应的
*.snap文件;旧的*.snap文件或 WAL 日志也可能被清理。当前这些文件大致与 WAL 日志中的 Snapshot entry 1-1 关联。随着 store v2 退役,预计这些文件将完全停止写入(3.5.x 为 opt-in,3.6.x 为强制)。
8.5.3 内容结构
文件包含序列化的 snapdb.snapshot proto,即 (uint32 crc, bytes data);其 data 字段中是 Raftpb.Snapshot:(bytes data, SnapshotMetadata{index, term, conf} metadata);再嵌套的 data 中是 JSON 序列化的 store v2 内容。
snapdb.snapshot
├── crc (uint32)
└── data (bytes)
└── Raftpb.Snapshot
├── data (bytes) ── JSON 序列化的 store v2 内容
└── metadata SnapshotMetadata{ index, term, conf }
JSON 内容具体包括:
| 项 | 内容 |
|---|---|
| Term | — |
| Index | — |
| Membership data | /0/members/8e9e05c52164694d/attributes -> {"name":"default","clientURLs":["http://localhost:2379"]} |
| Membership data | /0/members/8e9e05c52164694d/RaftAttributes -> "{\"peerURLs\":[\"http://localhost:2380\"]}" |
| Storage version | /0/version -> 3.5.0 |
8.5.4 protoc 解码示例
在 etcd 根目录执行:
cat default.etcd/member/snap/0000000000000002-0000000000049425.snap |
protoc --decode=snappb.snapshot \
server/etcdserver/api/snap/snappb/snap.proto \
-I $(go list -f '{{.Dir}}' github.com/gogo/protobuf/proto)/.. \
-I . \
-I $(go list -m -f '{{.Dir}}' github.com/gogo/protobuf)/protobuf
原文注解:类似地,可提取
data字段并按Raftpb.Snapshot解码。
8.5.5 示例 JSON(etcd 3.4 *.snap 文件中的 store v2 内容)
顶层对象含 Root 与 WatcherHub 两个键。
{
"Root": {
"Path": "/",
"CreatedIndex": 0,
"ModifiedIndex": 0,
"ExpireTime": "0001-01-01T00:00:00Z",
"Value": "",
"Children": {
"0": {
"Path": "/0",
"Children": {
"members": {
"CreatedIndex": 1,
"ModifiedIndex": 1,
"Children": {
"8e9e05c52164694d": {
"Children": {
"attributes": { "CreatedIndex": 2, "ModifiedIndex": 2,
"Value": "{\"name\":\"default\",\"clientURLs\":[\"http://localhost:2379\"]}" },
"RaftAttributes": { "CreatedIndex": 1, "ModifiedIndex": 1,
"Value": "{\"peerURLs\":[\"http://localhost:2380\"]}" }
}
}
}
},
"version": { "CreatedIndex": 3, "ModifiedIndex": 3, "Value": "3.5.0" }
}
},
"1": {}
}
},
"WatcherHub": {
"EventHistory": {
"Queue": {
"Events": [
{ "action": "create", "node": { "key": "/0/members/8e9e05c52164694d/RaftAttributes", "modifiedIndex": 1, "createdIndex": 1 } },
{ "action": "set", "node": { "key": "/0/members/8e9e05c52164694d/attributes", "modifiedIndex": 2, "createdIndex": 2 } },
{ "action": "set", "node": { "key": "/0/version", "modifiedIndex": 3, "createdIndex": 3 } }
]
}
}
}
}
结构说明(整理自原文逐项描述):
| 路径 | index | 值 |
|---|---|---|
/0/members |
CreatedIndex / ModifiedIndex = 1 | — |
/0/members/8e9e05c52164694d/attributes |
2 | {"name":"default","clientURLs":["http://localhost:2379"]} |
/0/members/8e9e05c52164694d/RaftAttributes |
1 | {"peerURLs":["http://localhost:2380"]} |
/0/version |
3 | "3.5.0" |
/1 |
— | 空节点 |
WatcherHub.EventHistory.Queue.Events |
— | 事件数组,每个元素形如 `{"action":"create" |
8.6 版本间格式变化
📌 官方文档未提供:
learning/persistent-storage-files.md的 Changes 小节保留用于描述不同 etcd 版本之间文件格式的变化,原文当前为空。
9. 系统限制
9.1 请求大小限制
| 项 | 值 |
|---|---|
| 设计定位 | etcd 被设计用于处理元数据类型的小 key-value 对 |
| 大请求的影响 | 更大的请求也能工作,但可能增加其他请求的延迟 |
| 默认最大请求大小 | 1.5 MiB |
| 配置方式 | 通过 etcd server 的 --max-request-bytes flag 配置 |
9.2 存储大小限制
| 项 | 值 |
|---|---|
| 默认存储大小限制 | 2 GiB |
| 配置方式 | 通过 --quota-backend-bytes flag 配置 |
| 普通环境下建议的最大值 | 8 GiB |
| 超过建议值的行为 | 若配置值超过它,etcd 会在启动时发出警告 |
9.3 官方文档未提供的其他限制
📌 官方文档未提供:
dev-guide/limit.md(v3.7)原文仅含上述两项限制,未列出 watch 数量等其他数值型限制。本章不补充任何来源不明的数字。
💡 补充:虽然 limit 页没写,但不得超过 8 GiB 的建议在实践中通常与 8.1 里提到的 WAL/
*.snap.db磁盘占用叠加考虑——配额只约束member/snap/db,不约束 WAL 与快照文件,因此实际磁盘规划要在配额之外再留出 WAL 与快照的空间(WAL 单文件约~64*10^6字节,最多--max-wals=5个;*.snap.db可能达 O(GBs))。
10. etcd 特性清单
etcd 特性(feature)分为三个阶段:experimental(实验)、stable(稳定) 和 unsafe(不安全)。可通过运行 etcd --help 获取特性列表。
10.1 三个阶段总览
| 阶段 | 默认状态 | 是否受 feature deprecation 策略约束 | 识别方式 |
|---|---|---|---|
| Experimental | 默认禁用 | 否(移除无需遵循策略,除非毕业为 stable) | flag 名称带 --experimental 前缀 |
| Stable | 可能默认启用 | 是 | etcd 中最常见的特性阶段 |
| Unsafe | 默认禁用 | 否 | 在用法文档的 Unsafe feature: 小节下列出 |
10.2 Experimental
为了尽早获得反馈,任何新特性通常先作为实验特性加入。实验特性可通过 flag 名称识别——应带有 --experimental 前缀。使用时需注意(逐条照录):
| # | 注意事项 |
|---|---|
| 1 | 由于缺乏用户测试,它可能有 bug;启用后可能不按预期工作 |
| 2 | 默认禁用 |
| 3 | 对这类特性的支持可能随时被取消,且不另行通知 |
| 3.1 | 它可能在下一个 minor 或 major 版本中被移除,无需遵循 feature deprecation 策略,除非它毕业(graduate)为 stable |
| 3.2 | 项目团队欢迎用户报告与实验特性相关的问题,但这类问题的优先级可能低于稳定特性相关问题 |
| 4 | 当实验性 feature flag 毕业到 stable 阶段时即被弃用;用户应尽快改用稳定 feature flag |
10.3 Stable
这是 etcd 中最常见的特性阶段,其特征为(逐条照录):
| # | 特征 |
|---|---|
| 1 | 作为 etcd 受支持发行版的一部分获得支持 |
| 2 | 可能默认启用 |
| 3 | 终止支持必须遵循 feature deprecation 策略 |
10.4 Unsafe
| 项 | 内容 |
|---|---|
| 出现频率 | Unsafe 特性很少见 |
| 文档位置 | 在 etcd 用法文档中的 Unsafe feature: 小节下列出 |
| 默认状态 | 默认禁用 |
| 使用要求 | 应谨慎并按文档使用 |
| 移除规则 | 可在下一个 minor 或 major 版本中被移除,无需遵循 feature deprecation 策略 |
10.5 Feature Deprecation
10.5.1 Experimental 的弃用
实验特性在毕业到 stable 阶段时即被弃用。
| 步骤 | 内容 |
|---|---|
| 文档表现 | 该实验特性文档会显示弃用消息,并推荐使用相关的稳定特性 flag,例如:DEPRECATED. Use <feature-name> instead. |
| 移除时机 | 被弃用的特性将在随后的发行版中被移除 |
10.5.2 Stable 的弃用
随着项目演进,稳定特性有时也需要被弃用和移除。此时(逐条照录):
| 步骤 | 内容 |
|---|---|
| 1 | 特性文档会在计划弃用的发行版之前显示警告消息,例如:To be deprecated in <release>.。若已有新特性计划取代该 To be deprecated 特性,文档还会给出提示,例如:Use <feature-name> instead. |
| 2 | 该特性将在计划的发行版中被弃用。届时特性文档会显示弃用消息并推荐使用相关的稳定特性,例如:DEPRECATED. Use <feature-name> instead. |
| 3 | 被弃用的特性将在随后的发行版中被移除 |
弃用流程示意(三个阶段 × 两条路径)
Experimental --(graduate)--> 文档显示 "DEPRECATED. Use <feature-name> instead."
--> 随后的发行版中移除
Stable --(计划弃用)--> 文档先显示 "To be deprecated in <release>." / "Use ...instead."
--> 计划发行版中显示 "DEPRECATED. Use <feature-name> instead."
--> 随后的发行版中移除
Unsafe --> 可在下一个 minor/major 版本直接移除(无需遵循策略)
10.6 查看特性清单的方法
$ etcd --help
原文说明:可通过运行
etcd --help获取特性列表。
💡 补充:升级/变更版本时,建议把
etcd --help输出中带--experimental前缀的 flag 全部记录下来——它们是唯一"可能随时被取消且不另行通知"的一类,也是升级后最容易突然失效的配置项。
11. 设计原理
本章覆盖四份官方设计文档。每节按 是什么 / 为什么 / 内部机制 / 怎么用 / 注意事项 五问展开。
11.1 客户端设计
引自:etcd client design — 作者:Gyuho Lee (github.com/gyuho, AWS)、Joe Betz (github.com/jpbetz, Google)
是什么
etcd server 自身经过了多年的故障注入测试,绝大多数复杂逻辑(集群成员关系对客户端透明、Raft 层把提案转发给 leader)都在服务端完成。但"服务端正确"与"客户端+服务端组合正确"是两件事:理想情况下服务端对外呈现一个逻辑集群视图,客户端负责在多个副本之间做自动 failover。
为什么:客户端的四项要求
| 要求 | 内容 |
|---|---|
| Correctness | 请求可能因服务端故障失败,但绝不破坏一致性保证 —— 全局顺序属性、不写入损坏数据、可变操作的 at-most once 语义、watch 不会看到部分事件 |
| Liveness | 服务端可能短暂失败或断连,客户端无论如何都要能向前推进;除非显式配置,客户端不应因等待离线节点而死锁(参考 etcd#8980)。理想做法是用 HTTP/2 ping 探测不可用节点并带清晰错误信息切换 |
| Effectiveness | 以最小资源开销运行 —— endpoint 切换后旧 TCP 连接应优雅关闭(etcd#9212);failover 要能有效预测下一个副本,不在已失败的节点上浪费重试 |
| Portability | 官方客户端实现应可移植到其他语言绑定,错误处理与重试策略在各语言间保持一致;由于 etcd 全面押注 gRPC,实现须紧贴 gRPC 长期设计目标(如可插拔重试策略要与 gRPC retry A6 兼容);两个客户端版本之间升级应无中断 |
内部机制(一):术语表
| 术语 | 含义 |
|---|---|
| clientv3 | etcd v3 API 的官方 Go 客户端 |
| clientv3-grpc1.0 | 基于 grpc-go v1.0.x,用于最新的 etcd v3.1 |
| clientv3-grpc1.7 | 基于 grpc-go v1.7.x,用于最新的 etcd v3.2 和 v3.3 |
| clientv3-grpc1.23 | 基于 grpc-go v1.23.x,用于最新的 etcd v3.4 |
| Balancer | etcd 客户端负载均衡器,实现重试与 failover 机制,应在多个 endpoint 间自动均衡负载 |
| Endpoints | 客户端可连接的 etcd server endpoint 列表,典型为 3 个或 5 个 client URL |
| Pinned endpoint | 配多个 endpoint 时,<= v3.3 的 balancer 只挑一个建 TCP 连接以节省总连接数;v3.4 中 balancer 对每个请求轮转 pinned endpoint,负载更均匀 |
| Client Connection | 通过 gRPC Dial 与某台 etcd server 建立的 TCP 连接 |
| Sub Connection | gRPC SubConn 接口,每个 sub-connection 含一组地址;一个 ClientConn 可映射多个 SubConn(如 example.com 解析为 10.10.10.1、10.10.10.2 两个 sub-connection)。etcd v3.4 balancer 用内部 resolver 为每个 endpoint 建一个 sub-connection |
| Transient disconnect | gRPC server 返回状态错误 code Unavailable |
内部机制(二):三个组件与两类错误
etcd client 实现三个组件:
| # | 组件 | 职责 |
|---|---|---|
| 1 | balancer | 建立到 etcd 集群的 gRPC 连接 |
| 2 | API client | 向 etcd server 发送 RPC |
| 3 | error handler | 判断失败请求应重试还是切换 endpoint |
各语言在建连方式(如配置 TLS)、Protocol Buffer 编解码、流式 RPC 处理上可能不同,但服务端返回的错误是一致的,因此错误处理与重试策略也应一致。
两类典型错误:
| 错误 | 类别 | 处理 |
|---|---|---|
"rpc error: code = Unavailable desc = etcdserver: request timed out" |
瞬时错误 | 应重试 |
rpc error: code = InvalidArgument desc = etcdserver: key is not provided |
请求本身非法 | 不应重试 |
解析方式:
| 语言 | 解析方式 |
|---|---|
| Go 客户端 | google.golang.org/grpc/status.FromError |
| Java 客户端 | io.grpc.Status.fromThrowable |
内部机制(三):三代客户端演进
| 版本 | 适用 etcd | gRPC | Balancer 行为 | 局限 |
|---|---|---|---|---|
| clientv3-grpc1.0 | v3.1 | grpc-go v1.0.x |
配置多个 endpoint 时维持多条 TCP 连接,然后挑一个地址发送全部客户端请求;pinned 地址一直保持到 client 对象关闭(Figure 1)。客户端收到错误时随机挑另一个地址重试 | 开多条 TCP 连接虽然 failover 更快但更耗资源;balancer 不理解节点健康状态与集群成员关系,可能卡在某个已失败或被分区的节点上 |
| clientv3-grpc1.7 | v3.2 / v3.3 | grpc-go v1.7.x |
只维持一条到选定 etcd server 的 TCP 连接。给定多个 endpoint 时先尝试连所有,一旦某个连接成功即 pin 该地址并关闭其余连接(Figure 2)。来自服务端或客户端网络故障的错误都送进客户端 error handler(Figure 3)。error handler 依据错误码与错误消息决定"原地重试"还是"切换地址"(Figure 4、Figure 5) | 见下 |
| clientv3-grpc1.23 | v3.4 | grpc-go v1.23.x |
简化 balancer failover 逻辑:不再维护可能过时的 unhealthy endpoint 列表,而是只要与当前 endpoint 断开就 round robin 到下一个 endpoint;不对 endpoint 状态做任何假设,因此不需要复杂的状态跟踪 | 见下 |
clientv3-grpc1.7 的流式 RPC 处理:Watch、KeepAlive 这类 stream RPC 通常不设超时;客户端改为发送周期性 HTTP/2 ping 检查 pinned endpoint 状态,服务端不应答 ping 时 balancer 切到其他 endpoint(Figure 6)。
clientv3-grpc1.7 的三条局限:
| # | 局限 |
|---|---|
| 1 | HTTP/2 keepalive 只是简单的 gRPC server ping 机制,不理解集群成员关系,因此无法检测网络分区 —— 被分区的 gRPC server 仍能回应 ping,balancer 会卡在分区节点上。理想情况是在请求超时之前就由 keepalive ping 探测到分区并触发切换(etcd#8673、Figure 7) |
| 2 | balancer 维护 unhealthy endpoint 列表:断连地址被加入 "unhealthy" 列表,直到一段 wait duration 之后才重新可用;该 wait duration 硬编码为 dial timeout,默认值 5 秒。会产生误判:节点 A 刚被拉黑就恢复了,但接下来 5 秒仍不可用(Figure 8)。clientv3-grpc1.0 存在同样问题 |
| 3 | 上游 gRPC Go 已迁移到新 balancer 接口,底层兼容虽勉强维持,但 etcd 客户端仍受细微破坏性变更影响(grpc-go#1649);gRPC 维护者也建议不要依赖旧 balancer 接口(grpc-go#1942)。新特性(如 retry policy)不会 backport 到 gRPC 1.7 分支,故服务端与客户端都必须迁移到最新 gRPC |
clientv3-grpc1.23 的设计要点:
| 项 | 内容 |
|---|---|
| 设计目标 | 简化 balancer failover 逻辑;升级到 clientv3-grpc1.23 应该没有问题:所有改动都是内部的,并保持向后兼容 |
| 连接模型 | 给定多个 endpoint 时,创建多个 sub-connection(每个 endpoint 一个);而 clientv3-grpc1.7 只创建一条到 pinned endpoint 的连接(Figure 9)。例如 5 节点集群中 clientv3-grpc1.23 需要 5 条 TCP 连接,clientv3-grpc1.7 只需 1 条。保留 TCP 连接池会消耗更多资源,但换来更灵活的负载均衡与更好的 failover 性能 |
| 负载均衡策略 | 默认负载均衡策略是 round robin,但容易扩展为其他类型(如 power of two、pick leader 等) |
| 实现差异 | clientv3-grpc1.23 使用 gRPC resolver group 并实现 balancer picker policy,把复杂均衡工作交给上游 gRPC;clientv3-grpc1.7 手工管理每条 gRPC 连接与 failover。clientv3-grpc1.23 把重试实现在 gRPC interceptor chain 中,自动处理 gRPC 内部错误并支持 backoff 等高级重试策略;clientv3-grpc1.7 手工解释 gRPC 错误来决定重试 |
| 局限 | 可缓存各 endpoint 状态做改进(提前 ping 维持健康候选列表、断开时优先健康 endpoint),但会复杂化实现,留待后续版本;客户端 keepalive ping 仍不理解网络分区,流式请求可能卡在分区节点,需要能理解集群成员关系的高级健康检查服务(etcd#8673);当前重试逻辑是以 interceptor 手工实现的,未来可用官方 gRPC retries 简化 |
怎么用:配置项与默认值清单
| 项 | 值 / 默认 |
|---|---|
| unhealthy endpoint 等待时长 | 硬编码为 dial timeout,默认 5 秒 |
| 默认负载均衡策略 | round robin |
| 每 endpoint 连接数(grpc1.23) | 1 个 sub-connection;5 节点集群 = 5 条 TCP 连接 |
| 每 endpoint 连接数(grpc1.7) | 1 条 TCP 连接(pinned) |
| 流式 RPC 健康探测 | 周期性 HTTP/2 ping(keepalive) |
连接数对照表:
| 集群规模 | clientv3-grpc1.23 | clientv3-grpc1.7 | clientv3-grpc1.0 |
|---|---|---|---|
| 3 节点 | 3 条 TCP | 1 条(pinned) | 多条(挑 1 个发全部请求) |
| 5 节点 | 5 条 TCP | 1 条 | 多条 |
注意事项
| # | 注意 |
|---|---|
| 1 | 不是所有错误都该重试。Unavailable 可重试;InvalidArgument(如 key is not provided)重试无意义 |
| 2 | dial timeout 默认 5 秒同时充当 unhealthy endpoint 的冷却时长(grpc1.7),会出现"节点已恢复但还要等 5 秒"的误判 |
| 3 | keepalive ping 无法检测网络分区(grpc1.7 与 grpc1.23 都存在该问题,见 etcd#8673),流式请求可能卡在分区节点 |
| 4 | 必须迁移到最新 gRPC —— 新特性不会 backport 到 gRPC 1.7 |
📌 官方文档未提供:
learning/design-client.md是架构设计文档,未逐项列出clientv3.Config的字段名(如DialTimeout、MaxCallSendMsgSize、AutoSyncInterval等),也未出现clientv3.New、kv.Put、OpGet等 Go API 名。上表仅包含源文中明确出现的值,未做任何补充。
💡 补充:工程实践上,客户端初始化建议至少显式设置
DialTimeout(默认 5 秒在跨可用区场景下可能偏短)与Endpoints(填全部成员地址而不是只填一个,这样 balancer 才有 failover 空间)。这一条不是官方文档原文。
11.2 Learner 设计
引自:etcd learner design — 作者:Gyuho Lee (github.com/gyuho, AWS)、Joe Betz (github.com/jpbetz, Google)
是什么
为填补成员重配置的可用性缺口,Raft §4.2.1 引入新的节点状态 "Learner":以非投票成员身份加入集群,直到追上 leader 的日志。
为什么:成员重配置的三大运维挑战
挑战 1 —— 新成员压垮 leader
| 环节 | 现象 |
|---|---|
| 起步 | 新加入的 etcd 成员从零数据起步,需要从 leader 拉取大量更新直到追上 leader 的日志 |
| 后果 | leader 网络更容易过载,导致发往 follower 的心跳被阻塞或丢弃 |
| 连锁 | follower 可能 election-timeout 而发起新一轮 leader 选举 |
| 结论 | 即"带新成员的集群更易发生 leader 选举";选举本身与随后向新成员传播更新的过程都容易造成集群不可用(Figure 1) |
挑战 2 —— 网络分区场景(四个子场景,quorum 数字逐条保留)
| 子场景 | 情形 | quorum | 结果 |
|---|---|---|---|
| 基础 | 若 leader 仍保有 active quorum | — | 集群继续正常运作(Figure 2) |
| 2.1 Leader isolation | leader 被孤立 | — | leader 监控每个 follower 的进度;一旦失去与 quorum 的连接,leader 退回 follower,影响集群可用性(Figure 3) |
| 2.2 Cluster Split 3+1 | 3 节点集群加入新成员后集群规模变 4、quorum 变 3;若新节点恰好与 leader 处于同一分区 | 3 | leader 仍保有 active quorum 3,不发生选举,可用性不受影响(Figure 4) |
| 2.3 Cluster Split 2+2 | 2-2 分区 | 3 | 两边都没有 quorum 3,触发 leader 选举(Figure 5) |
| 2.4 Quorum Lost | 先发生分区、再加入新成员。已分区的 3 节点集群本就有一个断连 follower,加入新成员时 quorum 从 2 变 3 | 2 → 3 | 此时 4 个节点只有 2 个活跃,失去 quorum 并启动新一轮选举(Figure 6) |
关键结论(原文):
- 因为
member add会改变 quorum 大小,始终推荐先member remove再替换不健康节点; - 向 1 节点集群加成员会把 quorum 变为 2,前一个 leader 一旦发现 quorum 不活跃就立即触发选举;
- 根因是
member add是两步过程:先执行 member add 命令,再启动新节点进程(Figure 7)。
挑战 3 —— 集群配置错误
| 环节 | 现象 |
|---|---|
| 两步流程 | 成员重配置是两步:etcdctl member add + 以给定 peer URL 启动 etcd 进程 |
| 致命点 | member add 命令不论 URL 是否有效都会被应用;若第一步用了非法 URL,第二步根本起不来 |
| 不可逆 | 一旦集群丢失 quorum,成员变更就无法回滚(Figure 8) |
| 多节点情形 | 两个成员 down(一个故障、一个配置错误)、两个 up,但要变更成员资格至少需要 3 票(Figure 9) |
| 运维代价 | 此时运维必须手工用 etcd --force-new-cluster flag 重建集群 |
由此引出四个核心问题(原文):
- 能否让成员重配置不改变 quorum 大小从而降低破坏性?
- 能否让新节点先空闲、只向 leader 请求最少更新直到追上?
- 能否让配置错误始终可回滚(错误的
member add命令绝不该搞挂集群)? - 用户加成员时还需要关心网络拓扑吗?
member addAPI 能否不关心节点位置与正在发生的分区?
背景补充(原文):etcd 已成为 Kubernetes 的关键服务,因此这些问题格外重要。
内部机制(一):v3.4 已实现的特性
| # | 特性 |
|---|---|
| 1 | 运维加 learner 节点的工作量应尽可能小:member add --learner 命令添加 learner,它以非投票成员身份加入集群,但仍然接收 leader 的全部数据(Figure 10) |
| 2 | learner 追上 leader 进度后,可用 member promote API 提升为投票成员,此后才计入 quorum(Figure 11) |
| 3 | etcd server 校验 promote 请求以确保操作安全:只有日志追上 leader 之后,learner 才能被提升为投票成员(Figure 12) |
| 4 | learner 在被提升前只作为 standby 节点:leadership 不能转移给 learner;learner 拒绝客户端的读写(客户端 balancer 不应把请求路由到 learner),这意味着 learner 不需要向 leader 发起 Read Index 请求。该限制简化了 v3.4 中 learner 的初始实现(Figure 13) |
| 5 | 此外,etcd 限制集群可拥有的 learner 总数,避免 leader 因日志复制过载。learner 永远不会自我提升;etcd 只提供 learner 状态信息与安全检查,是否 promote 由集群运维做最终决定 |
内部机制(二):未来版本计划特性(4 项)
| # | 计划 | 说明 |
|---|---|---|
| 1 | Make learner state only and default | 把新成员默认状态设为 learner 将大幅提升成员重配置安全性,因为 learner 不改变 quorum 大小,配置错误总能回滚且不会丢 quorum |
| 2 | Make voting-member promotion fully automatic | learner 追上 leader 日志后集群自动提升。etcd 要求用户定义若干阈值,满足条件后 learner 自动提升为投票成员;从用户视角 member add 用法不变但更安全 |
| 3 | Make learner standby failover node | learner 作为 standby 节点加入,当集群可用性受影响时自动提升 |
| 4 | Make learner read-only | learner 可作为永不提升的只读节点。弱一致模式下 learner 只从 leader 接收数据、不处理写,本地提供读可大幅降低 leader 负载但可能返回过期数据;强一致模式下 learner 向 leader 请求 read index 以提供最新数据,但仍拒绝写 |
内部机制(三):v3.4 实现细节(附录)
| 项 | 内容 |
|---|---|
向 MemberAdd API 暴露 "Learner" 节点类型 |
etcd client 在 MemberAdd API 上增加一个 learner 节点 flag;etcd server handler 以 pb.ConfChangeAddLearnerNode 类型应用成员变更条目。命令被应用后,server 以 etcd --initial-cluster-state=existing flag 加入集群。该 learner 节点既不能投票也不计入 quorum |
| leadership 转移 | etcd server 绝不能把 leadership 转移给 learner(它可能仍然落后且不计入 quorum) |
| learner 数量限制 | etcd 把集群可拥有的 learner 数量限制为一个;learner 越多,leader 要传播的数据越多 |
| learner 拒绝的请求 | 客户端可以与 learner 节点通信,但 learner 拒绝除 serializable read 与 member status API 之外的所有请求(为简化初始实现)。未来 learner 可扩展为持续镜像集群数据的只读 server |
| 客户端 balancer 义务 | 客户端 balancer 必须提供 helper 函数排除 learner 节点 endpoint,否则发往 learner 的请求会失败;客户端 sync member 调用与 endpoints update 调用都要把 learner 节点类型纳入考虑 |
| 响应应指出 learner | MemberList 与 MemberStatus 的响应应指出哪个节点是 learner |
新增 MemberPromote API |
在 Raft 内部,对 learner 节点发起第二次 MemberAdd 调用即将其提升为投票成员。leader 维护每个 follower 与 learner 的进度。若 learner 尚未完成其 snapshot message,则拒绝 promote 请求 |
接受 promote 的充要条件:
| # | 条件 |
|---|---|
| 1 | learner 节点处于健康状态 |
| 2 | learner 与 leader 已同步,或差距在阈值内(例如待复制给 learner 的条目数少于 snapshot count 的 1/10,即提升后 leader 大概率也不必再发 snapshot 给该 learner) |
原文强调:所有这些逻辑硬编码在
etcdserver包中,不可配置。
内部机制(四):Learner vs. Mirror Maker
| 项 | Mirror Maker | Learner |
|---|---|---|
| 实现 | etcd 用 watch API 实现 "mirror maker",持续把 key 的创建与更新中继到另一个集群;完成初始同步后镜像通常延迟开销很低 | Raft learner,复制 Raft 日志 |
| 重叠点 | 二者在"复制现有数据用于只读"上有重叠 | 同左 |
| 一致性 | 镜像不保证 linearizability | 保证 |
| 顺序 | 镜像没有顺序保证:网络断连期间先前的 key-value 可能已被丢弃,客户端需自行校验 watch 响应的顺序 | 有 |
| 结论 | 要最低延迟(如跨数据中心)用 mirror,代价是一致性 | 要保留全部历史数据及其顺序用 learner |
怎么用:相关 RPC 与参数汇总
| 类别 | 名称 |
|---|---|
| RPC / API | MemberAdd(带 learner flag)、MemberPromote、MemberList、MemberStatus |
| 内部类型 | pb.ConfChangeAddLearnerNode |
| etcdctl 命令 | member add --learner、member promote、member remove |
| server flag | --initial-cluster-state=existing、--force-new-cluster |
参考 issue(原文给出):原始 issue etcd#9161;use case etcd#3715、etcd#8888、etcd#10114。
注意事项
| # | 注意 |
|---|---|
| 1 | learner 数量上限:正文表述为"限制 learner 总数",附录明确为"限制为一个"。本册以附录为准 |
| 2 | learner 拒绝读写:除 serializable read 与 member status API 之外的所有请求都会被拒 |
| 3 | 客户端必须排除 learner endpoint,否则请求失败 |
| 4 | promote 前必须已追上,否则被拒绝;promote 逻辑硬编码、不可配置 |
| 5 | member add 会改变 quorum:始终推荐先 member remove 再替换不健康节点 |
| 6 | learner 永远不会自我提升,是否 promote 由运维决定 |
📌 官方文档未提供:源文中没有出现任何
--experimental-*参数(v3.4 中 learner 相关曾以实验特性引入,但本页未提及,故本册不列)。
💡 补充:把 learner 用在实践中最典型的姿势是——加新成员时先加 learner,等它追上再 promote,这样在整个追赶过程中 quorum 大小不变,不会出现"加节点把集群搞挂"的事故。这也是 11.4 两阶段模型在运维上的具体落地。
11.3 认证设计 v3
是什么
v3 协议用 gRPC 作为传输层(而非 v2 的 RESTful 接口),因此 v3 auth 是基于连接的认证(connection based authentication),而 v2 是较慢的逐请求认证(per-request)。v3 对认证机制有明确的定义与实现,修复了 v2 的缺陷。
为什么:不复用 v2 auth 的三条理由
| # | 理由 |
|---|---|
| 1 | gRPC 传输层带来迭代改进机会:每连接认证取代逐请求认证,更快 |
| 2 | v2 auth 的语义在一致性推理上很笨拙 |
| 3 | v3 是扁平 key space(不同于 v2 的目录结构),权限检查以**区间匹配(interval matching)**实现 |
功能性需求(逐条照录):
| 需求 | 说明 |
|---|---|
| 每连接认证,而非每请求 | ① 基于 User ID + password 的认证,实现在 gRPC API 上;② 认证策略变更后必须刷新认证 |
| 功能应与 v2 一样简单实用 | v3 提供扁平 key space(不同于 v2 的目录结构),权限检查以**区间匹配(interval matching)**实现 |
| 应提供比 v2 auth 更强的一致性保证 | — |
主要改动(3 条):
| # | 改动 |
|---|---|
| 1 | 客户端在发送认证请求前,必须先建立一条专用于认证的独立连接 |
| 2 | 向 Raft 命令(etcdserverpb.InternalRaftRequest)中加入权限信息(user ID 与 authorized revision) |
| 3 | 每个请求在状态机层做权限检查,而不是在 API 层 |
内部机制(一):权限元数据的一致性
| 论点 | 内容 |
|---|---|
| 存放位置 | auth 的元数据必须与其他数据一样,存放在由 etcd Raft 协议控制的存储中,否则会牺牲整个集群的可用性与一致性 |
| 错误方案 | 若读写权限元数据需要每个节点一致同意(超过 quorum),那么单节点故障就能停掉整个集群 |
| 更坏的方案 | 要求所有节点同时同意 ⇒ "只要任一成员 down,普通读写请求的权限检查就完不成" —— 即使集群仍有可用 quorum |
| 结论 | 一致同意方案最终会降低可用性;基于 Raft 的 quorum 共识就够了,因为一致的顺序自然蕴含一致性 |
v2 的反面教材:
| 项 | v2 的问题 |
|---|---|
| 检查位置 | 每个权限检查由收到客户端请求的那个 etcd 成员(包括 follower)处理(server/etcdserver/api/v2http/client.go) |
| 后果 | 检查可能基于过期的元数据 |
| 现象 | 运维执行 etcdctl 后 auth 配置不能立即生效,且无从得知过期元数据会活跃多久 |
| 实践 | 配置变更在命令执行后立即反映,但在高负载场景下不一致状态会被延长,导致反直觉的结果(需要类似 PR#4317 的 workaround) |
内部机制(二):为什么权限不一致对线性化请求不安全
| 论点 | 内容 |
|---|---|
| 最危险的是写 | 即使运维禁用了某用户的写权限,若写操作只相对 key-value store 排序而未相对认证系统排序,该写仍可能成功 |
| 攻击面 | 缺少对 auth store 与 key-value store 两者的统一排序,系统就容易遭受 stale permission 攻击 |
| 解决方案 | 权限检查逻辑必须加入 etcd 的状态机:每个状态机在 apply 阶段基于自身权限信息检查请求(此时 auth 信息必然不过期) |
内部机制(三):认证流程
| 步骤 | 内容 |
|---|---|
| 1 | 客户端首先必须创建一条仅用于认证的 gRPC 连接,提交 user ID 与 password |
| 2 | etcd server 返回认证应答:成功返回 authentication token,失败返回错误 |
| 3 | 客户端随后可用该 token 在 API 请求中出示凭据 |
为什么用于请求 token 的那条连接通常会被丢弃(原文解释):
因为它无法承载新 token 的凭据 —— gRPC 没有提供在创建连接(调用
grpc.Dial())之后追加 per-RPC credential 的机制,客户端无法把通过该连接拿到的 token 再赋给这条连接,因此需要一个新连接来使用 token。
内部机制(四):Authenticate() RPC 与性能取舍
Authenticate() RPC 基于给定的 user name 与 password 生成认证 token。
| 项 | 内容 |
|---|---|
| 密码存储 | etcd 使用 Go 的 bcrypt 包保存与校验密码 |
| 校验代价 | bcrypt 的校验机制计算代价高,在普通 x64 服务器上耗时接近 100ms |
| 若放在状态机 apply 阶段 | 会有性能问题:整个 etcd 集群每秒只能处理约 10 个 Authenticate() 请求 |
| 实际做法 | 为性能考虑,v3 auth 把密码校验放在 etcd 的 API 层(可在 raft 之外并行化) |
由此引入的 TOCTOU(time-of-check/time-of-use)权限漏洞(原文 6 步场景):
| 步 | 事件 |
|---|---|
| 1 | 客户端 A 发送 Authenticate() 请求 |
| 2 | API 层处理 Authenticate() 的密码校验部分 |
| 3 | 另一客户端 B 发送 ChangePassword(),服务端完成它 |
| 4 | 状态机层处理"为 A 的 Authenticate() 获取 revision number"的部分 |
| 5 | 服务端向 A 返回成功 |
| 6 | 此时 A 是用已过时的密码完成认证的 |
修复机制:API 层执行基于 auth store revision number 的 version number validation:
| 步骤 | 内容 |
|---|---|
| 1 | 密码校验期间,API 层保存 auth store 的 revision number |
| 2 | 校验成功后比较保存值与最新值 |
| 3 | 若不同(说明有人更新了 auth 元数据),则重试校验 |
| 效果 | 该机制可避免基于过时密码的校验成功 |
内部机制(五):在 API 层解析 token
| 环节 | 机制 |
|---|---|
| 客户端侧 | 通过 Authenticate() 认证之后,客户端可像无 auth 时一样创建 gRPC 连接;除既有初始化流程外,还必须把 token 关联到新建连接上,这由 grpc.WithPerRPCCredentials() 提供 |
| 请求携带 | 客户端的每个认证请求都携带 token |
| 服务端获取 | 服务端可用 grpc.metadata.FromIncomingContext() 获取 token,从而得知请求发起者及其被授权的时间 |
| 写入 Raft | 这些信息由 API 层填入 raft 日志条目(etcdserverpb.InternalRaftRequest)的头部:etcdserverpb.RequestHeader.Username 与 etcdserverpb.RequestHeader.AuthRevision |
内部机制(六):在状态机中检查权限
etcdserverpb.RequestHeader 中的 auth 信息在状态机的 apply 阶段被检查:这一步基于 auth store 的最新 revision,检查用户是否被授予了对所请求 key 的权限。
内部机制(七):两种 token —— simple 与 JWT
| simple token | JWT token | |
|---|---|---|
| 设计用途 | 不面向生产,仅用于开发测试 | 生产部署应使用 |
| 签名 | 未做密码学签名 | 已密码学签名并可校验 |
| 服务端状态 | 有状态:server 必须记录 token 与 user 的对应关系 | 无状态(stateless) |
| 元数据 | — | token 本身可包含 username、revision 等元数据,服务端无需记住 token 与元数据的对应关系 |
Note(warning,原文保留):simple token 存在已知问题 #18437。etcd server 内部在 API 层解析 token,而 simple token 是有状态的,该过程不受 linearizable check 保护:某个 etcd 成员可能还没处理完前一个认证请求就收到下一个请求,此时可能向客户端返回 "invalid auth token" 错误。该问题在网络状况良好的节点上很少见,但在延迟显著时会发生。workaround:应用程序可自行实现重试机制来处理该错误。
怎么用(一):直接设置 JWT token
除标准 Authenticate() RPC 流程外,etcd 支持在客户端层面直接设置 JWT token,允许应用在 etcd 之外管理 JWT token 的完整生命周期(生成、校验、轮换)。
适用场景(3 条):
| # | 场景 |
|---|---|
| 1 | 由 etcd 之外的独立 token 管理系统负责 JWT 生成与生命周期 |
| 2 | 应用通过外部机制(如环境变量、配置服务)获得预先签名的 JWT token |
| 3 | token 生命周期必须完全由客户端应用管理,而非由 etcd 自动生成 |
典型流程(6 步):
| 步 | 内容 |
|---|---|
| 1 | 外部权威机构(非 etcd)生成包含 username 及其他 claim 的签名 JWT token |
| 2 | 应用收到该预签名 token,并以此配置 etcd 客户端 |
| 3 | 客户端直接在请求中提交 JWT token(不调用 Authenticate()) |
| 4 | etcd server 使用其配置的公钥校验 token 签名,并依据 token 中的 username 授予访问权 |
| 5 | token 过期前,应用从外部权威机构获取新 token |
| 6 | 应用用更新后的 token 创建新的客户端(token 更新需要重建 client) |
与标准认证的差异:
标准 Authenticate() 流程 |
直接设置 JWT | |
|---|---|---|
| 起点 | 客户端用 username/password 调用 Authenticate() |
客户端以预签名 JWT 初始化 |
| token 来源 | etcd 生成并返回 token | 不调用 Authenticate() |
| 使用 | 客户端自动在后续请求中携带 | token 直接用于所有请求 |
| 刷新 | 需再次调用 Authenticate() |
客户端应用自行负责在过期前获取新 token 并管理客户端生命周期 |
怎么用(二):AuthStatus 无需有效 token
为支持自建 JWT 的应用,AuthStatus RPC 被设计为允许客户端判断认证是否启用并获取当前 authRevision。
为什么重要(原文论证):这在恢复场景中很重要 —— token 已过期时,客户端需要最新 revision 才能从外部 token provider 获取新的有效 token。缺少该能力,过期 token 会阻止客户端得知当前 authRevision,从而陷入"无法生成新 token"的死锁。
注意事项:KVS 模型与文件系统模型的差异
| 项 | etcd v3(KVS) | 类文件系统系统(Chubby、ZooKeeper) |
|---|---|---|
| 权限授予形式 | 可以精确的 key 名或 key 区间 ["start key", "end key") 的形式授予用户 |
inode 式数据结构可承载权限信息 |
| 对不存在的 key 授权 | 是可能的,用户需注意非预期的权限授予 | 不可能(sticky bit 情形除外) |
| 元数据查找 | 需要多次查找元数据;最坏情况下的查找成本是"用户被授予的全部 key 与区间之和" | 每个 inode 自带权限元数据 |
| 成本可否避免 | 无法避免,因为 v3 的扁平 key space 与 Unix 文件模型完全不同 | — |
| 是否构成问题 | 实践中不构成严重问题,因为元数据足够小、能受益于缓存 | — |
📌 官方文档未提供:
learning/design-auth-v3.md是设计文档,源文中未出现 user/role/permission 的具体数据模型定义、未出现READ/WRITE/READWRITE权限类型枚举、未出现 root 用户与开启 auth 的 etcdctl 操作步骤及命令(如etcdctl user add、etcdctl role grant-permission、etcdctl auth enable、--auth-token等)。按抓取规则不得凭已有知识补充;操作层面的内容见op-guide/security.md等页面(本册可在 4.10 看到demo.md给出的 auth 操作脚本,在 6.8 看到Permission/Role/User的 proto 字段)。
💡 补充:把设计文档与 proto 对照可以得到一条实践规则——权限是"区间"而非"前缀匹配"。
RoleGrantPermission允许授予任意["start key","end key")区间,因此range_end填错(例如填成\0)会一次性授权"所有大于等于 key 的 key",这是最容易发生的越权配置。
11.4 运行时重配置设计
是什么
运行时重配置是分布式系统(尤其是 etcd 这种基于共识的系统)中最困难、最容易出错的特性之一。本文说明 etcd 运行时重配置命令的设计思路及其问题解法。
为什么:必须两阶段
出于安全原因,etcd 中每一次运行时重配置都必须经过两个阶段(以加成员为例):先通知集群新配置,再启动新成员。
Phase 1 —— 告知集群新配置(Inform cluster of new configuration)
| 项 | 内容 |
|---|---|
| 做法 | 要向 etcd 集群加入成员,需通过 API 调用请求把新成员加入集群 |
| 唯一性 | 这是向既有集群添加新成员的唯一途径 |
| 返回时机 | 该 API 调用在集群就配置变更达成一致后才返回 |
Phase 2 —— 启动新成员(Start new member)
| 项 | 内容 |
|---|---|
| 配置 | 需指定正确的 initial-cluster 并把 initial-cluster-state 设为 existing |
| 启动校验 | 成员启动时会先联系既有集群,并校验当前集群配置与 initial-cluster 中指定的期望配置一致 |
| 完成 | 新成员成功启动后,集群即达到期望配置 |
内部机制:为何拆成两个离散阶段
强制用户对集群成员变更保持显式操作,反而带来更多灵活性与更清晰的可推理性。举例(原文):
| 例子 | 行为 |
|---|---|
| 添加与既有成员 ID 相同的成员 | 该操作会在 phase one 立即失败,且不影响运行中的集群 |
| 防止误加成员 | 若新 etcd 成员在集群接受配置变更之前就尝试加入,它不会被集群接受 |
如果没有这套显式工作流会怎样(原文举例):etcd 就容易遭受非预期的成员变更。例如:
在 systemd 这类 init 系统下运行 etcd 时,成员若通过 membership API 被移除,systemd 会重启 etcd,而 etcd 启动时又会尝试重新加入集群 —— 只要通过 API 移除成员、且 systemd 配置为失败后重启,这个循环就会反复发生。
etcd 的立场(原文):运行时重配置应当是低频操作,因此刻意保持"显式、由用户驱动",以确保配置安全、让集群始终在显式控制下平稳运行。
注意事项(一):永久失去 quorum 需要新建集群
| 项 | 内容 |
|---|---|
| 情形 | 若集群永久性地失去多数派成员 |
| 做法 | 需要用旧的数据目录启动一个新集群来恢复先前的状态 |
| 为何不支持"强制移除" | 技术上完全可以强制移除故障成员来恢复,但 etcd 决定不支持这种方式,因为它绕过了正常的共识提交阶段,是不安全的 |
| 危险后果 | 若要移除的成员实际上并未死亡,或同一集群中通过不同成员做了强制移除,etcd 最终会得到一个具有相同 clusterID 的分裂集群 —— 这非常危险且事后极难排查修复 |
| 官方建议 | 在正确部署下,永久失去多数派的可能性很低,但问题足够严重、值得特别对待。强烈建议阅读 disaster recovery 文档并在投产前为永久多数派丢失做好准备 |
本页引用的链接:../runtime-configuration/#add-a-new-member、../recovery/。
注意事项(二):不要用公共 discovery service 做运行时重配置
| 项 | 内容 |
|---|---|
| 正确用途 | 公共 discovery service 只应用于集群引导(bootstrapping) |
| 加成员的正确做法 | 要把成员加入既有集群,请使用运行时重配置 API |
| discovery service 的设计目的 | 在云环境中引导 etcd 集群(此时所有成员的 IP 地址事先未知)。成功引导后所有成员 IP 都已确定,技术上不再需要 discovery service |
依赖它做运行时重配置的三个麻烦(逐条照录):
| # | 麻烦 |
|---|---|
| 1 | 它为集群的整个生命周期(而不只是引导期)引入外部依赖;集群与公共 discovery service 之间一旦出现网络问题,集群就会受累 |
| 2 | 公共 discovery service 必须在集群整个生命周期中反映正确的运行时配置,为此必须提供安全机制来防范恶意行为,而这很难做到 |
| 3 | 公共 discovery service 必须保存数以万计的集群配置;etcd 的公共 discovery service 后端尚未为该量级负载做好准备 |
结论(原文):若确实需要支持运行时重配置的 discovery service,最佳选择是自建私有的。
怎么用:关键配置名与命令汇总
| 类别 | 名称 |
|---|---|
| 配置 / flag | initial-cluster、initial-cluster-state=existing |
| 阶段动作 | Phase 1:通过 API 请求加入成员(集群达成一致后返回);Phase 2:以 initial-cluster + initial-cluster-state=existing 启动新成员,启动时校验集群配置 |
| 恢复手段 | 用旧 data directory 启动新集群(见 ../recovery/);不支持强制移除成员 |
| 相关命令页 | ../runtime-configuration/#add-a-new-member(add a new member) |
注意事项(三):operational checklist
加成员(务必按序):
1. etcdctl member add <name> --peer-urls=<url> # Phase 1:集群达成一致
2. 用返回的 initial-cluster 启动新成员进程,
并设 --initial-cluster-state=existing # Phase 2:启动并校验
3. (可选)先加为 learner:member add --learner,
确认追上后再 member promote # 见 11.2
换掉不健康节点:
1. etcdctl member remove <id> # 先移除
2. 再按上面的流程加新成员 # member add 会改变 quorum
💡 补充:上表最后一步"先加 learner 再 promote"是把 11.2 与本节合并后的推荐做法;本节原文只强调"先
member remove再替换"来自design-learner.md,而两阶段模型来自本页。
📌 官方文档未提供:
op-guide/runtime-reconf-design.md明确描述的是两阶段(Phase 1 / Phase 2)模型,源文中没有"四个阶段/状态机"的表述,也没有 quorum 计算公式或成员变更限制清单(如一次只能变更一个成员、2 节点集群的限制等)。本页出现的唯一数值化 quorum 讨论实际位于learning/design-learner.md(3 节点加成员后规模变 4、quorum 变 3;先分区再加成员 quorum 由 2 变 3),已在 11.2 收录。此处未做任何补充。
12. etcdctl v3 交互入门
用户主要通过 put/get key 的 value 与 etcd 交互。本节用 etcdctl(etcd 的命令行工具)说明,所述概念同样适用于 gRPC API 与客户端库 API。
12.1 环境变量与 API 版本
etcdctl 与 etcd 通信所用的 API 版本可通过环境变量 ETCDCTL_API 设为 2 或 3。
| 版本 | 默认值 |
|---|---|
| master(3.4)上的 etcdctl | 默认使用 v3 API |
| 3.3 及更早 | 默认为 v2 API |
export ETCDCTL_API=3
重要注意(原文):用 v2 API 创建的 key 无法通过 v3 API 查询。v3 的
etcdctl get查一个 v2 key 会退出码 0 且不返回任何 key 数据,这是预期行为。
12.2 查看版本
etcdctl version 与 Server API version 有助于确定该用哪些命令:
$ etcdctl version
etcdctl version: 3.1.0-alpha.0+git
API version: 3.1
📌 版本不一致提示:本页示例中出现的版本号为
etcdctl version: 3.1.0-alpha.0+git/API version: 3.1,与文档站点 v3.7 的当前版本不一致(源文未随版本更新)。本册照录原文输出,不做改写。
12.3 写 key put
应用通过写 key 把键值存入 etcd 集群。每个存储的 key 都会通过 Raft 协议复制到所有 etcd 集群成员,以实现一致性与可靠性。
$ etcdctl put foo bar
OK
也可以通过挂载 lease 让 key 只在指定时间段内有效。把 key foo1 的值设为 bar1,有效期 10s:
$ etcdctl put foo1 bar1 --lease=1234abcd
OK
Note(info,原文保留):上述命令中的 lease id
1234abcd指创建 10s lease 时返回的 id,该 id 随后可挂载到 key 上。
12.4 读 key get
应用可从 etcd 集群读取 key 的值。查询可以读单个 key,也可以读一个区间的 key。
假设集群已存有以下 key:
foo = bar
foo1 = bar1
foo2 = bar2
foo3 = bar3
| 场景 | 命令 | 输出 |
|---|---|---|
读 key foo 的值 |
etcdctl get foo |
foo / bar |
| 以 hex 格式读 | etcdctl get foo --hex |
\x66\x6f\x6f # Key / \x62\x61\x72 # Value |
| 只读值(不输出 key) | etcdctl get foo --print-value-only |
bar |
范围读取 [foo, foo3) |
etcdctl get foo foo3 |
foo/bar、foo1/bar1、foo2/bar2 |
| 前缀读取 | etcdctl get --prefix foo |
4 个 key 全部返回 |
| 前缀 + 限制数量 2 | etcdctl get --prefix --limit=2 foo |
foo/bar、foo1/bar1 |
| 用 RangeStream RPC 读 | etcdctl get --stream --prefix foo |
与 unary Range 结果相同 |
$ etcdctl get foo
foo
bar
$ etcdctl get foo --hex
\x66\x6f\x6f # Key
\x62\x61\x72 # Value
$ etcdctl get foo --print-value-only
bar
$ etcdctl get foo foo3
foo
bar
foo1
bar1
foo2
bar2
Note(info,原文保留):
foo3被排除,因为区间是半开区间[foo, foo3),不含foo3。
$ etcdctl get --prefix foo
foo
bar
foo1
bar1
foo2
bar2
foo3
bar3
$ etcdctl get --prefix --limit=2 foo
foo
bar
foo1
bar1
$ etcdctl get --stream --prefix foo
foo
bar
foo1
bar1
foo2
bar2
foo3
bar3
原文注解:
--stream不支持--order、--sort-by以及 revision 过滤器。
12.5 读历史版本
应用可能需要读取 key 被覆盖前的版本:例如回滚到旧配置,或通过访问 key 历史在多次请求间获得对多个 key 的一致视图。由于对 etcd 集群 key-value store 的每次修改都会递增集群的全局 revision,应用可通过提供一个较旧的 etcd revision 来读取被取代的 key。
假设集群已有:
foo = bar # revision = 2
foo1 = bar1 # revision = 3
foo = bar_new # revision = 4
foo1 = bar1_new # revision = 5
$ etcdctl get --prefix foo # access the most recent versions of keys
foo
bar_new
foo1
bar1_new
$ etcdctl get --prefix --rev=4 foo # access the versions of keys at revision 4
foo
bar_new
foo1
bar1
$ etcdctl get --prefix --rev=3 foo # access the versions of keys at revision 3
foo
bar
foo1
bar1
$ etcdctl get --prefix --rev=2 foo # access the versions of keys at revision 2
foo
bar
$ etcdctl get --prefix --rev=1 foo # access the versions of keys at revision 1
12.6 读大于等于指定 key 的 key
假设集群已有:
a = 123
b = 456
z = 789
读取字节值大于等于 key b 的 key:
$ etcdctl get --from-key b
b
456
z
789
12.7 删除 key del
应用可删除单个 key 或一个区间的 key。假设集群已有:
foo = bar
foo1 = bar1
foo3 = bar3
zoo = val
zoo1 = val1
zoo2 = val2
a = 123
b = 456
z = 789
| 场景 | 命令 | 输出 |
|---|---|---|
删除 key foo |
etcdctl del foo |
1 # one key is deleted |
删除 [foo, foo9) 区间 |
etcdctl del foo foo9 |
2 # two keys are deleted |
删除 zoo 并返回被删的键值对 |
etcdctl del --prev-kv zoo |
1 / zoo / val |
删除有 zoo 前缀的 key |
etcdctl del --prefix zoo |
2 # two keys are deleted |
删除字节值 >= key b 的 key |
etcdctl del --from-key b |
2 # two keys are deleted |
$ etcdctl del foo
1 # one key is deleted
$ etcdctl del foo foo9
2 # two keys are deleted
$ etcdctl del --prev-kv zoo
1 # one key is deleted
zoo # deleted key
val # the value of the deleted key
$ etcdctl del --prefix zoo
2 # two keys are deleted
$ etcdctl del --from-key b
2 # two keys are deleted
12.8 watch key 变化
应用可 watch 一个 key 或一个区间的 key 以监控更新。
$ etcdctl watch foo
# in another terminal: etcdctl put foo bar
PUT
foo
bar
$ etcdctl watch foo --hex
# in another terminal: etcdctl put foo bar
PUT
\x66\x6f\x6f # Key
\x62\x61\x72 # Value
$ etcdctl watch foo foo9
# in another terminal: etcdctl put foo bar
PUT
foo
bar
# in another terminal: etcdctl put foo1 bar1
PUT
foo1
bar1
$ etcdctl watch --prefix foo
# in another terminal: etcdctl put foo bar
PUT
foo
bar
# in another terminal: etcdctl put fooz1 barz1
PUT
fooz1
barz1
用交互式会话同时 watch 多个 key foo 与 zoo:
$ etcdctl watch -i
$ watch foo
$ watch zoo
# in another terminal: etcdctl put foo bar
PUT
foo
bar
# in another terminal: etcdctl put zoo val
PUT
zoo
val
12.9 watch 历史变更
若应用始终保持连接,watch 已足够;但若应用或 etcd 发生故障,故障期间的变更就无法实时收到。为保证变更一定被投递,应用必须能 watch key 的历史变更 —— 就像读历史版本一样,可在 watch 上指定一个历史 revision。
假设已完成如下操作序列:
$ etcdctl put foo bar # revision = 2
OK
$ etcdctl put foo1 bar1 # revision = 3
OK
$ etcdctl put foo bar_new # revision = 4
OK
$ etcdctl put foo1 bar1_new # revision = 5
OK
# watch for changes on key `foo` since revision 2
$ etcdctl watch --rev=2 foo
PUT
foo
bar
PUT
foo
bar_new
# watch for changes on key `foo` since revision 3
$ etcdctl watch --rev=3 foo
PUT
foo
bar_new
只 watch 最近一次历史变更(附带上一次的值):
# watch for changes on key `foo` and return last revision value along with modified value
$ etcdctl watch --prev-kv foo
# in another terminal: etcdctl put foo bar_latest
PUT
foo # key
bar_new # last value of foo key before modification
foo # key
bar_latest # value of foo key after modification
12.10 watch progress
应用可能需要检查 watch 的进度,以判断 watch 流的更新程度(例如用 watch 更新缓存时,需要知道缓存相对 quorum read 的 revision 是否已过期)。可在交互式 watch 会话中用 progress 命令请求 etcd server 在 watch 流中发送一条 progress notify 更新:
$ etcdctl watch -i
$ watch a
$ progress
progress notify: 1
# in another terminal: etcdctl put x 0
# in another terminal: etcdctl put y 1
$ progress
progress notify: 3
Note(info,原文保留):progress notify 响应中的 revision 号来自 watch 流所连接的那个本地 etcd 节点。若该节点被分区、不属于 quorum,此 progress notify revision 可能低于对未分区节点做 quorum read 得到的 revision。
12.11 压缩 revision compact
etcd 保留 revision 以便应用读取历史版本,但为避免历史无界累积,必须压缩过去的 revision。压缩后 etcd 移除历史 revision、释放资源;所有 revision 早于压缩点的被取代数据将不可访问。
$ etcdctl compact 5
compacted revision 5
# any revisions before the compacted one are not accessible
$ etcdctl get --rev=4 foo
Error: rpc error: code = 11 desc = etcdserver: mvcc: required revision has been compacted
Note(info,原文保留):etcd server 的当前 revision 可用
get命令以 json 格式查询任意 key(存在或不存在均可)。下例中的mykey在 etcd server 中并不存在:
$ etcdctl get mykey -w=json
{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":15,"raft_term":4}}
💡 补充:这条"查不存在的 key 拿当前 revision"的技巧非常实用——它等价于一次极轻量的"现在集群到哪个 revision 了"的探测,可用于实现 7.8.5 里"先取当前 revision、再建 watch"的初始化逻辑。
12.12 lease 三连
12.12.1 授予 lease(LEASE GRANT)
应用可从 etcd 集群为 key 授予 lease。key 挂到 lease 上后,其生命周期与 lease 绑定,而 lease 由 **TTL(time-to-live)**控制。
| 规则 | 说明 |
|---|---|
| 最小 TTL | 每个 lease 在 grant 时由应用指定最小 TTL 值 |
| 实际 TTL | lease 的实际 TTL 至少为该最小值,具体由 etcd 集群选择 |
| 到期 | 一旦 lease 的 TTL 到期,lease 过期,其挂载的所有 key 都被删除 |
# grant a lease with 60 second TTL
$ etcdctl lease grant 60
lease 32695410dcc0ca06 granted with TTL(60s)
# attach key foo to lease 32695410dcc0ca06
$ etcdctl put --lease=32695410dcc0ca06 foo bar
OK
12.12.2 撤销 lease(LEASE REVOKE)
应用按 lease ID 撤销 lease;撤销会删除该 lease 上挂载的所有 key。
$ etcdctl lease grant 60
lease 32695410dcc0ca06 granted with TTL(60s)
$ etcdctl put --lease=32695410dcc0ca06 foo bar
OK
$ etcdctl lease revoke 32695410dcc0ca06
lease 32695410dcc0ca06 revoked
$ etcdctl get foo
# empty response since foo is deleted due to lease revocation
12.12.3 保持 lease 存活(LEASE KEEP-ALIVE)
应用可通过刷新 TTL 使 lease 不过期。
$ etcdctl lease grant 60
lease 32695410dcc0ca06 granted with TTL(60s)
$ etcdctl lease keep-alive 32695410dcc0ca06
lease 32695410dcc0ca06 keepalived with TTL(60)
lease 32695410dcc0ca06 keepalived with TTL(60)
lease 32695410dcc0ca06 keepalived with TTL(60)
...
12.12.4 查询 lease 信息(LEASE TIMETOLIVE)
应用可能需要了解 lease 信息以便续约、检查 lease 是否仍存在或已过期,也可能需要知道某个 lease 上挂了哪些 key。
# grant a lease with 500 second TTL
$ etcdctl lease grant 500
lease 694d5765fc71500b granted with TTL(500s)
# attach key zoo1 to lease 694d5765fc71500b
$ etcdctl put zoo1 val1 --lease=694d5765fc71500b
OK
# attach key zoo2 to lease 694d5765fc71500b
$ etcdctl put zoo2 val2 --lease=694d5765fc71500b
OK
查询 lease 信息:
$ etcdctl lease timetolive 694d5765fc71500b
lease 694d5765fc71500b granted with TTL(500s), remaining(258s)
查询 lease 信息及其挂载的 key:
$ etcdctl lease timetolive --keys 694d5765fc71500b
lease 694d5765fc71500b granted with TTL(500s), remaining(132s), attached keys([zoo2 zoo1])
# if the lease has expired or does not exist it will give the below response:
Error: etcdserver: requested lease not found
12.13 flag 清单
本页出现的 flag / 环境变量(原样保留):
| 类别 | 名称 | 值 / 用途 |
|---|---|---|
| 环境变量 | ETCDCTL_API |
2 或 3 |
| flag | --lease= |
挂载 lease id |
| flag | --hex |
以 hex 格式输出 / watch |
| flag | --print-value-only |
只读值,不输出 key |
| flag | --prefix |
前缀匹配 |
| flag | --limit= |
限制返回数量 |
| flag | --stream |
使用 RangeStream RPC |
| flag | --rev= |
指定历史 revision |
| flag | --from-key |
大于等于指定 key |
| flag | --prev-kv |
返回修改/删除前的 kv |
| flag | -i |
交互式 watch |
| flag | --order |
排序(--stream 不支持) |
| flag | --sort-by |
排序字段(--stream 不支持) |
| flag | -w=json |
json 格式输出 |
命令一览(本页出现的所有子命令):
| 子命令 | 用途 |
|---|---|
etcdctl version |
查看 etcdctl 与 API 版本 |
etcdctl put |
写 key(可带 --lease=) |
etcdctl get |
读 key(可带 --hex、--print-value-only、--prefix、--limit=、--stream、--rev=、--from-key、-w=json) |
etcdctl del |
删 key(可带 --prev-kv、--prefix、--from-key) |
etcdctl watch |
watch key(可带 --hex、--prefix、--rev=、--prev-kv、-i) |
etcdctl compact |
压缩 revision |
etcdctl lease grant |
授予 lease |
etcdctl lease revoke |
撤销 lease |
etcdctl lease keep-alive |
续约 |
etcdctl lease timetolive |
查询 lease 信息(可带 --keys) |
12.14 官方文档未提供的命令
📌 官方文档未提供:
dev-guide/interacting_v3.md未包含TXN(事务)命令的示例与输出,源文到此为止;按规则未做补充。
💡 补充:需要事务示例时,可参考 proto 层的
TxnRequest语义(见 6.3.6)与 Go 客户端的cli.Txn(ctx).If(...).Then(...).Else(...).Commit()写法(见 5.9.2)。etcdctl 侧的交互式txn不在本页覆盖范围内。
13. 附录
13.1 官方页面与章节映射表
本册覆盖的官方页面共 20 个(含 2 个作为方法清单补充来源的 API 参考页)。逐条标注覆盖状态:
13.2 本册未覆盖的页面
全套 v3.7 文档共 79 页纳入,本册覆盖其中 20 页。其余页面按主题划入兄弟分册(下表为主题级归类,供导航用):
| 主题分组 | 代表页面(源文件) | 归属分册 |
|---|---|---|
| 集群搭建、配置、安全、维护、监控、恢复、性能、故障 | op-guide/clustering.md、op-guide/configuration.md、op-guide/security.md、op-guide/maintenance.md、op-guide/monitoring.md、op-guide/recovery.md、op-guide/performance.md、op-guide/failures.md、op-guide/hardware.md、op-guide/supported-platform.md、op-guide/versioning.md、op-guide/container.md、op-guide/kubernetes.md、op-guide/gateway.md、op-guide/grpc_proxy.md、op-guide/data_corruption.md、op-guide/runtime-configuration.md、op-guide/authentication/* |
《03-运维运营篇》 |
| 调优、基准测试、指标、升级、降级、feature gates | tuning.md、benchmarks/*、metrics/*、upgrades/*、downgrades/*、feature-gates/index.md |
《03-运维运营篇》 |
| 开发实战:任务指南、API 网关、grpc naming、嵌入式、集成 | tasks/developer/*、tasks/operator/*、dev-guide/api_grpc_gateway.md、dev-guide/grpc_naming.md、dev-guide/golang_embed_pkg.md、dev-guide/discovery_protocol.md、integrations.md |
《02-开发实战篇》 |
| 其他 | faq.md、reporting_bugs.md、dev-internal/*、triage/* |
视主题分入两册 |
13.3 本册发现的三处源文疑点
| # | 页面 | 疑点 | 本册处理 |
|---|---|---|---|
| 1 | learning/glossary.md |
素材批注写"术语条目共 23 条",但源文实际列出 24 个术语名 | 以源文实际条目数 24 为准,全量收录(见 3.1) |
| 2 | dev-guide/interacting_v3.md |
示例版本号为 etcdctl version: 3.1.0-alpha.0+git / API version: 3.1,与 v3.7 站点版本不一致(源文未随版本更新) |
照录原文输出,并在 12.2 显式标注不一致 |
| 3 | learning/design-learner.md |
learner 数量上限出现两处表述:正文"限制 learner 总数"、附录"限制为一个" | 以附录为准(见 11.2 注意事项第 1 条) |
本册完。兄弟分册:《02-开发实战篇》《03-运维运营篇》。