服务注册必须调用Register并持续心跳续期,否则注册信息会因TTL过期而消失;应使用Txn事务保证注册原子性,服务发现需通过Watch监听而非轮询,并与gRPC等框架生命周期对齐。
服务注册必须调用 Register 且保持心跳续期
Go 服务要被其他节点发现,不能只“启动就完事”。以 etcd 或 consul 为例,注册本质是往 KV 存储写一条带 TTL 的键值对(如 /services/myapp/10.0.1.2:8080),但该键会自动过期。因此必须持续发送心跳(TTL 续期),否则注册信息几秒后就消失。
常见错误是:调用一次 Register 后就不管了,结果服务在注册中心里“闪现”几秒就下线。
- 使用
clientv3.NewLease创建租约,Grant获取 lease ID,再用该 ID 注册服务键 - 用
KeepAlive流持续刷新租约——这是必须的 goroutine,不能漏 - 注册键建议包含 IP、端口、元数据(如
version=1.2.0),方便后续路由或灰度 - 服务退出前
务必调用
Revoke主动清理,避免僵尸节点
etcd 客户端需区分 Put 和 Txn 注册逻辑
直接 Put 键值无法保证“先检查未注册再写入”,并发注册时可能覆盖或重复。生产环境应使用事务(Txn)做原子性校验。
resp, err := cli.Txn(context.TODO()).If(
clientv3.Compare(clientv3.Version("/services/myapp/"+addr), "=", 0),
).Then(
clientv3.OpPut("/services/myapp/"+addr, payload, clientv3.WithLease(leaseID)),
).Commit()
这段代码确保:只有该地址此前没注册过,才允许写入。否则 resp.Succeeded 为 false,可返回冲突错误给调用方。
-
Version比较比Value比较更轻量,适合判断是否存在 - 不要用
Get+Put两步操作,中间存在竞态窗口 - 注册失败时别盲目重试,先查是否已有同名实例在运行
服务发现应监听 Watch 而非轮询 Get
客户端如果每隔几秒 Get 一遍 /services/myapp/ 下所有服务,不仅增加 etcd 压力,还会错过瞬时上下线事件。正确做法是用 Watch 监听前缀变更。
watchCh := cli.Watch(context.TODO(), "/services/myapp/", clientv3.WithPrefix())
for wresp := range watchCh {
for _, ev := range wresp.Events {
switch ev.Type {
case mvccpb.PUT:
// 新实例上线,解析 ev.Kv.Key 和 ev.Kv.Value
case mvccpb.DELETE:
// 实例下线,从本地缓存移除对应 addr
}
}
}
Watch 是长连接,服务端推送变更,延迟通常在百毫秒级。但要注意:
- Watch 可能断连,需在
for循环外捕获 channel 关闭并重连 - 首次 Watch 后应补一次
Get全量,避免错过连接建立前的变更 - 不要在 Watch 回调里做耗时操作(如 HTTP 请求),应投递到 worker 队列
gRPC 服务集成时注意 resolver.Builder 的生命周期
如果你用 gRPC,想让 ClientConn 自动感知后端实例变化,得实现 resolver.Builder 并注册。关键点在于:Build 返回的 Resolver 必须自己维护 Watch,并把地址更新通过 cc.UpdateState 推给 gRPC 内部负载均衡器。
容易忽略的是:当 Resolver 被 gRPC 销毁(如 ClientConn.Close)时,你自己的 Watch goroutine 必须退出,否则造成 goroutine 泄漏。
- 在
Close方法里关闭 Watch channel、取消 context、等待 goroutine 结束 - 不要复用全局 etcd client 的 Watch —— 每个 Resolver 应管理自己的 Watch 实例
- gRPC 默认使用
round_robin策略,但前提是你的 Resolver 正确传入resolver.State{Addresses: [...]}









