上一篇文章一起学习了Resolver的原理和源码分析,本篇继续和大家一起学习下和Resolver关系密切的Balancer的相关内容。这里说的负载均衡主要指数据中心内的负载均衡,即RPC间的负载均衡。
传送门 服务发现原理分析与源码解读
基于go-zero v1.3.5 和 grpc-go v1.47.0
负载均衡
每一个被调用服务都会有多个实例,那么服务的调用方应该将请求,发向被调用服务的哪一个服务实例,这就是负载均衡的业务场景。
负载均衡的第一个关键点是公平性,即负载均衡需要关注被调用服务实例组之间的公平性,不要出现旱的旱死,涝的涝死的情况。
负载均衡的第二个关键点是正确性,即对于有状态的服务来说,负载均衡需要关心请求的状态,将请求调度到能处理它的后端实例上,不要出现不能处理和错误处理的情况。
无状态的负载均衡
无状态的负载均衡是我们日常工作中接触比较多的负载均衡模型,它指的是参与负载均衡的后端实例是无状态的,所有的后端实例都是对等的,一个请求不论发向哪一个实例,都会得到相同的并且正确的处理结果,所以无状态的负载均衡策略不需要关心请求的状态。下面介绍两种无状态负载均衡算法。
轮询
轮询的负载均衡策略非常简单,只需要将请求按顺序分配给多个实例,不用再做其他的处理。例如,轮询策略会将第一个请求分配给第一个实例,然后将下一个请求分配给第二个实例,这样依次分配下去,分配完一轮之后,再回到开头分配给第一个实例,再依次分配。轮询在路由时,不利用请求的状态信息,属于无状态的负载均衡策略,所以它不能用于有状态实例的负载均衡器,否则正确性会出现问题。在公平性方面,因为轮询策略只是按顺序分配请求,所以适用于请求的工作负载和实例的处理能力差异都较小的情况。
权重轮询
权重轮询的负载均衡策略是将每一个后端实例分配一个权重,分配请求的数量和实例的权重成正比轮询。例如有两个实例 A,B,假设我们设置 A 的权重为 20,B 的权重为 80,那么负载均衡会将 20% 的请求数量分配给 A,80 % 的请求数量分配给 B。权重轮询在路由时,不利用请求的状态信息,属于无状态的负载均衡策略,所以它也不能用于有状态实例的负载均衡器,否则正确性会出现问题。在公平性方面,因为权重策略会按实例的权重比例来分配请求数,所以,我们可以利用它解决实例的处理能力差异的问题,认为它的公平性比轮询策略要好。
有状态负载均衡
有状态负载均衡是指,在负载均衡策略中会保存服务端的一些状态,然后根据这些状态按照一定的算法选择出对应的实例。
P2C+EWMA
在go-zero中默认使用的是P2C的负载均衡算法。该算法的原理比较简单,即随机从所有可用节点中选择两个节点,然后计算这两个节点的负载情况,选择负载较低的一个节点来服务本次请求。为了避免某些节点一直得不到选择导致不平衡,会在超过一定的时间后强制选择一次。
在该复杂均衡算法中,多出采用了EWMA指数移动加权平均的算法,表示是一段时间内的均值。该算法相对于算数平均来说对于突然的网络抖动没有那么敏感,突然的抖动不会体现在请求的lag中,从而可以让算法更加均衡。
go-zero/zrpc/internal/balancer/p2c/p2c.go:133
atomic.StoreUint64(&c.lag, uint64(float64(olag)*w+float64(lag)*(1-w)))
go-zero/zrpc/internal/balancer/p2c/p2c.go:139
atomic.StoreUint64(&c.success, uint64(float64(osucc)*w+float64(success)*(1-w)))
系数w是一个时间衰减值,即两次请求的间隔越大,则系数w就越小。
go-zero/zrpc/internal/balancer/p2c/p2c.go:124
w := math.Exp(float64(-td) / float64(decayTime))
节点的load值是通过该连接的请求延迟 lag 和当前请求数 inflight 的乘积所得,如果请求的延迟越大或者当前正在处理的请求数越多表明该节点的负载越高。
go-zero/zrpc/internal/balancer/p2c/p2c.go:199
func (c *subConn) load() int64 {
// plus one to avoid multiply zero
lag := int64(math.Sqrt(float64(atomic.LoadUint64(&c.lag) + 1)))
load := lag * (atomic.LoadInt64(&c.inflight) + 1)
if load == 0 {
return penalty
}
return load
}
源码分析
如下源码会涉及go-zero和gRPC,请根据给出的代码路径进行区分
在gRPC中,Balancer和Resolver一样也可以自定义,同样也是通过Register方法进行注册
grpc-go/balancer/balancer.go:53
func Register(b Builder) {
m[strings.ToLower(b.Name())] = b
}
Register的参数Builder为接口,在Builder接口中,Build方法的第一个参数ClientConn也为接口,Build方法的返回值Balancer同样也是接口,定义如下:
可以看出,要想实现自定义的Balancer的话,就必须要实现balancer.Builder接口。
在了解了gRPC提供的Balancer的注册方式之后,我们看一下go-zero是在什么地方进行Balancer注册的
go-zero/zrpc/internal/balancer/p2c/p2c.go:36
func init() {
balancer.Register(newBuilder())
}
在go-zero中并没有实现 balancer.Builder 接口,而是使用gRPC提供的 base.baseBuilder 进行注册,