# 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. 本册导读](#1-本册导读) - [1.1 本册覆盖的官方页面](#11-本册覆盖的官方页面) - [1.2 怎么读这册](#12-怎么读这册) - [1.3 学习路径图](#13-学习路径图) - [1.4 本册的写作约定](#14-本册的写作约定) - [2. 概述](#2-概述) - [2.1 etcd 是什么](#21-etcd-是什么) - [2.2 etcd 解决什么问题](#22-etcd-解决什么问题) - [2.3 核心特点](#23-核心特点) - [2.4 典型用例](#24-典型用例) - [2.5 适用场景与不适用场景](#25-适用场景与不适用场景) - [2.6 竞品对比表](#26-竞品对比表) - [2.7 ZooKeeper](#27-zookeeper) - [2.8 Consul](#28-consul) - [2.9 NewSQL](#29-newsql) - [2.10 用 etcd 存元数据](#210-用-etcd-存元数据) - [2.11 用 etcd 做分布式协调](#211-用-etcd-做分布式协调) - [2.12 lock 与 lease 的使用注意](#212-lock-与-lease-的使用注意) - [3. 术语表](#3-术语表) - [3.1 全量术语表](#31-全量术语表) - [3.2 容易混淆的术语辨析](#32-容易混淆的术语辨析) - [4. 快速上手](#4-快速上手) - [4.1 环境要求](#41-环境要求) - [4.2 安装方式一 预编译二进制](#42-安装方式一-预编译二进制) - [4.3 安装方式二 源码构建](#43-安装方式二-源码构建) - [4.4 安装方式三 Docker](#44-安装方式三-docker) - [4.5 安装方式四 操作系统包管理器](#45-安装方式四-操作系统包管理器) - [4.6 安装检查](#46-安装检查) - [4.7 五分钟启动单节点](#47-五分钟启动单节点) - [4.8 启动三节点本地集群](#48-启动三节点本地集群) - [4.9 测试容错](#49-测试容错) - [4.10 第一个 put 与 get 示例](#410-第一个-put-与-get-示例) - [5. 数据模型](#5-数据模型) - [5.1 设计目标](#51-设计目标) - [5.2 逻辑视图](#52-逻辑视图) - [5.3 物理视图](#53-物理视图) - [5.4 扁平二进制键空间](#54-扁平二进制键空间) - [5.5 range 与前缀](#55-range-与前缀) - [5.6 lease 与 key 过期](#56-lease-与-key-过期) - [5.7 watch 机制](#57-watch-机制) - [5.8 最小示例](#58-最小示例) - [5.9 进阶示例](#59-进阶示例) - [6. etcd API](#6-etcd-api) - [6.1 gRPC services 总览](#61-grpc-services-总览) - [6.2 统一请求响应格式](#62-统一请求响应格式) - [6.3 KV service](#63-kv-service) - [6.4 Watch service](#64-watch-service) - [6.5 Lease service](#65-lease-service) - [6.6 Cluster service](#66-cluster-service) - [6.7 Maintenance service](#67-maintenance-service) - [6.8 Auth service](#68-auth-service) - [6.9 Lock service](#69-lock-service) - [6.10 Election service](#610-election-service) - [6.11 方法清单汇总](#611-方法清单汇总) - [7. API 保证](#7-api-保证) - [7.1 纳入保证范围的 API](#71-纳入保证范围的-api) - [7.2 时延特性](#72-时延特性) - [7.3 持久性 durability](#73-持久性-durability) - [7.4 严格可串行化](#74-严格可串行化) - [7.5 原子性](#75-原子性) - [7.6 线性一致](#76-线性一致) - [7.7 serializable 读的语义](#77-serializable-读的语义) - [7.8 watch 的六条保证](#78-watch-的六条保证) - [7.9 watch 不保证线性一致](#79-watch-不保证线性一致) - [7.10 lease 语义](#710-lease-语义) - [7.11 操作完成的定义](#711-操作完成的定义) - [7.12 revision 语义](#712-revision-语义) - [8. 持久化存储文件](#8-持久化存储文件) - [8.1 目录结构与长期存在的文件](#81-目录结构与长期存在的文件) - [8.2 临时文件](#82-临时文件) - [8.3 bbolt db 文件](#83-bbolt-db-文件) - [8.4 WAL 文件](#84-wal-文件) - [8.5 snapshot 文件](#85-snapshot-文件) - [8.6 版本间格式变化](#86-版本间格式变化) - [9. 系统限制](#9-系统限制) - [9.1 请求大小限制](#91-请求大小限制) - [9.2 存储大小限制](#92-存储大小限制) - [9.3 官方文档未提供的其他限制](#93-官方文档未提供的其他限制) - [10. etcd 特性清单](#10-etcd-特性清单) - [10.1 三个阶段总览](#101-三个阶段总览) - [10.2 Experimental](#102-experimental) - [10.3 Stable](#103-stable) - [10.4 Unsafe](#104-unsafe) - [10.5 Feature Deprecation 流程](#105-feature-deprecation) - [10.6 查看特性清单的方法](#106-查看特性清单的方法) - [11. 设计原理](#11-设计原理) - [11.1 客户端设计](#111-客户端设计) - [11.2 Learner 设计](#112-learner-设计) - [11.3 认证设计 v3](#113-认证设计-v3) - [11.4 运行时重配置设计](#114-运行时重配置设计) - [12. etcdctl v3 交互入门](#12-etcdctl-v3-交互入门) - [12.1 环境变量与 API 版本](#121-环境变量与-api-版本) - [12.2 查看版本](#122-查看版本) - [12.3 写 key put](#123-写-key-put) - [12.4 读 key get](#124-读-key-get) - [12.5 读历史版本](#125-读历史版本) - [12.6 读大于等于指定 key 的 key](#126-读大于等于指定-key-的-key) - [12.7 删除 key del](#127-删除-key-del) - [12.8 watch key 变化](#128-watch-key-变化) - [12.9 watch 历史变更](#129-watch-历史变更) - [12.10 watch progress](#1210-watch-progress) - [12.11 压缩 revision compact](#1211-压缩-revision-compact) - [12.12 lease 三连](#1212-lease-三连) - [12.13 flag 清单](#1213-flag-清单) - [12.14 官方文档未提供的命令](#1214-官方文档未提供的命令) - [13. 附录](#13-附录) - [13.1 官方页面与章节映射表](#131-官方页面与章节映射表) - [13.2 本册未覆盖的页面](#132-本册未覆盖的页面) - [13.3 本册发现的三处源文疑点](#133-本册发现的三处源文疑点) --- ## 1. 本册导读 本册是 etcd v3.7 学习手册的第一册,目标是给读者一张**认知地图**:先知道 etcd 是什么、能做什么、不能做什么,再理解它的数据模型与 API 语义,最后读懂四份核心设计文档(客户端、Learner、认证、运行时重配置)。读完本册,你应当能够回答三个问题: 1. 一条 `put` 从客户端发出到落盘,中间经过了哪些数据结构(keyspace → revision → MVCC → bbolt / WAL)? 2. etcd 给你的**保证边界**在哪里(哪些操作是线性一致的、watch 不保证什么、lease 不保证什么)? 3. 集群成员变更为什么必须"显式两阶段"、learner 为什么存在? ### 1.1 本册覆盖的官方页面 本册共纳入并展开 **20 个官方页面**(含 2 个作为方法清单补充来源的 API 参考页)。逐条清单见 [13.1 官方页面与章节映射表](#131-官方页面与章节映射表)。 按主题分组如下: | 主题 | 官方页面(源文件) | 本册章节 | | --- | --- | --- | | 版本入口 | `_index.md` | [第 1 章](#1-本册导读)、[第 2 章](#2-概述) | | 定位与选型 | `learning/why.md` | [第 2 章](#2-概述) | | 术语 | `learning/glossary.md` | [第 3 章](#3-术语表) | | 安装与起步 | `install.md`、`quickstart.md`、`demo.md`、`dev-guide/local_cluster.md` | [第 4 章](#4-快速上手) | | 数据模型 | `learning/data_model.md` | [第 5 章](#5-数据模型) | | API | `learning/api.md`、`dev-guide/api_reference_v3.md`、`dev-guide/api_concurrency_reference_v3.md` | [第 6 章](#6-etcd-api) | | API 保证 | `learning/api_guarantees.md` | [第 7 章](#7-api-保证) | | 持久化 | `learning/persistent-storage-files.md` | [第 8 章](#8-持久化存储文件) | | 限制 | `dev-guide/limit.md` | [第 9 章](#9-系统限制) | | 特性阶段 | `dev-guide/features.md` | [第 10 章](#10-etcd-特性清单) | | 设计文档 | `learning/design-client.md`、`learning/design-learner.md`、`learning/design-auth-v3.md`、`op-guide/runtime-reconf-design.md` | [第 11 章](#11-设计原理) | | 命令行交互 | `dev-guide/interacting_v3.md` | [第 12 章](#12-etcdctl-v3-交互入门) | ### 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 | 阅读时的三条硬建议: 1. **第 5 章(数据模型)与第 7 章(API 保证)不要跳**。etcd 绝大多数"反直觉"行为都能在这两章找到解释,例如"为什么 `get --rev=N` 报 `required revision has been compacted`"、为什么 watch 会收到 `compact_revision`。 2. **第 11 章是设计文档,不是操作手册**。原文明确写了"是否 promote 由集群运维做最终决定""etcd 决定不支持强制移除成员"这类**立场**,读的时候要区分"etcd 保证了什么"与"etcd 建议你怎么做"。 3. **凡是标注 `> 💡 补充:` 的内容都不是官方文档原文**,是基于工程经验的补充,请以官方文档为准做最终判断。 ### 1.3 学习路径图 ```text 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](#121-环境变量与-api-版本))。 --- ## 2. 概述 > 引自:[v3.7 docs](https://etcd.io/docs/v3.7/)、[etcd versus other key-value stores](https://etcd.io/docs/v3.7/learning/why/) ### 2.1 etcd 是什么 etcd 的名字来自两个概念:unix 的 **`/etc`** 目录和 **"d"istributed systems**。 > 引自:[etcd versus other key-value stores](https://etcd.io/docs/v3.7/learning/why/) > > `/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 versus other key-value stores](https://etcd.io/docs/v3.7/learning/why/) **作者声明(原文保留)**:本文档由 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](#113-认证设计-v3));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 > 引自:[etcd versus other key-value stores](https://etcd.io/docs/v3.7/learning/why/) **关系**: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 > 引自:[etcd versus other key-value stores](https://etcd.io/docs/v3.7/learning/why/) **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 > 引自:[etcd versus other key-value stores](https://etcd.io/docs/v3.7/learning/why/) **代表产品(原文点名)**:**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](https://etcd.io/docs/v3.7/learning/why/) — "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](https://etcd.io/docs/v3.7/learning/why/) — "Using etcd for distributed coordination" **开箱即用提供的四个原语**: 1. **event watches** 2. **leases** 3. **elections** 4. **distributed shared locks**(注意:分布式共享锁有一些**非显而易见**的性质,见 [2.12](#212-lock-与-lease-的使用注意)) **为什么不该把这些原语交给外部库**(原文论证链): | 论据 | 原文表述 | | --- | --- | | 责任归属 | 把这些原语交给外部库,等于**回避了开发基础分布式软件的责任**,本质上使系统不完整 | | 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](https://etcd.io/docs/v3.7/learning/why/) — "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](https://etcd.io/docs/v3.7/learning/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. 快速上手 > 引自:[Install](https://etcd.io/docs/v3.7/install/)、[Quickstart](https://etcd.io/docs/v3.7/quickstart/)、[Demo](https://etcd.io/docs/v3.7/demo/)、[Set up a local cluster](https://etcd.io/docs/v3.7/dev-guide/local_cluster/) ### 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 验证: ```bash $ 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` 验证 | ```bash $ 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): ```bash 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](https://etcd.io/docs/v3.7/install/) — "Installation check" 想做**稍微更完整**的健全性检查,参见 Quickstart 页面(即本章 [4.7](#47-五分钟启动单节点))。 ### 4.7 五分钟启动单节点 > 引自:[Quickstart](https://etcd.io/docs/v3.7/quickstart/) 本页目标:**5 分钟**内在本地安装、运行并测试一个**单成员(single-member)** etcd 集群。 ```text 步骤 1 安装 etcd(预编译二进制或源码),务必执行安装说明的最后一步,确认 etcd 已在 PATH 中 步骤 2 启动:etcd 步骤 3 另一个终端写入:etcdctl put greeting "Hello, etcd" 步骤 4 同一终端读回:etcdctl get greeting ``` **步骤 2 启动**: ```console $ 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 写入**(在**另一个终端**): ```console $ etcdctl put greeting "Hello, etcd" OK ``` **步骤 4 读回**: ```console $ 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 启动三节点本地集群 > 引自:[Set up a local cluster](https://etcd.io/docs/v3.7/dev-guide/local_cluster/) 测试与开发部署**最快最简单**的方式是配置本地集群;生产部署请参考 clustering 章节(`op-guide/clustering/`)。 #### 一、Local standalone cluster(本地单节点集群) ```bash $ ./etcd ``` > 原文注解:若当前工作目录下没有 `etcd` 二进制,它可能在 **`$GOPATH/bin/etcd`** 或 **`/usr/local/bin/etcd`**,按实际位置执行命令。运行起来的 etcd member 在 **`localhost:2379`** 上监听客户端请求。 交互: ```bash $ ./etcdctl put foo bar OK ``` ```bash $ ./etcdctl get foo bar ``` #### 二、Local multi-member cluster(本地多成员集群) etcd git 仓库根目录下提供一个 **`Procfile`**,用于方便地配置本地多成员集群。操作前需先进入 **etcd 源码树根目录**。 ```bash $ go install github.com/mattn/goreman@latest ``` ```bash $ goreman -f Procfile start ``` 成员启动后,客户端请求监听端口: | 成员 | client 端口 | peer 端口 | | --- | --- | --- | | `infra1` | **2379** | **2380** | | `infra2` | **22379** | **22380** | | `infra3` | **32379** | **32380** | 打印成员列表: ```bash $ etcdctl --write-out=table --endpoints=localhost:2379 member list ``` ```text +------------------+---------+--------+------------------------+------------------------+ | 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`。 存入示例键值对: ```bash $ 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 的预期错误输出(原文照录): ```text 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 章](#12-etcdctl-v3-交互入门)。 ### 4.10 第一个 put 与 get 示例 > 引自:[Demo](https://etcd.io/docs/v3.7/demo/) Demo 页当前正文只有一个小节:**Auth**,演示 `auth`、`user`、`role` 三个子命令做鉴权。先设置环境变量: ```bash export ETCDCTL_API=3 ENDPOINTS=localhost:2379 ``` 完整脚本(原文照录,注释为原文自带): ```bash 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](#113-认证设计-v3) 里最重要的两个概念——**role 是 key range 上的权限单元**(`role grant-permission role0 readwrite foo`),以及**开启 auth 后未认证请求一律被拒**。注意 `readwrite` 是 etcdctl 命令行层的权限名写法,对应 proto 层的 `Permission` 消息(`permType` / `key` / `range_end`,见 [6.8](#68-auth-service))。 --- ## 5. 数据模型 > 引自:[Data model](https://etcd.io/docs/v3.7/learning/data_model/)、[etcd API](https://etcd.io/docs/v3.7/learning/api/) ### 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 之前设置的值,**除最新一个之外都会被移除** | 用一个具体例子理解(示例为整理,规则来自原文): ```text 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 整体读取路径 ```text 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 ```bash $ 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 ``` ```bash $ 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** | 最小示例: ```bash # 授予一个 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](#83-bbolt-db-文件))。实际工程中不要依赖 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 消息 ```protobuf 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 ```bash # 终端 A $ etcdctl watch foo # 终端 B:etcdctl put foo bar PUT foo bar ``` ```bash # 交互式会话同时 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 最小示例 **最小闭环:写 → 读 → 删** ```bash export ETCDCTL_API=3 $ etcdctl put foo bar OK $ etcdctl get foo foo bar $ etcdctl del foo 1 # one key is deleted ``` **最小闭环:带 revision 的历史读** ```bash $ etcdctl put foo bar # revision = 2 OK $ etcdctl put foo bar_new # revision = 4 OK $ etcdctl get --rev=2 foo foo bar ``` **最小闭环:watch** ```bash $ etcdctl watch --rev=2 foo PUT foo bar PUT foo bar_new ``` > 💡 补充(Go 客户端最小示例,非官方文档原文): ```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 视图" ```bash # 先制造历史 $ 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 ``` ```bash $ 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](#51-设计目标) 里说的 **time travel queries**:因为 revision 是集群级逻辑时钟,"在某个 revision 读一批 key"天然得到一致快照,无需额外加锁。 #### 5.9.2 用 Txn 做 compare-and-swap(进阶:分布式互斥的基础) `Txn` 的语义见 [6.3](#63-kv-service)。这里给出一个最小的"仅当值等于旧值时才写入": ```go // 💡 补充(工程示例,非官方文档原文):基于 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](#1214-官方文档未提供的命令)): > 📌 官方文档未提供:`dev-guide/interacting_v3.md` **未包含 `TXN`(事务)命令**的示例与输出。上例为本册基于 API 语义整理的工程示例。 #### 5.9.3 lease + keepalive 的存活检测 ```bash $ 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 用法——服务注册 + 存活检测 + 变更订阅。 ```bash # 服务实例注册: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 > 引自:[etcd API](https://etcd.io/docs/v3.7/learning/api/)、[API reference](https://etcd.io/docs/v3.7/dev-guide/api_reference_v3/)、[API reference: concurrency](https://etcd.io/docs/v3.7/dev-guide/api_concurrency_reference_v3/) ### 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(所有响应都附带) ```protobuf 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 可操作的最小单位) ```protobuf 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](#55-range-与前缀)。补充 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 ```protobuf 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 ```protobuf 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`,客户端收到的是: ```protobuf 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`** 辅助函数 | ```bash # 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 ```protobuf 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 不存在时返回错误** | ```protobuf message PutResponse { ResponseHeader header = 1; mvccpb.KeyValue prev_kv = 2; } ``` `Prev_Kv` 为被 `Put` 覆盖的 key-value 对(**仅当请求中设置了 `Prev_Kv`**)。 #### 6.3.5 DeleteRangeRequest / DeleteRangeResponse ```protobuf message DeleteRangeRequest { bytes key = 1; bytes range_end = 2; bool prev_kv = 3; } ``` | 字段 | 说明 | | --- | --- | | `Key, Range_End` | 要删除的 key 区间 | | `Prev_Kv` | 设置时返回**被删除的 key-value 内容** | ```protobuf 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`,可嵌套) | ```protobuf message TxnRequest { repeated Compare compare = 1; repeated RequestOp success = 2; repeated RequestOp failure = 3; } ``` | 字段 | 说明 | | --- | --- | | `Compare` | 守卫事务的**谓词合取列表** | | `Success` | 全部比较为真时执行的请求列表 | | `Failure` | 任一比较为假时执行的请求列表 | ```protobuf 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 ```protobuf message CompactionRequest { int64 revision = 1; bool physical = 2; } ``` | 字段 | 说明 | | --- | --- | | `revision` | 压缩操作所针对的 key-value store revision;**所有 revision 小于该压缩 revision 的被取代 key 都会被移除** | | `physical` | 设置后 RPC 会**等待压缩被物理应用**到本地数据库,使被压缩条目**从后端数据库中彻底移除** | ```bash $ 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 ```protobuf 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 ```protobuf 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 ```protobuf 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](https://etcd.io/docs/v3.7/dev-guide/api_concurrency_reference_v3/) — `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](#212-lock-与-lease-的使用注意) 呼应:`Lock` 返回的 key 之所以要"与事务配合使用",正是因为 **lock APIs 本身不能当作互斥机制**,真正的互斥靠 `Txn` 里的 revision/lease 条件校验。 ### 6.10 Election service > 引自:[API reference: concurrency](https://etcd.io/docs/v3.7/dev-guide/api_concurrency_reference_v3/) — `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 描述里没有单独展开,但它是 `KV` service 的方法之一(来自 `api_reference_v3`)。使用 `etcdctl compact` 时走的就是它。 --- ## 7. API 保证 > 引自:[etcd API guarantees](https://etcd.io/docs/v3.7/learning/api_guarantees/) 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](#78-watch-的六条保证) 里的 **Resumable**(用最后收到的 revision 重建 watch),并在关键路径上用 KV 读做兜底。 ### 7.3 持久性 durability **保证内容**:任何**已完成**的操作都是持久的;**所有可访问的数据也就是持久的数据**;**读永远不会返回尚未被持久化的数据**。 **对使用者的意义**: | 推论 | 说明 | | --- | --- | | 读到即已落盘 | 只要 `get` 返回一个值,这个值一定已经被持久化 | | 收到响应即完成 | 客户端**收到 etcd server 响应**时即可知道操作已完成(见 [7.11](#711-操作完成的定义)) | | 不需要额外 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 线性一致 **保证内容**(含原文引文): > 引自:[etcd API guarantees](https://etcd.io/docs/v3.7/learning/api_guarantees/) > > *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](#1214-官方文档未提供的命令))。proto 层的开关是 `RangeRequest.serializable`(见 [6.3.1](#631-rangerequest))。 > 💡 补充:用 `serializable` 读时,判断"我读到的值有多旧"的实用方法是比对响应 `header.revision` 与 `etcdctl get <不存在的key> -w=json` 得到的当前 revision(见 [12.11](#1211-压缩-revision-compact)),两者差值就是陈旧程度。 ### 7.8 watch 的六条保证 > 引自:[etcd API guarantees](https://etcd.io/docs/v3.7/learning/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](#26-竞品对比表) 的 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](#641-watchcreaterequest))。 - **违反后果**:无法区分"没有事件"和"连接假死",故障检测会失效。 ```bash # 交互式 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](#786-bookmarkable-可打书签)) | **违反后果**:把 watch 当成线性一致读来用,会因为事件延迟无上界([7.2](#72-时延特性))而读到"过期的当前状态",且不会有任何错误提示。 ### 7.10 lease 语义 **保证内容**(原文完整段落要点): | 项 | 内容 | | --- | --- | | 主要用例 | lease 机制的主要用例是**实现分布式协调机制**(如**分布式锁**) | | 机制简单性 | lease 可用 **`grant` API 创建**、用 **`put` API 附加到 key**、用 **`revoke` API 撤销**,并会按**墙上时钟的 TTL** 过期 | | 使用者义务 | 用户需了解这些 API 与用法的**重要属性**,才能**正确**实现分布式协调机制 | **对使用者的意义**:lease 只是"带 TTL 的、可续约的、到期删 key 的契约"。它的语义与 [2.12](#212-lock-与-lease-的使用注意) 的限制必须一起读:**lease 本身不保证互斥**,TTL 是**物理时间间隔**,服务端与客户端各自用自己的时钟度量。 **违反后果**: - 把 lease 当互斥锁 → 出现"服务端已撤销、客户端仍以为持有"的**双持有**; - 用 etcd 的 lock 保护**外部资源** → 无效(外部资源必须自己提供版本号校验与副本一致性); - keepalive 断连 → TTL 到期后 key 被删,业务误判为"资源已释放"。 > 📌 官方文档指向:`api_guarantees.md` 的 Lease APIs 一节明确链接到 `../why/#notes-on-the-usage-of-lock-and-lease`,即本册 [2.12](#212-lock-与-lease-的使用注意)。两节必须合并阅读。 ### 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](#591-用-revision-做一致的多-key-视图)) | | 锁 / fencing | revision 可充当 **fencing token** 的等价物(见 [2.12](#212-lock-与-lease-的使用注意)) | **违反后果**:若 revision 不唯一/不单调,则"用 revision 做游标、做快照、做 fencing"的全部上层机制都会失效,且失效方式通常是**静默**的。 --- ## 8. 持久化存储文件 > 引自:[etcd persistent storage files](https://etcd.io/docs/v3.7/learning/persistent-storage-files/) 本文说明 etcd 的持久化存储格式:**命名(naming)、内容(content)** 以及可用于检视它们的**工具**,面向需要**数据恢复**的 etcd 开发者。前置阅读:etcd data model 概览(本册 [第 5 章](#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 容量不足而**卡死且无法发出告警** | 目录结构总览(整理自上表): ```text 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` …) | ```bash # 列出 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 检视工具 ```bash 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 内容**。 ```text 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 根目录**执行: ```bash 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`** 两个键。 ```json { "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"|"set","node":{"key":...,"value":...,"modifiedIndex":N,"createdIndex":N}}`,依次记录 `RaftAttributes`(create, index 1)、`attributes`(set, index 2)、`/0/version`(set, index 3) | ### 8.6 版本间格式变化 > 📌 官方文档未提供:`learning/persistent-storage-files.md` 的 **Changes** 小节保留用于描述不同 etcd 版本之间文件格式的变化,**原文当前为空**。 --- ## 9. 系统限制 > 引自:[System limits](https://etcd.io/docs/v3.7/dev-guide/limit/) ### 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](#81-目录结构与长期存在的文件) 里提到的 WAL/`*.snap.db` 磁盘占用叠加考虑——配额只约束 `member/snap/db`,不约束 WAL 与快照文件,因此实际磁盘规划要在配额之外再留出 WAL 与快照的空间(WAL 单文件约 `~64*10^6` 字节,最多 `--max-wals=5` 个;`*.snap.db` 可能达 O(GBs))。 --- ## 10. etcd 特性清单 > 引自:[etcd features](https://etcd.io/docs/v3.7/dev-guide/features/) 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 instead.`** | | 移除时机 | 被弃用的特性将在**随后的发行版**中被移除 | #### 10.5.2 Stable 的弃用 随着项目演进,稳定特性有时也需要被弃用和移除。此时(**逐条照录**): | 步骤 | 内容 | | --- | --- | | 1 | 特性文档会在**计划弃用的发行版之前**显示**警告消息**,例如:**`To be deprecated in .`**。若已有新特性计划取代该 `To be deprecated` 特性,文档还会给出提示,例如:**`Use instead.`** | | 2 | 该特性将在**计划的发行版**中被弃用。届时特性文档会显示弃用消息并推荐使用相关的稳定特性,例如:**`DEPRECATED. Use instead.`** | | 3 | 被弃用的特性将在**随后的发行版**中被移除 | ```text 弃用流程示意(三个阶段 × 两条路径) Experimental --(graduate)--> 文档显示 "DEPRECATED. Use instead." --> 随后的发行版中移除 Stable --(计划弃用)--> 文档先显示 "To be deprecated in ." / "Use ...instead." --> 计划发行版中显示 "DEPRECATED. Use instead." --> 随后的发行版中移除 Unsafe --> 可在下一个 minor/major 版本直接移除(无需遵循策略) ``` ### 10.6 查看特性清单的方法 ```bash $ etcd --help ``` > 原文说明:可通过运行 **`etcd --help`** 获取特性列表。 > 💡 补充:升级/变更版本时,建议把 `etcd --help` 输出中带 **`--experimental`** 前缀的 flag 全部记录下来——它们是唯一"可能随时被取消且不另行通知"的一类,也是升级后最容易突然失效的配置项。 --- ## 11. 设计原理 本章覆盖四份官方设计文档。每节按 **是什么 / 为什么 / 内部机制 / 怎么用 / 注意事项** 五问展开。 ### 11.1 客户端设计 > 引自:[etcd client design](https://etcd.io/docs/v3.7/learning/design-client/) — 作者: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](https://github.com/grpc/proposal/blob/master/A6-client-retries.md) 兼容);**两个客户端版本之间升级应无中断** | #### 内部机制(一):术语表 | 术语 | 含义 | | --- | --- | | *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](https://etcd.io/docs/v3.7/learning/design-learner/) — 作者:Gyuho Lee (github.com/gyuho, AWS)、Joe Betz (github.com/jpbetz, Google) #### 是什么 为填补成员重配置的可用性缺口,[Raft §4.2.1](https://github.com/ongardie/dissertation/blob/master/stanford.pdf) 引入新的节点状态 **"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 重建集群 | 由此引出四个核心问题(原文): 1. 能否让成员重配置**不改变 quorum 大小**从而降低破坏性? 2. 能否让新节点**先空闲**、只向 leader 请求**最少更新**直到追上? 3. 能否让配置错误**始终可回滚**(错误的 `member add` 命令绝不该搞挂集群)? 4. 用户加成员时还需要关心网络拓扑吗?`member add` API 能否不关心节点位置与正在发生的分区? > 背景补充(原文):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](https://github.com/etcd-io/etcd/issues/9161);use case [etcd#3715](https://github.com/etcd-io/etcd/issues/3715)、[etcd#8888](https://github.com/etcd-io/etcd/issues/8888)、[etcd#10114](https://github.com/etcd-io/etcd/issues/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](#114-运行时重配置设计) 两阶段模型在运维上的具体落地。 ### 11.3 认证设计 v3 > 引自:[etcd v3 authentication design](https://etcd.io/docs/v3.7/learning/design-auth-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](https://github.com/etcd-io/etcd/pull/4317#issuecomment-179037582) 的 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**](https://github.com/etcd-io/etcd/issues/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](#410-第一个-put-与-get-示例) 看到 `demo.md` 给出的 auth 操作脚本,在 [6.8](#68-auth-service) 看到 `Permission` / `Role` / `User` 的 proto 字段)。 > 💡 补充:把设计文档与 proto 对照可以得到一条实践规则——**权限是"区间"而非"前缀匹配"**。`RoleGrantPermission` 允许授予任意 `["start key","end key")` 区间,因此 `range_end` 填错(例如填成 `\0`)会一次性授权"所有大于等于 key 的 key",这是最容易发生的越权配置。 ### 11.4 运行时重配置设计 > 引自:[Design of runtime reconfiguration](https://etcd.io/docs/v3.7/op-guide/runtime-reconf-design/) #### 是什么 运行时重配置是分布式系统(尤其是 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 ```text 加成员(务必按序): 1. etcdctl member add --peer-urls= # Phase 1:集群达成一致 2. 用返回的 initial-cluster 启动新成员进程, 并设 --initial-cluster-state=existing # Phase 2:启动并校验 3. (可选)先加为 learner:member add --learner, 确认追上后再 member promote # 见 11.2 换掉不健康节点: 1. etcdctl member remove # 先移除 2. 再按上面的流程加新成员 # member add 会改变 quorum ``` > 💡 补充:上表最后一步"先加 learner 再 promote"是把 [11.2](#112-learner-设计) 与本节合并后的推荐做法;本节原文只强调"先 `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](#112-learner-设计) 收录。此处未做任何补充。 --- ## 12. etcdctl v3 交互入门 > 引自:[Interacting with etcd](https://etcd.io/docs/v3.7/dev-guide/interacting_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** | ```bash export ETCDCTL_API=3 ``` > **重要注意(原文)**:**用 v2 API 创建的 key 无法通过 v3 API 查询**。v3 的 `etcdctl get` 查一个 v2 key 会**退出码 0 且不返回任何 key 数据**,这是**预期行为**。 ### 12.2 查看版本 `etcdctl version` 与 Server API version 有助于确定该用哪些命令: ```bash $ 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 集群成员**,以实现一致性与可靠性。 ```bash $ etcdctl put foo bar OK ``` 也可以通过**挂载 lease** 让 key 只在指定时间段内有效。把 key `foo1` 的值设为 `bar1`,有效期 **10s**: ```bash $ 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: ```bash 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` 结果相同 | ```bash $ etcdctl get foo foo bar ``` ```bash $ etcdctl get foo --hex \x66\x6f\x6f # Key \x62\x61\x72 # Value ``` ```bash $ etcdctl get foo --print-value-only bar ``` ```bash $ etcdctl get foo foo3 foo bar foo1 bar1 foo2 bar2 ``` > Note(info,原文保留):**`foo3` 被排除**,因为区间是**半开区间 `[foo, foo3)`**,不含 `foo3`。 ```bash $ etcdctl get --prefix foo foo bar foo1 bar1 foo2 bar2 foo3 bar3 ``` ```bash $ etcdctl get --prefix --limit=2 foo foo bar foo1 bar1 ``` ```bash $ 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。 假设集群已有: ```bash foo = bar # revision = 2 foo1 = bar1 # revision = 3 foo = bar_new # revision = 4 foo1 = bar1_new # revision = 5 ``` ```bash $ 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 假设集群已有: ```bash a = 123 b = 456 z = 789 ``` 读取**字节值大于等于 key `b`** 的 key: ```bash $ etcdctl get --from-key b b 456 z 789 ``` ### 12.7 删除 key del 应用可删除**单个 key** 或**一个区间**的 key。假设集群已有: ```bash 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` | ```bash $ etcdctl del foo 1 # one key is deleted ``` ```bash $ etcdctl del foo foo9 2 # two keys are deleted ``` ```bash $ etcdctl del --prev-kv zoo 1 # one key is deleted zoo # deleted key val # the value of the deleted key ``` ```bash $ etcdctl del --prefix zoo 2 # two keys are deleted ``` ```bash $ etcdctl del --from-key b 2 # two keys are deleted ``` ### 12.8 watch key 变化 应用可 watch **一个 key** 或**一个区间**的 key 以监控更新。 ```bash $ etcdctl watch foo # in another terminal: etcdctl put foo bar PUT foo bar ``` ```bash $ etcdctl watch foo --hex # in another terminal: etcdctl put foo bar PUT \x66\x6f\x6f # Key \x62\x61\x72 # Value ``` ```bash $ etcdctl watch foo foo9 # in another terminal: etcdctl put foo bar PUT foo bar # in another terminal: etcdctl put foo1 bar1 PUT foo1 bar1 ``` ```bash $ 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`: ```bash $ 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**。 假设已完成如下操作序列: ```bash $ 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 ``` ```bash # watch for changes on key `foo` since revision 2 $ etcdctl watch --rev=2 foo PUT foo bar PUT foo bar_new ``` ```bash # watch for changes on key `foo` since revision 3 $ etcdctl watch --rev=3 foo PUT foo bar_new ``` 只 watch **最近一次**历史变更(**附带上一次的值**): ```bash # 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** 更新: ```bash $ 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 早于压缩点的被取代数据将不可访问**。 ```bash $ 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 中**并不存在**: ```bash $ etcdctl get mykey -w=json {"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":15,"raft_term":4}} ``` > 💡 补充:这条"查不存在的 key 拿当前 revision"的技巧非常实用——它等价于一次极轻量的"现在集群到哪个 revision 了"的探测,可用于实现 [7.8.5](#785-resumable-可恢复) 里"先取当前 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 都被删除** | ```bash # 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**。 ```bash $ etcdctl lease grant 60 lease 32695410dcc0ca06 granted with TTL(60s) $ etcdctl put --lease=32695410dcc0ca06 foo bar OK ``` ```bash $ 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 不过期。 ```bash $ etcdctl lease grant 60 lease 32695410dcc0ca06 granted with TTL(60s) ``` ```bash $ 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**。 ```bash # 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 信息: ```bash $ etcdctl lease timetolive 694d5765fc71500b lease 694d5765fc71500b granted with TTL(500s), remaining(258s) ``` 查询 lease 信息及其挂载的 key: ```bash $ 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](#636-transactiontxn))与 Go 客户端的 `cli.Txn(ctx).If(...).Then(...).Else(...).Commit()` 写法(见 [5.9.2](#592-用-txn-做-compare-and-swap进阶分布式互斥的基础))。etcdctl 侧的交互式 `txn` 不在本页覆盖范围内。 --- ## 13. 附录 ### 13.1 官方页面与章节映射表 本册覆盖的官方页面共 **20 个**(含 2 个作为方法清单补充来源的 API 参考页)。逐条标注覆盖状态: | # | 官方页面标题 | 线上 URL | 源文件 | 本册章节 | 状态 | | --- | --- | --- | --- | --- | --- | | 1 | v3.7 docs | https://etcd.io/docs/v3.7/ | `_index.md` | [1. 本册导读](#1-本册导读)、[2. 概述](#2-概述) | 已覆盖 | | 2 | Install | https://etcd.io/docs/v3.7/install/ | `install.md` | [4. 快速上手](#4-快速上手)(4.1–4.6) | 已覆盖 | | 3 | Quickstart | https://etcd.io/docs/v3.7/quickstart/ | `quickstart.md` | [4.7 五分钟启动单节点](#47-五分钟启动单节点) | 已覆盖 | | 4 | Demo | https://etcd.io/docs/v3.7/demo/ | `demo.md` | [4.10 第一个 put 与 get 示例](#410-第一个-put-与-get-示例) | 已覆盖 | | 5 | Set up a local cluster | https://etcd.io/docs/v3.7/dev-guide/local_cluster/ | `dev-guide/local_cluster.md` | [4.8](#48-启动三节点本地集群)、[4.9](#49-测试容错) | 已覆盖 | | 6 | etcd versus other key-value stores | https://etcd.io/docs/v3.7/learning/why/ | `learning/why.md` | [2. 概述](#2-概述)(2.1–2.12 全量) | 已覆盖 | | 7 | Glossary | https://etcd.io/docs/v3.7/learning/glossary/ | `learning/glossary.md` | [3. 术语表](#3-术语表)(24 条全量) | 已覆盖 | | 8 | Data model | https://etcd.io/docs/v3.7/learning/data_model/ | `learning/data_model.md` | [5. 数据模型](#5-数据模型) | 已覆盖 | | 9 | etcd API | https://etcd.io/docs/v3.7/learning/api/ | `learning/api.md` | [6. etcd API](#6-etcd-api)(6.1–6.8) | 已覆盖 | | 10 | API reference | https://etcd.io/docs/v3.7/dev-guide/api_reference_v3/ | `dev-guide/api_reference_v3.md` | [6. etcd API](#6-etcd-api)(方法清单与字段表补充来源) | 已覆盖 | | 11 | API reference: concurrency | https://etcd.io/docs/v3.7/dev-guide/api_concurrency_reference_v3/ | `dev-guide/api_concurrency_reference_v3.md` | [6.9 Lock service](#69-lock-service)、[6.10 Election service](#610-election-service) | 已覆盖 | | 12 | etcd API guarantees | https://etcd.io/docs/v3.7/learning/api_guarantees/ | `learning/api_guarantees.md` | [7. API 保证](#7-api-保证)(全量逐条) | 已覆盖 | | 13 | etcd persistent storage files | https://etcd.io/docs/v3.7/learning/persistent-storage-files/ | `learning/persistent-storage-files.md` | [8. 持久化存储文件](#8-持久化存储文件) | 已覆盖 | | 14 | System limits | https://etcd.io/docs/v3.7/dev-guide/limit/ | `dev-guide/limit.md` | [9. 系统限制](#9-系统限制) | 已覆盖 | | 15 | etcd features | https://etcd.io/docs/v3.7/dev-guide/features/ | `dev-guide/features.md` | [10. etcd 特性清单](#10-etcd-特性清单) | 已覆盖 | | 16 | etcd client design | https://etcd.io/docs/v3.7/learning/design-client/ | `learning/design-client.md` | [11.1 客户端设计](#111-客户端设计) | 已覆盖 | | 17 | etcd learner design | https://etcd.io/docs/v3.7/learning/design-learner/ | `learning/design-learner.md` | [11.2 Learner 设计](#112-learner-设计) | 已覆盖 | | 18 | etcd v3 authentication design | https://etcd.io/docs/v3.7/learning/design-auth-v3/ | `learning/design-auth-v3.md` | [11.3 认证设计 v3](#113-认证设计-v3) | 已覆盖 | | 19 | Design of runtime reconfiguration | https://etcd.io/docs/v3.7/op-guide/runtime-reconf-design/ | `op-guide/runtime-reconf-design.md` | [11.4 运行时重配置设计](#114-运行时重配置设计) | 已覆盖 | | 20 | Interacting with etcd | https://etcd.io/docs/v3.7/dev-guide/interacting_v3/ | `dev-guide/interacting_v3.md` | [12. etcdctl v3 交互入门](#12-etcdctl-v3-交互入门) | 已覆盖 | ### 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](#31-全量术语表)) | | 2 | `dev-guide/interacting_v3.md` | 示例版本号为 `etcdctl version: 3.1.0-alpha.0+git` / `API version: 3.1`,与 v3.7 站点版本不一致(源文未随版本更新) | **照录原文输出**,并在 [12.2](#122-查看版本) 显式标注不一致 | | 3 | `learning/design-learner.md` | learner 数量上限出现两处表述:正文"限制 learner **总数**"、附录"限制为**一个**" | 以**附录为准**(见 [11.2](#112-learner-设计) 注意事项第 1 条) | --- > 本册完。兄弟分册:《02-开发实战篇》《03-运维运营篇》。