Go 语言分布式系统:etcd / Consul 服务注册与发现
围绕 Go 分布式系统中的服务注册与发现,说明注册中心需具备注册、保活、发现和变更通知能力,对比 etcd 以租约、前缀查询和 Watch 实现注册与下线通知,而 Consul 内置健康检查、DNS 与多数据中心支持,并给出 etcd 可复用代码、Key 命名规范及 10~30 秒 TTL 等续约和优雅下线细节。
照这篇做完,你能用 Go 写出一套能跑在生产的服务注册与发现:etcd 靠租约保活、Consul 靠自带健康检查,两条路线都有可直接抄的代码和下线细节。
第一步:先想清楚注册与发现要解决什么
分布式系统里,服务实例的 IP 和端口是动态的,写死在配置里必然出问题。注册中心要提供四件事:注册(实例启动后把自己写进去)、保活(实例挂了要能被摘掉)、发现(调用方拿到当前健康的实例列表)、变更通知(实例增减时调用方不用轮询)。
etcd 和 Consul 都能干这四件事,差别在于:etcd 是一个强一致的 KV 存储,健康检查语义要靠租约(Lease)自己实现;Consul 是专门的服务网格注册中心,健康检查、DNS 接口、多数据中心是内置的。
第二步:etcd 方案——租约 + 前缀 Watch
引入依赖:
go get go.etcd.io/etcd/client/v3
注册端核心就三步:申请租约 → 带租约写 Key → 后台续约。
cli, _ := clientv3.New(clientv3.Config{
Endpoints: []string{"127.0.0.1:2379"},
DialTimeout: 5 * time.Second,
})
defer cli.Close()
ctx := context.Background()
lease, _ := cli.Grant(ctx, 10) // 10 秒 TTL
key := "/services/order/10.0.0.5:8080"
cli.Put(ctx, key, `{"weight":1}`, clientv3.WithLease(lease.ID))
// 自动续约,进程活着就一直续
ch, _ := cli.KeepAlive(ctx, lease.ID)
go func() {
for range ch {
}
}()
// 优雅下线:撤销租约,实例立刻消失
defer cli.Revoke(context.Background(), lease.ID)
发现端用前缀查询拿全量列表,再用 Watch 拿增量:
resp, _ := cli.Get(ctx, "/services/order/", clientv3.WithPrefix())
for _, kv := range resp.Kvs {
fmt.Println(string(kv.Key), string(kv.Value))
}
watchCh := cli.Watch(ctx, "/services/order/", clientv3.WithPrefix())
for wresp := range watchCh {
for _, ev := range wresp.Events {
if len(ev.Kv.Value) == 0 { // 空值 = 删除事件
fmt.Println("下线:", string(ev.Kv.Key))
} else {
fmt.Println("上线:", string(ev.Kv.Key))
}
}
}
注意:Key 的命名必须规整成
/services/{服务名}/{实例ID},前缀查询和 Watch 全靠这个约定。实例 ID 用IP:Port或主机名-端口,保证全局唯一。
注意:TTL 不要设太短。10~30 秒比较稳妥,太短会因网络抖动误摘健康实例;同时 KeepAlive 的意义在于——进程假死(死锁、长 GC)时续约中断,实例自动被摘掉,这正是我们要的效果。
第三步:Consul 方案——ServiceRegister + 健康检查
Consul 把保活做进了 Agent,你不用自己维护租约。
go get github.com/hashicorp/consul/api
cfg := api.DefaultConfig()
cfg.Address = "127.0.0.1:8500"
c, _ := api.NewClient(cfg)
reg := &api.AgentServiceRegistration{
ID: "order-10.0.0.5-8080",
Name: "order",
Address: "10.0.0.5",
Port: 8080,
Check: &api.AgentServiceCheck{
HTTP: "http://10.0.0.5:8080/health",
Interval: "10s",
Timeout: "3s",
DeregisterCriticalServiceAfter: "60s", // 关键
},
}
c.Agent().ServiceRegister(reg)
defer c.Agent().ServiceDeregister(reg.ID)
发现端查健康实例,只返回 passing 状态的:
entries, _, _ := c.Health().Service("order", "", true, nil)
for _, e := range entries {
addr := fmt.Sprintf("%s:%d", e.Service.Address, e.Service.Port)
fmt.Println(addr)
}
如果你不想暴露 HTTP 健康端点,把 HTTP/Interval 换成 TTL: "15s",然后在业务里定时上报:
go func() {
t := time.NewTicker(5 * time.Second)
for range t.C {
c.Agent().PassTTL("service:order-10.0.0.5-8080", "")
}
}()
注意:
DeregisterCriticalServiceAfter不设置的话,服务彻底挂掉后实例会永远留在 Catalog 里,调用方一直拿到死地址。这是 Consul 最常见的坑。
注意:TTL 模式下的 CheckID 格式是
service:{注册时的ID},写错不会报错,但心跳等于白发。
第四步:调用方怎么做负载均衡
拿到实例列表后,别每个请求都去查一次注册中心。标准做法是本地缓存 + Watch/Blocking Query 更新:后台协程维护一份内存列表,请求时对它做轮询或随机挑选。etcd 用 Watch,Consul 用 Health().Service 的 QueryOptions{WaitIndex: lastIndex, WaitTime: 5 * time.Minute} 长轮询。
再进一步就是接入 gRPC 的 resolver.Builder,让 gRPC 自己按服务名拨号;或者用 go-kit、grpc-go 官方的 etcd resolver 插件,避免手写。
第五步:怎么选
- 已经在用 Kubernetes、etcd 集群现成:选 etcd,少一套组件,Watch 语义干净。
- 需要开箱即用的健康检查、DNS 查询、跨数据中心:选 Consul。
- 注册中心本身必须集群部署——etcd 至少 3 节点,Consul 3 或 5 节点。单点注册中心挂了,整个系统就没法扩缩容。
注意:无论用哪个,服务关闭时一定要走
defer Revoke / Deregister,否则要等 TTL 过期才摘除,滚动发布期间会有一段时间请求打到已停的实例上。
小结
- 注册 = 写 Key(etcd 带 Lease / Consul 带 Service),保活 = KeepAlive 或 PassTTL。
- etcd 用前缀 + Watch 做发现;Consul 用
Health().Service(..., passingOnly=true)。 - Key/ID 命名要有约定且全局唯一,否则发现端拿不到正确列表。
- 调用方本地缓存实例列表,靠 Watch 或阻塞查询更新,不要每次请求查注册中心。
- 优雅下线(Revoke / Deregister)和
DeregisterCriticalServiceAfter是两个必配项。
转载请注明出处,版权归原作者所有。
正式会员
认证极客






