租约与保活
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 立即删除,但服务端存在极微小执行延迟 |