跳到主要内容

服务发现

问题

微服务架构中服务发现是如何工作的?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"}`),
)

方案对比

方案一致性健康检查WatchGo 生态
etcd强一致 (Raft)Lease TTL支持⭐⭐⭐
Consul强一致 (Raft)内置多种支持⭐⭐
NacosAP/CP 切换心跳支持⭐(Java 为主)
ZooKeeper强一致 (ZAB)临时节点支持

常见面试问题

Q1: 为什么 Go 微服务多用 etcd 而非 ZooKeeper?

答案

  1. Go 原生:etcd 用 Go 写的,Go 客户端体验最好
  2. API 简洁:HTTP/gRPC API,比 ZK 的 TCP 协议简单
  3. Kubernetes 基础组件:K8s 用 etcd 存储,运维团队熟悉
  4. Watch 高效:基于 gRPC 长连接推送

Q2: 服务实例挂了怎么自动移除?

答案:靠租约(Lease)机制

  1. 注册时绑定 10s 的租约
  2. 每 3s 发一次 KeepAlive 续租
  3. 实例崩溃 → 停止续租 → 10s 后租约过期 → etcd 自动删除 Key
  4. Watch 的调用方收到删除事件 → 更新本地服务列表

相关链接