Files
wiki/数据库/ETCD/etcd-v3.7-学习手册-02-开发实战篇.md
T
2026-10-08 15:05:32 +08:00

132 KiB
Raw Blame History

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. 本册导读

本册回答一个问题:在应用代码里怎么用 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(该页 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、etcd API、Interacting with etcd、etcd client design

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

export ETCDCTL_API=3

引自:Interacting with etcd(原文: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 版本是否匹配):

$ 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 代码)。

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(非本手册杜撰)。

cli, err := clientv3.NewFromURL("http://localhost:2379")

💡 补充:上面两行中的 clientv3.NewFromURL 用法引自官方页面 gRPC naming and discovery,clientv3.New 用法引自 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

项 官方记录
客户端三组件 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

前提:安装 etcdctl。

做法:用 put 子命令写入一个键值对。

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 一致):

{"header":{"cluster_id":"12585971608760269493","member_id":"13847567121247652255","revision":"2","raft_term":"3"}}

引自:Why gRPC gateway

3.4 Go 写入示例

💡 补充:以下 Go 示例基于官方 API 语义编写,非官方文档原文。

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 语义编写,非官方文档原文。

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、Interacting with etcd

前提:安装 etcdctl。用 get 子命令读取。

$ 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

$ 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:

$ 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 读(官方示例原样)

$ 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  # 空
$ 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 会失败:

$ 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

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 语义编写,非官方文档原文。

// 单 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 语义编写,非官方文档原文。

// 前缀 + 限量 + 按 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

前提:安装 etcdctl;搭建本地集群。

$ etcdctl --endpoints=$ENDPOINTS get PREFIX --prefix
  • 全局选项:--endpoints=[127.0.0.1:2379], gRPC endpoints
  • 选项:--prefix, get a range of keys with matching prefix

示例:

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

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。

// 前缀查询
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)。

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(枚举定义 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、Interacting with etcd

前提:安装 etcd 与 etcdctl。用 del 删除指定的单个 key 或一段 key 区间。

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 删除官方示例

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):

$ 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 语义编写,非官方文档原文。

// 单 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

前提:安装 etcd 与 etcdctl;一个运行中的 etcd 集群。

术语:txn 是 "transaction" 的缩写,它从标准输入读取多个 etcd 请求,并作为单个原子事务提交。一个事务由三部分组成:一组条件(conditions)、全部条件为真时要执行的请求列表、任一条件为假时要执行的请求列表。compare 子句即事务中的条件检查,只有当键值存储的当前状态满足预期条件时才继续应用变更,从而保证数据一致性、避免并发冲突。

查看帮助:

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/{<user_id>/email 表示该用户 email 地址的 etcd key(原文如此,含多余左花括号)
/users/<user_id>/phone 表示该用户电话号码的 etcd key
--interactive 允许手工输入事务数据的 flag

7.3 etcdctl 交互式 txn 完整示例

步骤 1:准备初始数据

   etcdctl put /users/12345/email "old.address@johndoe.com"
   etcdctl put /users/12345/phone "123-456-7890"

步骤 2:执行事务

   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

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("<key>") = "<v>" VALUE 比较 value
create_revision("<key>") = N CREATE 比较 create revision
mod_revision("<key>") = N MOD 比较 mod revision(CAS 常用)
version("<key>") = 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:内层响应中的 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。

// 场景:确认 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 的语义。

// 读-改-写:只有在我读到的版本之后没人改过,才写入
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 语义编写,非官方文档原文。

// 库存扣减:判断余量足够再扣,整个“判断 + 扣减”在一个事务里
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、Interacting with etcd、etcd API、etcd API guarantees

前提:安装 etcd 与 etcdctl。用 watch 订阅未来的变更通知。

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 示例

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 都会各自产生事件。

$ 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):

$ 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):

$ 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 命令查询进度:

$ 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
$ 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

# 保证 含义
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 示例)。

// 从当前时刻开始监听前缀
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 语义编写,非官方文档原文。

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、Interacting with etcd、etcd API

用 lease 实现带 TTL 的写入。

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 <ttl> etcdctl lease grant 60 lease 32695410dcc0ca06 granted with TTL(60s)
lease revoke <id> etcdctl lease revoke 32695410dcc0ca06 lease 32695410dcc0ca06 revoked;所有附加 key 被删除,get foo 返回空
lease keep-alive <id> etcdctl lease keep-alive 32695410dcc0ca06 lease 32695410dcc0ca06 keepalived with TTL(60)(持续输出,每轮一行)
lease timetolive <id> etcdctl lease timetolive 694d5765fc71500b lease 694d5765fc71500b granted with TTL(500s), remaining(258s)
lease timetolive --keys <id> 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 示例)。

// 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、How to conduct leader election in etcd cluster、API reference: concurrency、etcd API

10.1 etcdctl lock

LOCK 以给定名字获取一个分布式互斥锁(distributed mutex)。一旦拿到锁就会一直持有,直到 etcdctl 进程终止——这是官方页给出的 unlock 语义:锁的释放由进程退出(进而租约过期)触发,而非显式命令。

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(选主命令)

etcdctl --endpoints=$ENDPOINTS elect <election-name> [proposal]
项 说明
--endpoints 每个 etcd 集群成员的地址
election-name 选举的字符串标识;所有参与竞选者必须使用同一个选举名
leader-name(proposal) 新 leader 的提案值
./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 为准。

// 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 语义编写,非官方文档原文。

// 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

etcd 提供一个 gRPC resolver,支持一套替代性命名系统:从 etcd 获取 endpoint 来发现 gRPC 服务。底层机制基于监听以服务名为前缀的 key 的更新。

注意:该特性是 experimental(实验性) 的,因为它依赖 google.golang.org/grpc/resolver 包,而该包在 grpc-go 中仍处于实验状态。

11.1 初始化 resolver(官方原文 Go 代码)

💡 补充:以下代码引自官方页面原文(非本手册杜撰)——gRPC naming and discovery。

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://<name>/<key> 之外的具体 scheme 语义;实际解析规则以源文所述"解析目标前缀 + / 下的所有 key"为准(例如目标前缀 foo/bar/my-service 对应 foo/bar/my-service/ 下的所有 key)。

11.3 添加 endpoint

etcdctl:

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。

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。

conn, gerr := grpc.NewClient("etcd:///foo", grpc.WithResolvers(etcdResolver),
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`))

11.4 删除 endpoint

ETCDCTL_API=3 etcdctl del foo/bar/my-service/1.2.3.4

💡 补充:以下代码引自官方页面原文(非本手册杜撰)——gRPC naming and discovery。

em := endpoints.NewManager(client, "foo/bar/my-service")
err := em.DeleteEndpoint(context.TODO(), "foo/bar/my-service/e1")

11.5 带 lease 注册 endpoint

带 lease 注册可保证:当主机无法维持 keepalive 心跳(例如机器故障)时,它会被从服务中移除。

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。

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。

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

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):

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 且后台运行):

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:

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:

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。

# 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:

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 头即可带凭据访问:

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、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:

$ 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 记录):

$ 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
$ 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:启动与基本使用

$ etcd grpc-proxy start --endpoints=infra0.example.com,infra1.example.com,infra2.example.com --listen-addr=127.0.0.1:2379
$ 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。

$ 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):

ETCDCTL_API=3 etcdctl --endpoints=http://localhost:23790 member list --write-out table

客户端可通过 Sync 自动发现 proxy endpoint:

💡 补充:以下代码引自官方页面原文(非本手册杜撰)——gRPC proxy。

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:

$ 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 返回响应时剥掉前缀,客户端感知不到前缀。

$ etcd grpc-proxy start --endpoints=localhost:2379 \
  --listen-addr=127.0.0.1:23790 \
  --namespace=my-prefix/
$ 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 的单成员集群:

$ 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
# 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(对外未加密):

$ 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 &
$ 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:

$ 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 的次要接口则无此要求:

 $ 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

如实说明:该官方页面正文(去除 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 为准,官方页面未给出。

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

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。版本一致性更新脚本:

    % 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 脚本:

    % DRY_RUN=false REMOTE_REPO="origin" ./scripts/release_mod.sh push_mod_tags
    
  3. 所有 etcd module 应依赖相同版本的底层依赖。校验方式:

    % PASSES="dep" ./test.sh
    
  4. go.mod 文件不得包含未被使用的依赖,且必须符合 go mod tidy 格式。校验方式:

    % PASSES="mod_tidy" ./test.sh
    
  5. 要在所有 module 上触发动作(例如自动格式化所有文件),请使用/扩展脚本:

    % ./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、API reference: concurrency 说明:两份源文均标注 "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/{<user_id>/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 <cmd> --help 与 https://etcd.io/docs/v3.7/ 为准;Go API 以 https://pkg.go.dev/go.etcd.io/etcd/client/v3 为准。