go-zero 服务发现源码笔记:discov 复用 etcd Resolver,etcd 挂了靠本地 map 撑住
项目地址:。本文对应 go-zero v1.3.5 / grpc-go v1.47.0。
服务发现要解决的三件事
- 注册(Service Registration):服务启动时通过调 API、上线事件、写 Etcd 或数据库,把自己的信息通知注册中心。这一步一般由微服务框架做掉,业务代码无感知。
- 维护(Service Maintaining):宕机、断网这类突然失联没法避免,框架要保证服务列表尽量正确,别把请求打到已经不可用的节点上。
- 发现(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.Dial → DialContext),最后 Invoke。DialContext 的第二个参数 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,第二个参数 cc 是 ccResolverWrapper,实现了 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:directResolverBuilder、discovResolverBuilder、etcdResolverBuilder、k8sResolverBuilder。
goctl 生成的 rpc 代码默认用 etcd 做注册与发现。etcdBuilder 的 Scheme() 返回 "etcd"(EtcdScheme = "etcd")。
DialContext 的 target 由 BuildTarget 生成:Endpoints 优先返回 direct target,其次 Target,否则走 Etcd 配置(Validate、RegisterAccount、RegisterTLS),最后 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.MustNewServer → NewRpcPubServer,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
注册完成后 KeepAlive 调 keepAliveAsync 续租。进程异常退出就没法续租,10 秒租约到期后这个节点自动被判定为下线——不需要额外的优雅下线协议来兜底。
服务发现:Watch 加本地 map
etcdBuilder 的 Build 实际是 discovBuilder.Build(discovbuilder.go:14):从 target 解析出 etcd 地址与 key,创建 Subscriber,定义 update 方法调用 cc.UpdateState(resolver.State{Addresses}),把 sub.AddListener(update) 注册为监听,并立刻 update() 一次。
Watch 事件监听在 discov/internal/registry.go:295 的 watchStream:
rch := cli.Watch(clientv3.WithRequireLeader(c.context(cli)), makeKeyPrefix(key), clientv3.WithPrefix())
返回结果里处理 wresp.Canceled 与 wresp.Err(),正常则进入 handleWatchEvents。WithRequireLeader 的作用是保证读到的是 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,属笔误。