在微服务架构中,有许多绕不开的技术话题。比如服务发现、负载均衡、指标监控、链路追踪,以及服务治理相关的超时控制、熔断、降级、限流等,还有 RPC 框架。
本篇文章介绍相对比较简单的服务发现相关内容。
服务发现
为什么在微服务架构中,需要引入服务发现呢?本质上,服务发现的目的是解耦程序对服务具体位置的依赖,对于微服务架构来说,服务发现不是可选的,而是必须的。因为在生产环境中服务提供方都是以集群的方式对外提供服务,集群中服务的 IP 随时都可能发生变化,比如服务重启,发布,扩缩容等,因此我们需要用一本 “通讯录” 及时获取到对应的服务节点,这个获取的过程其实就是 “服务发现”。

要理解服务发现,需要知道服务发现解决了如下三个问题:
- 服务的注册(Service Registration)
当服务启动的时候,应该通过某种形式(比如调用 API、产生上线事件消息、在 Etcd 中记录、存数据库等等)把自己(服务)的信息通知给服务注册中心,这个过程一般是由微服务框架来完成,业务代码无感知。
- 服务的维护(Service Maintaining)
尽管在微服务框架中通常都提供下线机制,但并没有办法保证每次服务都能优雅下线(Graceful Shutdown),而不是由于宕机、断网等原因突然失联,所以,在微服务框架中就必须要尽可能的保证维护的服务列表的正确性,以避免访问不可用服务节点的尴尬。
- 服务的发现(Service Discovery)
这里所说的发现是狭义的,它特指消费者从微服务框架(服务发现模块)中,把一个服务标识(一般是服务名)转换为服务实际位置(一般是 ip 地址)的过程。这个过程(可能是调用 API,监听 Etcd,查询数据库等)业务代码无感知。
服务发现有两种模式,分别是服务端服务发现和客户端服务发现,下面分别进行介绍。
服务端服务发现
对于服务端服务发现来说,服务调用方无需关注服务发现的具体细节,只需要知道服务的 DNS 域名即可,支持不同语言的接入,对基础设施来说,需要专门支持负载均衡器,对于请求链路来说多了一次网络跳转,可能会有性能损耗。也可以把咱们比较熟悉的 nginx 反向代理理解为服务端服务发现。

客户端服务发现
对于客户端服务发现来说,由于客户端和服务端采用了直连的方式,比服务端服务发现少了一次网络跳转,对于服务调用方来说需要内置负载均衡器,不同的语言需要各自实现。
对于微服务架构来说,我们期望的是去中心化依赖,中心化的依赖会让架构变得复杂,当出现问题的时候也会让整个排查链路变得繁琐,所以在 go-zero 中采用的是客户端服务发现的模式。

gRPC的服务发现
gRPC 提供了自定义 Resolver 的能力来实现服务发现,通过 Register 方法来进行注册自定义的 Resolver,自定义的 Resolver 需要实现 Builder 接口,定义如下:
grpc-go/resolver/resolver.go:261
1 | type Builder interface { |
先说下 Scheme()
方法的作用,该方法返回一个 stirng。注册的 Resolver
会被保存在一个全局的变量 m 中,m 是一个 map,这个 map 的 key 即为 Scheme()
方法返回的字符串。也就是多个 Resolver
是通过 Scheme
来进行区分的,所以我们定义 Resolver
的时候 Scheme
不要重复,否则 Resolver 就会被覆盖。
grpc-go/resolver/resolver.go:49
1 | func Register(b Builder) { |
再来看下 Build
方法,Build
方法有三个参数,还有 Resolver
返回值,乍一看不知道这些参数是干嘛的,遇到这种情况该怎么办呢?其实也很简单,去源码里看一下 Build
方法在哪里被调用的,就知道传入的参数是哪里来的,是什么含义了。
使用 gRPC 进行服务调用前,需要先创建一个 ClientConn 对象,最终发起调用的时候,其实是调用了 ClientConn
的 Invoke
方法,可以看下如下代码,其中 ClientConn
是通过调用 NewGreeterClient
传入的,NewGreeterClient
为 protoc
自动生成的代码,并赋值给 cc 属性,示例代码中创建 ClientConn
调用的是 Dial
方法,底层也会调用 DialContext
:
grpc-go/clientconn.go:104
1 | func Dial(target string, opts ...DialOption) (*ClientConn, error) { |
创建 ClientConn 对象,并传递给自动生成的 greeterClient
grpc-go/examples/helloworld/greeter_client/main.go:42
1 | func main() { |
最终通过 Invoke 方法真正发起调用请求。
grpc-go/examples/helloworld/helloworld/helloworld_grpc.pb.go:39
1 | func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) { |
在了解了客户端调用发起的流程后,我们重点看下 ClientConn 方法,该方法巨长,只看我们关注的 Resolver 部分。ClientConn 第二个参数 Target 的语法可以参考 github.com/grpc/grpc/blob/master/d… ,采用了 URI 的格式,其中第一部分表示 Resolver 的名称,即自定义 Builder 方法 Scheme 的返回值。格式如下:
1 | dns:[//authority/]host[:port] -- DNS(默认) |
继续往下看,通过调用 parseTargetAndFindResolver 方法来获取 Resolver
grpc-go/clientconn.go:251
1 | resolverBuilder, err := cc.parseTargetAndFindResolver() |
在 parseTargetAndFindResolver 方法中,主要就是把 target 中的 resolver name 解析出来,然后根据 resolver name 去上面我们提到的保存 Resolver 的全局变量 m 中去找对应的 Resolver。
grpc-go/clientconn.go:1574
1 | func (cc *ClientConn) parseTargetAndFindResolver() (resolver.Builder, error) { |
接着往下看,找到我们自己注册的 Resolver 之后,又调用了 newCCResolverWrapper 方法,把我们自己的 Resolver 也传了进去
grpc-go/clientconn.go:292
1 | rWrapper, err := newCCResolverWrapper(cc, resolverBuilder) |
进入到 newCCResolverWrapper 方法中,在这个方法中终于找到了我们自定义的 Builder 的 Build 方法在哪里被调用了,在 grpc-go/resolver_conn_wrapper.go:72 调用了我们自定义的 Build 方法,其中第一参数 target 传入的为 cc.parseTarget,cc 为 newCCResolverWrapper 第一个参数,即 ClientConn 对象。cc.parseTarget 是在上面提到的获取自定义 Resolver 方法 parseTargetAndFindResolver 中最后赋值的,其中 Scheme、Authority、Endpoint 分别对应 Target 语法中定义的三部分,这几个属性即将被废弃,只保留 URL 属性,定义如下:
grpc-go/resolver/resolver.go:245
1 | type Target struct { |
URL 的 Scheme 对应 Target 的 Scheme,URL 的 Host 对应 Target 的 Authority,URL 的 Path 对应 Target 的 Endpoint
/usr/local/go/src/net/url/url.go:358
1 | type URL struct { |
继续看传入自定义 Build 方法的第二个参数 cc,这个 cc 参数是一个接口 ClientConn,不要和我们之前讲的创建客户端调用用的 ClientConn 混淆,这个 ClientConn 定义如下:
grpc-go/resolver/resolver.go:203
1 | type ClientConn interface { |
ccResolverWrapper 实现了这个接口,并作为自定义 Build 方法的第二个参数传入
grpc-go/resolver_conn_wrapper.go:36
1 | type ccResolverWrapper struct { |
自定义 Build 方法的第三个参数为一些配置项,newCCResolverWrapper 实现如下:
grpc-go/resolver_conn_wrapper.go:48
1 | func newCCResolverWrapper(cc *ClientConn, rb resolver.Builder) (*ccResolverWrapper, error) { |
好了,到这里我们已经知道了自定 Resolver 的 Build 方法在哪里被调用,以及传入的参数的由来以及含义,如果你是第一次看 gRPC 源码的话可能现在已经有点懵了,可以多读几遍,为大家提供了时序图配合代码阅读效果更佳:

go-zero中如何实现的服务发现
通过对 gRPC 服务发现相关内容的学习,我们大概已经知道了服务发现是怎么回事了,有了理论,接下来我们就一起看下 go-zero 是如何基于 gRPC 做服务发现的。
通过上面的时序图可以看到第一步是需要自定义 Resolver,第二步注册自定义的 Resolver。
go-zero 的服务发现是在客户端实现的。在创建 zRPC 客户端的时候,通过 init 方法进行了自定义 Resolver 的注册。
go-zero/zrpc/internal/client.go:23
1 | func init() { |
在 go-zero 中默认注册了四个自定义的 Resolver。
go-zero/zrpc/resolver/internal/resolver.go:35
1 | func RegisterResolver() { |
通过 goctl 自动生成的 rpc 代码默认使用的是 etcd 作为服务注册与发现组件的,因此我们重点来看下 go-zero 是如何基于 etcd 实现服务注册与发现的。
etcdBuilder 返回的 Scheme 值为 etcd
go-zero/zrpc/resolver/internal/etcdbuilder.go:7
1 | func (b *etcdBuilder) Scheme() string { |
go-zero/zrpc/resolver/internal/resolver.go:15
1 | EtcdScheme = "etcd" |
还记得我们上面讲过的吗?在时序图的第五步和第六步,会通过 scheme 去全局的 m 中寻找自定义的 Resolver,而 scheme 是从 DialContext 第二个参数 target 中解析出来的,那我们看下 go-zero 调用 DialContext 的时候,传入的 target 值是什么。target 是通过 BuildTarget
方法获取来的,定义如下:
go-zero/zrpc/config.go:72
1 | func (cc RpcClientConf) BuildTarget() (string, error) { |
最终生成 target 结果的方法如下,也就是对于 etcd 来说,最终生成的 target 格式为:
1 | etcd://127.0.0.1:2379/product.rpc |
go-zero/zrpc/resolver/target.go:17
1 | func BuildDiscovTarget(endpoints []string, key string) string { |
似乎有点不对劲,scheme 不应该是 etcd 么?为什么是 discov?其实是因为 etcd 和 discov 共用了一套 Resolver 逻辑,也就是 gRPC 通过 scheme 找到已经注册的 discov Resolver,该 Resolver 对应的 Build 方法同样适用于 etcd,discov 可以认为是对服务发现的一个抽象,etcdResolver 的定义如下:
go-zero/zrpc/resolver/internal/etcdbuilder.go:3
1 | type etcdBuilder struct { |
go-zero服务注册
在详细看基于 etcd 的自定义 Resolver 逻辑之前,我们先来看下 go-zero 的服务注册,即如何把服务信息注册到 etcd 中的,我们以 lebron/apps/product/rpc 这个服务为例进行说明。
在 product-rpc 的配置文件中配置了 Etcd,包括 etcd 的地址和服务对应的 key,如下:
lebron/apps/product/rpc/etc/product.yaml:4
1 | ListenOn: 127.0.0.1:9002 |
调用 zrpc.MustNewServer 创建 gRPC server,接着会调用 NewRpcPubServer 方法,定义如下:
go-zero/zrpc/internal/rpcpubserver.go:17
1 | func NewRpcPubServer(etcd discov.EtcdConf, listenOn string, opts ...ServerOption) (Server, error) { |
在启动 Server 的时候,调用 Start 方法,在 Start 方法中会调用 registerEtcd 进行真正的服务注册
go-zero/zrpc/internal/rpcpubserver.go:44
1 | func (s keepAliveServer) Start(fn RegisterFn) error { |
在 KeepAlive 方法中,首先创建 etcd 连接,然后调用 register 方法进行服务注册,在 register 首先创建租约,租约默认时间为 10 秒钟,最后通过 Put 方法进行注册。
go-zero/core/discov/publisher.go:125
1 | func (p *Publisher) register(client internal.EtcdClient) (clientv3.LeaseID, error) { |
key 的规则定义如下,其中 key 为在配置文件中配置的 Key,这里为 product.rpc,id 为租约 id。value 为服务的地址。
go-zero/core/discov/clients.go:39
1 | func makeEtcdKey(key string, id int64) string { |
在了解了服务注册的流程后,我们启动 product-rpc 服务,然后通过如下命令查看服务注册的地址:
1 | etcdctl get product.rpc --prefix |
在 KeepAlive 方法中,服务注册完后,最后会调用 keepAliveAsync 进行租约的续期,以保证服务一直是存活的状态,如果服务异常退出了,那么也就无法进行续期,服务发现也就能自动识别到该服务异常下线了。
go-zero服务发现
现在已经把服务注册到 etcd 中了,继续来看如何发现这些服务地址。我们回到 etcdBuilder 的 Build 方法的实现。
还记得第一个参数 target 是什么吗?如果不记得了可以往上翻再复习一下,首先从 target 中解析出 etcd 的地址,和服务对应的 key。然后创建 etcd 连接,接着执行 update 方法,在 update 方法中,通过调用 cc.UpdateState 方法进行服务状态的更新。
go-zero/zrpc/resolver/internal/discovbuilder.go:14
1 | func (b *discovBuilder) Build(target resolver.Target, cc resolver.ClientConn, _ resolver.BuildOptions) ( |
如果忘记了 Build 方法第二个参数 cc 的话,可以往上翻翻再复习一下,cc.UpdateState 方法定义如下,最终会调用 ClientConn 的 updateResolverState 方法:
grpc-go/resolver_conn_wrapper.go:94
1 | func (ccr *ccResolverWrapper) UpdateState(s resolver.State) error { |
继续看 Build 方法,update 方法会被添加到事件监听中,当有 PUT 和 DELETE 事件触发,都会调用 update 方法进行服务状态的更新,事件监听是通过 etcd 的 Watch 机制实现,代码如下:
go-zero/core/discov/internal/registry.go:295
1 | func (c *cluster) watchStream(cli EtcdClient, key string) bool { |
当有事件触发的时候,会调用事件处理函数 handleWatchEvents ,最终会调用 Build 方法中定义的 update 进行服务状态的更新:
go-zero/core/discov/internal/registry.go:172
1 | func (c *cluster) handleWhandleWatchEventsatchEvents(key string, events []*clientv3.Event) { |
第一次会调用 load 方法,获取 key 对应的服务列表,通过 etcd 前缀匹配的方式获取,获取方式如下:
1 | func (c *cluster) load(cli EtcdClient, key string) { |
获取的服务地址列表,通过 map 存储在本地,当有事件触发的时候通过操作 map 进行服务列表的更新,这里有个隐藏的设计考虑是当 etcd 连不上或者出现故障时,内存里的服务地址列表不会被更新,保障了当 etcd 有问题时,服务发现依然可以工作,保障服务继续正常运行。逻辑相对比较直观,这里就不再赘述,代码逻辑在 go-zero/core/discov/subscriber.go:76 ,下面是 go-zero 服务发现的时序图

结束语
到这里服务发现相关的内容已经讲完了,内容还是有点多的,特别是代码部分需要反复仔细阅读才能加深理解。
我们一起来简单回顾下本篇的内容:
首先介绍了服务发现的概念,以及服务发现需要解决哪些问题
服务发现的两种模式,分别是服务端发现模式和客户端发现模式
接着一起学习了 gRPC 提供的注册 Resolver 的能力,通过注册 Resolver 来实现自定义的服务发现功能,以及 gRPC 内部是如何寻找到自定义的 Resolver 和触发调用自定义 Resolver 的逻辑
最后学习了 go-zero 中服务发现的实现原理,
先是介绍了 go-zero 的服务注册流程,演示了最终注册的效果
接着从自定义 Resolver 的 Build 方法出发,了解到先是通过前缀匹配的方式获取对应的服务列表存在本地,然后调用 UpdateState 方法更新服务状态
通过 Watch 的方式监听服务状态的变化,监听到变化后会触发调用 update 方法更新本地的服务列表和调用 UpdateState 更新服务的状态。
服务发现是理解微服务架构的基础,希望大家能仔细的阅读本文,如果有疑问可以随时找我讨论,在社区群中可以搜索 dawn_zhou 找到我。
通过服务发现获取到服务列表后,接着就会通过 Invoke 方法进行服务调用,在服务调用的时候就涉及到负载均衡,通过负载均衡选择一个合适的节点发起请求。负载均衡是下一篇文章要讲的内容,敬请期待。