132 KiB
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. 本册导读
- 2. 开发前置 数据模型速览与连接方式
- 3. 写入数据 writing-to-etcd
- 4. 读取数据 reading-from-etcd
- 5. 前缀查询 how-to-get-key-by-prefix
- 6. 删除数据 how-to-delete-keys
- 7. 事务写 how-to-transactional-write
- 8. Watch 机制 how-to-watch-keys
- 9. Lease 租约 how-to-create-lease
- 10. 分布式锁与选主 how-to-create-locks
- 11. gRPC 直连与命名发现 grpc_naming
- 12. gRPC-gateway api_grpc_gateway
- 13. 通过代理接入 etcd gateway 与 gRPC proxy
- 14. 在 Go 应用里嵌入 etcd golang_embed_pkg
- 15. Go 模块划分 dev-internal-modules
- 16. gRPC API 速查表
- 17. 附录 官方页面与章节映射表
1. 本册导读
本册回答一个问题:在应用代码里怎么用 etcd。所有内容来自 etcd v3.7 官方文档的 developer 任务页、dev-guide 页与 API 参考页,命令、flag 名、默认值、字段名一律保留英文原文。
1.1 覆盖的官方页面
引自: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 示例,规则如下:
- 凡本册出现的 Go 代码块,其上方必有一行
> 💡 补充:说明其来源; > 💡 补充:后面的内容会注明"基于官方 API 语义编写"还是"引自官方页面原文";- 唯一含 Go 代码的官方页面是
dev-guide/grpc_naming.md、op-guide/grpc_proxy.md、upgrades/*(本册第 11、13 章引用),这些也会显式标注出处,不混入自编示例; - 凡官方源文没有的东西(枚举全部取值、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) |
客户端日志 |
客户端的故障切换行为(写业务代码前必须知道)
| 项 | 官方记录 |
|---|---|
| 客户端三组件 | 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
前提:安装 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"}}
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
前提:安装 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
前提:安装 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
前提:安装 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 规则整理。
- revision 同样会 +1:删除不是"抹掉历史",而是在新的 revision 上记录一次变更;因此删除也会产生事件、也会推进全局逻辑时钟。
- 每个被删 key 产生一个 delete 事件:所有 watch 该 key(或其前缀)的客户端都会收到
DELETE事件,事件里的kv.mod_revision就是删除发生时的 revision。做本地缓存的业务必须处理DELETE,否则缓存里会留下幽灵数据。 - 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
前提:安装 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 官方三条重要注意事项
- 原子性(Atomicity):两个更新要么一起生效,要么都不生效;初始条件不满足时两个更新都不会被应用。
- 一致性(Consistency):用事务维护多个相关更新之间的数据一致性。
- 避免对同一 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 的六条保证 + 一条例外
| # | 保证 | 含义 |
|---|---|---|
| 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 两条保证):
- 建立 watch 时记录
header.revision(或progress-notify推送的 revision); - 每处理完一个事件,把
event.Kv.ModRevision记为lastRev; - 断线后,不要从
lastRev起,而是从lastRev + 1起重新 watch; - 若服务端返回
canceled=true且compact_revision != 0,说明你要的起点已被压缩,不能用同一start_revision重建,必须全量Get一次拿当前快照,再从其header.revision + 1继续 watch; - 起点被压缩的另一种表现是
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字段语义推导)。
标准姿势是"先全量、后增量":
- 启动时先
Get一次全量数据,记下响应的header.revision(记为rev0); - 立刻用
WithRev(rev0 + 1)建立 watch——注意是 +1,否则会重复收到rev0那一次的事件(幂等的话重复也无害,但会浪费一次处理); - 之后所有事件按
ModRevision顺序应用到本地缓存; - 断线后从"最后处理成功的 revision + 1"重连;
- 一旦收到
Canceled=true且CompactRevision != 0,说明中间的历史已被压缩,必须回到第 1 步全量重建; - 用
WithProgressNotify()定期拿到"截止某 revision 已投递完毕"的书签,用于判断缓存是否已经追上(注意该 revision 来自本地节点,节点被分区时可能偏低,关键路径要用 quorum 读校验)。
另外两条硬约束:watch 不保证线性一致性,所以它只能保证"事件顺序正确",不能保证"事件与你在别处发起的读操作之间的实时先后";watch 事件的到达没有时间上限,不要用"多久没收到事件"来判断"没有变更",要用 progress notify 或主动读。
9. Lease 租约 how-to-create-lease
用 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()
💡 补充:接上例,以下选主示例同样基于官方
Electionservice 语义编写,非官方文档原文。
// 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
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
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 的
Authservice 拿 token,放进Authorization头。 - watch 要用长连接:
/v3/watch用curl -N且通常后台运行;生产环境如果既要 watch 又要 keepalive 双向流,还是回到 gRPC 客户端更稳妥。 - 路径随版本变:v3.5 及之后只有
[CLIENT-URL]/v3/*,v3beta/v3alpha已废弃,别把旧脚本直接搬上来。
13. 通过代理接入 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
如实说明:该官方页面正文(去除 front matter 后)只有一段话:
etcd 的
embedGo 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/v3module,而该 module 的代码属于 etcd 内部实现,"不应被外部项目消费",包布局与 API 可能在小版本之间变化(见第 15 章)。生产环境请权衡这一风险。
15. Go 模块划分 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(操作规范)
-
所有 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 -
已发布的 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 -
所有 etcd module 应依赖相同版本的底层依赖。校验方式:
% PASSES="dep" ./test.sh -
go.mod文件不得包含未被使用的依赖,且必须符合go mod tidy格式。校验方式:% PASSES="mod_tidy" ./test.sh -
要在所有 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
.protofiles."。本节为全量速查: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. 附录 官方页面与章节映射表
覆盖页面数: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 为准。