# etcd v3.7 学习手册 · 02 开发实战篇 > 基于 https://etcd.io/docs/v3.7/ 整理 > 文档版本:v3.7.0 > 整理日期:2026-09-18 > 本册定位:开发实战篇(写代码用 etcd)。兄弟分册:《01-核心概念篇》《03-运维运营篇》。 > 说明:etcd v3.7 官方 tasks/developer 页面只提供 etcdctl 示例,**未包含 Go clientv3 代码示例**。凡本文出现的 Go 代码,均为基于官方文档 API 语义的工程化示例,必须以 `> 💡 补充:` 显式标注,不得伪装成官方文档原文。 --- ## 目录 - [1. 本册导读](#1-本册导读) - [2. 开发前置 数据模型速览与连接方式](#2-开发前置-数据模型速览与连接方式) - [3. 写入数据 writing-to-etcd](#3-写入数据-writing-to-etcd) - [4. 读取数据 reading-from-etcd](#4-读取数据-reading-from-etcd) - [5. 前缀查询 how-to-get-key-by-prefix](#5-前缀查询-how-to-get-key-by-prefix) - [6. 删除数据 how-to-delete-keys](#6-删除数据-how-to-delete-keys) - [7. 事务写 how-to-transactional-write](#7-事务写-how-to-transactional-write) - [8. Watch 机制 how-to-watch-keys](#8-watch-机制-how-to-watch-keys) - [9. Lease 租约 how-to-create-lease](#9-lease-租约-how-to-create-lease) - [10. 分布式锁与选主 how-to-create-locks](#10-分布式锁与选主-how-to-create-locks) - [11. gRPC 直连与命名发现 grpc_naming](#11-grpc-直连与命名发现-grpc_naming) - [12. gRPC-gateway api_grpc_gateway](#12-grpc-gateway-api_grpc_gateway) - [13. 通过代理接入 etcd gateway 与 gRPC proxy](#13-通过代理接入-etcd-gateway-与-grpc-proxy) - [14. 在 Go 应用里嵌入 etcd golang_embed_pkg](#14-在-go-应用里嵌入-etcd-golang_embed_pkg) - [15. Go 模块划分 dev-internal-modules](#15-go-模块划分-dev-internal-modules) - [16. gRPC API 速查表](#16-grpc-api-速查表) - [17. 附录 官方页面与章节映射表](#17-附录-官方页面与章节映射表) --- ## 1. 本册导读 本册回答一个问题:**在应用代码里怎么用 etcd**。所有内容来自 etcd v3.7 官方文档的 developer 任务页、dev-guide 页与 API 参考页,命令、flag 名、默认值、字段名一律保留英文原文。 ### 1.1 覆盖的官方页面 | # | 页面标题 | 线上 URL | 本册章节 | | - | -------- | -------- | -------- | | 1 | Developer Tasks(目录页,仅 front matter) | https://etcd.io/docs/v3.7/tasks/developer/ | 1 | | 2 | Writing to etcd | https://etcd.io/docs/v3.7/tasks/developer/writing-to-etcd/ | 3 | | 3 | Reading from etcd | https://etcd.io/docs/v3.7/tasks/developer/reading-from-etcd/ | 4 | | 4 | How to get keys by prefix | https://etcd.io/docs/v3.7/tasks/developer/how-to-get-key-by-prefix/ | 5 | | 5 | How to delete keys | https://etcd.io/docs/v3.7/tasks/developer/how-to-delete-keys/ | 6 | | 6 | How to make multiple writes in a transaction | https://etcd.io/docs/v3.7/tasks/developer/how-to-transactional-write/ | 7 | | 7 | How to watch keys | https://etcd.io/docs/v3.7/tasks/developer/how-to-watch-keys/ | 8 | | 8 | How to create lease | https://etcd.io/docs/v3.7/tasks/developer/how-to-create-lease/ | 9 | | 9 | How to create locks | https://etcd.io/docs/v3.7/tasks/developer/how-to-create-locks/ | 10 | | 10 | How to conduct leader election in etcd cluster | https://etcd.io/docs/v3.7/tasks/operator/how-to-conduct-elections/ | 10 | | 11 | gRPC naming and discovery | https://etcd.io/docs/v3.7/dev-guide/grpc_naming/ | 11 | | 12 | Why gRPC gateway | https://etcd.io/docs/v3.7/dev-guide/api_grpc_gateway/ | 12 | | 13 | etcd gateway | https://etcd.io/docs/v3.7/op-guide/gateway/ | 13 | | 14 | gRPC proxy | https://etcd.io/docs/v3.7/op-guide/grpc_proxy/ | 13 | | 15 | Embedding etcd in a Go Application | https://etcd.io/docs/v3.7/dev-guide/golang_embed_pkg/ | 14 | | 16 | Golang modules | https://etcd.io/docs/v3.7/dev-internal/modules/ | 15 | | 17 | API reference | https://etcd.io/docs/v3.7/dev-guide/api_reference_v3/ | 16 | | 18 | API reference: concurrency | https://etcd.io/docs/v3.7/dev-guide/api_concurrency_reference_v3/ | 10、16 | | 19 | Interacting with etcd | https://etcd.io/docs/v3.7/dev-guide/interacting_v3/ | 3、4、6、8、9 | | 20 | Data model / etcd API / etcd API guarantees | https://etcd.io/docs/v3.7/learning/data_model/ 、https://etcd.io/docs/v3.7/learning/api/ 、https://etcd.io/docs/v3.7/learning/api_guarantees/ | 2、16(详见《01-核心概念篇》) | | 21 | etcd client design | https://etcd.io/docs/v3.7/learning/design-client/ | 2 | > 引自:[Developer Tasks](https://etcd.io/docs/v3.7/tasks/developer/)(该页 description 为 `Step-by-step guides for developers using etcd as a key-value store in their applications.`,8 个子页按 `weight` 排列依次为:Reading from etcd(200)、Writing to etcd(250)、How to get keys by prefix(300)、How to delete keys(400)、How to make multiple writes in a transaction(500)、How to watch keys(600)、How to create lease(700)、How to create locks(800)) ### 1.2 读者对象与前置依赖 | 项 | 说明 | | -- | ---- | | 读者对象 | 需要把 etcd 当作键值存储写进应用的开发工程师;需要选型 gRPC / HTTP/JSON / 代理接入方式的架构师 | | 前置依赖(官方页面写明) | 安装 `etcd` 与 `etcdctl`;一个运行中的 etcd 集群;读写练习还需搭建本地集群 | | 语言前提 | 本册 Go 示例使用 `go.etcd.io/etcd/client/v3`(module 划分见第 15 章);其余语言可走第 12 章的 gRPC-gateway | | 不需要先看 | 《01-核心概念篇》里的数据模型细节,本册第 2 章给出速览版本 | ### 1.3 与另外两册的分工 | 分册 | 关注点 | 与本册边界 | | ---- | ------ | ---------- | | 《01-核心概念篇》 | 数据模型物理/逻辑视图、Raft、API guarantees、持久化文件格式 | 本册只做 revision / lease / watch 三件套的"够用版"速览,实现原理一律指向该册 | | 《02-开发实战篇》(本册) | 读写删、事务、watch、lease、锁选主、gRPC / gateway / proxy / embed / modules 接入方式 | 只讲"怎么写代码、怎么配命令" | | 《03-运维运营篇》 | 部署、容器 / Kubernetes、配置 flag、集群维护、备份恢复、监控 | 本册第 13 章的 gateway / gRPC proxy 只讲"客户端接入视角",部署与运维细节归该册 | ### 1.4 关于本册 Go 代码的重要声明 etcd v3.7 官方 `tasks/developer/` 下的 9 个页面**全部只给 etcdctl 示例,没有任何 Go clientv3 代码**。本册因此需要大量自行编写 Go 示例,规则如下: 1. 凡本册出现的 Go 代码块,其上方必有一行 `> 💡 补充:` 说明其来源; 2. `> 💡 补充:` 后面的内容会注明"基于官方 API 语义编写"还是"引自官方页面原文"; 3. 唯一含 Go 代码的官方页面是 `dev-guide/grpc_naming.md`、`op-guide/grpc_proxy.md`、`upgrades/*`(本册第 11、13 章引用),这些也会显式标注出处,不混入自编示例; 4. 凡官方源文没有的东西(枚举全部取值、gRPC code 对照表、`clientv3.Config` 字段清单、`embed.Config` 字段清单),本册要么不写,要么在标注 `💡 补充` 后给出工程化版本,并提示以 `pkg.go.dev` / `etcdctl --help` 为准。 ### 1.5 怎么读本册(三条阅读路径) **路径 A:先把功能跑通(推荐第一次接触 etcd 的开发者)。** 按第 2 章配好 `ETCDCTL_API=3` 与 endpoint,然后顺着第 3 → 4 → 5 → 6 章把增删改查跑一遍,理解 `header.revision` 每次写入都在变。此时先不要碰事务与 watch。 **路径 B:要写并发安全的代码。** 第 2 章的 revision / version 区别必须先看懂,然后直奔第 7 章(事务与 CAS)。etcd 的并发控制完全建立在"比较 + 原子提交"上,理解 `mod_revision` 与 `version` 两个比较目标之后,第 10 章的锁与选主只是这套机制的上层封装。 **路径 C:要决定接入方式。** 应用是 Go 技术栈,直接用第 2 章的 `clientv3`;没有 gRPC 栈的语言看第 12 章的 gRPC-gateway;客户端数量多、watch 量大或需要 keyspace 隔离时看第 13 章的 gRPC proxy;需要服务注册发现看第 11 章。第 14、15 章是"要不要把 etcd 编进自己进程"与"该依赖哪个 module"的决策依据。 无论哪条路径,第 16 章都当作工具页翻,不必通读;第 17 章给出每个官方页面与本册章节的对应关系,方便回查原文。 --- ## 2. 开发前置 数据模型速览与连接方式 > 引自:[Data model](https://etcd.io/docs/v3.7/learning/data_model/)、[etcd API](https://etcd.io/docs/v3.7/learning/api/)、[Interacting with etcd](https://etcd.io/docs/v3.7/dev-guide/interacting_v3/)、[etcd client design](https://etcd.io/docs/v3.7/learning/design-client/) ### 2.1 三件套如何配合工作 三件套不是三个孤立功能,而是同一套"全局逻辑时钟 + 存活凭证 + 事件流"的组合: - **先写后读,用 revision 对齐**:一次 `put` 让全局 revision +1,响应 `header.revision` 就是这次写入的全局序号;后续按这个序号读历史版本、或从它继续 watch,就不会漏事件。 - **lease 给数据装上寿命**:把 key 绑到 lease 上,进程活着就一直续期,进程挂了 TTL 一到 key 自动消失,并产生 delete 事件——服务注册、会话、锁、选主都建立在这个"不会有人来清理的自动清理"上。 - **watch 把变更变成流**:watch 不是轮询,而是服务端按 revision 顺序把事件推给客户端;断线后用最后观测到的 revision 续传即可,这也是它能扛住长时间断连的原因。 > 工程建议(非官方原文):设计 key 时就把这三件事想清楚——哪些 key 需要绑 lease(有生命周期的)、哪些 key 会被 watch(变更要广播的)、哪些 key 要做 CAS(会并发写的)。这三类 key 最好不要混在同一个前缀下,否则 watch 风暴与 CAS 冲突会互相放大。 ### 2.2 三件套:revision / lease / watch | 概念 | 一句话含义 | 开发时怎么用 | 深读 | | ---- | ---------- | ------------ | ---- | | revision | key 空间维护的**全局逻辑时钟**。存储创建时初始 revision 为 **1**;每个**原子性变更操作**(一个事务可能含多个操作,但只产生一个 revision)都会创建新 revision | 用 `--rev` 读历史版本;用 watch 的 `start_revision` 断点续传;用 `header.revision` 判断写入后的全局版本 | 《01-核心概念篇》 | | lease | 带 TTL 的存活凭证。集群授予 lease 一个 TTL,若 TTL 周期内未收到 keepAlive 即过期;每个 key **最多附加一个 lease**,lease 过期或撤销时其上所有 key 被删除,并各生成一个 delete 事件 | 服务注册、会话保活、锁与选主的底层机制 | 本册第 9、10 章 | | watch | 基于事件的异步监控接口,从给定 revision(当前或历史)持续等待变化并流式返回 | 配置下发、缓存失效、leader 变更感知 | 本册第 8 章 | 配套两个容易混的概念(详见《01-核心概念篇》): - **generation**:一个 key 从创建到删除的生命周期; - **version**:key 的版本,创建时从 **1** 开始,每次修改递增,删除时重置为 **0** 并终结当前 generation。 > 提示:revision 是**集群级**计数器,version 是**单 key 级**计数器。做 CAS 时,通常用 `mod_revision`(最后一次修改的 revision)或 `version`,不要用 `create_revision` 判断"是否被改过"。 ### 2.3 连接方式一:etcdctl 的 API 版本与 endpoint ```bash export ETCDCTL_API=3 ``` > 引自:[Interacting with etcd](https://etcd.io/docs/v3.7/dev-guide/interacting_v3/)(原文:`The API version used by etcdctl to speak to etcd may be set to version 2 or 3 via the ETCDCTL_API environment variable.`) 注意:**用 v2 API 创建的 key 无法通过 v3 API 查询**;对 v2 key 执行 v3 的 `etcdctl get` 会**返回 0 且没有 key 数据**,这是预期行为。 endpoint 设置三条等价路径: | 方式 | 写法 | 出处 | | ---- | ---- | ---- | | 命令行 flag | `etcdctl --endpoints=$ENDPOINTS get foo` | 全部 developer 任务页 | | 环境变量 | `ETCDCTL_ENDPOINTS`(容器内让 etcdctl 默认连本节点,见 Kubernetes 示例) | op-guide/kubernetes 的环境变量段 | | 默认值 | `--endpoints=[127.0.0.1:2379]` / `--endpoints="127.0.0.1:2379"` | how-to-get-key-by-prefix / how-to-delete-keys / how-to-watch-keys | 查看版本(用于确认命令与 API 版本是否匹配): ```bash $ etcdctl version etcdctl version: 3.1.0-alpha.0+git API version: 3.1 ``` ### 2.4 连接方式二:Go clientv3 官方推荐的新用法一律使用 `go.etcd.io/etcd/client/v3`(module 说明见第 15 章)。 > 💡 补充:以下 Go 示例基于官方 API 语义编写(官方 `tasks/developer/` 页面无 Go 代码)。 ```go cli, err := clientv3.New(clientv3.Config{ Endpoints: []string{"http://127.0.0.1:2379"}, DialTimeout: 5 * time.Second, }) if err != nil { log.Fatal(err) } defer cli.Close() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := cli.Sync(ctx); err != nil { // 从集群刷新 endpoint 列表 log.Fatal(err) } ``` `clientv3` 还支持从 URL 直接建客户端(官方 grpc_naming 页面用法见第 11 章): > 💡 补充:下面这一行 `clientv3.NewFromURL` 的用法引自官方页面 [gRPC naming and discovery](https://etcd.io/docs/v3.7/dev-guide/grpc_naming/)(非本手册杜撰)。 ```go cli, err := clientv3.NewFromURL("http://localhost:2379") ``` > 💡 补充:上面两行中的 `clientv3.NewFromURL` 用法引自官方页面 [gRPC naming and discovery](https://etcd.io/docs/v3.7/dev-guide/grpc_naming/),`clientv3.New` 用法引自 [gRPC proxy](https://etcd.io/docs/v3.7/op-guide/grpc_proxy/);第一行中的 `Endpoints`/`DialTimeout` 字段取值与包裹的示例结构为本手册补充。 #### clientv3.Config 常用字段表 > 💡 补充:官方文档**未逐项列出** `clientv3.Config` 的字段名与默认值(client design 页面仅记录了 dial timeout 默认 5 秒)。下表为工程化常用字段速查,最终以 `pkg.go.dev/go.etcd.io/etcd/client/v3` 为准。 | 字段 | 类型 | 默认值 | 说明 | | ---- | ---- | ------ | ---- | | `Endpoints` | `[]string` | 无(必填);etcdctl 侧默认 `127.0.0.1:2379` | 客户端可连的 etcd server endpoint 列表,典型 3 个或 5 个 client URL | | `DialTimeout` | `time.Duration` | 官方 client design 页记录的 dial timeout 默认 **5 秒** | 建连超时;该值同时被 grpc1.7 balancer 用作 unhealthy endpoint 的等待时长 | | `AutoSyncInterval` | `time.Duration` | `0`(关闭自动同步) | 非 0 时周期性自动 `Sync` 集群 endpoint 列表 | | `DialKeepAliveTime` | `time.Duration` | 官方页面未给默认值 | 客户端发起 HTTP/2 keepalive ping 的间隔(流式 RPC 用它探测不可用节点) | | `DialKeepAliveTimeout` | `time.Duration` | 官方页面未给默认值 | keepalive ping 的应答超时 | | `MaxCallSendMsgSize` | `int` | 官方页面未给默认值 | 单条 gRPC 消息发送上限,需与服务端 `--max-request-bytes`(默认 **1.5 MiB**)对齐 | | `MaxCallRecvMsgSize` | `int` | 官方页面未给默认值 | 单条 gRPC 消息接收上限 | | `Username` / `Password` | `string` | 空(未开启认证) | 对应 `Auth` service 的 `Authenticate` | | `TLS` | `*tls.Config` | `nil`(明文) | 启用客户端到服务器的 TLS | | `Context` | `context.Context` | `context.Background()` | 控制 client 生命周期 | | `Logger` | `*zap.Logger` | `nil`(使用默认 logger) | 客户端日志 | #### 客户端的故障切换行为(写业务代码前必须知道) > 引自:[etcd client design](https://etcd.io/docs/v3.7/learning/design-client/) | 项 | 官方记录 | | -- | -------- | | 客户端三组件 | balancer(建立 gRPC 连接)、API client(发 RPC)、error handler(判断重试还是切换 endpoint) | | 默认负载均衡策略 | **round robin** | | 连接模型 | `clientv3-grpc1.23` 为**每个 endpoint 建一个 sub-connection**(5 节点集群 = 5 条 TCP 连接);`clientv3-grpc1.7` 只维持一条到 pinned endpoint 的连接 | | unhealthy endpoint 等待时长 | 硬编码为 dial timeout,**默认 5 秒** | | 流式 RPC 健康探测 | 周期性 HTTP/2 ping(keepalive);该机制**不理解网络分区**,被分区的 gRPC server 仍能回应 ping,流式请求可能卡在分区节点(etcd#8673) | | 该重试的错误 | `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`。 --- ## 3. 写入数据 writing-to-etcd > 引自:[Writing to etcd](https://etcd.io/docs/v3.7/tasks/developer/writing-to-etcd/) **前提**:安装 `etcdctl`。 **做法**:用 `put` 子命令写入一个键值对。 ```bash etcdctl --endpoints=$ENDPOINTS put foo "Hello World!" ``` - `foo` 是 key 名;`"Hello World!"` 是用引号包裹的 value。 - 写入成功后 etcd 会**递增全局 revision**。 > 说明:官方该页**未给出** `lease`、`prev-kv` 等额外 flag 的说明,本册按下表补齐(均对应 `PutRequest` 的 proto 字段,见第 16 章)。 ### 3.1 put 相关 flag | flag | 默认 | 含义 | 来源 | | ---- | ---- | ---- | ---- | | `--endpoints` | `"127.0.0.1:2379"`(继承自父命令) | gRPC endpoints | how-to-delete-keys / how-to-watch-keys 的"继承自父命令的选项" | | `--lease` | 未设置(0 = 无 lease) | 把 key 绑定到指定 lease ID;lease 到期时该 key 自动删除 | dev-guide/interacting_v3 的 `put foo1 bar1 --lease=1234abcd`;how-to-create-lease | | `--prev-kv` | `false` | 修改前先取回上一个 kv 对,随 put 响应返回 | `PutRequest.prev_kv`(api.md) | | `--ignore-value` | `false` | 用当前 value 更新 key;**key 不存在则报错** | `PutRequest.ignore_value`(api.md) | | `--ignore-lease` | `false` | 用当前 lease 更新 key;**key 不存在则报错** | `PutRequest.ignore_lease`(api.md) | ### 3.2 PutRequest / PutResponse 字段 | 消息 | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ---- | ------ | | PutRequest | `key` | bytes | 要写入的 key | — | | PutRequest | `value` | bytes | 关联的 value | — | | PutRequest | `lease` | int64 | 关联到 key 的 lease ID | `0` = 无 lease | | PutRequest | `prev_kv` | bool | 修改前先取回上一个 kv 对,随 put 响应返回 | `false` | | PutRequest | `ignore_value` | bool | 用当前 value 更新 key;key 不存在则报错 | `false` | | PutRequest | `ignore_lease` | bool | 用当前 lease 更新 key;key 不存在则报错 | `false` | | PutResponse | `header` | ResponseHeader | 见 3.3 | — | | PutResponse | `prev_kv` | mvccpb.KeyValue | 被本次 put 覆盖的 kv 对(仅 `prev_kv=true` 时) | — | ### 3.3 返回字段(含 header revision) `ResponseHeader` 是每个响应都带的公共头: | 字段 | 类型 | 说明 | | ---- | ---- | ---- | | `cluster_id` | uint64 | 生成该响应的集群 ID(应用可据此确认正在与预期集群通信) | | `member_id` | uint64 | 生成该响应的成员 ID | | `revision` | int64 | **请求被应用时的 kv store revision**;未与 kv store 交互的调用中为 `0` | | `raft_term` | uint64 | 生成响应时成员的 Raft term(可用于检测是否完成了新一轮 leader 选举) | etcdctl 的默认输出只有一行 `OK`;加 `--write-out="json"` 可看到 `header` 全貌(下例取自 gRPC-gateway 的等价响应,字段名与 gRPC 一致): ```json {"header":{"cluster_id":"12585971608760269493","member_id":"13847567121247652255","revision":"2","raft_term":"3"}} ``` > 引自:[Why gRPC gateway](https://etcd.io/docs/v3.7/dev-guide/api_grpc_gateway/) ### 3.4 Go 写入示例 > 💡 补充:以下 Go 示例基于官方 API 语义编写,非官方文档原文。 ```go resp, err := cli.Put(ctx, "foo", "Hello World!", clientv3.WithPrevKV()) if err != nil { log.Fatal(err) } // header.revision:本次写入被应用后的全局 revision fmt.Printf("revision=%d cluster=%d member=%d\n", resp.Header.Revision, resp.Header.ClusterId, resp.Header.MemberId) if resp.PrevKv != nil { // 仅 WithPrevKV() 时非空 fmt.Printf("old value=%q version=%d mod_revision=%d\n", resp.PrevKv.Value, resp.PrevKv.Version, resp.PrevKv.ModRevision) } ``` 带 lease 写入(lease 用第 9 章的方式先创建): > 💡 补充:以下 Go 示例基于官方 API 语义编写,非官方文档原文。 ```go leaseResp, err := cli.Grant(ctx, 60) // TTL 60 秒 if err != nil { log.Fatal(err) } if _, err := cli.Put(ctx, "sample", "value", clientv3.WithLease(leaseResp.ID)); err != nil { log.Fatal(err) } ``` --- ## 4. 读取数据 reading-from-etcd > 引自:[Reading from etcd](https://etcd.io/docs/v3.7/tasks/developer/reading-from-etcd/)、[Interacting with etcd](https://etcd.io/docs/v3.7/dev-guide/interacting_v3/) **前提**:安装 `etcdctl`。用 `get` 子命令读取。 ```bash $ etcdctl --endpoints=$ENDPOINTS get foo foo Hello World! $ ``` ### 4.1 全部读 flag 逐一说明 | flag | 默认值 | 含义 | 出处 | | ---- | ------ | ---- | ---- | | `--prefix` | `false` | 取出所有带该前缀的 key(范围查询) | how-to-get-key-by-prefix、interacting_v3 | | `--limit` | `0`(无限制) | 返回 key 数量上限 | interacting_v3 `get --prefix --limit=2 foo`;`RangeRequest.limit` | | `--rev` | `0`(最新) | 读取该 revision 时间点的版本;已被压缩则返回 `ErrCompacted` | interacting_v3 `get --prefix --rev=4 foo`;`RangeRequest.revision` | | `--from-key` | `false` | 按字节比较,读取**大于等于**给定 key 的全部 key | interacting_v3 `get --from-key b` | | `--sort-by` | 未设置(`SortOrder=NONE`) | 排序目标:`CREATE` / `KEY` / `MOD` / `VALUE` / `VERSION` | 对应 `RangeRequest.sort_target`;官方页面仅出现 `--sort-by` 名称(见 4.6 说明) | | `--order` | 未设置(`NONE`) | 排序方向:`ASCEND` / `DESCEND` | 对应 `RangeRequest.sort_order`;interacting_v3 提到 `--stream` 不支持 `--order` | | `--keys-only` | `false` | 只返回 key 不返回 value | `RangeRequest.keys_only` | | `--count-only` | `false` | 只返回区间内 key 的数量 | `RangeRequest.count_only` | | `--consistency` | `l`(linearizable) | `l` / `linearizable` 走 Raft 共识;`s` / `serializable` 用成员本地读 | 对应 `RangeRequest.serializable`(api.md / api_guarantees.md) | | `--hex` | `false` | 以十六进制输出 key/value | interacting_v3 `get foo --hex` | | `--print-value-only` | `false` | 只打印 value | interacting_v3 `get foo --print-value-only` | | `--stream` | `false` | 改用 `RangeStream` RPC 分片流式返回 | interacting_v3 `get --stream --prefix foo` | | `--write-out` / `-w` | 简单文本 | 输出格式,源文中出现 `json`(`--write-out="json"`、`-w=json`)与 `table`(`-wtable`) | reading-from-etcd、interacting_v3、op-guide/kubernetes | | `--endpoints` | `"127.0.0.1:2379"` | gRPC endpoints | how-to-get-key-by-prefix | > 说明:上表中带具体示例的 flag(`--prefix`、`--limit`、`--rev`、`--from-key`、`--hex`、`--print-value-only`、`--stream`、`--write-out`)在 v3.7 官方页面有原文出处;`--sort-by`、`--order`、`--keys-only`、`--count-only`、`--consistency` 官方页面**未逐项列出**,其语义与 `RangeRequest` 的 `sort_target` / `sort_order` / `keys_only` / `count_only` / `serializable` 字段一一对应(字段表见 4.3),实际取值以 `etcdctl get --help` 为准。 ### 4.2 单 key 读、JSON 输出与 base64 ```bash $ etcdctl --endpoints=$ENDPOINTS --write-out="json" get foo {"header":{"cluster_id":289318470931837780,"member_id":14947050114012957595,"revision":3,"raft_term":4, "kvs":[{"key":"Zm9v","create_revision":2,"mod_revision":3,"version":2,"value":"SGVsbG8gV29ybGQh"}]}} $ ``` - `header` 携带 `cluster_id`、`member_id`、`revision`、`raft_term`; - `kvs` 数组每条记录含 `key`、`create_revision`、`mod_revision`、`version`、`value`; - **key 与 value 在 JSON 中是 base64 编码**(`Zm9v` = `foo`,`SGVsbG8gV29ybGQh` = `Hello World!`)。 > 源文疑点(如实记录):该页正文称 `write-out="json"` 时 "the key is not returned",但同一示例输出中确实包含 `"key":"Zm9v"`,两者矛盾,以实际输出为准。 小技巧:对**任意 key(存在或不存在)**执行 `get -w=json` 可以拿到集群当前 revision: ```bash $ etcdctl get mykey -w=json {"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":15,"raft_term":4}} ``` ### 4.3 RangeRequest 字段全表 | 字段 | 类型 | 说明 | 默认值 / 取值 | | ---- | ---- | ---- | ------------- | | `key` | bytes | 区间的第一个 key;若未给 `range_end`,则只查找 `key` 本身 | — | | `range_end` | bytes | 区间上界 `[key, range_end)` | 未给 = 单 key;`'\0'` = 所有 >= key 的 key;key+1 = 前缀匹配;key 与 range_end 均为 `'\0'` = 所有 key | | `limit` | int64 | 返回 key 数量上限 | `0` = 无限制 | | `revision` | int64 | 读取的时间点 revision | <=0 取最新;已被压缩则返回 `ErrCompacted` | | `sort_order` | SortOrder | 返回结果的排序方向 | `NONE = 0`、`ASCEND = 1`、`DESCEND = 2` | | `sort_target` | SortTarget | 用于排序的 kv 字段 | `KEY = 0`、`VERSION = 1`、`CREATE = 2`、`MOD = 3`、`VALUE = 4` | | `serializable` | bool | 使用 serializable 成员本地读(默认 linearizable);延迟低吞吐高但可能读到陈旧数据 | `false` = linearizable | | `keys_only` | bool | 只返回 key 不返回 value | `false` | | `count_only` | bool | 只返回区间内 key 的数量 | `false` | | `min_mod_revision` | int64 | mod revision 下界,更小者被过滤 | `0` = 不过滤 | | `max_mod_revision` | int64 | mod revision 上界,更大者被过滤 | `0` = 不过滤 | | `min_create_revision` | int64 | create revision 下界,更小者被过滤 | `0` = 不过滤 | | `max_create_revision` | int64 | create revision 上界,更大者被过滤 | `0` = 不过滤 | ### 4.4 RangeResponse 字段 | 字段 | 类型 | 说明 | | ---- | ---- | ---- | | `header` | ResponseHeader | 见 3.3 | | `kvs` | (slice of) mvccpb.KeyValue | 匹配到的 kv 列表;设置 `count_only` 时为空 | | `more` | bool | 若设置了 `limit`,指示区间内是否还有更多 key | | `count` | int64 | 区间内实际数量,**不受 `limit` 与 Min/Max Create/Mod Revision 过滤影响** | ### 4.5 历史版本读、前缀读、限量读、from-key 读(官方示例原样) ```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 foo bar foo1 bar1 $ etcdctl get --prefix --rev=2 foo foo bar $ etcdctl get --prefix --rev=1 foo # 空 ``` ```bash $ etcdctl get foo foo3 # 半开区间 [foo, foo3),foo3 被排除 foo bar foo1 bar1 foo2 bar2 $ etcdctl get --prefix --limit=2 foo foo bar foo1 bar1 $ etcdctl get --from-key b # 读 >= b 的全部 key(a=123, b=456, z=789) b 456 z 789 ``` 压缩之后读旧 revision 会失败: ```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 ``` ### 4.6 JSON 与 table 输出差异 | 维度 | 默认(简单文本) | `--write-out="json"` | `-wtable` | | ---- | ---------------- | -------------------- | --------- | | 内容 | 每个 key 一行、value 一行 | `header` 四字段 + `kvs` 数组(含 `create_revision`/`mod_revision`/`version`) | 人类可读的列式表格 | | key/value 编码 | 原文 | **base64** | 原文 | | 用途 | 人肉观察 | 脚本解析、取 `header.revision` | 人肉观察多列结果(源文在 `member list --write-out table` 中使用) | | 备注 | — | 源文正文称"不返回 key",但示例输出含 key(见 4.2 疑点) | 源文未给出 `get -wtable` 的完整输出样例 | ### 4.7 RangeStream:大区间读 > 引自:[etcd API](https://etcd.io/docs/v3.7/learning/api/) `RangeStream` 返回与 `Range` 相同的结果集,但服务端把响应**切分成一系列 chunk 流式**下发,避免两端在内存里缓冲整个大区间。它接受相同的 `RangeRequest`,客户端收到 `RangeStreamResponse { RangeResponse range_response = 1; }` 流。 | 规则 | 说明 | | ---- | ---- | | `kvs` | 每个 chunk 携带**互不相交**的一个切片;按到达顺序拼接即等于单次 `Range` 的结果 | | `header` / `more` / `count` | **只在最后一个 chunk 上填充**,且仅在流无错完成时;之前 chunk 为零值 | | 出错 | 流以错误结束时**没有任何 chunk** 携带有效的 `header`、`more`、`count` | | 一致性 | 流中每个 chunk 基于**同一 revision**;若请求未设 `revision`,服务端在流开始时捕获最新已提交 revision 并全程复用 | | 限制 | **不支持自定义排序**与 revision 过滤器(`min_mod_revision`、`max_mod_revision`、`min_create_revision`、`max_create_revision`),使用这些的请求返回 `Unimplemented`;**etcd gRPC proxy 也不支持 `RangeStream`** | etcdctl 侧:`--stream` 不支持 `--order`、`--sort-by` 与 revision 过滤器。Go 侧可用 `clientv3.GetStreamToGetResponse` 把多个 chunk 合并(用 `proto.Merge`)成单个 `RangeResponse`。 ### 4.8 Go 读取示例 > 💡 补充:以下 Go 示例基于官方 API 语义编写,非官方文档原文。 ```go // 单 key 读 + 历史版本读 resp, err := cli.Get(ctx, "foo") if err != nil { log.Fatal(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.Println("header.revision =", resp.Header.Revision) // 读 revision=4 时的历史版本 hist, err := cli.Get(ctx, "foo", clientv3.WithRev(4)) ``` 第二个示例,覆盖前缀 / 限量 / 排序 / 计数 / 只取 key: > 💡 补充:以下 Go 示例基于官方 API 语义编写,非官方文档原文。 ```go // 前缀 + 限量 + 按 key 升序 r1, _ := cli.Get(ctx, "web", clientv3.WithPrefix(), clientv3.WithLimit(2), clientv3.WithSort(clientv3.SortByKey, clientv3.SortAscend)) // from-key:读 >= "b" 的全部 key r2, _ := cli.Get(ctx, "b", clientv3.WithFromKey()) // 只数数量 / 只取 key r3, _ := cli.Get(ctx, "web", clientv3.WithPrefix(), clientv3.WithCountOnly()) r4, _ := cli.Get(ctx, "web", clientv3.WithPrefix(), clientv3.WithKeysOnly()) // serializable 本地读(可能陈旧,但延迟低) r5, _ := cli.Get(ctx, "foo", clientv3.WithSerializable()) _ = r1.More // 还有更多 key(配合 WithLimit 做分页) _ = r3.Count // 区间内 key 总数 ``` ### 4.9 读一致性与输出格式怎么选 > 工程建议(非官方原文,依据官方 `RangeRequest.serializable` 与 API guarantees 的语义推导)。 | 场景 | 建议取值 | 理由 | | ---- | -------- | ---- | | 读配置后要立刻据此做写决策(如库存、配额、开关) | 默认 `--consistency=l`(linearizable) | 只有线性一致才能保证读到"最新已提交"的值;陈旧读可能导致超卖 | | 大批量扫描、监控采集、对短暂陈旧不敏感 | `--consistency=s`(serializable) | 成员本地读,无需走 Raft 共识,延迟低、吞吐高 | | 要拿到"当前集群 revision"做后续对齐 | 任意 key 上 `-w=json` 读 `header.revision` | 官方给出的取当前 revision 的做法 | | 脚本解析 | `-w=json` | 唯一机器可读且带 `header` 的输出;注意 key/value 是 base64,需解码 | | 人肉排查 | 默认文本或 `-wtable` | 不需要解码中文/二进制 value | | 大区间(万级 key 以上) | `--stream`(`RangeStream`) | 分 chunk 下发,避免两端缓冲整个结果集;但**不支持排序与 revision 过滤**,且 gRPC proxy 不支持 | 还有一个容易踩的点:`--limit` 与 `count` 的含义不同。`limit` 限制的是**本次返回的 kvs 条数**,`count` 是**区间内满足条件的总数**,不受 `limit` 影响。因此"分页 + 显示总数"可以直接用 `count`,而判断是否还有下一页要看 `more`。 --- ## 5. 前缀查询 how-to-get-key-by-prefix > 引自:[How to get keys by prefix](https://etcd.io/docs/v3.7/tasks/developer/how-to-get-key-by-prefix/) **前提**:安装 `etcdctl`;搭建本地集群。 ```bash $ etcdctl --endpoints=$ENDPOINTS get PREFIX --prefix ``` - 全局选项:`--endpoints=[127.0.0.1:2379], gRPC endpoints` - 选项:`--prefix, get a range of keys with matching prefix` 示例: ```bash etcdctl --endpoints=$ENDPOINTS put web1 value1 etcdctl --endpoints=$ENDPOINTS put web2 value2 etcdctl --endpoints=$ENDPOINTS put web3 value3 etcdctl --endpoints=$ENDPOINTS get web --prefix ``` ### 5.1 range_end 语义(前缀查询的底层) > 引自:[etcd API](https://etcd.io/docs/v3.7/learning/api/) etcd 数据模型在**扁平二进制 key space** 上索引所有 key,按区间 `[a, b)` 列出。约定由 `key` 与 `range_end` 两个字段表示: | 写法 | 含义 | | ---- | ---- | | `range_end` 未给出或为空 | 区间只包含 `key` 本身(单 key 查找) | | `range_end` = `key` 加一(如 `"aa"+1 == "ab"`、`"a\xff"+1 == "b"`) | 所有以 `key` 为前缀的 key(前缀匹配) | | `range_end` = `\0` | 所有**大于等于** `key` 的 key | | `key` 与 `range_end` 都是 `\0` | **所有 key** | `--prefix` 就是让 etcdctl 帮你算出"key+1"这个 `range_end`。gRPC-gateway 里需要自己算,例如取 `foo` 前缀时 `range_end` 传 `Zm9w`(即 `foo` → `fop`,见第 12 章)。 ### 5.2 clientv3 的 range 选项 > 💡 补充:以下 Go 示例基于官方 API 语义编写(官方该页只有 etcdctl 示例)。各选项与 `RangeRequest` 字段的对应关系为:`WithPrefix` → `range_end = key+1`;`WithFromKey` → `range_end = '\0'`;`WithLimit` → `limit`;`WithSort` → `sort_target` + `sort_order`。 ```go // 前缀查询 resp, err := cli.Get(ctx, "web", clientv3.WithPrefix()) if err != nil { log.Fatal(err) } fmt.Println("count =", resp.Count, "more =", resp.More) for _, kv := range resp.Kvs { fmt.Printf("%s = %s\n", kv.Key, kv.Value) } // 等价手写区间:[web, wez) 不是通用写法,通用做法是 WithPrefix; // 需要显式半开区间时可直接用 Get(ctx, start, clientv3.WithRange(end)) r2, _ := cli.Get(ctx, "web1", clientv3.WithRange("web3")) // [web1, web3) ``` ### 5.3 WithLimit + More 做分页 > 💡 补充:以下 Go 示例基于官方 API 语义编写,非官方文档原文。分页依赖 `RangeResponse.more` 与 `count` 字段(字段语义见 4.4)。 ```go start := "web" for { resp, err := cli.Get(ctx, start, clientv3.WithPrefix(), clientv3.WithLimit(100), clientv3.WithSort(clientv3.SortByKey, clientv3.SortAscend)) if err != nil { log.Fatal(err) } for _, kv := range resp.Kvs { handle(kv) } if !resp.More { // 没有更多 key,结束 break } // 下一页:从最后一个 key 的下一个字节串开始 start = string(resp.Kvs[len(resp.Kvs)-1].Key) + "\x00" } ``` ### 5.4 WithSort 的排序目标与方向 | 目标(SortTarget) | 值 | 方向(SortOrder) | 值 | | ------------------ | -- | ----------------- | -- | | `KEY` | 0 | `NONE` | 0 | | `VERSION` | 1 | `ASCEND` | 1 | | `CREATE` | 2 | `DESCEND` | 2 | | `MOD` | 3 | — | — | | `VALUE` | 4 | — | — | > 引自:[etcd API](https://etcd.io/docs/v3.7/learning/api/)(枚举定义 `SortOrder { NONE = 0; ASCEND = 1; DESCEND = 2; }`、`SortTarget { KEY = 0; VERSION = 1; CREATE = 2; MOD = 3; VALUE = 4; }`) ### 5.5 前缀设计的工程建议 > 工程建议(非官方原文,依据"扁平二进制 key space + 字典序索引"这一官方事实推导)。 - etcd **没有目录**,所谓"目录"只是 `/` 分隔的前缀约定;前缀查询之所以快,是因为 key 空间在字节串上维护了**字典序索引**。 - 前缀末尾要不要带 `/` 要想清楚:`/svc/order` 会同时匹配 `/svc/order-created` 这类"看起来不是同族"的 key,而 `/svc/order/` 不会。建议**统一带尾斜杠**。 - 前缀查询天然是全序扫描的起点,前缀选得太粗(例如直接用 `/`)会扫到整个 keyspace;建议按"服务 / 租户 / 业务对象"分层,如 `/tenantA/order/2026-09-18/xxx`。 - 需要逆序或按时间排序时,用 `WithSort` 指定 `SortTarget` 与 `SortOrder`(见 5.4),而不是在客户端排序——服务端排可以利用索引。 - 大前缀下的遍历务必配 `WithLimit` 做分页(见 5.3),一次性拉全量容易触发请求大小上限(默认 **1.5 MiB**,服务端 `--max-request-bytes`)。 --- ## 6. 删除数据 how-to-delete-keys > 引自:[How to delete keys](https://etcd.io/docs/v3.7/tasks/developer/how-to-delete-keys/)、[Interacting with etcd](https://etcd.io/docs/v3.7/dev-guide/interacting_v3/) **前提**:安装 `etcd` 与 `etcdctl`。用 `del` 删除指定的单个 key 或一段 key 区间。 ```bash etcdctl del $KEY [$END_KEY] ``` ### 6.1 del 的 flag | flag | 默认值 | 含义 | | ---- | ------ | ---- | | `--prefix` | `[=false]` | 删除所有带该前缀的 key | | `--prev-kv` | `[=false]` | 在删除的同时返回被删掉的键值对(便于审计或回滚) | | `--from-key` | `[=false]` | 按字节比较,删除所有**大于等于**给定 key 的 key | | `--range` | `[=false]` | 立即删除一段区间的 key(不延迟) | | `--endpoints` | `"127.0.0.1:2379"`(继承自父命令) | gRPC endpoints | ### 6.2 删除官方示例 ```bash etcdctl --endpoints=$ENDPOINTS put key myvalue etcdctl --endpoints=$ENDPOINTS del key etcdctl --endpoints=$ENDPOINTS put k1 value1 etcdctl --endpoints=$ENDPOINTS put k2 value2 etcdctl --endpoints=$ENDPOINTS del k --prefix ``` 带返回值的删除(源数据:`foo = bar`、`foo1 = bar1`、`foo3 = bar3`、`zoo = val`、`zoo1 = val1`、`zoo2 = val2`、`a = 123`、`b = 456`、`z = 789`): ```bash $ etcdctl del foo 1 # one key is deleted $ etcdctl del foo foo9 2 # two keys are deleted $ etcdctl del --prev-kv zoo 1 # one key is deleted zoo # deleted key val # the value of the deleted key $ etcdctl del --prefix zoo 2 # two keys are deleted $ etcdctl del --from-key b 2 # two keys are deleted ``` ### 6.3 DeleteRange 字段与 deleted 数量 | 消息 | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ---- | ------ | | DeleteRangeRequest | `key` | bytes | 要删除的区间的第一个 key | — | | DeleteRangeRequest | `range_end` | bytes | 区间 `[key, range_end)` 的尾后 key | 未给 = 仅 key;key+1 = 前缀;`'\0'` = 所有 >= key | | DeleteRangeRequest | `prev_kv` | bool | 删除前取回旧 kv 对,随 delete 响应返回 | `false` | | DeleteRangeResponse | `header` | ResponseHeader | 见 3.3 | — | | DeleteRangeResponse | `deleted` | int64 | **删除的 key 数量** | — | | DeleteRangeResponse | `prev_kvs` | (slice of) mvccpb.KeyValue | 本次删掉的所有 kv 对 | — | 语义补充:`DeleteRange` 会**递增 revision**,并为**每个被删 key 生成一个 delete 事件**(第 16 章 KV 方法表)。 ### 6.4 Go 删除示例 > 💡 补充:以下 Go 示例基于官方 API 语义编写,非官方文档原文。 ```go // 单 key 删除 + 返回被删内容 resp, err := cli.Delete(ctx, "zoo", clientv3.WithPrevKV()) if err != nil { log.Fatal(err) } fmt.Println("deleted =", resp.Deleted, "revision =", resp.Header.Revision) for _, kv := range resp.PrevKvs { fmt.Printf("deleted key=%s old value=%s\n", kv.Key, kv.Value) } // 前缀删除 r2, err := cli.Delete(ctx, "zoo", clientv3.WithPrefix()) // from-key 删除(删 >= "b" 的全部 key) r3, err := cli.Delete(ctx, "b", clientv3.WithFromKey()) ``` ### 6.5 删除会带来的三个后果 > 依据官方 `DeleteRange` 语义(KV 方法表)与 data model 的 generation / tombstone 规则整理。 1. **revision 同样会 +1**:删除不是"抹掉历史",而是在新的 revision 上记录一次变更;因此删除也会产生事件、也会推进全局逻辑时钟。 2. **每个被删 key 产生一个 delete 事件**:所有 watch 该 key(或其前缀)的客户端都会收到 `DELETE` 事件,事件里的 `kv.mod_revision` 就是删除发生时的 revision。做本地缓存的业务必须处理 `DELETE`,否则缓存里会留下幽灵数据。 3. **version 归零、generation 终结**:删除会把 key 的 version 重置为 **0** 并生成 tombstone,终结当前 generation;下次再写入同一个 key,version 从 **1** 重新计数。因此**不要用 version 判断"这个 key 一共被改过多少次"**,跨 generation 的 version 不可比,要跨生命周期统计请用 mod_revision 或自己记录。 补充:删除区间用 `[key, range_end)` 半开区间;`--from-key` 会删掉所有**大于等于**给定 key 的 key,误用会删掉半个 keyspace,执行前建议先用同样的条件 `get` 一遍确认范围。 --- ## 7. 事务写 how-to-transactional-write > 引自:[How to make multiple writes in a transaction](https://etcd.io/docs/v3.7/tasks/developer/how-to-transactional-write/) **前提**:安装 `etcd` 与 `etcdctl`;一个运行中的 etcd 集群。 **术语**:`txn` 是 "transaction" 的缩写,它从标准输入读取多个 etcd 请求,并作为**单个原子事务**提交。一个事务由三部分组成:一组条件(conditions)、全部条件为真时要执行的请求列表、任一条件为假时要执行的请求列表。`compare` 子句即事务中的条件检查,只有当键值存储的当前状态满足预期条件时才继续应用变更,从而保证数据一致性、避免并发冲突。 查看帮助: ```bash etcdctl txn --help ``` ### 7.1 compare / If / Then / Else 结构 | 结构 | etcdctl 交互模式的段名 | proto 字段 | 语义 | | ---- | ---------------------- | ---------- | ---- | | 条件 | `compares:` | `TxnRequest.compare` | 谓词列表(**合取**),全部为真才走 Then | | 条件为真 | `success requests (get, put, delete):` | `TxnRequest.success` | compare 求值为 true 时按序应用 | | 条件为假 | `failure requests (get, put, delete):` | `TxnRequest.failure` | compare 求值为 false 时按序应用 | ### 7.2 变量与 flag(原样保留,含官方笔误) | 项 | 说明 | | -- | ---- | | `/users/{/email` | 表示该用户 email 地址的 etcd key(**原文如此,含多余左花括号**) | | `/users//phone` | 表示该用户电话号码的 etcd key | | `--interactive` | 允许手工输入事务数据的 flag | ### 7.3 etcdctl 交互式 txn 完整示例 步骤 1:准备初始数据 ```bash etcdctl put /users/12345/email "old.address@johndoe.com" etcdctl put /users/12345/phone "123-456-7890" ``` 步骤 2:执行事务 ```bash etcdctl txn --interactive compares: value("/users/12345/email") = "old.address@johndoe.com" success requests (get, put, delete): put /users/12345/email "new.address@johndoe.com" put /users/12345/phone "098-765-4321" failure requests (get, put, delete): get /users/12345/email ``` 三段对应关系: - **Compare**:检查 email 当前值是否为 `old.address@johndoe.com`,只有符合预期事务才继续; - **Success(Then)**:比较为真时,同时更新 email 与 phone; - **Failure(Else)**:比较失败时,读取 `/users/12345/email` 的当前值,以便判断事务为何没有执行。 ### 7.4 比较算子与比较目标 etcdctl 交互式写法使用 `=`、`!=`、`>`、`<`(源文示例中出现 `=`),对应 proto 的 `CompareResult` 与 `CompareTarget`: | 维度 | 取值 | 值 | | ---- | ---- | -- | | CompareResult(比较算子) | `EQUAL` | 0 | | CompareResult | `GREATER` | 1 | | CompareResult | `LESS` | 2 | | CompareResult | `NOT_EQUAL` | 3 | | CompareTarget(比较目标) | `VERSION` | 0 | | CompareTarget | `CREATE` | 1 | | CompareTarget | `MOD` | 2 | | CompareTarget | `VALUE` | 3 | > 引自:[etcd API](https://etcd.io/docs/v3.7/learning/api/) `Compare` 消息字段(v3.7 `api/etcdserverpb/rpc.proto`,见第 16 章): | 字段 | 类型 | 说明 | | ---- | ---- | ---- | | `result` | CompareResult | 本次比较的逻辑比较操作 | | `target` | CompareTarget | 要检查的 kv 字段 | | `key` | bytes | 比较操作的主体 key | | `target_union` | oneof | 比较目标的联合类型:`version` / `create_revision` / `mod_revision` / `value` | | `version` | int64 | 给定 key 的 version | | `create_revision` | int64 | 给定 key 的 creation revision | | `mod_revision` | int64 | 给定 key 的最后修改 revision | | `value` | bytes | 给定 key 的 value | | `lease` | int64 | 给定 key 的 lease id | | `range_end` | bytes | 将该 target 与 `[key, range_end)` 内所有 key 比较 | 对照到 etcdctl 交互式写法,就是"比较目标 + key"这一层: | 交互式写法 | 对应 target | 含义 | | ---------- | ----------- | ---- | | `value("") = ""` | `VALUE` | 比较 value | | `create_revision("") = N` | `CREATE` | 比较 create revision | | `mod_revision("") = N` | `MOD` | 比较 mod revision(CAS 常用) | | `version("") = N` | `VERSION` | 比较 version | ### 7.5 Txn 的 RequestOp / ResponseOp 与响应 | 消息 | 字段 | 类型 | 说明 | | ---- | ---- | ---- | ---- | | TxnRequest | `compare` | (slice of) Compare | 谓词列表(合取);全部成功则按序处理 `success` 并回各自响应,失败则按序处理 `failure` | | TxnRequest | `success` | (slice of) RequestOp | compare 为 true 时应用 | | TxnRequest | `failure` | (slice of) RequestOp | compare 为 false 时应用 | | RequestOp | `request` | oneof | `request_range` / `request_put` / `request_delete_range` / `request_txn` | | ResponseOp | `response` | oneof | `response_range` / `response_put` / `response_delete_range` / `response_txn` | | TxnResponse | `header` | ResponseHeader | 顶层 header | | TxnResponse | `succeeded` | bool | compare 求值为 true 则为 true | | TxnResponse | `responses` | (slice of) ResponseOp | 与所应用的 RequestOp 列表一一对应 | > 引自:[etcd API](https://etcd.io/docs/v3.7/learning/api/):**内层响应中的 `ResponseHeader` 不应以任何方式解读**;客户端若需获取最新 revision,应始终检查 `TxnResponse` 中**顶层**的 `ResponseHeader`。 其他硬性约束: - 事务在单个请求中原子处理多个请求;对修改操作,**存储 revision 只递增一次**,事务产生的所有事件拥有**相同 revision**; - **同一事务内多次修改同一个 key 是被禁止的**(`request_put` 的 key 必须唯一,不得与其他 Put 或 Delete 共享 key); - 对**不含嵌套 TXN** 的事务,操作执行顺序保证与其操作列表顺序相同(因此事务内的 GET 响应是稳定的);**含嵌套 TXN** 时执行顺序不做规定。 ### 7.6 官方三条重要注意事项 1. **原子性(Atomicity)**:两个更新要么一起生效,要么都不生效;初始条件不满足时两个更新都不会被应用。 2. **一致性(Consistency)**:用事务维护多个相关更新之间的数据一致性。 3. **避免对同一 key 多次 put**:不要在单个事务里对同一个 key 写入多个值,否则会产生非预期结果;每个 key 在一个事务中只应更新一次。 ### 7.7 Go Txn 写法 > 💡 补充:以下 Go 示例基于官方 API 语义编写(官方该页仅提供 etcdctl 交互式示例)。`clientv3.Compare(...)` 对应 `Compare` 消息,`OpPut` / `OpGet` / `OpDelete` 对应 `RequestOp`。 ```go // 场景:确认 email 仍为旧值,才同时更新 email 与 phone txnResp, err := cli.Txn(ctx). If(clientv3.Compare(clientv3.Value("/users/12345/email"), "=", "old.address@johndoe.com")). Then( clientv3.OpPut("/users/12345/email", "new.address@johndoe.com"), clientv3.OpPut("/users/12345/phone", "098-765-4321"), ). Else( clientv3.OpGet("/users/12345/email"), ).Commit() if err != nil { log.Fatal(err) } if txnResp.Succeeded { fmt.Println("txn applied, revision =", txnResp.Header.Revision) // 只看顶层 header } else { // 读 Else 分支里 OpGet 的结果,判断为何失败 for _, r := range txnResp.Responses { get := r.GetResponseRange() for _, kv := range get.Kvs { fmt.Printf("current email = %s (mod_revision=%d)\n", kv.Value, kv.ModRevision) } } } ``` ### 7.8 典型场景一:乐观锁 CAS(用 mod_revision) > 💡 补充:以下 Go 示例基于官方 API 语义编写,非官方文档原文。思路来自 `Compare.target = MOD` 的语义。 ```go // 读-改-写:只有在我读到的版本之后没人改过,才写入 getResp, err := cli.Get(ctx, "/config/feature-flag") if err != nil { log.Fatal(err) } if len(getResp.Kvs) == 0 { log.Fatal("key not found") } kv := getResp.Kvs[0] txnResp, err := cli.Txn(ctx). If(clientv3.Compare(clientv3.ModRevision("/config/feature-flag"), "=", kv.ModRevision)). Then(clientv3.OpPut("/config/feature-flag", "new-value")). Else(clientv3.OpGet("/config/feature-flag")). Commit() if err != nil { log.Fatal(err) } if !txnResp.Succeeded { // 被并发修改,重读重试即可 log.Println("CAS failed, retry") } ``` 要点: - 用 `mod_revision` 而不是 `value` 做比较,可以覆盖"A 改成 B、B 又改回 A"这种 ABA 场景; - 失败分支里直接 `OpGet` 拿最新值,省掉一次额外 RTT; - 事务成功后**只信顶层 header 的 revision**。 ### 7.9 典型场景二:库存扣减 > 💡 补充:以下 Go 示例基于官方 API 语义编写,非官方文档原文。 ```go // 库存扣减:判断余量足够再扣,整个“判断 + 扣减”在一个事务里 const key = "/sku/10001/stock" resp, err := cli.Get(ctx, key) if err != nil { log.Fatal(err) } cur := parseStock(resp.Kvs) // 当前库存 want := cur - 1 // 拟扣减后的值 if cur <= 0 { return ErrSoldOut } txnResp, err := cli.Txn(ctx). If( // 1) 库存没有被别人动过 clientv3.Compare(clientv3.ModRevision(key), "=", resp.Kvs[0].ModRevision), // 2) 值仍然等于我读到的值(双保险) clientv3.Compare(clientv3.Value(key), "=", formatStock(cur)), ). Then(clientv3.OpPut(key, formatStock(want))). Commit() if err != nil { log.Fatal(err) } if !txnResp.Succeeded { return ErrConcurrentUpdate // 让上层重试 } ``` 注意:etcd 事务里**没有算术运算**,"扣减"必须在客户端算好新值再写回,靠 `compare` 保证中间没有被别的写者插队;这就是典型的乐观并发控制(compare-and-swap)。超高并发下单点热点请配合批处理或分片,避免所有请求都打到同一 key。 --- ## 8. Watch 机制 how-to-watch-keys > 引自:[How to watch keys](https://etcd.io/docs/v3.7/tasks/developer/how-to-watch-keys/)、[Interacting with etcd](https://etcd.io/docs/v3.7/dev-guide/interacting_v3/)、[etcd API](https://etcd.io/docs/v3.7/learning/api/)、[etcd API guarantees](https://etcd.io/docs/v3.7/learning/api_guarantees/) **前提**:安装 `etcd` 与 `etcdctl`。用 `watch` 订阅未来的变更通知。 ```bash etcdctl watch $KEY [$END_KEY] ``` ### 8.1 watch 的 flag | flag | 默认值 | 含义 | | ---- | ------ | ---- | | `-i, --interactive` | `[=false]` | 交互模式,可连续输入多个 watch 请求 | | `--prefix` | `[=false]` | 监听整个前缀 | | `--rev` | `0` | 从哪个 revision 开始监听;**默认 0 表示只监听当前时刻之后的变更** | | `--prev-kv` | `[=false]` | 让事件里带上变更发生前的那个键值对 | | `--progress-notify` | `[=false]` | 让服务端周期性推送 watch 进度通知 | | `--endpoints` | `"127.0.0.1:2379"`(继承自父命令) | gRPC endpoints | ### 8.2 revision 语义与事件类型 - `--rev` 指定从哪个 revision 开始监听(默认 0,即只监听当前时刻之后的变更),这是 watch 的 revision 语义核心——指定历史 revision 可以**回放自该版本以来的全部事件**; - 整个可用事件历史可从**最后一次 compaction revision** 开始 watch; - 事件用 `Event` 消息表达: | 字段 | 类型 | 说明 | | ---- | ---- | ---- | | `type` | EventType | `PUT` = 有新数据存入该 key;`DELETE` = key 被删除 | | `kv` | KeyValue | PUT 含当前 kv 对,**`kv.Version=1` 的 PUT 表示 key 被创建**;DELETE / EXPIRE 含被删 key,其 `mod_revision` 设为删除时的 revision | | `prev_kv` | KeyValue | 事件发生前的 kv 对;为节省带宽,**仅当 watch 显式开启时才填充** | ### 8.3 官方 watch 示例 ```bash etcdctl --endpoints=$ENDPOINTS watch stock1 etcdctl --endpoints=$ENDPOINTS put stock1 1000 etcdctl --endpoints=$ENDPOINTS watch stock --prefix etcdctl --endpoints=$ENDPOINTS put stock1 10 etcdctl --endpoints=$ENDPOINTS put stock2 20 ``` 第一个 watch 只盯 `stock1`;第二个 watch 盯 `stock` 前缀,`put stock1 10` 与 `put stock2 20` 都会各自产生事件。 ```bash $ etcdctl watch foo # in another terminal: etcdctl put foo bar PUT foo bar $ etcdctl watch foo foo9 # 区间 [foo, foo9) PUT foo bar PUT foo1 bar1 ``` 历史回放(`--rev`): ```bash $ etcdctl put foo bar # revision = 2 $ etcdctl put foo1 bar1 # revision = 3 $ etcdctl put foo bar_new # revision = 4 $ etcdctl put foo1 bar1_new # revision = 5 # watch for changes on key `foo` since revision 2 $ etcdctl watch --rev=2 foo PUT foo bar PUT foo bar_new # watch for changes on key `foo` since revision 3 $ etcdctl watch --rev=3 foo PUT foo bar_new ``` 带变更前值(`--prev-kv`): ```bash $ 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 ``` 交互模式监听多个 key,以及用 `progress` 命令查询进度: ```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 ``` ```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 ``` > 注意(官方 alert):progress notify 响应里的 revision 来自**该 watch 流所连接的本地 etcd 节点**。若该节点被分区、不在 quorum 内,这个 progress notify revision **可能低于**对非分区节点做 quorum 读得到的 revision。 ### 8.4 WatchCreateRequest 字段 | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ------ | | `key` | bytes | 要注册的监听 key | — | | `range_end` | bytes | 监听区间 `[key, range_end)` | 未给 = 仅 key;`'\0'` = 所有 >= key;key+1 = 前缀 | | `start_revision` | int64 | 从该 revision 开始监听(**含**) | 未给 = "now" | | `progress_notify` | bool | 无近期事件时服务端周期性发送无事件的 WatchResponse;频率由服务端按当前负载决定 | `false` | | `filters` | (slice of) FilterType | 服务端下发前过滤事件(`NOPUT` / `NODELETE`) | — | | `prev_kv` | bool | 获取事件发生前的上一个 KV;**若已被压缩则不返回** | `false` | | `watch_id` | int64 | 若提供且非 0,则赋给该 watcher(创建非同步,用于保证同一流上多 watcher 的顺序);复用已用 ID 会返回错误 | `0` = 服务端分配 | | `fragment` | bool | 将大 revision 拆分为多个 watch 响应 | `false` | ### 8.5 WatchResponse 字段 | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ------ | | `header` | ResponseHeader | — | — | | `watch_id` | int64 | 该响应对应的 watcher ID | — | | `created` | bool | 是 create watch 请求的响应则为 true;客户端应记录 `watch_id`,后续事件同 `watch_id` | `false` | | `canceled` | bool | 是 cancel 请求、或 `start_revision` 已被压缩时为 true;此后不再发送事件 | `false` | | `compact_revision` | int64 | 在已压缩 index 上监听时被设为最小 index;客户端应视为 canceled 且**不应再用同一 `start_revision` 创建 watcher** | `0` | | `cancel_reason` | string | 取消 watcher 的原因 | — | | `fragment` | bool | 大 watch 响应被拆分时为 true | `false` | | `events` | (slice of) mvccpb.Event | 事件列表 | — | 取消:`WatchCancelRequest { int64 watch_id = 1; }`——传入要取消的 watch ID,取消后不再传输事件。 ### 8.6 watch 的六条保证 + 一条例外 > 引自:[etcd API guarantees](https://etcd.io/docs/v3.7/learning/api_guarantees/) | # | 保证 | 含义 | | - | ---- | ---- | | 1 | Ordered(有序) | 事件按 revision 排序;不含嵌套 TXN 的事务,事件顺序与操作列表顺序相同;含嵌套 TXN 则不做规定 | | 2 | Unique(唯一) | 一个事件绝不会在同一 watch 上出现两次 | | 3 | Reliable(可靠) | 在可用历史窗口内,事件序列不会丢弃子序列:收到 a 和 c 则保证也收到 b | | 4 | Atomic(原子) | 事件列表保证涵盖**完整的 revision**;同一 revision 对多个 key 的更新不会被拆分到多个事件列表 | | 5 | Resumable(可恢复) | 中断的 watch 可用**新的 watch** 恢复,起点为断连前收到的最后一个 revision **之后**(只要仍在历史窗口内) | | 6 | Bookmarkable(可打书签) | Progress notification 保证截止到某一 revision 的所有事件都已投递完毕 | **重要例外**:etcd **不保证 watch 操作的线性一致性**。用户应自行校验 watch 事件的 revision,以确保与其他操作的正确排序。 时延特性:调用 KV API 会**立即生效**,而 Watch API 的返回带有**无界的延迟**——正常工作的集群中事件应在发生后约 **10ms** 出现,但**没有上限**,不健康的集群中事件可能永远不到达。 ### 8.7 断线重连与 revision 续传 正确姿势(依据 Resumable + Bookmarkable 两条保证): 1. 建立 watch 时记录 `header.revision`(或 `progress-notify` 推送的 revision); 2. 每处理完一个事件,把 `event.Kv.ModRevision` 记为 `lastRev`; 3. 断线后,**不要**从 `lastRev` 起,而是从 `lastRev + 1` 起重新 watch; 4. 若服务端返回 `canceled=true` 且 `compact_revision != 0`,说明你要的起点已被压缩,**不能**用同一 `start_revision` 重建,必须全量 `Get` 一次拿当前快照,再从其 `header.revision + 1` 继续 watch; 5. 起点被压缩的另一种表现是 `ErrCompacted`(`etcdserver: mvcc: required revision has been compacted`)。 ### 8.8 Go WatchChannel 用法 > 💡 补充:以下 Go 示例基于官方 API 语义编写(官方该页仅提供 etcdctl 示例)。 ```go // 从当前时刻开始监听前缀 watchChan := cli.Watch(ctx, "stock", clientv3.WithPrefix()) for wresp := range watchChan { for _, ev := range wresp.Events { switch ev.Type { case clientv3.EventTypePut: fmt.Printf("PUT %s = %s (rev=%d, version=%d)\n", ev.Kv.Key, ev.Kv.Value, ev.Kv.ModRevision, ev.Kv.Version) case clientv3.EventTypeDelete: fmt.Printf("DELETE %s (mod_rev=%d)\n", ev.Kv.Key, ev.Kv.ModRevision) } if ev.PrevKv != nil { // 需要 WithPrevKV() fmt.Printf(" prev value = %s\n", ev.PrevKv.Value) } } } ``` 带历史回放与 prev-kv: > 💡 补充:以下 Go 示例基于官方 API 语义编写,非官方文档原文。 ```go rch := cli.Watch(ctx, "foo", clientv3.WithRev(2), // 从 revision 2 开始(含) clientv3.WithPrevKV(), // 带变更前的值 clientv3.WithProgressNotify(), // 周期性 progress 通知 ) for wresp := range rch { if wresp.Canceled { // 被取消:常见原因是起点被压缩或消费者太慢 log.Printf("watch canceled: reason=%s compact_revision=%d", wresp.CancelReason, wresp.CompactRevision) return } if wresp.IsProgressNotify() { log.Printf("progress: revision=%d", wresp.Header.Revision) // 可打书签 continue } for _, ev := range wresp.Events { handle(ev) lastRev = ev.Kv.ModRevision // 记录续传起点 } } ``` ### 8.9 watch 泄漏与慢消费者的坑 | 坑 | 现象 | 规避 | | -- | ---- | ---- | | watch 泄漏 | 业务代码忘记 `cancel()` 或 channel 无人消费,watcher 在服务端一直存在 | 每个 watch 都绑定一个明确生命周期的 `context`;退出路径上 `cancel()` | | 慢消费者被取消 | 客户端处理速度跟不上事件产生速度,服务端缓冲积压,最终**取消该 watcher** 并在 `compact_revision` 里给出可用最小 revision;`cancel_reason` 说明原因 | watch 与业务处理解耦:watch 协程只负责把事件丢进有界队列,业务另起消费者;监控 `Canceled` / `CompactRevision` | | 起点被压缩后重复失败 | 用旧的 `start_revision` 无限重建 watch,一直拿到 canceled | 一旦 `Canceled && CompactRevision != 0`,改为"全量 Get + 从新 revision 续 watch" | | 误把 progress revision 当全局最新 | 该 revision 来自**本地节点**,节点被分区时会偏低 | 关键路径用 quorum 读(默认 linearizable)校验 | | 单 watch 流塞太多 key | 一个 stream 上多 watcher 虽然省连接,但顺序与背压相互影响 | 高价值 key 独立 watch;同一 stream 内可用 `watch_id` 保证顺序 | | watch 不保证线性一致 | 事件顺序可靠,但与其它操作之间的实时先后无保证 | 业务侧自行比对 revision | ### 8.10 watch 驱动本地缓存的实践要点 > 工程建议(非官方原文,依据 watch 的六条保证与 `WatchResponse` 字段语义推导)。 标准姿势是"**先全量、后增量**": 1. 启动时先 `Get` 一次全量数据,记下响应的 `header.revision`(记为 `rev0`); 2. 立刻用 `WithRev(rev0 + 1)` 建立 watch——注意是 **+1**,否则会重复收到 `rev0` 那一次的事件(幂等的话重复也无害,但会浪费一次处理); 3. 之后所有事件按 `ModRevision` 顺序应用到本地缓存; 4. 断线后从"最后处理成功的 revision + 1"重连; 5. 一旦收到 `Canceled=true` 且 `CompactRevision != 0`,说明中间的历史已被压缩,必须回到第 1 步全量重建; 6. 用 `WithProgressNotify()` 定期拿到"截止某 revision 已投递完毕"的书签,用于判断缓存是否已经追上(注意该 revision 来自**本地节点**,节点被分区时可能偏低,关键路径要用 quorum 读校验)。 另外两条硬约束:watch **不保证线性一致性**,所以它只能保证"事件顺序正确",不能保证"事件与你在别处发起的读操作之间的实时先后";watch 事件的到达**没有时间上限**,不要用"多久没收到事件"来判断"没有变更",要用 progress notify 或主动读。 --- ## 9. Lease 租约 how-to-create-lease > 引自:[How to create lease](https://etcd.io/docs/v3.7/tasks/developer/how-to-create-lease/)、[Interacting with etcd](https://etcd.io/docs/v3.7/dev-guide/interacting_v3/)、[etcd API](https://etcd.io/docs/v3.7/learning/api/) 用 `lease` 实现带 TTL 的写入。 ```bash etcdctl --endpoints=$ENDPOINTS lease grant 300 # lease 2be7547fbc6a5afa granted with TTL(300s) etcdctl --endpoints=$ENDPOINTS put sample value --lease=2be7547fbc6a5afa etcdctl --endpoints=$ENDPOINTS get sample etcdctl --endpoints=$ENDPOINTS lease keep-alive 2be7547fbc6a5afa etcdctl --endpoints=$ENDPOINTS lease revoke 2be7547fbc6a5afa # or after 300 seconds etcdctl --endpoints=$ENDPOINTS get sample ``` ### 9.1 流程与语义(逐步) | 步 | 命令 | 语义 | | -- | ---- | ---- | | 1 | `lease grant 300` | 申请 TTL 为 300 秒的租约,返回租约 ID `2be7547fbc6a5afa`(输出 `lease 2be7547fbc6a5afa granted with TTL(300s)`) | | 2 | `put sample value --lease=2be7547fbc6a5afa` | 把 key `sample` 绑定到该租约;`--lease` 指定租约 ID,租约到期时该 key 会被自动删除 | | 3 | `get sample` | 在租约有效期内可以读到该 key | | 4 | `lease keep-alive 2be7547fbc6a5afa` | 持续为该租约续期(**需客户端保持运行**,否则租约仍会到期) | | 5 | `lease revoke 2be7547fbc6a5afa` | 主动撤销租约,绑定其上的 key 立即失效;不撤销则 300 秒后同样失效,此时 `get sample` 读不到内容 | ### 9.2 lease 子命令一览(官方示例覆盖范围内) | 子命令 | 示例 | 输出 / 语义 | | ------ | ---- | ----------- | | `lease grant ` | `etcdctl lease grant 60` | `lease 32695410dcc0ca06 granted with TTL(60s)` | | `lease revoke ` | `etcdctl lease revoke 32695410dcc0ca06` | `lease 32695410dcc0ca06 revoked`;所有附加 key 被删除,`get foo` 返回空 | | `lease keep-alive ` | `etcdctl lease keep-alive 32695410dcc0ca06` | `lease 32695410dcc0ca06 keepalived with TTL(60)`(持续输出,每轮一行) | | `lease timetolive ` | `etcdctl lease timetolive 694d5765fc71500b` | `lease 694d5765fc71500b granted with TTL(500s), remaining(258s)` | | `lease timetolive --keys ` | `etcdctl lease timetolive --keys 694d5765fc71500b` | `lease 694d5765fc71500b granted with TTL(500s), remaining(132s), attached keys([zoo2 zoo1])` | | 过期 / 不存在 | — | `Error: etcdserver: requested lease not found` | > 关于"一次性续期":v3.7 官方页面**没有**给出 `etcdctl lease keep-alive --once` 这类 flag 的示例;一次性续期在 Go client 侧对应 `clientv3.KeepAliveOnce`(见 9.5,标注为补充)。gRPC 层只有双向流的 `LeaseKeepAlive`。 ### 9.3 Lease 机制要点 - Lease 是检测客户端**存活(liveness)**的机制;集群授予 lease 一个 TTL,**在给定 TTL 周期内未收到 keepAlive 即过期**; - 每个 key **最多只能附加一个 lease**; - lease 过期或被撤销时,附加到该 lease 的所有 key 都会被删除;**每个过期的 key 都会在事件历史中生成一个 delete 事件**(因此 watch 能观察到租约到期); - 保活走 `LeaseKeepAlive` 建立的**双向流**:客户端发 `LeaseKeepAliveRequest { int64 ID = 1; }`,服务端回 `LeaseKeepAliveResponse { header, ID, TTL }`(`TTL` 为刷新后的剩余存活秒数)。 ### 9.4 Lease 相关字段表 | 消息 | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ---- | ------ | | LeaseGrantRequest | `TTL` | int64 | 建议的 time-to-live,单位**秒**;过期的 lease 返回 **-1** | — | | LeaseGrantRequest | `ID` | int64 | 请求的 lease ID | `0` = 由 lessor 选择 | | LeaseGrantResponse | `header` / `ID` / `TTL` / `error` | — | `ID` 授予的 lease ID;`TTL` 服务端选定的 TTL 秒数;`error` 为 string(源文未给取值说明) | — | | LeaseRevokeRequest | `ID` | int64 | 要撤销的 lease ID;撤销后关联 key 全部删除 | — | | LeaseKeepAliveRequest | `ID` | int64 | 要保活的 lease ID | — | | LeaseKeepAliveResponse | `header` / `ID` / `TTL` | — | `TTL` 为新的 TTL | — | | LeaseTimeToLiveRequest | `ID` | int64 | 该 lease 的 ID | — | | LeaseTimeToLiveRequest | `keys` | bool | `true` 则查询附加到该 lease 的所有 key | `false` | | LeaseTimeToLiveResponse | `header` / `ID` / `TTL` / `grantedTTL` / `keys` | — | `TTL` 剩余秒数(将在 TTL+1 秒内过期);`grantedTTL` 创建/续期时授予的初始秒数;`keys` 为 (slice of) bytes | — | | LeaseLeasesResponse | `header` / `leases` | — | `leases` 为 (slice of) LeaseStatus,LeaseStatus 仅有 `ID` | — | | LeaseCheckpoint | `ID` / `remaining_TTL` | int64 | 要 checkpoint 的 lease ID / 到期前剩余时间 | — | ### 9.5 Go Lease 示例(含一次性续期) > 💡 补充:以下 Go 示例基于官方 API 语义编写(官方该页仅提供 etcdctl 示例)。 ```go // 1) 创建 60 秒租约 grantResp, err := cli.Grant(ctx, 60) if err != nil { log.Fatal(err) } leaseID := grantResp.ID // 2) 绑定 key if _, err := cli.Put(ctx, "sample", "value", clientv3.WithLease(leaseID)); err != nil { log.Fatal(err) } // 3) 持续保活:拿到的是一个 channel,需要持续读取 ka, err := cli.KeepAlive(ctx, leaseID) // 返回 <-chan *LeaseKeepAliveResponse if err != nil { log.Fatal(err) } go func() { for resp := range ka { log.Printf("keepalived, new TTL = %d", resp.TTL) } }() // 4) 一次性续期(阻塞一次,返回刷新后的 TTL) if _, err := cli.KeepAliveOnce(ctx, leaseID); err != nil { log.Fatal(err) } // 5) 查询租约信息(含附加 key) ttlResp, err := cli.TimeToLive(ctx, leaseID, clientv3.WithAttachedKeys()) if err != nil { log.Fatal(err) } log.Printf("grantedTTL=%d ttl=%d keys=%v", ttlResp.GrantedTTL, ttlResp.TTL, ttlResp.Keys) // 6) 主动撤销(其上 key 立即删除) if _, err := cli.Revoke(ctx, leaseID); err != nil { log.Fatal(err) } ``` ### 9.6 keepalive 的心跳间隔坑 | 坑 | 说明 | 建议 | | -- | ---- | ---- | | 心跳间隔与 TTL 的关系 | TTL 是"至少"值,由 etcd 集群选定;keepalive 必须在 TTL 内到达 | 续约周期取 TTL 的 1/3 甚至更小,留出重试余量 | | 只有客户端在跑才续得上 | 官方明确:`keep-alive` 需客户端保持运行,否则租约仍会到期 | 把 keepalive 协程与业务进程生命周期绑定,进程退出要主动 `Revoke` 或等 TTL | | GC / STW / 网络抖动 | 续约请求迟到即过期,key 被删、watch 收到 delete 事件 | TTL 不要设得太紧(秒级 TTL 风险高),并对 delete 事件做"误删"补偿逻辑 | | 读 keepalive channel | 不消费 keepalive 响应 channel 会导致续约响应堆积 | 有 goroutine 持续读,或直接用 `KeepAliveOnce` 自己做定时循环 | | 崩溃循环的服务端不释放 lease | 官方文档记录:TTL 以秒为单位持久化,`LeaseCheckpoint` 只扩展 `RemainingTTL`,**崩溃循环(crash-looping)的服务器不会释放 lease** | 不要依赖"服务端重启来清理 lease";客户端自己保证 `Revoke` | | 分区节点 | keepalive 流可能连在被分区的节点上(HTTP/2 ping 不理解分区) | 配合 endpoint 切换与超时,必要时重建 client | --- ## 10. 分布式锁与选主 how-to-create-locks > 引自:[How to create locks](https://etcd.io/docs/v3.7/tasks/developer/how-to-create-locks/)、[How to conduct leader election in etcd cluster](https://etcd.io/docs/v3.7/tasks/operator/how-to-conduct-elections/)、[API reference: concurrency](https://etcd.io/docs/v3.7/dev-guide/api_concurrency_reference_v3/)、[etcd API](https://etcd.io/docs/v3.7/learning/api/) ### 10.1 etcdctl lock `LOCK` 以给定名字获取一个**分布式互斥锁(distributed mutex)**。一旦拿到锁就会一直持有,**直到 etcdctl 进程终止**——这是官方页给出的 unlock 语义:锁的释放由进程退出(进而租约过期)触发,而非显式命令。 ```bash etcdctl --endpoints=$ENDPOINTS lock mutex1 ``` | 选项 | 说明 | | ---- | ---- | | `endpoints` | 集群中机器地址的逗号分隔列表 | | `ttl` | 锁会话的超时时间(秒) | ### 10.2 Lock service(gRPC)字段与语义 | 方法 | 请求消息 | 响应消息 | 用途 | | ---- | -------- | -------- | ---- | | `Lock` | LockRequest | LockResponse | 在给定命名的锁上获取分布式共享锁;成功返回一个在持锁期间一直存在的唯一 key,可配合事务保证更新只在持锁期间发生;锁一直持有到对该 key 调用 `Unlock` 或持有者关联 lease 过期 | | `Unlock` | UnlockRequest | UnlockResponse | 传入 `Lock` 返回的 key 释放锁;下一个等待该锁的 `Lock` 调用者被唤醒并获得锁所有权 | | 消息 | 字段 | 类型 | 说明 | | ---- | ---- | ---- | ---- | | LockRequest | `name` | bytes | 要获取的分布式共享锁的标识 | | LockRequest | `lease` | int64 | 绑定到锁所有权的 lease ID;**lease 过期或被撤销且正持有锁时锁自动释放**;**同一 lease 重复 Lock 视为单次获取,重复加锁是 no-op** | | LockResponse | `header` | etcdserverpb.ResponseHeader | — | | LockResponse | `key` | bytes | Lock 调用者持锁期间在 etcd 上存在的 key;**用户不应修改它,否则锁行为未定义** | | UnlockRequest | `key` | bytes | Lock 授予的锁所有权 key | | UnlockResponse | `header` | etcdserverpb.ResponseHeader | — | ### 10.3 Election service(gRPC)字段与语义 | 方法 | 请求消息 | 响应消息 | 用途 | 语义要点 | | ---- | -------- | -------- | ---- | -------- | | `Campaign` | CampaignRequest | CampaignResponse | 等待获取选举的领导权,成功返回表示领导权的 `LeaderKey` | `LeaderKey` 可用于发布新值、事务性地守卫"仍持有领导权"的 API 请求、以及退出选举 | | `Proclaim` | ProclaimRequest | ProclaimResponse | 用新值更新 leader 已发布的值 | — | | `Leader` | LeaderRequest | LeaderResponse | 返回当前选举公告(若有) | — | | `Observe` | LeaderRequest | LeaderResponse | 按当选 leader 发布顺序,**流式推送**选举公告 | 请求/响应消息与 `Leader` 相同,区别在流式 | | `Resign` | ResignRequest | ResignResponse | 释放选举领导权,使其他竞选者可获取该选举的领导权 | — | | 消息 | 字段 | 类型 | 说明 | | ---- | ---- | ---- | ---- | | CampaignRequest | `name` | bytes | 本次竞选的选举标识 | | CampaignRequest | `lease` | int64 | 绑定到选举领导权的 lease ID;**在 resign 之前 lease 过期或被撤销,领导权转移给下一个竞选者(若有)** | | CampaignRequest | `value` | bytes | 竞选者胜出时设置的初始公告值 | | CampaignResponse | `header` / `leader` | — | `leader` 为 LeaderKey,描述持有该选举领导权所用资源 | | LeaderRequest | `name` | bytes | 要查询领导权信息的选举标识 | | LeaderResponse | `header` / `kv` | — | `kv` 为代表最新 leader 更新的 mvccpb.KeyValue | | ProclaimRequest | `leader` / `value` | LeaderKey / bytes | 对该选举的持有权 / 用于覆盖 leader 当前值的更新 | | ResignRequest | `leader` | LeaderKey | 要通过辞职让出的领导权 | `LeaderKey`: | 字段 | 类型 | 说明 | | ---- | ---- | ---- | | `name` | bytes | 与领导权 key 对应的选举标识 | | `key` | bytes | 表示选举所有权的不透明 key;**key 被删除则失去领导权** | | `rev` | int64 | key 的创建 revision;**可在事务中通过比对 key 的创建 revision 与 `rev` 来检验是否拥有该选举** | | `lease` | int64 | 选举 leader 的 lease ID | ### 10.4 etcdctl elect(选主命令) ```bash etcdctl --endpoints=$ENDPOINTS elect [proposal] ``` | 项 | 说明 | | -- | ---- | | `--endpoints` | 每个 etcd 集群成员的地址 | | `election-name` | 选举的字符串标识;**所有参与竞选者必须使用同一个选举名** | | `leader-name`(proposal) | 新 leader 的提案值 | ```bash ./etcdctl elect my-election proposal1 my-election/694d99fafea88404 proposal1 another election: ./etcdctl elect new-election proposal1 new-election/694d99fafea8840f proposal1 ``` > 注意:`elect` 是**阻塞式**的——客户端成为 leader 后命令保持持有,退出即释放领导权。 ### 10.5 Go concurrency 包:Session / Mutex / Election > 💡 补充:etcd v3.7 的 `tasks/developer/how-to-create-locks` 页面**没有 Go 代码**,concurrency 参考页只描述 gRPC service(`Lock` / `Election`)。下面 Go 示例基于上述官方 API 语义与 `go.etcd.io/etcd/client/v3/concurrency` 的公开 API 编写,字段与语义以 [pkg.go.dev](https://pkg.go.dev/go.etcd.io/etcd/client/v3/concurrency) 为准。 ```go // 1) 建立带 TTL 的会话(Session 内部会创建 lease 并自动 keepalive) s, err := concurrency.NewSession(cli, concurrency.WithTTL(15)) if err != nil { log.Fatal(err) } defer s.Close() // Close 会撤销 lease,锁自动释放 // 2) 互斥锁 m := concurrency.NewMutex(s, "/locks/order-create") if err := m.Lock(ctx); err != nil { // 阻塞直到拿到锁 log.Fatal(err) } defer func() { if err := m.Unlock(ctx); err != nil { // 释放,唤醒下一个等待者 log.Print(err) } }() doCriticalWork() ``` > 💡 补充:接上例,以下选主示例同样基于官方 `Election` service 语义编写,非官方文档原文。 ```go // 3) 选主 e := concurrency.NewElection(s, "/election/order-worker") if err := e.Campaign(ctx, "node-1"); err != nil { // 阻塞直到成为 leader log.Fatal(err) } leaderRev := e.Rev() // LeaderKey.rev,可在事务里守卫领导权 // 成为 leader 后更新公告值 if err := e.Proclaim(ctx, "node-1@10.0.0.1:8080"); err != nil { log.Fatal(err) } // 观察 leader 变化(Observe 为流式) ch := e.Observe(ctx) go func() { for resp := range ch { for _, kv := range resp.Kvs { log.Printf("leader = %s (mod_rev=%d)", kv.Value, kv.ModRevision) } } }() // 主动放弃领导权,让下一个竞选者接手 if err := e.Resign(ctx); err != nil { log.Fatal(err) } ``` `Leader()` 用于查询当前选举公告(对应 gRPC `Leader` 方法),`e.Key()` 返回 `LeaderKey.key`。 ### 10.6 锁的公平性与 session 过期风险 | 议题 | 官方语义 / 结论 | | ---- | --------------- | | 公平性 | etcd 客户端的**分布式共享锁使用 create revision 来等待锁归属**——等待者按各自 key 的 create revision 排队,先到先得;`Unlock` 时"下一个等待该锁的 Lock 调用者被唤醒并获得锁所有权" | | 锁的释放条件 | 对 `LockResponse.key` 调用 `Unlock`,**或**持有者关联 lease 过期/被撤销 | | 同一 lease 重复加锁 | 视为**单次获取**,重复加锁是 **no-op**(不要指望用它做重入计数) | | 不要改锁 key | `LockResponse.key` 是锁所有权凭证,**用户不应修改它,否则锁行为未定义** | | session 过期风险 | Session 的 TTL 就是锁的 TTL:进程 GC 停顿、网络分区、keepalive 迟到 → lease 过期 → **锁自动释放,而业务可能还在临界区里**。必须做" fencing":在写操作的事务里用 `LeaderKey.rev`(比对 key 的 create revision 与 `rev`)或锁 key 的 create revision 做守卫,确认仍持有锁 | | 选主过期风险 | `Campaign` 的 lease 在 resign 之前过期或被撤销,**领导权转移给下一个竞选者(若有)**;旧 leader 必须通过守卫事务发现这一点并停止工作 | | TTL 取值 | 太短容易误释放,太长故障切换慢;需在"故障检测速度"与"抖动容忍度"之间权衡 | --- ## 11. gRPC 直连与命名发现 grpc_naming > 引自:[gRPC naming and discovery](https://etcd.io/docs/v3.7/dev-guide/grpc_naming/) etcd 提供一个 **gRPC resolver**,支持一套替代性命名系统:从 etcd 获取 endpoint 来发现 gRPC 服务。**底层机制基于监听以服务名为前缀的 key 的更新**。 > 注意:该特性是 **experimental(实验性)** 的,因为它依赖 `google.golang.org/grpc/resolver` 包,而该包在 grpc-go 中仍处于实验状态。 ### 11.1 初始化 resolver(官方原文 Go 代码) > 💡 补充:以下代码引自官方页面原文(非本手册杜撰)——[gRPC naming and discovery](https://etcd.io/docs/v3.7/dev-guide/grpc_naming/)。 ```go import ( clientv3 "go.etcd.io/etcd/client/v3" etcdnaming "go.etcd.io/etcd/client/v3/naming/resolver" "google.golang.org/grpc" ) ... cli, err := clientv3.NewFromURL("http://localhost:2379") if err != nil { // ... } r, err := etcdnaming.NewBuilder(cli) if err != nil { // ... } conn, gerr := grpc.NewClient("my-service", grpc.WithResolvers(r), ...) ``` ### 11.2 endpoint 的组织格式 etcd resolver 把**解析目标前缀后跟一个 "/"** 下的所有 key(例如 `"foo/bar/my-service/"`)视为潜在服务端点,其值是 **JSON 编码**的(历史上为 go-grpc 的 `naming.Update`)。**创建 key 即加入 endpoint,删除 key 即移除 endpoint**。 关于"endpoint 格式",源文实际出现的两种 `grpc.NewClient` 目标写法: | 写法 | 出处 | | ---- | ---- | | `grpc.NewClient("my-service", grpc.WithResolvers(r), ...)` | 与 go-grpc 一起使用的初始化示例 | | `grpc.NewClient("etcd:///foo", grpc.WithResolvers(etcdResolver), grpc.WithDefaultServiceConfig(...))` | 多 endpoint 轮询负载均衡示例 | > 说明:本手册不额外假设 `etcd:///` 之外的具体 scheme 语义;实际解析规则以源文所述"**解析目标前缀 + `/` 下的所有 key**"为准(例如目标前缀 `foo/bar/my-service` 对应 `foo/bar/my-service/` 下的所有 key)。 ### 11.3 添加 endpoint etcdctl: ```bash ETCDCTL_API=3 etcdctl put foo/bar/my-service/1.2.3.4 '{"Addr":"1.2.3.4"}' ``` Go(`endpoints.Manager`,官方原文): > 💡 补充:以下代码引自官方页面原文(非本手册杜撰)——[gRPC naming and discovery](https://etcd.io/docs/v3.7/dev-guide/grpc_naming/)。 ```go em := endpoints.NewManager(client, "foo/bar/my-service") err := em.AddEndpoint(context.TODO(),"foo/bar/my-service/e1", endpoints.Endpoint{Addr:"1.2.3.4"}) ``` 服务有多个 endpoint 时用 grpc 内置的 **round-robin** 负载均衡器: > 💡 补充:以下代码引自官方页面原文(非本手册杜撰)——[gRPC naming and discovery](https://etcd.io/docs/v3.7/dev-guide/grpc_naming/)。 ```go conn, gerr := grpc.NewClient("etcd:///foo", grpc.WithResolvers(etcdResolver), grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`)) ``` ### 11.4 删除 endpoint ```bash ETCDCTL_API=3 etcdctl del foo/bar/my-service/1.2.3.4 ``` > 💡 补充:以下代码引自官方页面原文(非本手册杜撰)——[gRPC naming and discovery](https://etcd.io/docs/v3.7/dev-guide/grpc_naming/)。 ```go em := endpoints.NewManager(client, "foo/bar/my-service") err := em.DeleteEndpoint(context.TODO(), "foo/bar/my-service/e1") ``` ### 11.5 带 lease 注册 endpoint 带 lease 注册可保证:当主机无法维持 keepalive 心跳(例如机器故障)时,它会被从服务中移除。 ```bash lease=`ETCDCTL_API=3 etcdctl lease grant 5 | cut -f2 -d' '` ETCDCTL_API=3 etcdctl put --lease=$lease my-service/1.2.3.4 '{"Addr":"1.2.3.4"}' ETCDCTL_API=3 etcdctl lease keep-alive $lease ``` > 💡 补充:以下代码引自官方页面原文(非本手册杜撰)——[gRPC naming and discovery](https://etcd.io/docs/v3.7/dev-guide/grpc_naming/)。 ```go em := endpoints.NewManager(client, "foo/bar/my-service") err := em.AddEndpoint(context.TODO(), "foo/bar/my-service/e1", endpoints.Endpoint{Addr:"1.2.3.4"}) ``` > 说明:源文在"带 lease 注册"小节的 Go 侧写法与 11.3 的 `AddEndpoint` 完全相同;要让 key 随主机下线而消失,实践上需把该 key 绑到 lease 上(结合第 9 章的 lease 用法)。 ### 11.6 在单个事务里原子更新 endpoints > 💡 补充:以下代码引自官方页面原文(非本手册杜撰);源文该代码块未标注语言,本手册按 Go 处理——[gRPC naming and discovery](https://etcd.io/docs/v3.7/dev-guide/grpc_naming/)。 ```go em := endpoints.NewManager(c, "foo") err := em.Update(context.TODO(), []*endpoints.UpdateWithOpts{ endpoints.NewDeleteUpdateOpts("foo/bar/my-service/e1", endpoints.Endpoint{Addr: "1.2.3.4"}), endpoints.NewAddUpdateOpts("foo/bar/my-service/e1", endpoints.Endpoint{Addr: "1.2.3.14"})}) ``` ### 11.7 与 gRPC proxy 的关系 gRPC proxy 的 **`--resolver-prefix` 注册正是作为 etcd gRPC naming 的 endpoint provider**(见第 13 章),两者配套使用可让客户端自动发现一组 proxy endpoint。 --- ## 12. gRPC-gateway api_grpc_gateway > 引自:[Why gRPC gateway](https://etcd.io/docs/v3.7/dev-guide/api_grpc_gateway/) ### 12.1 作用 etcd v3 的消息协议使用 gRPC。etcd 项目提供基于 gRPC 的 Go client 和命令行工具 etcdctl。对于**没有 gRPC 支持的语言**,etcd 提供 JSON **gRPC gateway**:它是一个 RESTful 代理,把 **HTTP/JSON 请求翻译成 gRPC 消息**。 ### 12.2 映射规则与注意事项 - gateway 接受针对 etcd protocol buffer 消息定义的 **JSON mapping**; - 关键点:**`key` 和 `value` 字段被定义为 byte array,因此在 JSON 中必须 base64 编码**; - 示例均用 `curl`,但任何 HTTP/JSON 客户端都同样可用; - **gRPC-gateway 不支持使用 TLS Common Name 做认证**。 endpoint 路径随版本变化: | 版本 | 可用路径 | | ---- | -------- | | etcd v3.2 及之前 | 仅 `[CLIENT-URL]/v3alpha/*` | | etcd v3.3 | `[CLIENT-URL]/v3beta/*`,同时保留 `[CLIENT-URL]/v3alpha/*` | | etcd v3.4 | `[CLIENT-URL]/v3/*`,同时保留 `[CLIENT-URL]/v3beta/*`;**`[CLIENT-URL]/v3alpha/*` 已废弃** | | etcd v3.5 及之后 | 仅 `[CLIENT-URL]/v3/*`;**`[CLIENT-URL]/v3beta/*` 已废弃** | ### 12.3 Put 和 get keys 用 `/v3/kv/range` 与 `/v3/kv/put` 读写 key(`foo` 的 base64 是 `Zm9v`,`bar` 是 `YmFy`): ```bash curl -L http://localhost:2379/v3/kv/put \ -X POST -d '{"key": "Zm9v", "value": "YmFy"}' # {"header":{"cluster_id":"12585971608760269493","member_id":"13847567121247652255","revision":"2","raft_term":"3"}} curl -L http://localhost:2379/v3/kv/range \ -X POST -d '{"key": "Zm9v"}' # {"header":{"cluster_id":"12585971608760269493","member_id":"13847567121247652255","revision":"2","raft_term":"3"},"kvs":[{"key":"Zm9v","create_revision":"2","mod_revision":"2","version":"1","value":"YmFy"}],"count":"1"} # get all keys prefixed with "foo" curl -L http://localhost:2379/v3/kv/range \ -X POST -d '{"key": "Zm9v", "range_end": "Zm9w"}' # {"header":{"cluster_id":"12585971608760269493","member_id":"13847567121247652255","revision":"2","raft_term":"3"},"kvs":[{"key":"Zm9v","create_revision":"2","mod_revision":"2","version":"1","value":"YmFy"}],"count":"1"} ``` 前缀查询通过 `range_end` 实现(`Zm9v` 的下一值 `Zm9w`,即 `foo` → `fop`)。 ### 12.4 Watch keys 用 `/v3/watch` 服务 watch key(注意用 `curl -N` 且后台运行): ```bash curl -N http://localhost:2379/v3/watch \ -X POST -d '{"create_request": {"key":"Zm9v"} }' & # {"result":{"header":{"cluster_id":"12585971608760269493","member_id":"13847567121247652255","revision":"1","raft_term":"2"},"created":true}} curl -L http://localhost:2379/v3/kv/put \ -X POST -d '{"key": "Zm9v", "value": "YmFy"}' >/dev/null 2>&1 # {"result":{"header":{"cluster_id":"12585971608760269493","member_id":"13847567121247652255","revision":"2","raft_term":"2"},"events":[{"kv":{"key":"Zm9v","create_revision":"2","mod_revision":"2","version":"1","value":"YmFy"}}]}} ``` ### 12.5 Transactions 用 `/v3/kv/txn` 发起事务。target 为 `CREATE`: ```bash curl -L http://localhost:2379/v3/kv/txn \ -X POST \ -d '{"compare":[{"target":"CREATE","key":"Zm9v","createRevision":"2"}],"success":[{"requestPut":{"key":"Zm9v","value":"YmFy"}}]}' # {"header":{"cluster_id":"12585971608760269493","member_id":"13847567121247652255","revision":"3","raft_term":"2"},"succeeded":true,"responses":[{"response_put":{"header":{"revision":"3"}}}]} ``` target 为 `VERSION`: ```bash curl -L http://localhost:2379/v3/kv/txn \ -X POST \ -d '{"compare":[{"version":"4","result":"EQUAL","target":"VERSION","key":"Zm9v"}],"success":[{"requestRange":{"key":"Zm9v"}}]}' # {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"6","raft_term":"3"},"succeeded":true,"responses":[{"response_range":{"header":{"revision":"6"},"kvs":[{"key":"Zm9v","create_revision":"2","mod_revision":"6","version":"4","value":"YmZ6"}],"count":"1"}}]} ``` ### 12.6 Authentication 用 `/v3/auth` 建立认证:创建 root 用户、创建 root 角色、授予角色、启用 auth。 ```bash # create root user curl -L http://localhost:2379/v3/auth/user/add \ -X POST -d '{"name": "root", "password": "pass"}' # {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"1","raft_term":"2"}} # create root role curl -L http://localhost:2379/v3/auth/role/add \ -X POST -d '{"name": "root"}' # {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"1","raft_term":"2"}} # grant root role curl -L http://localhost:2379/v3/auth/user/grant \ -X POST -d '{"user": "root", "role": "root"}' # {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"1","raft_term":"2"}} # enable auth curl -L http://localhost:2379/v3/auth/enable -X POST -d '{}' # {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"1","raft_term":"2"}} ``` 用 `/v3/auth/authenticate` 换取 token: ```bash curl -L http://localhost:2379/v3/auth/authenticate \ -X POST -d '{"name": "root", "password": "pass"}' # {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"1","raft_term":"2"},"token":"sssvIpwfnLAcWAQH.9"} ``` 把 token 放进 `Authorization` 头即可带凭据访问: ```bash curl -L http://localhost:2379/v3/kv/put \ -H 'Authorization: sssvIpwfnLAcWAQH.9' \ -X POST -d '{"key": "Zm9v", "value": "YmFy"}' # {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"2","raft_term":"2"}} ``` ### 12.7 错误响应(v3.6 起变化) gRPC gateway 把 gRPC status 翻译为 HTTP 状态码和 JSON 错误体。**自 etcd v3.6 起,升级到 grpc-gateway v2 后错误处理发生变化**:行为对齐 `google.rpc.Status`(`code`、`message`、`details`),遵循 Google API error model。历史版本中还存在顶层 `error` 字段,但**该字段在 etcd v3.6 及以上不再支持**(v3.7 同样不支持)。 - 客户端应**以 HTTP 状态码作为成功/失败的主要判据**; - 请求失败时应以 `message` 字段作为错误信息的主要来源,`details` 用于补充上下文。 ### 12.8 Swagger 生成的 Swagger API 定义位于 `rpc.swagger.json`(页面内链接为 `../apispec/swagger/rpc.swagger.json`)。 ### 12.9 与 gRPC 的取舍 | 维度 | gRPC(clientv3 / 各语言 gRPC 客户端) | gRPC-gateway(HTTP/JSON) | | ---- | ------------------------------------ | ------------------------- | | 适用语言 | 有 gRPC 支持的语言:官方主推 Go `clientv3` | **没有 gRPC 支持的语言**;任何 HTTP/JSON 客户端(含 curl)都可用 | | 传输效率 | 二进制 protobuf,带宽与 CPU 更省 | JSON + base64(key/value 必须 base64),**体积更大** | | 流式能力 | Watch、LeaseKeepAlive 双向流原生支持 | `/v3/watch` 需 `curl -N` 长连接;keepalive 流不友好 | | 类型保真 | protobuf 原生类型(int64 等) | JSON 数字为字符串(如 `"revision":"2"`),需按字符串解析大整数 | | 认证 | 支持完整 `Auth` service 与 mTLS | **不支持使用 TLS Common Name 做认证**;token 用 `Authorization` 头传递 | | 错误判据 | gRPC status code | HTTP 状态码为主 + `message` / `details` | | 一致性 | 默认 linearizable,可选 serializable | 由后端 gRPC 调用决定,语义一致 | | 典型用法 | 服务端应用、控制面组件 | 脚本、运维平台、无 gRPC 栈的语言、快速联调 | ### 12.10 从 curl 联调到生产客户端 > 工程建议(非官方原文,依据 gRPC-gateway 的映射规则与注意事项推导)。 - **base64 是硬要求**:`key` 与 `value` 在 JSON 里必须是 base64,忘记编码会得到"看起来没报错但数据不对"的结果;注意是**标准 base64**,不要与 URL-safe 变体混用。 - **整数是字符串**:响应里的 `revision`、`cluster_id`、`member_id`、`count` 都是字符串(如 `"revision":"2"`)。它们本该是 64 位整数,JSON 数字精度不够,客户端必须用能处理大整数的类型解析,不能当 32 位 int 处理。 - **错误判据变了**:v3.6 起对齐 `google.rpc.Status`,历史版本里的顶层 `error` 字段**已不再支持**;客户端要以 HTTP 状态码为主、`message` 为辅、`details` 补充上下文。 - **不要指望 Common Name 认证**:gRPC-gateway 不支持使用 TLS Common Name 做认证,要走 etcd 的 `Auth` service 拿 token,放进 `Authorization` 头。 - **watch 要用长连接**:`/v3/watch` 用 `curl -N` 且通常后台运行;生产环境如果既要 watch 又要 keepalive 双向流,还是回到 gRPC 客户端更稳妥。 - **路径随版本变**:v3.5 及之后只有 `[CLIENT-URL]/v3/*`,`v3beta` / `v3alpha` 已废弃,别把旧脚本直接搬上来。 --- ## 13. 通过代理接入 etcd gateway 与 gRPC proxy > 引自:[etcd gateway](https://etcd.io/docs/v3.7/op-guide/gateway/)、[gRPC proxy](https://etcd.io/docs/v3.7/op-guide/grpc_proxy/) 两者都能让客户端"少操心 endpoint",但工作层与能力完全不同。 | 维度 | etcd gateway | gRPC proxy | | ---- | ------------ | ---------- | | 工作层 | **TCP 层**(简单 TCP 代理) | **gRPC 层(L7)**,无状态反向代理 | | 是否检查请求 | **无状态且透明**:既不检查客户端请求,也不干预集群响应 | 理解 gRPC 语义,可合并与缓存 | | TLS | **不终止 TLS**、不代客户端做 TLS 握手、也不校验连接是否安全 | **可终止**来自安全集群的 TLS,对外提供未加密本地 endpoint | | endpoint 选择 | 支持多个 endpoint,**简单轮询(round-robin)**;只路由到可用 endpoint | 启动时**随机挑选**一个 endpoint 服务全部请求,故障时才切换 | | 能力 | 仅转发 | **合并 watch**(N→1)、**合并 lease stream**(N→1)、**缓存 key range 请求** | | 目标 | 稳定的本地 endpoint,隐藏集群变更 | 降低核心集群总处理负载、提升可扩展性、防滥用客户端 | | 不支持 | 缓存、watch 合并、批处理 | `RangeStream`;对未指定 revision 的 watch 不保证从最新 revision 开始 | ### 13.1 etcd gateway:何时用 / 何时不要用 **何时使用**:每个访问 etcd 的应用都必须先知道 etcd 集群的 client endpoint 地址;同机多个应用访问同一集群时都要各自维护该列表,集群重新配置后还要逐一更新。gateway 作为**稳定的本地 endpoint** 解决这个问题:典型配置是每台机器跑一个 gateway 监听本地地址,本机所有 etcd 应用连本地 gateway,集群变更时只需更新 gateway 的 endpoints。 总结:为了让集群 endpoint 变化自动传播,应在**每台运行多个访问同一 etcd 集群应用的机器上**部署 etcd gateway。 **何时不要用**: - **提升性能**:gateway 不是为提升集群性能设计的,不提供缓存、watch 合并(coalescing)或批处理;etcd 团队另有面向集群可扩展性的缓存代理(即 gRPC proxy); - **运行在集群管理系统上**:Kubernetes 这类高级集群管理系统原生支持服务发现,应用可用 DNS 名或系统托管的虚拟 IP 访问 etcd;kube-proxy 就等价于 etcd gateway。 ### 13.2 etcd gateway 启动与 flag 静态 endpoint: ```bash $ etcd gateway start --endpoints=infra0.example.com:2379,infra1.example.com:2379,infra2.example.com:2379 2016-08-16 11:21:18.867350 I | tcpproxy: ready to proxy client requests to [...] ``` 用 DNS SRV 做服务发现(先有 SRV 记录): ```bash $ dig +noall +answer SRV _etcd-client._tcp.example.com _etcd-client._tcp.example.com. 300 IN SRV 0 0 2379 infra0.example.com. _etcd-client._tcp.example.com. 300 IN SRV 0 0 2379 infra1.example.com. _etcd-client._tcp.example.com. 300 IN SRV 0 0 2379 infra2.example.com. $ dig +noall +answer infra0.example.com infra1.example.com infra2.example.com infra0.example.com. 300 IN A 10.0.1.10 infra1.example.com. 300 IN A 10.0.1.11 infra2.example.com. 300 IN A 10.0.1.12 ``` ```bash $ etcd gateway start --discovery-srv=example.com 2016-08-16 11:21:18.867350 I | tcpproxy: ready to proxy client requests to [...] ``` | 类别 | flag | 默认值 | 说明 | | ---- | ---- | ------ | ---- | | etcd cluster | `--endpoints` | `127.0.0.1:2379` | 转发客户端连接的目标,逗号分隔;**必须带端口**;非法示例 `https://127.0.0.1:2379`(gateway 不终止 TLS) | | etcd cluster | `--discovery-srv` | 未设置 | 通过 SRV 记录引导集群 endpoint 的 DNS 域名 | | Network | `--listen-addr` | `127.0.0.1:23790` | 接受客户端请求所绑定的接口与端口 | | Network | `--retry-delay` | `1m0s` | 重连失败 endpoint 前的延迟;非法示例 `"123"`(需带时间单位) | | Security | `--insecure-discovery` | `false` | 接受不安全或易受中间人攻击的 SRV 记录 | | Security | `--trusted-ca-file` | 未设置 | 客户端 TLS CA 文件路径,用于校验 SRV 发现返回的 endpoint;**仅**用于认证被发现的 endpoint,不用于创建数据传输连接(gateway 永不终止 TLS) | ### 13.3 gRPC proxy:启动与基本使用 ```bash $ etcd grpc-proxy start --endpoints=infra0.example.com,infra1.example.com,infra2.example.com --listen-addr=127.0.0.1:2379 ``` ```bash $ ETCDCTL_API=3 etcdctl --endpoints=127.0.0.1:2379 put foo bar OK $ ETCDCTL_API=3 etcdctl --endpoints=127.0.0.1:2379 get foo foo bar ``` ### 13.4 可代理的 API 范围与一致性注意事项 | 能力 | 官方说明 | 一致性影响 | | ---- | -------- | ---------- | | 可扩展 watch | proxy 把同一 key/range 上的多个客户端 watcher(`c-watcher`)合并为连到 etcd server 的单个 watcher(`s-watcher`),再广播事件。N 个客户端 watch 同一 key 时,把 server 上 watch 负载从 N 降到 1 | **当 watch revision 未指定时,proxy 不保证 `c-watcher` 从最新的 store revision 开始 watch**:直连可能从 revision 1000 开始,经 proxy 可能从 990 开始。取消操作有类似限制:watcher 被取消时,etcd server 的 revision 可能大于取消响应的 revision | | 可扩展 lease | 支持 **lease stream 合并**:N 个客户端更新 lease 时,把 server 上的流负载从 N 降到 1 | 依赖 proxy 的转发时延,TTL 取值需留出余量 | | 防滥用客户端 | 在**不破坏一致性要求**的前提下缓存请求响应,保护 server 免受紧循环(tight for loop)客户端冲击 | 缓存不会破坏一致性 | | `RangeStream` | **不支持** | 使用 `--stream` 的客户端不要走 proxy | | 批量/写事务 | 源文未逐项列出可代理的 RPC 清单;proxy 是 L7 反向代理,理解 gRPC 语义 | 未列出部分不做推断 | > 官方提示:watch revision 与取消 revision 的两点偏差"对多数场景无碍",将来可能提供让 watcher 绕过 proxy 以获得更精确 revision 的选项。若业务强依赖精确 revision(如做增量同步),应直连 etcd 或显式指定 `start_revision`。 ### 13.5 Client endpoint 同步与名字解析 proxy 支持把自己的 endpoint 注册到用户定义的 endpoint 上供发现,用途有二:让客户端在一组 proxy endpoint 之间同步自己的 endpoint 以实现高可用;作为 **etcd gRPC naming 的 endpoint provider**。 ```bash $ etcd grpc-proxy start --endpoints=localhost:2379 \ --listen-addr=127.0.0.1:23790 \ --advertise-client-url=127.0.0.1:23790 \ --resolver-prefix="___grpc_proxy_endpoint" \ --resolver-ttl=60 $ etcd grpc-proxy start --endpoints=localhost:2379 \ --listen-addr=127.0.0.1:23791 \ --advertise-client-url=127.0.0.1:23791 \ --resolver-prefix="___grpc_proxy_endpoint" \ --resolver-ttl=60 ``` 带 resolver prefix 时,`member list` 会列出所有成员(示例中两条记录的 CLIENT ADDRS 分别为 `127.0.0.1:23791` 与 `127.0.0.1:23790`,PEER ADDRS 为空,ID 均为 0): ```bash ETCDCTL_API=3 etcdctl --endpoints=http://localhost:23790 member list --write-out table ``` 客户端可通过 `Sync` 自动发现 proxy endpoint: > 💡 补充:以下代码引自官方页面原文(非本手册杜撰)——[gRPC proxy](https://etcd.io/docs/v3.7/op-guide/grpc_proxy/)。 ```go cli, err := clientv3.New(clientv3.Config{ Endpoints: []string{"http://localhost:23790"}, }) if err != nil { log.Fatal(err) } defer cli.Close() // fetch registered grpc-proxy endpoints if err := cli.Sync(context.Background()); err != nil { log.Fatal(err) } ``` 若启动时**不带** resolver prefix,`member list` 只返回它自己的 `advertise-client-url`: ```bash $ etcd grpc-proxy start --endpoints=localhost:2379 \ --listen-addr=127.0.0.1:23792 \ --advertise-client-url=127.0.0.1:23792 ``` ### 13.6 Namespacing 当应用期望对**整个 key space** 拥有完全控制权、而 etcd 集群又被其他应用共享时,可用 `--namespace` 让 proxy 对 keyspace 分区:所有进入 proxy 的客户端请求被翻译成带用户定义前缀的 key,访问 etcd 集群时挂上该前缀,proxy 返回响应时剥掉前缀,**客户端感知不到前缀**。 ```bash $ etcd grpc-proxy start --endpoints=localhost:2379 \ --listen-addr=127.0.0.1:23790 \ --namespace=my-prefix/ ``` ```bash $ ETCDCTL_API=3 etcdctl --endpoints=localhost:23790 put my-key abc # OK $ ETCDCTL_API=3 etcdctl --endpoints=localhost:23790 get my-key # my-key # abc $ ETCDCTL_API=3 etcdctl --endpoints=localhost:2379 get my-prefix/my-key # my-prefix/my-key # abc ``` ### 13.7 TLS 终止 先启动 https 的单成员集群: ```bash $ etcd --listen-client-urls https://localhost:2379 --advertise-client-urls https://localhost:2379 --cert-file=peer.crt --key-file=peer.key --trusted-ca-file=ca.crt --client-cert-auth ``` ```bash # fails $ ETCDCTL_API=3 etcdctl --endpoints=http://localhost:2379 endpoint status # works $ ETCDCTL_API=3 etcdctl --endpoints=https://localhost:2379 --cert=client.crt --key=client.key --cacert=ca.crt endpoint status ``` 用客户端证书连接 `https://localhost:2379`,在 `localhost:12379` 启动 proxy(对外未加密): ```bash $ etcd grpc-proxy start --endpoints=https://localhost:2379 --listen-addr localhost:12379 --cert client.crt --key client.key --cacert=ca.crt --insecure-skip-tls-verify & ``` ```bash $ ETCDCTL_API=3 etcdctl --endpoints=http://localhost:12379 put abc def # OK ``` ### 13.8 Metrics 与健康检查 gRPC proxy 为 `--endpoints` 定义的 etcd 成员暴露 `/health` 与 Prometheus `/metrics`;也可以用 `--metrics-addr` 额外定义一个同时响应 `/metrics` 与 `/health` 的 URL: ```bash $ etcd grpc-proxy start \ --endpoints https://localhost:2379 \ --metrics-addr https://0.0.0.0:4443 \ --listen-addr 127.0.0.1:23790 \ --key client.key \ --key-file proxy-server.key \ --cert client.crt \ --cert-file proxy-server.crt \ --cacert ca.pem \ --trusted-ca-file proxy-ca.pem ``` **已知问题**:proxy 主接口同时服务 HTTP2 与 HTTP/1.1。若按上例配置了 TLS,用 cURL 之类的客户端访问主接口取 `/metrics` 或 `/health` 时,必须显式指定 HTTP/1.1;使用 `--metrics-addr` 的次要接口则无此要求: ```bash $ curl --cacert proxy-ca.pem --key proxy-client.key --cert proxy-client.crt https://127.0.0.1:23790/metrics --http1.1 ``` ### 13.9 gRPC proxy flag 汇总 `--endpoints`、`--listen-addr`、`--advertise-client-url`、`--resolver-prefix`、`--resolver-ttl`、`--namespace`、`--metrics-addr`、`--cert`、`--cert-file`、`--key`、`--key-file`、`--cacert`、`--trusted-ca-file`、`--insecure-skip-tls-verify`。 ### 13.10 客户端如何配置 | 场景 | 客户端配置 | | ---- | ---------- | | 走 etcd gateway | `Endpoints` 填 gateway 的 `--listen-addr`(默认 `127.0.0.1:23790`);etcdctl 用 `--endpoints=127.0.0.1:23790`。TLS 由客户端与 etcd server 端到端建立,gateway 不参与 | | 走 gRPC proxy(固定地址) | `Endpoints` 填 proxy 的 `--listen-addr`;etcdctl 用 `ETCDCTL_API=3 etcdctl --endpoints=127.0.0.1:23790 ...` | | 走 gRPC proxy(自动发现) | 启动时带 `--resolver-prefix` / `--resolver-ttl` / `--advertise-client-url`,客户端连任一 proxy 后调用 `cli.Sync(ctx)`(见 13.5) | | 需要 keyspace 隔离 | 用 `--namespace=my-prefix/`,客户端完全无感 | | 强依赖精确 watch revision 或使用 `RangeStream` | **不要用 proxy**(见 13.4),直连 etcd | ### 13.11 接入方式选型建议 > 工程建议(非官方原文,依据两页的"何时使用 / 何时不要用"与能力边界推导)。 | 你的情况 | 建议 | | -------- | ---- | | 一台机器上跑多个访问同一 etcd 集群的应用,不想每个应用都维护 endpoint 列表 | **etcd gateway**:每机一个,应用连 `127.0.0.1:23790`,集群变更只改 gateway | | 已经跑在 Kubernetes 上 | 都不要:直接用 DNS 名或系统托管的虚拟 IP(kube-proxy 就等价于 gateway) | | 想提升性能 / 降低集群负载 / watch 客户端特别多 | **gRPC proxy**(watch 与 lease 合并、range 缓存);gateway 明确不是为性能设计的 | | 需要 watch revision 精确到"从最新 store revision 开始" | **不要走 proxy**(未指定 revision 时可能从更早的 revision 开始),直连 | | 客户端用了 `RangeStream`(etcdctl `--stream`) | **不要走 proxy**(proxy 不支持) | | 多应用共享一个 etcd 集群,想各自独占 keyspace | **gRPC proxy 的 `--namespace`**,客户端无感 | | 应用是 Go,需要服务注册发现 | `grpc_naming` resolver(**experimental**)+ proxy 的 `--resolver-prefix` | | etcd 集群开了 TLS,本机应用不想配证书 | **gRPC proxy 做 TLS 终止**,对外提供未加密本地 endpoint | 兜底原则:gateway 解决"地址管理",proxy 解决"规模与隔离",两者都不解决"一致性增强"——任何代理都不会让读变得更强一致,只会引入额外的 revision 偏差风险。 --- ## 14. 在 Go 应用里嵌入 etcd golang_embed_pkg > 引自:[Embedding etcd in a Go Application](https://etcd.io/docs/v3.7/dev-guide/golang_embed_pkg/) **如实说明**:该官方页面正文(去除 front matter 后)**只有一段话**: > etcd 的 `embed` Go package 提供了一种把 etcd server **直接嵌入应用程序**的简单方式。更多细节见 embed 包文档:https://pkg.go.dev/go.etcd.io/etcd/server/v3/embed 页面本身**未包含** `embed.New(embed.Config{...})` 代码骨架,也**未列出** `Config` 的任何字段。同目录(`dev-guide`)下没有其他 embed 相关文档页面。因此本册不把它当成有内容的章节来写,字段级信息一律以 pkg.go.dev 为准。 典型使用场景(工程化归纳,非官方原文):单元测试与集成测试里起一个真实 etcd、单机工具/边缘场景希望零外部依赖、需要把 etcd 作为库发布。 > 💡 补充:以下骨架基于官方"embed 包提供直接嵌入方式"这一句语义编写,**字段名与默认值以 https://pkg.go.dev/go.etcd.io/etcd/server/v3/embed 为准**,官方页面未给出。 ```go import ( "time" "go.etcd.io/etcd/server/v3/embed" ) cfg := embed.NewConfig() // 建议从 NewConfig() 的默认值出发,避免漏字段 cfg.Dir = "/tmp/etcd-embed-data" // 数据目录(含 member/snap、member/wal) cfg.LogLevel = "info" // 监听地址必须能被客户端与 peer 访问 // cfg.ListenClientUrls / cfg.ListenPeerUrls / cfg.AdvertiseClientUrls / cfg.AdvertisePeerUrls // cfg.Name / cfg.InitialCluster e, err := embed.StartEtcd(cfg) if err != nil { log.Fatal(err) } defer e.Close() select { case <-e.Server.ReadyNotify(): log.Print("etcd server is ready") case <-time.After(60 * time.Second): e.Server.Stop() // 超时未就绪,按官方建议直接停 log.Fatal("etcd took too long to start") } ``` > 注意:`embed` 属于 `go.etcd.io/etcd/server/v3` module,而该 module 的代码属于 etcd **内部实现**,"**不应被外部项目消费**",包布局与 API 可能在小版本之间变化(见第 15 章)。生产环境请权衡这一风险。 --- ## 15. Go 模块划分 dev-internal-modules > 引自:[Golang modules](https://etcd.io/docs/v3.7/dev-internal/modules/) etcd 项目**自 3.5 版本起**被组织为多个 golang module,托管在**单一仓库**中。 ### 15.1 各 module 路径与职责 | module | 职责 | 对应用开发者的意义 | | ------ | ---- | ------------------ | | `go.etcd.io/etcd/api/v3` | API 定义(protos 及 proto 生成的库),定义客户端与服务端之间的通信协议 | 只用消息类型时引它,最轻 | | `go.etcd.io/etcd/pkg/v3` | etcd 使用的工具包集合,本身不特定于 etcd;一个包只有**未来可能被移出到独立仓库**时才应放这里;**避免**往这里放自身依赖很重的代码(会自动成为 client 库的依赖,client 库要保持轻量) | 间接依赖,不建议直接引 | | `go.etcd.io/etcd/client/v3` | 通过网络(grpc)访问 etcd 的客户端库,**所有新的 etcd 用法都推荐使用它** | **本册所有 Go 示例使用的 module** | | `go.etcd.io/etcd/client/v2` | 通过 HTTP 协议访问 etcd 的**遗留**客户端库,**已废弃**;所有新用法应依赖 /v3 库 | 不要用 | | `go.etcd.io/etcd/raft/v3` | 分布式一致性协议的实现,**不应包含 etcd 特定代码** | 想单独用 raft 时引它 | | `go.etcd.io/etcd/server/v3` | etcd 实现。该包中的代码属于 etcd **内部**,**不应被外部项目消费**;包布局和 API **可能在小版本之间变化** | 含 `embed`;外部项目慎用 | | `go.etcd.io/etcd/etcdctl/v3` | 访问和管理 etcd 的命令行工具 | — | | `go.etcd.io/etcd/tests/v3` | 包含 etcd 所有集成测试的模块。注意:所有单元测试(快速且不需要跨模块依赖的)都应保留在被测试代码所在的本地模块中 | — | | `go.etcd.io/bbolt` | 持久化 b-tree 实现,托管在独立仓库 https://github.com/etcd-io/bbolt | 后端存储实现 | ### 15.2 Operations(操作规范) 1. 所有 etcd module 应**以相同版本发布**,例如 `go.etcd.io/etcd/client/v3@v3.5.10` 必须依赖 `go.etcd.io/etcd/api/v3@v3.5.10`。版本一致性更新脚本: ```bash % DRY_RUN=false TARGET_VERSION="v3.5.10" ./scripts/release_mod.sh update_versions ``` 2. 已发布的 module 应按 https://golang.org/ref/mod#vcs-version 规则打 tag,即**每个 module 有自己的 tag**。打 tag 脚本: ```bash % DRY_RUN=false REMOTE_REPO="origin" ./scripts/release_mod.sh push_mod_tags ``` 3. 所有 etcd module 应依赖**相同版本的底层依赖**。校验方式: ```bash % PASSES="dep" ./test.sh ``` 4. `go.mod` 文件**不得包含未被使用的依赖**,且必须符合 `go mod tidy` 格式。校验方式: ```bash % PASSES="mod_tidy" ./test.sh ``` 5. 要在所有 module 上触发动作(例如自动格式化所有文件),请使用/扩展脚本: ```bash % ./scripts/fix.sh ``` ### 15.3 Future(演进方向) - 把 **etcdmigrate/etcdadm** 从 etcdctl 二进制中拆出去,使 etcdctl 明确成为网络 client API 的命令行封装,而 etcdmigrate/etcdadm 支持对 etcd 存储文件的直接物理操作; - 把 **etcd-proxy** 从 `./etcd` 二进制中拆出去,因为它包含更多实验性代码,带来额外风险与依赖; - **废弃 v2 协议**支持。 > 依赖规则小结:应用只应依赖 `client/v3`(必要时加 `api/v3`);看到 `server/v3`、`client/v2` 出现在业务 `go.mod` 里,就是这个模块划分要阻止的用法。 --- ## 16. gRPC 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/) > 说明:两份源文均标注 "This API reference is autogenerated from the named `.proto` files."。本节为**全量速查**:8 个 service、48 个方法;非核心细节(枚举全部取值、gRPC code 与 etcd 错误对照)官方文档未提供,见末尾 16.10。 ### 16.1 service 总清单 | # | Service | proto 文件 | 方法数 | | - | ------- | ---------- | ------ | | 1 | `KV` | api/etcdserverpb/rpc.proto | 5 | | 2 | `Watch` | api/etcdserverpb/rpc.proto | 1 | | 3 | `Lease` | api/etcdserverpb/rpc.proto | 5 | | 4 | `Cluster` | api/etcdserverpb/rpc.proto | 5 | | 5 | `Maintenance` | api/etcdserverpb/rpc.proto | 8 | | 6 | `Auth` | api/etcdserverpb/rpc.proto | 17 | | 7 | `Lock` | server/etcdserver/api/v3lock/v3lockpb/v3lock.proto | 2 | | 8 | `Election` | server/etcdserver/api/v3election/v3electionpb/v3election.proto | 5 | **合计:8 个 service,48 个方法。** `Election` 与 `Lock` 只在 concurrency 参考页出现;`KV` / `Watch` / `Lease` / `Cluster` / `Maintenance` / `Auth` 只在 API reference 页出现。 ### 16.2 service `KV` | 方法 | 请求消息 | 响应消息 | 用途 | | ---- | -------- | -------- | ---- | | `Range` | RangeRequest | RangeResponse | 从 kv store 读取区间内的 keys | | `Put` | PutRequest | PutResponse | 写入给定 key;使 store revision +1 并产生一个事件 | | `DeleteRange` | DeleteRangeRequest | DeleteRangeResponse | 删除给定区间的 key;revision +1,每个被删 key 产生一个 delete 事件 | | `Txn` | TxnRequest | TxnResponse | 在单个事务里处理多个请求,完成后产生同一 revision 的事件;不允许同一 txn 内多次修改同一 key | | `Compact` | CompactionRequest | CompactionResponse | 压缩事件历史;需定期压缩否则历史无限增长 | `RangeRequest` 字段(完整)见 4.3;`PutRequest` / `PutResponse` 见 3.2;`DeleteRange` 见 6.3;`Txn` / `Compare` 见 7.5、7.4。 `CompactionRequest`: | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ------ | | `revision` | int64 | 压缩操作的 kv store revision;所有 revision 小于它的被取代 key 会被移除 | — | | `physical` | bool | RPC 等待压缩被物理应用到本地数据库,压缩条目从后端彻底删除 | `false` | `CompactionResponse`:`header`。 ### 16.3 service `Watch` | 方法 | 请求消息 | 响应消息 | 用途 | | ---- | -------- | -------- | ---- | | `Watch` | WatchRequest(流) | WatchResponse(流) | 监听正在发生或已发生的事件;输入流用于创建/取消 watcher,输出流发送事件;一次 RPC 可监听多个 key 区间,可从最后压缩 revision 起监听整个事件历史 | | 消息 | 字段 | 类型 | 说明 | | ---- | ---- | ---- | ---- | | WatchRequest | `request_union` | oneof | 创建 / 取消 / progress 请求联合 | | WatchRequest | `create_request` | WatchCreateRequest | 创建 | | WatchRequest | `cancel_request` | WatchCancelRequest | 取消 | | WatchRequest | `progress_request` | WatchProgressRequest | 请求尽快在响应流中发送 watch 流 progress 状态(该消息无业务字段) | | WatchCancelRequest | `watch_id` | int64 | 要取消的 watcher id,取消后不再传输事件 | `WatchCreateRequest` 字段见 8.4;`WatchResponse` 字段见 8.5。 ### 16.4 service `Lease` | 方法 | 请求消息 | 响应消息 | 用途 | | ---- | -------- | -------- | ---- | | `LeaseGrant` | LeaseGrantRequest | LeaseGrantResponse | 创建租约;TTL 内未收到 keepAlive 则过期,附加的 key 全部过期删除,每个过期 key 产生 delete 事件 | | `LeaseRevoke` | LeaseRevokeRequest | LeaseRevokeResponse | 撤销租约,附加的 key 全部过期删除 | | `LeaseKeepAlive` | LeaseKeepAliveRequest(流) | LeaseKeepAliveResponse(流) | 客户端→服务端流式 keepAlive 请求、服务端→客户端流式响应的双向流保活 | | `LeaseTimeToLive` | LeaseTimeToLiveRequest | LeaseTimeToLiveResponse | 查询租约信息 | | `LeaseLeases` | LeaseLeasesRequest | LeaseLeasesResponse | 列出所有现存租约(请求无业务字段) | 字段表见 9.4。 ### 16.5 service `Cluster` | 方法 | 请求消息 | 响应消息 | 用途 | | ---- | -------- | -------- | ---- | | `MemberAdd` | MemberAddRequest | MemberAddResponse | 向集群添加成员 | | `MemberRemove` | MemberRemoveRequest | MemberRemoveResponse | 从集群移除已有成员 | | `MemberUpdate` | MemberUpdateRequest | MemberUpdateResponse | 更新成员配置 | | `MemberList` | MemberListRequest | MemberListResponse | 列出集群所有成员 | | `MemberPromote` | MemberPromoteRequest | MemberPromoteResponse | 将成员从 raft learner(无投票权)提升为 raft 投票成员 | | 消息 | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ---- | ------ | | MemberAddRequest | `peerURLs` | (slice of) string | 新成员与集群通信使用的 URL 列表 | — | | MemberAddRequest | `isLearner` | bool | 新成员是否为 raft learner | `false` | | MemberRemoveRequest | `ID` | uint64 | 要移除的成员 ID | — | | MemberUpdateRequest | `ID` | uint64 | 要更新的成员 ID | — | | MemberUpdateRequest | `peerURLs` | (slice of) string | 该成员新的通信 URL 列表 | — | | MemberPromoteRequest | `ID` | uint64 | 要提升的成员 ID | — | | MemberListRequest | `linearizable` | bool | 是否线性化读 | `false` | `Member` 消息字段: | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ------ | | `ID` | uint64 | 成员 ID | — | | `name` | string | 人类可读的成员名;成员未启动时为空字符串 | `""` | | `peerURLs` | (slice of) string | 成员暴露给集群用于通信的 URL 列表 | — | | `clientURLs` | (slice of) string | 成员暴露给客户端的 URL 列表;未启动时为空 | — | | `isLearner` | bool | 是否为 raft learner | `false` | 响应:`MemberAddResponse` = `header` + `member` + `members`(添加后全量);`MemberRemoveResponse` / `MemberUpdateResponse` / `MemberPromoteResponse` = `header` + `members`;`MemberListResponse` = `header` + `members`。 ### 16.6 service `Maintenance` | 方法 | 请求消息 | 响应消息 | 用途 | | ---- | -------- | -------- | ---- | | `Alarm` | AlarmRequest | AlarmResponse | 激活、去激活、查询集群健康告警 | | `Status` | StatusRequest | StatusResponse | 获取成员状态(请求无业务字段) | | `Defragment` | DefragmentRequest | DefragmentResponse | 碎片整理成员后端数据库以回收存储空间(请求无业务字段) | | `Hash` | HashRequest | HashResponse | 计算整个后端 keyspace(含 key、lease 等 bucket)的哈希;**仅供测试**,不持有 MVCC 锁,勿用于生产(请求无业务字段) | | `HashKV` | HashKVRequest | HashKVResponse | 计算给定 revision 前所有 MVCC key 的哈希,只遍历 "key" bucket | | `Snapshot` | SnapshotRequest | SnapshotResponse | 以流方式将成员整个后端快照发给客户端(请求无业务字段) | | `MoveLeader` | MoveLeaderRequest | MoveLeaderResponse | 请求当前 leader 将领导权移交给 transferee | | `Downgrade` | DowngradeRequest | DowngradeResponse | 请求降级、校验可行性或取消集群版本降级;自 etcd 3.5 起支持 | | 消息 | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ---- | ------ | | AlarmRequest | `action` | AlarmAction | GET 告警状态 / ACTIVATE 激活 / DEACTIVATE 去激活 | — | | AlarmRequest | `memberID` | uint64 | 关联告警的成员 ID | `0` = 覆盖所有成员 | | AlarmRequest | `alarm` | AlarmType | 本次请求考虑的告警类型 | — | | HashKVRequest | `revision` | int64 | 哈希操作的 kv store revision | — | | MoveLeaderRequest | `targetID` | uint64 | 新 leader 的节点 ID | — | | DowngradeRequest | `action` | DowngradeAction | VALIDATE 目标版本 / DOWNGRADE 集群版本 / CANCEL 当前降级任务 | — | | DowngradeRequest | `version` | string | 降级的目标版本 | — | `StatusResponse`(常用): | 字段 | 类型 | 说明 | | ---- | ---- | ---- | | `header` | ResponseHeader | — | | `version` | string | 响应成员使用的集群协议版本 | | `dbSize` | int64 | 后端数据库物理分配大小(字节) | | `leader` | uint64 | 响应成员认为的当前 leader 成员 ID | | `raftIndex` | uint64 | 当前 raft committed index | | `raftTerm` | uint64 | 当前 raft term | | `raftAppliedIndex` | uint64 | 当前 raft applied index | | `errors` | (slice of) string | 告警/健康信息与状态 | | `dbSizeInUse` | int64 | 后端数据库逻辑使用大小(字节) | | `isLearner` | bool | 是否为 raft learner | | `storageVersion` | string | db 文件版本,可能滞后于目标集群版本 | | `dbSizeQuota` | int64 | 配置的存储配额字节数(`--quota-backend-bytes` 传入值) | | `downgradeInfo` | DowngradeInfo | 是否存在降级流程 | 其他响应:`AlarmResponse` = `header` + `alarms`(`AlarmMember`:`memberID` uint64、`alarm` AlarmType);`HashResponse` = `header` + `hash`(uint32);`HashKVResponse` = `header` + `hash` + `compact_revision` + `hash_revision`;`DowngradeResponse` = `header` + `version`;`DowngradeInfo` = `enabled`(bool)+ `targetVersion`(string);`SnapshotResponse` = `header`(首条表示快照时间点)+ `remaining_bytes`(uint64)+ `blob`(bytes)+ `version`(string)。 ### 16.7 service `Auth` | 方法 | 请求消息 | 响应消息 | 用途 | | ---- | -------- | -------- | ---- | | `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 或区间的权限 | | `RoleRevokePermission` | AuthRoleRevokePermissionRequest | AuthRoleRevokePermissionResponse | 撤销角色某个 key 或区间的权限 | | 消息 | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ---- | ------ | | AuthenticateRequest | `name` / `password` | string | 用户名 / 密码 | — | | AuthenticateResponse | `header` / `token` | ResponseHeader / string | `token` 为授权 token,可用于后续 RPC | — | | AuthStatusResponse | `enabled` | bool | 认证是否启用 | `false` | | AuthStatusResponse | `authRevision` | uint64 | auth store 当前 revision | — | | AuthUserAddRequest | `name` / `password` / `options` / `hashedPassword` | string / string / authpb.UserAddOptions / string | 用户名 / 密码 / 创建选项 / 已哈希密码 | — | | AuthUserChangePasswordRequest | `name` / `password` / `hashedPassword` | string | 待改密的用户名 / 新密码(**该字段将在 API 层被移除**)/ 新密码(**该字段将在 API 层被初始化**) | — | | AuthUserDeleteRequest | `name` | string | 要删除的用户名 | — | | AuthUserGrantRoleRequest | `user` / `role` | string | 被授予角色的用户名 / 要授予的角色名 | — | | AuthUserRevokeRoleRequest | `name` / `role` | string | 用户名 / 角色名 | — | | AuthUserGetResponse | `roles` | (slice of) string | 用户的角色列表 | — | | AuthUserListResponse | `users` | (slice of) string | 全部用户名 | — | | AuthRoleAddRequest | `name` | string | 要加入认证系统的角色名 | — | | AuthRoleDeleteRequest / AuthRoleGetRequest | `role` | string | 角色名 | — | | AuthRoleGetResponse | `perm` | (slice of) authpb.Permission | 角色权限列表 | — | | AuthRoleListResponse | `roles` | (slice of) string | 全部角色名 | — | | AuthRoleGrantPermissionRequest | `name` / `perm` | string / authpb.Permission | 被授予权限的角色名 / 要授予的权限 | — | | AuthRoleRevokePermissionRequest | `role` / `key` / `range_end` | string / bytes / bytes | 角色名 / 权限 key / 权限区间尾后 key | — | 其余 Auth 响应消息(AuthEnableResponse、AuthDisableResponse、AuthUserAddResponse、AuthUserChangePasswordResponse、AuthUserDeleteResponse、AuthUserGrantRoleResponse、AuthUserRevokeRoleResponse、AuthRoleAddResponse、AuthRoleDeleteResponse、AuthRoleGrantPermissionResponse、AuthRoleRevokePermissionResponse)**均只含** `header`(ResponseHeader)。 关联的 authpb 消息: | 消息 | 字段 | 类型 | 说明 | | ---- | ---- | ---- | ---- | | Permission | `permType` / `key` / `range_end` | Type / bytes / bytes | 权限类型(单一实体)/ 权限 key / 权限区间尾后 key | | Role | `name` / `keyPermission` | bytes / (slice of) Permission | authRoles bucket 中的单条记录 / 该角色的权限列表 | | User | `name` / `password` / `roles` / `options` | bytes / bytes / (slice of) string / UserAddOptions | authUsers bucket 中的单条记录 | | UserAddOptions | `no_password` | bool | 无密码标志 | ### 16.8 service `Lock`(concurrency) | 方法 | 请求消息 | 响应消息 | 用途 | | ---- | -------- | -------- | ---- | | `Lock` | LockRequest | LockResponse | 在给定命名的锁上获取分布式共享锁 | | `Unlock` | UnlockRequest | UnlockResponse | 传入 Lock 返回的 key 释放锁 | 字段与语义见 10.2。 ### 16.9 service `Election`(concurrency) | 方法 | 请求消息 | 响应消息 | 用途 | | ---- | -------- | -------- | ---- | | `Campaign` | CampaignRequest | CampaignResponse | 等待获取选举的领导权 | | `Proclaim` | ProclaimRequest | ProclaimResponse | 用新值更新 leader 已发布的值 | | `Leader` | LeaderRequest | LeaderResponse | 返回当前选举公告(若有) | | `Observe` | LeaderRequest | LeaderResponse | 按当选 leader 发布顺序流式推送选举公告 | | `Resign` | ResignRequest | ResignResponse | 释放选举领导权 | 字段与语义见 10.3。 ### 16.10 跨 service 公共消息 | 消息 | 字段 | 类型 | 说明 | 默认值 | | ---- | ---- | ---- | ---- | ------ | | ResponseHeader | `cluster_id` | uint64 | 发送响应的集群 ID | — | | ResponseHeader | `member_id` | uint64 | 发送响应的成员 ID | — | | ResponseHeader | `revision` | int64 | 请求被应用时的 kv store revision;未与 kv store 交互的调用中未设置(即 0);**watch progress 响应中 header.revision 表示进度**,该流后续收到的所有事件 revision 都保证大于它 | 0 | | ResponseHeader | `raft_term` | uint64 | 请求被应用时的 raft term | — | | KeyValue | `key` | bytes | key 字节;**不允许为空** | — | | KeyValue | `create_revision` | int64 | 该 key 最后一次创建的 revision | — | | KeyValue | `mod_revision` | int64 | 该 key 最后一次修改的 revision | — | | KeyValue | `version` | int64 | key 的版本;删除会将其重置为 0,任何修改使其递增 | — | | KeyValue | `value` | bytes | key 持有的 value | — | | KeyValue | `lease` | int64 | 附加到 key 的 lease ID;lease 过期时 key 被删除 | `0` = 无 lease | | Event | `type` | EventType | PUT 表示有新数据存入该 key,DELETE 表示 key 被删除 | — | | Event | `kv` | KeyValue | PUT 含当前 kv 对(`kv.Version=1` 表示 key 被创建);DELETE/EXPIRE 含被删 key,其 mod_revision 设为删除时的 revision | — | | Event | `prev_kv` | KeyValue | 事件发生前的 kv 对 | — | | Lease | `ID` / `TTL` / `RemainingTTL` | int64 | lease ID / TTL / 剩余 TTL | — | ### 16.11 源文未包含的内容(不凭知识补充) - 两份源文均**无** gRPC code 与 etcd 错误的对照表; - 均**无** enum 的具体成员值定义(`SortOrder`、`SortTarget`、`CompareResult`、`CompareTarget`、`AlarmType`、`AlarmAction`、`FilterType`、`EventType`、`DowngradeAction`、`Type` 等;本册出现的枚举取值均来自 `learning/api.md`,已在正文中标注出处); - 均**无** gRPC-gateway HTTP 映射(见第 12 章,来自 `api_grpc_gateway.md`); - 均**无**代码示例。 源文中与错误/异常相关的全部原文表述仅 4 处: | 出处 | 原文表述 | | ---- | -------- | | `RangeRequest.revision` | 若该 revision 已被压缩,返回 `ErrCompacted` 作为响应 | | `LeaseGrantRequest.TTL` | 过期的 lease 将返回 -1(`Expired lease will return -1`) | | `LeaseGrantResponse.error` | string 类型的 `error` 字段(源文未给取值说明) | | `WatchResponse.cancel_reason` | string 类型,指示 watcher 被取消的原因(源文未给取值说明) | --- ## 17. 附录 官方页面与章节映射表 | # | 官方页面 | 线上 URL | 本册章节 | | - | -------- | -------- | -------- | | 1 | Developer Tasks(目录页,仅 front matter,无正文) | https://etcd.io/docs/v3.7/tasks/developer/ | 1 | | 2 | Writing to etcd | https://etcd.io/docs/v3.7/tasks/developer/writing-to-etcd/ | 3 | | 3 | Reading from etcd | https://etcd.io/docs/v3.7/tasks/developer/reading-from-etcd/ | 4 | | 4 | How to get keys by prefix | https://etcd.io/docs/v3.7/tasks/developer/how-to-get-key-by-prefix/ | 5 | | 5 | How to delete keys | https://etcd.io/docs/v3.7/tasks/developer/how-to-delete-keys/ | 6 | | 6 | How to make multiple writes in a transaction | https://etcd.io/docs/v3.7/tasks/developer/how-to-transactional-write/ | 7 | | 7 | How to watch keys | https://etcd.io/docs/v3.7/tasks/developer/how-to-watch-keys/ | 8 | | 8 | How to create lease | https://etcd.io/docs/v3.7/tasks/developer/how-to-create-lease/ | 9 | | 9 | How to create locks | https://etcd.io/docs/v3.7/tasks/developer/how-to-create-locks/ | 10 | | 10 | How to conduct leader election in etcd cluster | https://etcd.io/docs/v3.7/tasks/operator/how-to-conduct-elections/ | 10 | | 11 | Interacting with etcd(etcdctl 读写/watch/lease 示例) | https://etcd.io/docs/v3.7/dev-guide/interacting_v3/ | 3、4、6、8、9 | | 12 | gRPC naming and discovery(experimental) | https://etcd.io/docs/v3.7/dev-guide/grpc_naming/ | 11 | | 13 | Why gRPC gateway | https://etcd.io/docs/v3.7/dev-guide/api_grpc_gateway/ | 12 | | 14 | etcd gateway | https://etcd.io/docs/v3.7/op-guide/gateway/ | 13 | | 15 | gRPC proxy | https://etcd.io/docs/v3.7/op-guide/grpc_proxy/ | 13 | | 16 | Embedding etcd in a Go Application(**stub 页**) | https://etcd.io/docs/v3.7/dev-guide/golang_embed_pkg/ | 14 | | 17 | Golang modules | https://etcd.io/docs/v3.7/dev-internal/modules/ | 15 | | 18 | API reference(6 service / 41 方法) | https://etcd.io/docs/v3.7/dev-guide/api_reference_v3/ | 16 | | 19 | API reference: concurrency(2 service / 7 方法) | https://etcd.io/docs/v3.7/dev-guide/api_concurrency_reference_v3/ | 10、16 | | 20 | Data model | https://etcd.io/docs/v3.7/learning/data_model/ | 2(详见《01-核心概念篇》) | | 21 | etcd API | https://etcd.io/docs/v3.7/learning/api/ | 2、4、5、7、8、16 | | 22 | etcd API guarantees | https://etcd.io/docs/v3.7/learning/api_guarantees/ | 2、8 | | 23 | etcd client design | https://etcd.io/docs/v3.7/learning/design-client/ | 2 | | 24 | System limits(请求 1.5 MiB / 存储 2 GiB) | https://etcd.io/docs/v3.7/dev-guide/limit/ | 2(消息大小上限) | **覆盖页面数:24 页**(`tasks/developer/` 9 页含目录页、`dev-guide/` 7 页、`op-guide/` 2 页、`learning/` 4 页、`dev-internal/` 1 页、`tasks/operator/` 1 页;其中 `golang_embed_pkg` 为 stub 页,已在本册第 14 章如实说明)。 ### 17.1 本册记录的官方源文疑点(忠实保留,不做修正) | # | 页面 | 疑点 | | - | ---- | ---- | | 1 | reading-from-etcd | 正文称 `--write-out="json"` 时 "the key is not returned",但同一示例输出中确实包含 `"key":"Zm9v"` | | 2 | how-to-transactional-write | 变量表 key 写作 `/users/{/email`,**多了一个左花括号** | | 3 | 多个 developer 页面 | 前置链接指向 `https://etcd.io/docs/v3.6/...`(install、local_cluster、api),而页面本身属 v3.7 | | 4 | how-to-watch-keys | 源文未逐字给出事件输出样例(PUT / DELETE 事件类型由示例行为推得) | | 5 | tasks/developer/_index.md | 仅含 front matter,无正文 | | 6 | golang_embed_pkg | 页面为 stub,仅一段正文 + pkg.go.dev 链接,无代码与 `Config` 字段 | | 7 | how-to-create-lease / interacting_v3 | 官方页面未提供"一次性续期"的 etcdctl flag 示例(本册 9.2 已注明) | | 8 | api_reference_v3 / api_concurrency_reference_v3 | 无 gRPC code 对照表、无 enum 取值表、无代码示例(本册 16.11 已注明) | --- > 本册完。命令与字段名请以 `etcdctl --help` 与 https://etcd.io/docs/v3.7/ 为准;Go API 以 https://pkg.go.dev/go.etcd.io/etcd/client/v3 为准。