Skip to content
租约与保活

租约与保活

etcd Lease 机制完整指南:Grant / Revoke / KeepAlive / KeepAliveOnce / TimeToLive,以及基于 Lease 的服务注册、分布式锁超时保护等实战模式。


核心概念

Lease(租约)是 etcd 中实现自动过期的机制:

  • 创建租约时指定 TTL(秒),到期后该 Lease 绑定的所有 Key 自动删除
  • 客户端通过 KeepAlive(续约)保持租约有效
  • 租约可绑定到多个 Key
Grant(ttl=10s)  →  创建 Lease-001
  ├── KeepAlive 定时续约(每 1/3 TTL 间隔)
  ├── Put("/svc/host1", "10.0.0.1", WithLease(Lease-001))   ← Key 绑定租约
  ├── Put("/locks/task", "ownerA", WithLease(Lease-001))
  │
  ├── 若 KeepAlive 中断(进程崩溃/网络断连)
  └── 10s 后 → Lease 过期 → 所有绑定 Key 自动删除

核心用途

  • 服务健康检查(注册 + 租约绑定 + 定期续约)
  • 分布式锁超时保护
  • 临时数据/会话数据自动清理

Lease API

Grant — 创建租约

import (
    "time"
    clientv3 "go.etcd.io/etcd/client/v3"
)

// 创建一个 60 秒的租约
resp, err := cli.Grant(ctx, 60)
fmt.Printf("Lease ID: %x, TTL: %d\n", resp.ID, resp.TTL)

// 绑定键到租约
_, err = cli.Put(ctx, "/service/host1", "10.0.0.1:8080", clientv3.WithLease(resp.ID))

Revoke — 吊销租约

// 立即吊销租约,绑定键全部删除
_, err := cli.Revoke(ctx, leaseID)

KeepAlive — 持续续约

// 自动续约:返回一个 channel,持续收到续约响应
lease := clientv3.NewLease(cli)
keepAliveCh, err := lease.KeepAlive(context.Background(), leaseID)

go func() {
    for resp := range keepAliveCh {
        if resp == nil {
            // channel 关闭 = 续约失败(租约可能已过期或被吊销)
            log.Println("Lease 续约失败,准备重新注册")
            return
        }
        fmt.Printf("续约成功: TTL=%d\n", resp.TTL)
    }
}()

💡 最佳实践:不要用 ctx 传入 KeepAlive 中做超时控制——一旦 ctx 超时,KeepAlive channel 关闭,租约到期导致所有 Key 被删除。使用 context.Background() 并在程序退出时调用 Revoke 或关闭 Client。

KeepAliveOnce — 续约一次

// 手动续约一次
resp, err := cli.KeepAliveOnce(ctx, leaseID)
fmt.Printf("续约一次后 TTL=%d\n", resp.TTL)

TimeToLive — 查看租约状态

// 查看租约剩余 TTL
resp, err := cli.TimeToLive(ctx, leaseID)
fmt.Printf("TTL=%d, GrantedTTL=%d, Keys=%d\n",
    resp.TTL, resp.GrantedTTL, len(resp.Keys))

// 附带列出绑定的 Keys
resp, err = cli.TimeToLive(ctx, leaseID, clientv3.WithAttachedKeys())
for _, key := range resp.Keys {
    fmt.Printf("绑定键: %s\n", string(key))
}

续约机制详解

🔬 深入原理

etcd 服务端对每个 Lease 维护一个 TTL 计时器。客户端 KeepAlive 是最少一次语义:

默认续约间隔 = TTL / 3

 TTL = 60s
 每 20s 发一次续约请求  ──►  服务端重置计时器为 60s
时间线:
  0s: Grant(TTL=60)
 20s: KeepAlive → 服务端重置 TTL=60
 40s: KeepAlive → 服务端重置 TTL=60
 60s: KeepAlive → 服务端重置 TTL=60
        ...客户端崩溃...
        (不再发 KeepAlive)
 80s: 距上次续约已过 20s,TTL 还剩 40s
 ...
 120s: TTL 归零,Lease 过期,所有绑定 Key 被删除

性能提示:服务端不会立即删除过期 Lease 绑定的 Key,存在微小延迟(通常 < 500ms)。不应依赖精确的到期时刻来协调流程。


实战模式

模式一:服务注册与发现

package main

import (
    "context"
    "log"
    "time"

    clientv3 "go.etcd.io/etcd/client/v3"
)

type ServiceRegistry struct {
    cli     *clientv3.Client
    leaseID clientv3.LeaseID
    key     string
    value   string
}

// Register 注册服务并开始续约
func (s *ServiceRegistry) Register(ctx context.Context, serviceName, instance string, ttl int64) error {
    // 1. 创建租约
    lease := clientv3.NewLease(s.cli)
    resp, err := lease.Grant(ctx, ttl)
    if err != nil {
        return err
    }
    s.leaseID = resp.ID

    // 2. 注册服务键(绑定租约)
    s.key = "/services/" + serviceName + "/" + instance
    s.value = instance
    _, err = s.cli.Put(ctx, s.key, s.value, clientv3.WithLease(s.leaseID))
    if err != nil {
        return err
    }

    // 3. 启动续约 goroutine
    keepAliveCh, err := lease.KeepAlive(context.Background(), s.leaseID)
    if err != nil {
        return err
    }

    go func() {
        for range keepAliveCh {
            // 续约成功,继续
        }
        log.Println("续约 channel 关闭,服务注册可能已失效")
    }()

    log.Printf("服务注册成功: %s", s.key)
    return nil
}

// Deregister 主动注销
func (s *ServiceRegistry) Deregister(ctx context.Context) error {
    _, err := s.cli.Revoke(ctx, s.leaseID)
    return err
}

模式二:带超时的分布式锁

// 获取锁(Key 绑定租约,避免持锁进程崩溃导致死锁)
func acquireLock(cli *clientv3.Client, lockKey, owner string, ttl int64) (clientv3.LeaseID, bool, error) {
    ctx := context.Background()

    // 创建租约
    lease := clientv3.NewLease(cli)
    resp, err := lease.Grant(ctx, ttl)
    if err != nil {
        return 0, false, err
    }
    leaseID := resp.ID

    // CAS:只在 Key 不存在时创建
    txnResp, err := cli.Txn(ctx).
        If(clientv3.Compare(clientv3.CreateRevision(lockKey), "=", 0)).
        Then(clientv3.OpPut(lockKey, owner, clientv3.WithLease(leaseID))).
        Commit()

    if err != nil || !txnResp.Succeeded {
        // 锁已被占用,释放租约
        lease.Revoke(ctx, leaseID)
        return 0, false, err
    }

    // 启动续约
    keepCh, err := lease.KeepAlive(context.Background(), leaseID)
    if err != nil {
        // 续约失败,释放锁
        lease.Revoke(ctx, leaseID)
        return 0, false, err
    }
    go func() {
        for range keepCh {
        }
    }()

    return leaseID, true, nil
}

// 释放锁
func releaseLock(cli *clientv3.Client, leaseID clientv3.LeaseID) error {
    _, err := cli.Revoke(context.Background(), leaseID)
    return err
}

模式三:批量 Key 绑定同一 Lease

// 一个租约可绑定多个 Key,实现"同生共死"
lease, _ := cli.Grant(ctx, 30)

// 注册多个服务实例
cli.Put(ctx, "/service/api/instance1", "10.0.0.1:8080", clientv3.WithLease(lease.ID))
cli.Put(ctx, "/service/api/instance2", "10.0.0.2:8080", clientv3.WithLease(lease.ID))
cli.Put(ctx, "/service/api/instance3", "10.0.0.3:8080", clientv3.WithLease(lease.ID))

// 主进程崩溃 → 30s 后所有 3 个服务实例 Key 一起删除

⚠️ 小心:一个 Lease 绑定太多 Key 会使误吊销的爆炸半径过大。推荐一个服务实例一个 Lease,而非共享。


最佳实践

实践 说明
💡 TTL 建议 10-60s 太短浪费续约请求,太长故障检测慢
💡 一个服务实例一个 Lease 而非一个应用一个 Lease,降低爆炸半径
💡 KeepAlive 用 Background ctx 不要传带超时的 ctx,否则超时后 channel 关闭
💡 监控 KeepAlive channel 关闭 收到 nil 表明续约失败,需重建 Lease 重新注册
💡 优雅退出时 Revoke 主动释放租约立即通知其他服务,避免等待 TTL 超时
💡 Lease 数量限制 单个 etcd 节点建议 Lease 数 < 10 万,超出需评估

常见陷阱

陷阱 说明
🚨 KeepAlive 用过期 ctx ctx 超时导致 channel 关闭,Lease 到期,所有绑定 Key 被删
🚨 不监控 KeepAlive channel channel 无声关闭后应用仍以为注册有效,实际 Key 已过期
🚨 TTL 设太小 网络抖动可能导致续约来不及,Key 被错误删除
🚨 TTL 设太大 进程崩溃后服务发现不能及时感知
🚨 Revoke 后 Key 不是立刻删 Lease 吊销后 Key 立即删除,但服务端存在极微小执行延迟