抽象

在 grpc-go中, 抽象了 几种连接对象, 主要区分如下:

  • addrConn: 指定地址的连接
  • SubConn: grpc的一个"子连接"抽象接口, 包含一系列地址
  • acBalancerWrapper: SubConn 的实现, 为了适配 balancer, 对addrConn做了封装, 主要提供了 #UpdateAddresses 方法, 在pickfirst、grclb、v1的balancerWrapper使用. (换句话说, 如果不支持这几种balancer, 这个抽象可以不用)
  • ClientConn: 连接端点的虚拟抽象, 底层可能创建多个连接, 负责处理 name resolution、TCP建连、TLS握手,
  • ccResolverWrapper: 用来解决name resolver的clientConn的封装. 负责调用 resolver实现
  • ccBalancerWrapper: 用来适配 balancer的clientConn的封装. 负责调用balancer实现 (分析下)

但是有意思的是, addrConn 其实本身并不是一个地址的抽象, 相反addrConn内部维护了多个地址, 但是只是对一个地址发起了连接, 因此, 更准确的说, addrConn 应该是对 地址连接 的抽象.

除此之外, 虽然有层层wrapper的封装, 但是 clientConn 真正维护的连接对象还是 addrConn, 如下:

type ClientConn struct {
	conns    map[*addrConn]struct{}
}

一个有意思的问题, 为什么分别在 clientConn 和 addrConn都做了一层balancer的封装? 为什么不抽象成一个呢? 其实, clientConn 并没与对外暴露wrapper的封装, 而是组合的模式, addrConn 也是, 只有专门的功能委托了wrapper, 为了更好的解耦. 职责也更加的单一、内聚.

subConn&addrConn

grpc设计中, 没有很好处理连接抽象, 导致了subConn 和 addrConn, 本质上而言, subConn的实现只是 addrConn 的balancer封装, 对外提供地址更新的功能, 也因此, addrConn 内部维护了地址列表. 我觉得这个完全可以避免, 对于只需要一个连接的场景, 应该是balancer内部处理 并记录所有地址.

  • 有意思的设计:

addrConn#tryUpdateAddrs 是对外提供更新地址列表的功能, 如果发现当前连接的地址在新列表里面, 则仅仅更新地址列表, 如果当前连接的地址不在里面, 则会关闭当前连接, 重新创建连接. 这样避免了因为地址变更带来的连接重连.

  • 多地区rpc请求:

根据subConn&addrConn的当前特性, 可以设计一套多地区的rpc请求设计: 一个client需要和多个区域发起rpc请求, 但是每个地区一个真实连接. 我们将每个区域设计成一个 subConn, 区域的地址都放到subConn中, 来保证这个区域只连接一个地址

创建和销毁

参考 resolver & loadBalancer 的实现

服务发现

在grpc中, 服务发现被抽象成 name resolver, 业务可以根据需要设计自己的 name resolver: 比如基于consul的. 官方提供的resolver 有 passthrough、manual、dns 三种. 也可以通过 resolver.Register 的方式进行注册. grpc 根据创建 grpc.DialContext 传递的 addr的 scheme前缀 判断使用的 resolver, 比如dns的scheme是 dns, passthrough 的scheme是 passthrough.

看服务发现的实现之前, 我们先思考这几个问题:

  1. 什么时候被动触发 服务发现
  2. 服务发现如何更新内部地址

关于问题1, grpc中, 服务发现被触发的机会很多, 除了 连接重建, 在loadbalancer创建连接都失败的时候也会触发(pickfirst). 关于问题2, 这个都是resolver具体实现进行主动触发的, 比如consul, 当定期观察到服务地址变更, 就调用 UpdateState 进行更新

ClientConn#resolveNow 本身是个异步的操作, 可以通过 connectivity.State 进行同步, 比如 grpc.DialContext 同步等待连接ready.

代码调用路径:

业务自定义resolver, 地址发现变更, 需要调用, 自定义定期调用 或者 resolver.ResolveNow 触发更新 -> 手动调用 ccResolverWrapper#UpdateState -> ClientConn#updateResolverState -> ccBalancerWrapper#updateClientConnState -> baseBalancer#UpdateClientConnState (注意, 这里创建了所有的连接, pickfirst的实现不一样) 

有人喜欢在 resolver 定期更新然后调用 ccResolverWrapper#UpdateState, 是不必要的, 因为grpc底层连接断开或者需要连接的时候都会调用 resolveNow

负载均衡&连接创建

在grpc中, 服务均衡实现很重要, 不仅仅是因为职责本身, 还因为 负载均衡负责了 连接的创建.

在balancer设计中, balancer被拆分成了两个组件, Balancer 和 Picker. pick 是我们之前认知的 负载均衡模块, 从一堆连接中选择一个. balancer 的设计是为了管理 subConn, 并调用picker进行负载均衡, balancer 主要对外提供 UpdateClientConnState 和 UpdateSubConnState, 处理客户端状态变化 和 连接变化. 客户端状态变化 主要是 resolver的地址信息变更

grpc内部balancer实现主要有两部分, 一部分是 基于baseBalancer 的实现体系, 比如 random、roundrobin; 另一类是自定义的实现, 主要是 pickfirst. 两者在创建连接的行为上存在差异,

baseBalancer baseBalancer在处理 UpdateClientConnState 的时候, 因为是地址发生变更, baseBalancer 会对所有没有记录的地址分别创建一个 subConn并进行连接, 对已经不存在的地址进行关闭. 这样, 连接就被创建了

针对 UpdateSubConnState, 如果这个连接丢失、新建, 则需要重新构建picker, 保证picker内部的信息是最新的

具体使用的balancer: roundrobin

pickfirstBalancer

和上面的不同, 在处理 UpdateClientConnState 逻辑的时候, 并不是一个addr一个 subConn, 而是所有的addr都放到了一个 subConn 当中, 这样, 最终 subConn只会创建一个连接, 这个连接是 遍历addr第一个成功创建的.

在处理 UpdateSubConnState 逻辑的时候, 仅仅负责传递状态, 因为如果连接断开, 并不需要关心.

调用

那么什么时候触发负载均衡呢? 在创建stream的时候触发, 创建stream是生成的代码调用的

ClientConn#NewStream -> newClientStream -> clientStream#newAttemptLocked -> ClientConn#getTransport -> pickerWrapper#pick 

状态通知

连接状态变更的行为:

addrConn#updateConnectivityState(连接状态变更) -> ClientConn#handleSubConnStateChange -> ccBalancerWrapper#handleSubConnStateChange -> scBuffer异步通知 -> ccBalancerWrapper#watcher实例化启动) -> Balancer#UpdateSubConnState (不同实现不一样) 

因此, 连接状态变更的最终处理是在 Balancer, 在baseBalacer中, 会触发picker重新构建, 这样做是为了确保 picker维护的状态是最新的, 避免 random/roundrobin 返回无用的地址

baseBalancer#regeneratePicker -> rrPickerBuilder#Build

优化

connection 优化

项目中一开始使用的负载均衡策略, 会导致 每个地址都会创建一个连接, 导致 sdk 视角下连接数非常多 (集群规模上去之后才发现), 为了降低不必要的oncall, 我们采用了 优化的pick_first, 但是 pick_first 容易存在问题, 因为 pick_first 总是返回第一个, 但是 第一个可能是 服务发现缓存的 bug 导致返回的是 一个不可用的ip, 在不正确设置 超时时间的情况下, ip:port 的连接可用性问题 会导致一直连接失败, 导致连接建连卡主, 因此需要使用 优化的 pick_first, 因此在 服务发现的实现中, 尽量随机 random 下.

但是 pick_first 也不是万能的, 因为感知到连接失败需要 15s, 不同公司的 linux 配置不一样, 在15s内 如果因为 认证、授权 以及 负载自我保护措施下, 会导致连接一直重连过去, 反而引起更高的cpu 负载, 所以 stream 应用场景下 尽可能使用 randon pick.