服务发现
问题
微服务架构中服务发现是如何工作的?Go 中常用哪些方案?
答案
服务发现流程
etcd 服务注册
etcd 是 Go 微服务最主流的注册中心:
import clientv3 "go.etcd.io/etcd/client/v3"
// 服务注册
func Register(client *clientv3.Client, serviceName, addr string) error {
// 创建租约(10秒 TTL)
lease, err := client.Grant(context.Background(), 10)
if err != nil {
return err
}
// 注册服务,绑定租约
key := fmt.Sprintf("/services/%s/%s", serviceName, addr)
_, err = client.Put(context.Background(), key, addr, clientv3.WithLease(lease.ID))
if err != nil {
return err
}
// 自动续租(keep alive)
ch, err := client.KeepAlive(context.Background(), lease.ID)
if err != nil {
return err
}
// 消费续租响应(必须读取,否则 channel 满会阻塞)
go func() {
for range ch {
// 续租成功
}
// channel 关闭 = 租约过期
log.Println("lease expired, re-registering...")
}()
return nil
}
etcd 服务发现
// 获取所有实例
func Discover(client *clientv3.Client, serviceName string) ([]string, error) {
prefix := fmt.Sprintf("/services/%s/", serviceName)
resp, err := client.Get(context.Background(), prefix, clientv3.WithPrefix())
if err != nil {
return nil, err
}
var addrs []string
for _, kv := range resp.Kvs {
addrs = append(addrs, string(kv.Value))
}
return addrs, nil
}
// Watch 监听变更
func Watch(client *clientv3.Client, serviceName string, onChange func([]string)) {
prefix := fmt.Sprintf("/services/%s/", serviceName)
watchCh := client.Watch(context.Background(), prefix, clientv3.WithPrefix())
for resp := range watchCh {
for _, ev := range resp.Events {
log.Printf("事件: %s, Key: %s", ev.Type, ev.Kv.Key)
}
// 重新获取完整列表
addrs, _ := Discover(client, serviceName)
onChange(addrs)
}
}
gRPC 服务发现(Resolver)
gRPC 内置了 Resolver 机制,可以集成 etcd:
import "google.golang.org/grpc/resolver"
// 实现 gRPC Resolver
type etcdResolver struct {
client *clientv3.Client
cc resolver.ClientConn
serviceName string
}
func (r *etcdResolver) ResolveNow(resolver.ResolveNowOptions) {}
func (r *etcdResolver) Close() {}
// 启动时获取地址 + Watch 变更
func (r *etcdResolver) start() {
addrs, _ := Discover(r.client, r.serviceName)
r.updateAddrs(addrs)
go Watch(r.client, r.serviceName, r.updateAddrs)
}
func (r *etcdResolver) updateAddrs(addrs []string) {
var addresses []resolver.Address
for _, addr := range addrs {
addresses = append(addresses, resolver.Address{Addr: addr})
}
r.cc.UpdateState(resolver.State{Addresses: addresses})
}
// 使用
conn, _ := grpc.Dial(
"etcd:///user-service",
grpc.WithResolvers(etcdResolverBuilder),
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
)
方案对比
| 方案 | 一致性 | 健康检查 | Watch | Go 生态 |
|---|---|---|---|---|
| etcd | 强一致 (Raft) | Lease TTL | 支持 | ⭐⭐⭐ |
| Consul | 强一致 (Raft) | 内置多种 | 支持 | ⭐⭐ |
| Nacos | AP/CP 切换 | 心跳 | 支持 | ⭐(Java 为主) |
| ZooKeeper | 强一致 (ZAB) | 临时节点 | 支持 | ⭐ |
常见面试问题
Q1: 为什么 Go 微服务多用 etcd 而非 ZooKeeper?
答案:
- Go 原生:etcd 用 Go 写的,Go 客户端体验最好
- API 简洁:HTTP/gRPC API,比 ZK 的 TCP 协议简单
- Kubernetes 基础组件:K8s 用 etcd 存储,运维团队熟悉
- Watch 高效:基于 gRPC 长连接推送
Q2: 服务实例挂了怎么自动移除?
答案:靠租约(Lease)机制:
- 注册时绑定 10s 的租约
- 每 3s 发一次 KeepAlive 续租
- 实例崩溃 → 停止续租 → 10s 后租约过期 → etcd 自动删除 Key
- Watch 的调用方收到删除事件 → 更新本地服务列表