服务注册后etcd/zookeeper无节点,主因是注册逻辑执行时机不当;应异步重试注册、预连通校验、规范路径;客户端需Get+Watch结合维护本地缓存;gRPC resolver需正确注册scheme并显式指定;健康检查宜用TTL+标记机制;watch事件须容错乱序。

服务注册时为什么 register 调用后 etcd/zookeeper 没有节点?
常见原因是服务端未真正启动监听,或注册逻辑在服务监听前就执行了。go 的 http.Serve 是阻塞的,如果把 Register 放在它后面,永远执行不到;放在前面又可能因网络未就绪而失败。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 用 goroutine 异步注册,但加简单重试(如 3 次,间隔 500ms),避免依赖服务端启动顺序
- 注册前先尝试连通注册中心(如用
clientv3.New建立 etcd client 并ctx, cancel := context.WithTimeout(ctx, 2*time.Second)调用c.Get(ctx, "")) - 注册路径别硬编码根路径,例如 etcd 推荐用
/services/{service-name}/{host}:{port},避免多实例覆盖
客户端如何安全地从 etcd 拉取可用服务实例?
直接轮询 Get 全量 key 不够实时,也浪费连接;用 Watch 又容易丢事件(如网络抖动期间的 delete/add)。关键不是“拉不拉”,而是“怎么保持本地缓存一致”。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 首次用
Get拉全量,设置WithPrefix()匹配服务前缀(如/services/user-service/) - 紧接着起一个长期
Watch,用watchChan := c.Watch(ctx, "/services/user-service/", clientv3.WithPrefix()),并在 goroutine 中持续消费resp.Events - 每个事件需校验
kv.Version > 0且kv.ModRevision > 0,过滤掉空值或过期事件 - 本地缓存建议用
map[String]*ServiceInstance,key 为{host}:{port},更新时加sync.RWMutex保护
grpc.Resolver 自定义解析器为何总 fallback 到 DNS?
gRPC Go 默认 resolver 是 dns:///,即使你调用了 resolver.Register,若 Dial 时没显式指定 scheme(如 etcd:///user-service),它根本不会触发你的 resolver。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 注册 resolver 时 scheme 名必须全小写、无下划线,例如
etcd,不能是ETCD或etcd-resolver - Dial 地址必须以
{scheme}:///{service-name}格式,如etcd:///user-service;注意是三个斜杠,不是两个 - resolver 实现里
ResolveNow方法不能阻塞,应只触发一次UpdateState;真正的 watch 应在Build阶段启动 goroutine 处理 - 调试时可设环境变量
GRPC_GO_LOG_VERBOSITY_LEVEL=99和GRPC_GO_LOG_SEVERITY_LEVEL=info,看是否打印parsed scheme和resolver state updated
健康检查失败后,服务该立即注销还是延迟注销?
立刻注销会导致短暂网络抖动被误判为宕机,频繁上下线;延迟注销又会让客户端拿到不可用地址。折中方案是“探测+标记+条件清理”。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 服务端单独起 goroutine,每 5s 向注册中心写一次带 TTL 的临时 key(如
/health/{instance-id}),TTL 设为 10s - 注册中心(如 etcd)靠 TTL 自动清理失效 key,客户端 watch 这个 health 路径,而非直接删服务节点
- 客户端缓存中对每个实例维护
lastHealthyAt time.Time,连续 2 次未收到 health 更新(即超 20s)才从可用列表剔除 - 不要在服务退出时依赖
defer Unregister—— 进程 kill -9 无法执行 defer,必须靠 TTL 保底
服务发现最易被忽略的点:**注册中心的 watch 事件不是严格有序的,尤其在集群扩缩容或 leader 切换时,DELETE 可能晚于后续的 PUT 到达客户端。缓存更新逻辑必须能处理乱序和重复事件。**