gRPC Go 服务发现与负载均衡(更新版)

gRPC 是 Google 开源的一款高性能, 支持多种语言的 RPC 框架. 已经被广泛用于集群内服务间调用. 为了大规模流量和避免单点故障, 所以服务往往是部署多实例的, 于是负载均衡就是硬需求了.
2022 年 05 月更新: 文章更新. 修改了之前版本的错误和不清晰的地方. 补充了负载均衡原理部分.
2021 年 03 月 06 日: 第一版.
注意: 本文所有内容均基于 grpc/grpc-go, 不同语言实现会有不同, 后面不再说明.
基本介绍⌗
由于 gRPC client 和 server 建立的长连接, 因而基于连接的负载均衡没有太大意义, 所以 gRPC 负载均衡是基于每次调用. 也就是你在同一个 client 发的请求也希望它被负载均衡到所有服务端.
为什么选择客户端负载均衡⌗
一般来说负载均衡器是独立的, 被放置在服务消费者和提供者之间. 代理通常需要保存请求响应副本, 因此有性能消耗也会造成额外延迟. 当请求量大时, lb 可能会变成瓶颈, 并且此时 lb 单点故障会影响整个服务.
原理⌗
gRPC 采取的客户端负载均衡, 主要由两个客户端组件来完成:
- 维护目标服务名称和真实地址列表的映射 (resolver)
- 控制该和哪些真实地址建立连接, 该将请求发送给哪个服务实例 (balancer)
这种方式是客户端直接请求服务端, 所以没有额外性能开销. 这种模式客户端可能会和多个服务端建立连接(balancer 部分详细介绍), gRPC 的 client connection 背后其实维护了一组 subConnections, 每个 subConnection 会与一个服务端建立连接. 详情参考文档 Load Balancing in gRPC.
Resolver⌗
Resolver
提供了 server -> addrs
的映射.
gRPC go client 中负责解析 server -> addrs
的模块是 google.golang.org/grpc/resolver 模块.
client 建立连接时, 会根据 URI scheme
选取 resolver 模块中全局注册的对应 resolver, 被选中的 resolver 负责根据 uri Endpoint
解析出对应的 addrs
. 因此我们实现自己服务发现模块就是通过扩展全局注册自定义 scheme resolver
实现. 详情参考 gRPC Name Resolution 文档.
扩展 resolver 核心就是实现 resolver.Builder 这个 interface.
gRPC 客户端在建立连接时, 地址解析部分大致会有以下几个步骤:
- 根据传入地址的
Scheme
在全局 resolver map (上面代码中的 m) 中找到与之对应的 resolver (Builder) - 将地址解析为
Target
作为参数调用resolver.Build
方法实例化出Resolver
- 使用用户实现
Resolver
中调用cc.UpdateState
传入的State.Addrs
中的地址建立连接
例如: 注册一个 test resolver, m 值会变为 {test: testResolver}
, 当连接地址为 test:///xxx
时,
会被匹配到 testResolver
, 并且地址会被解析为 &Target{Scheme: "test", Authority: "", Endpoint: "xxx"}
, 之后作为调用 testResolver.Build
方法的参数.
整理一下:
- 每个 Scheme 对应一个 Builder
- 相同 Scheme 每个不同 target 对应一个 Resolver, 通过 builder.Build 实例化
静态 resolver 例子⌗
实现一个写死路由表的例子:
可以这么使用:
原理非常简单, exampleResolver
只是把从路由表中查到的 addrs
更新到底层的 connection
中.
基于 etcd 的 resolver⌗
etcd 作为服务发现主要原理是:
- 服务端启动时, 向 etcd 中存一个 key 为
{{serverName}}/{{addr}}
, 并且设置一个较短的Lease
- 服务端
KeepAlive
定时续约这个 key - 客户端启动时
get
prefix 为{{serverName}}/
的所有 key, 得到当前服务列表 - 客户端
watch
prefix 为{{serverName}}/
的 key 就能得到服务列表变动事件
接着实现:
1. 服务端注册⌗
代码很简单不做过多说明.
2. 客户端⌗
上面代码核心在于 func (r *etcdResolver) start(ctx context.Context)
这个函数, 他做了下面三件事情:
watch
etcd 相应的 key prefix, 变更事件发生时, 更新本地缓存, 更新底层连接的addrs
r.rn channel
收到消息时做一次全量刷新,r.rn
消息在ResolveNow
被调用时产生- 全局设了一个 30 分钟全量刷新的兜底方案, 周期到达时, 做一次全量刷新
使用方法和静态路由差不多, 完整代码以及事例可以查看 zcong1993/grpc-example.
Balancer⌗
Balancer
负责根据 resolver 解析到的真实地址列表, 控制客户端和服务端实例底层连接建立, 还有发送 rpc 请求时连接的选取.
首先介绍下使用, 使用内置负载均衡只需要简单一个参数: grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`)
. 当然也是和 resolver 一样支持注册自己的负载均衡实现来进行扩展.
以前是可以使用 grpc.WithBalancerName("round_robin")
, 但是这个方法被废弃了. 我个人认为后者更加清晰, GitHub 上面也有一个 grpc-go/issues/3003 讨论此问题, 感兴趣的可以查看.
原理⌗
gRPC balancer 的默认行为是: 对于 resolver 中的一组地址, 只会尝试建立一个连接, 并使用此连接处理所有 RPC 请求.
首先需要介绍下 balancer.SubConn
.
SubConn 是 gRPC 对于连接的抽象, 代表了对于一个后端服务的连接. 它包含了 resolver 解析出来的该服务的地址列表. 当 Connect 方法被调用时, gRPC 会按照地址列表顺序尝试建立连接, 一旦有第一个连接建立成功就会停止其他连接建立(只会建立一个正常连接).
接着介绍 balancer.Balancer
和 balancer.Picker
接口, 他们就是我们扩展负载均衡需要实现的接口.
balancer 会再次对连接做一次抽象, 因为他可以控制建立几条连接, 所以他需要对多个连接状态做聚合, 并且需要维护更新 picker 的状态(例如剔除/增加连接).
看到这里大家应该还是一头雾水, 下面以 round_robin
balancer 的实现来举例, 便于大家理解.
首先分析, round_robin 需要将请求按照顺序依次分发到对应的后端服务实例, 也就是说我们需要和 所有后端实例 都建立连接. 因此需要实现一个和所有后端实例建立连接并且维护聚合状态的 Balancer
, 这其实是大多数负载均衡都需要做的事情(和所有后端实例建立连接), 所以 gRPC 把这个通用功能放在了 balancer/base
中实现.
balancer/base
包实现了 Balancer
接口解决了连接需要所有实例场景下的连接管理问题, 并抽象出了一个 PickerBuilder
接口, 可以通过实现此接口来实现核心的负载均衡逻辑, 简化了扩展负载均衡的难度.
PickerBuilder.Build
方法会在连接状态改变时被调用, 永远传递最新的所有健康连接, 实现方需要通过这些信息实现出 Picker
.
round_robin 实现的 PickerBuilder
就很简单:
接着介绍下 baseBalancer
的实现.
可以看到如果想要自己从头实现一个 Balancer
还是比较困难的, 连接管理那里需要对 gRPC 足够熟悉. 所以大多数时候一般都会使用 baseBalancer 来实现自己的负载均衡器. 如果结合 resolver.Address.Attributes
元信息可以实现更多的功能, 例如: 流量路由.
写在最后⌗
通过学习 gRPC 负载均衡我们可以看到不同类型负载均衡器的优缺点, gRPC 所采用的客户端负载均衡虽然解决了性能问题, 但是也为客户端代码增加了很多复杂度, 虽然我们使用者不太感知得到, 而且文章开头也说明了 gRPC 是支持多种语言的, 也就意味着每种语言客户端都得实现. 然而现状是不同语言的客户端对于一些新特性的实现周期有很大差异, 例如: c++
, golang
, java
的客户端新特性支持情况会最好, 但是 NodeJS 之类的语言支持情况就不那么好, 这也是长期 gRPC 面临的问题. 例如至今 NodeJS client 库还是 callback 的形式, 并且仍不支持 server interceptor
.