Skip to content
键值操作

键值操作

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 与顺序保证

🔬 深入原理

关键保证

  1. 单调递增:每个修改操作都会使全局 Revision +1
  2. 有序性:Revision 小的操作一定发生在 Revision 大的操作之前
  3. 原子性: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,需注意最后一页边界