键值操作
etcd 键值操作完整指南:Put、Get、Delete 的各种 Option 详解,包括前缀/范围查询、分页、排序、历史版本读取,以及 revision 保序性保证。
Put(写入)
基本用法
import (
"context"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// 最简单写入
resp, err := cli.Put(ctx, "/config/db/host", "192.168.1.100")
// 带租约写入(Key 会在 TTL 到期后自动删除)
resp, err := cli.Put(ctx, "/service/host1", "10.0.0.1:8080", clientv3.WithLease(leaseID))Put 响应
type PutResponse struct {
Header *ResponseHeader // 集群元信息(ClusterID, MemberID, Revision, RaftTerm)
PrevKv *mvccpb.KeyValue // 写入前的旧值(需 WithPrevKV)
}
fmt.Printf("写入成功,revision=%d\n", resp.Header.Revision)Put 选项
| Option | 说明 | 子客户端 |
|---|---|---|
WithLease(leaseID) |
绑定租约,到期自动删除 | 均可 |
WithPrevKV() |
返回写入前的旧 key-value | 均可 |
WithIgnoreLease() |
忽略请求中携带的 lease(用 PutWithIgnoreLease 场景) | KV only |
WithIgnoreValue() |
忽略请求中的 value(用于续约 Lease 等) | KV only |
实用模式
// 原子比较与交换(CAS)—— 只在特定条件下写入
// 如果 /lock 不存在(CreateRevision == 0),则写入
resp, err := cli.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision("/lock"), "=", 0)).
Then(clientv3.OpPut("/lock", "owner1", clientv3.WithLease(leaseID))).
Commit()
// 检查写入后的实际 revision
if resp.Succeeded {
fmt.Println("锁获取成功")
} else {
fmt.Println("锁已被占用")
}Get(读取)
基本用法
// 精确获取
resp, err := cli.Get(ctx, "/config/db/host")
if err != nil {
log.Fatal(err)
}
if len(resp.Kvs) == 0 {
fmt.Println("key 不存在")
} else {
fmt.Printf("%s = %s\n", resp.Kvs[0].Key, resp.Kvs[0].Value)
}Get 响应
type GetResponse struct {
Header *ResponseHeader
Kvs []*mvccpb.KeyValue // 匹配的 key-value 列表
Count int64 // 匹配总数(即使受 limit 限制只返回了部分)
More bool // 是否还有更多结果(配合 WithLimit 做分页)
}
type KeyValue struct {
Key []byte
Value []byte
CreateRevision int64 // 此 Key 被创建时的 revision
ModRevision int64 // 此 Key 最后一次修改时的 revision
Version int64 // 此 Key 被修改过的次数(1 表示创建后未修改)
Lease int64 // 绑定的租约 ID(0 表示无租约)
}Get 选项速查
| Option | 说明 | 示例 |
|---|---|---|
WithPrefix() |
按前缀匹配所有 Key | Get(ctx, "/config/", WithPrefix()) |
WithRange(end) |
按范围匹配 [key, end) | Get(ctx, "/a", WithRange("/z")) |
WithFromKey() |
从 key 开始匹配到末尾 | Get(ctx, "/a/", WithFromKey()) |
WithLimit(n) |
最多返回 n 个结果 | Get(ctx, "/", WithPrefix(), WithLimit(50)) |
WithRev(n) |
读取指定 revision 时的快照 | Get(ctx, "/foo", WithRev(100)) |
WithSort(sort, target) |
结果排序 | Get(ctx, "/", WithPrefix(), WithSort(SortByCreateRevision, SortDescend)) |
WithKeysOnly() |
只返回 Key,不返回 Value | Get(ctx, "/", WithPrefix(), WithKeysOnly()) |
WithCountOnly() |
只返回 Count,不返回 Kvs | Get(ctx, "/", WithPrefix(), WithCountOnly()) |
WithMinCreateRev(n) |
只返回 CreateRevision ≥ n 的 Key | — |
WithMaxCreateRev(n) |
只返回 CreateRevision ≤ n 的 Key | — |
WithMinModRev(n) |
只返回 ModRevision ≥ n 的 Key | — |
WithMaxModRev(n) |
只返回 ModRevision ≤ n 的 Key | — |
WithSerializable() |
线性一致性降级为串行读 | Get(ctx, "/", WithSerializable()) |
查询模式大全
// 1. 精确匹配
resp, _ := cli.Get(ctx, "/config/db/host")
// 2. 前缀匹配 — 最常用
resp, _ := cli.Get(ctx, "/config/", clientv3.WithPrefix())
// 3. 范围匹配 [start, end)
resp, _ := cli.Get(ctx, "/a", clientv3.WithRange("/z"))
// 4. 从某个 Key 开始到末尾
resp, _ := cli.Get(ctx, "/b", clientv3.WithFromKey())
// 5. 读取历史版本(快照读)
resp, _ := cli.Get(ctx, "/foo", clientv3.WithRev(100))
// 6. 只取 Key
resp, _ := cli.Get(ctx, "/config/", clientv3.WithPrefix(), clientv3.WithKeysOnly())
// 7. 只取数量
resp, _ := cli.Get(ctx, "/config/", clientv3.WithPrefix(), clientv3.WithCountOnly())
fmt.Printf("匹配 %d 个 key\n", resp.Count)
// 8. 按创建时间降序排列(最新的在前)
resp, _ := cli.Get(ctx, "/service/", clientv3.WithPrefix(),
clientv3.WithSort(clientv3.SortByCreateRevision, clientv3.SortDescend))
// 9. Serializable 读 — 低延迟,可能不最新
resp, _ := cli.Get(ctx, "/metric/", clientv3.WithPrefix(), clientv3.WithSerializable())分页查询
// etcd 原生分页:从上一页最后一个 Key 之后继续
func listAllKeys(cli *clientv3.Client, prefix string) ([]*mvccpb.KeyValue, error) {
var result []*mvccpb.KeyValue
opts := []clientv3.OpOption{
clientv3.WithPrefix(),
clientv3.WithSort(clientv3.SortByKey, clientv3.SortAscend),
clientv3.WithLimit(100),
}
// rangeEnd 记录本页最后一个 Key,用于下一页的起始
rangeEnd := prefix + "\x00" // 前缀查询的起始
initialKey := prefix
for {
optsWithStart := append(opts, clientv3.WithFromKey())
resp, err := cli.Get(ctx, initialKey, optsWithStart...)
if err != nil {
return nil, err
}
result = append(result, resp.Kvs...)
if !resp.More {
break
}
// 下一页从最后一个 Key + "\x00" 开始
lastKey := resp.Kvs[len(resp.Kvs)-1].Key
initialKey = string(append(lastKey, 0))
}
return result, nil
}Delete(删除)
基本用法
// 精确删除
resp, err := cli.Delete(ctx, "/obsolete/key")
// 前缀删除(删除整个子树)
resp, err := cli.Delete(ctx, "/old-config/", clientv3.WithPrefix())
// 范围删除
resp, err := cli.Delete(ctx, "/a", clientv3.WithRange("/z"))
// 返回被删除的内容
resp, err := cli.Delete(ctx, "/foo", clientv3.WithPrevKV())
for _, kv := range resp.PrevKvs {
fmt.Printf("已删除: %s = %s\n", kv.Key, kv.Value)
}
// 获取删除数量
fmt.Printf("删除了 %d 个 key\n", resp.Deleted)Delete 响应
type DeleteResponse struct {
Header *ResponseHeader
Deleted int64 // 删除了多少个 key
PrevKvs []*mvccpb.KeyValue // 被删除的 key-value(需 WithPrevKV)
}删除选项
| Option | 说明 |
|---|---|
WithPrefix() |
前缀匹配删除 |
WithRange(end) |
范围删除 |
WithFromKey() |
从 key 开始删到末尾 |
WithPrevKV() |
返回被删除的键值 |
🚨 陷阱:
clientv3.WithPrefix()对 Delete 同样有效,且没有二次确认。前缀删除是遍历匹配并逐个删除,数据量大时可能很慢,且不可回滚。
Revision 与顺序保证
🔬 深入原理
关键保证
- 单调递增:每个修改操作都会使全局 Revision +1
- 有序性:Revision 小的操作一定发生在 Revision 大的操作之前
- 原子性:Txn 内的多个操作共享一个 Revision
常见使用模式
// 模式一:先 Get 记住 revision,再 Watch 不丢事件
getResp, _ := cli.Get(ctx, "/service/", clientv3.WithPrefix())
startRev := getResp.Header.Revision + 1
// 处理当前已有数据
for _, kv := range getResp.Kvs {
handleService(kv)
}
// 从 startRev 开始监听,不会丢事件
watchChan := cli.Watch(ctx, "/service/",
clientv3.WithPrefix(),
clientv3.WithRev(startRev),
)
// 模式二:乐观并发控制
// 读取当前值及其 ModRevision
getResp, _ := cli.Get(ctx, "/counter")
modRev := getResp.Kvs[0].ModRevision
val := string(getResp.Kvs[0].Value)
// 原子更新:仅当 ModRevision 未变时才写入
txnResp, _ := cli.Txn(ctx).
If(clientv3.Compare(clientv3.ModRevision("/counter"), "=", modRev)).
Then(clientv3.OpPut("/counter", increment(val))).
Else(clientv3.OpGet("/counter")).
Commit()
if txnResp.Succeeded {
fmt.Println("更新成功")
} else {
fmt.Println("并发冲突,需要重试")
}常用键值模式汇总
| 模式 | 实现方式 | 适用场景 |
|---|---|---|
| 精确读写 | Put/Get |
配置项存取 |
| 前缀扫描 | Get + WithPrefix |
服务发现(列出所有服务实例) |
| 原子 CAS | Txn + Compare(ModRevision) |
乐观锁更新 |
| 原子不存在则创建 | Txn + Compare(CreateRevision, "=", 0) |
分布式锁获取 |
| 分页遍历 | Get + WithLimit + WithFromKey |
大数据量遍历 |
| 快照读 | Get + WithRev |
历史版本查询 |
| 批量删除 | Delete + WithPrefix |
清空配置子树 |
| 软删除 | Put + WithLease |
临时数据/会话数据 |
常见陷阱
| 陷阱 | 说明 |
|---|---|
| 🚨 空 Key 直接报错 | etcd 不允许空 Key,Put(ctx, "", "val") 返回 ErrEmptyKey |
| 🚨 WithPrefix 用错参数 | Get(ctx, "/foo", WithPrefix()) 匹配 /foo、/foo1、/foobar;若只想匹配 /foo/xxx,key 参数应写为 /foo/ |
| 🚨 Delete 不可回滚 | etcd 没有"回收站"机制,删除即永久 |
| 🚨 大 Value 性能问题 | Value 超过 1MB 时 Raft 提案和复制都会明显变慢,建议大对象存外部存储,etcd 只存引用 |
| 🚨 分页遗漏 | WithLimit 分页时若使用 WithPrefix + WithFromKey,需注意最后一页边界 |