编程 go-zero 服务发现源码笔记:discov 复用 etcd Resolver,etcd 挂了靠本地 map 撑住

2026-09-12 00:03:50

go-zero 服务发现源码笔记:discov 复用 etcd Resolver,etcd 挂了靠本地 map 撑住

项目地址:。本文对应 go-zero v1.3.5 / grpc-go v1.47.0。

服务发现要解决的三件事

  1. 注册(Service Registration):服务启动时通过调 API、上线事件、写 Etcd 或数据库,把自己的信息通知注册中心。这一步一般由微服务框架做掉,业务代码无感知。
  2. 维护(Service Maintaining):宕机、断网这类突然失联没法避免,框架要保证服务列表尽量正确,别把请求打到已经不可用的节点上。
  3. 发现(Service Discovery):消费者把服务标识(服务名)换成实际位置(IP)。手段可以是调 API、监听 Etcd、查数据库,同样对业务代码透明。

实现上有两条路:

  • 服务端发现:调用方只认 DNS 域名,不关心发现细节,多语言接入门槛低;代价是基础设施要专门支持负载均衡器,而且请求链路多一跳。nginx 反向代理就可以理解成服务端发现。
  • 客户端发现:客户端和服务端直连,少一跳;但调用方得内置负载均衡器,每种语言各写一套。

go-zero 选的是客户端发现,出发点是去中心化依赖——中心化依赖会让架构变复杂,排查链路也变长。

gRPC 的扩展点:自定义 Resolver

gRPC 允许注册自定义 Resolver,自定义 Resolver 需要实现 Builder 接口:

// grpc-go/resolver/resolver.go:261
type Builder interface {
Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
Scheme() string
}

Scheme() 返回的字符串就是注册 key,所有 Resolver 存在一个全局 map 变量 m 里:

func Register(b Builder) { m[b.Scheme()] = b }

这带来一个明确的失败模式:多个 Resolver 靠 Scheme 区分,Scheme 不能重复,重复注册会被直接覆盖,后注册的把先注册的顶掉,而且不会有任何报错。

调用侧流程是:发起请求前先建 ClientConn(grpc.DialDialContext),最后 InvokeDialContext 的第二个参数 Target 遵循 URI 语法(见 grpc naming.md),第一部分表示 Resolver 名称,也就是 Builder Scheme() 的返回值,格式为 dns:[//authority/]host[:port],其中 dns 是默认值。

parseTargetAndFindResolver 从 target 解出 resolver name,再去全局 m 里找 Resolver;找不到就返回 could not get resolver for default scheme。找到后 newCCResolverWrapper(resolver_conn_wrapper.go:72)调用自定义 Builder 的 Build,第一个参数是 cc.parseTarget,第二个参数 ccccResolverWrapper,实现了 ClientConn 接口:

type ClientConn interface {
UpdateState(State) error
ReportError(error)
NewAddress(addresses []Address)
NewServiceConfig(serviceConfig string)
ParseServiceConfig(serviceConfigJSON string) *serviceconfig.ParseResult
}

Target 结构体(resolver.go:245)里的 Scheme/Authority/Endpoint 三个字段即将废弃,只保留 URL:URL.Scheme 对应 Scheme,URL.Host 对应 Authority,URL.Path 对应 Endpoint

go-zero 里的 Resolver 注册

go-zero 的服务发现在客户端实现。创建 zRPC 客户端时通过 init 注册自定义 Resolver:resolver.Register()。默认注册四个 Builder:directResolverBuilderdiscovResolverBuilderetcdResolverBuilderk8sResolverBuilder

goctl 生成的 rpc 代码默认用 etcd 做注册与发现。etcdBuilderScheme() 返回 "etcd"(EtcdScheme = "etcd")。

DialContext 的 target 由 BuildTarget 生成:Endpoints 优先返回 direct target,其次 Target,否则走 Etcd 配置(ValidateRegisterAccountRegisterTLS),最后 BuildDiscovTarget:

func BuildDiscovTarget(endpoints []string, key string) string {
return fmt.Sprintf("%s://%s/%s", internal.DiscovScheme, strings.Join(endpoints, internal.EndpointSep), key)
}

注意这里生成的是 discov:// 而不是 etcd://,target 形如 discov://127.0.0.1:2379/product.rpc。原因是 etcd 和 discov 共用一套 Resolver 逻辑:gRPC 按 scheme 找到已注册的 discov Resolver,它的 Build 对 etcd 同样适用,discov 可以看作对服务发现这一层的抽象。etcdResolver 的定义很短:

type etcdBuilder struct { discovBuilder }

服务注册:租约 + KeepAlive

以 lebron/apps/product/rpc 为例:

ListenOn: 127.0.0.1:9002
Etcd:
Hosts:
- 127.0.0.1:2379
Key: product.rpc

zrpc.MustNewServerNewRpcPubServer,registerEtcd 里创建 NewPublisher 并调 KeepAlive。真正注册发生在 Server Start 调用 registerEtcd 时。

register(publisher.go:125)先 Grant 创建租约,租约默认时间 10 秒(TimeToLive),再用 Put 写入(WithLease)。key 的拼接规则:

func makeEtcdKey(key string, id int64) string {
return fmt.Sprintf("%s%c%d", key, internal.Delimiter, id)
}

也就是 product.rpc + 分隔符 + 租约 id,value 是服务地址。验证:

$ etcdctl get product.rpc --prefix
product.rpc/7587864068988009477
127.0.0.1:9002

注册完成后 KeepAlivekeepAliveAsync 续租。进程异常退出就没法续租,10 秒租约到期后这个节点自动被判定为下线——不需要额外的优雅下线协议来兜底。

服务发现:Watch 加本地 map

etcdBuilderBuild 实际是 discovBuilder.Build(discovbuilder.go:14):从 target 解析出 etcd 地址与 key,创建 Subscriber,定义 update 方法调用 cc.UpdateState(resolver.State{Addresses}),把 sub.AddListener(update) 注册为监听,并立刻 update() 一次。

Watch 事件监听在 discov/internal/registry.go:295watchStream:

rch := cli.Watch(clientv3.WithRequireLeader(c.context(cli)), makeKeyPrefix(key), clientv3.WithPrefix())

返回结果里处理 wresp.Canceledwresp.Err(),正常则进入 handleWatchEventsWithRequireLeader 的作用是保证读到的是 leader 上的数据,避免跟随者返回过期视图。

handleWatchEvents 处理 PUT/DELETE 事件,更新本地 values map 并通知 listeners 的 OnAdd/OnDelete

首次则走 load(registry.go:172),按前缀 Get 拉全量:

resp, err = cli.Get(ctx, makeKeyPrefix(key), clientv3.WithPrefix())

失败会重试(RequestTimeout + coolDownInterval sleep)。取到后由 handleChanges 更新本地 map。

这里有个不显眼但关键的设计:服务地址列表是存在本地 map 里的。etcd 连不上或发生故障时,内存里的列表不会被更新,也不会被清空。所以 etcd 本身出问题时,服务发现仍然能按已有列表工作,已有服务继续运行——代价是这段时间内的上下线变更不会被感知。

原文中 handleWatchEvents 曾被排版成 handleWhandleWatchEventsatchEvents,属笔误。

复制全文 生成海报 service discovery go-zero gRPC etcd 微服务

推荐文章

程序员茄子在线接单