负载均衡原理分析与源码解读

时间:2024-10-22 08:17:16

上一篇文章一起学习了Resolver的原理和源码分析,本篇继续和大家一起学习下和Resolver关系密切的Balancer的相关内容。这里说的负载均衡主要指数据中心内的负载均衡,即RPC间的负载均衡。

传送门 服务发现原理分析与源码解读

基于go-zero v1.3.5grpc-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/:133

64(&, uint64(float64(olag)*w+float64(lag)*(1-w)))

go-zero/zrpc/internal/balancer/p2c/:139

64(&, uint64(float64(osucc)*w+float64(success)*(1-w)))

系数w是一个时间衰减值,即两次请求的间隔越大,则系数w就越小。

go-zero/zrpc/internal/balancer/p2c/:124

w := (float64(-td) / float64(decayTime))

节点的load值是通过该连接的请求延迟 lag 和当前请求数 inflight 的乘积所得,如果请求的延迟越大或者当前正在处理的请求数越多表明该节点的负载越高。

go-zero/zrpc/internal/balancer/p2c/:199

  1. func (c *subConn) load() int64 {
  2.   // plus one to avoid multiply zero
  3.   lag := int64((float64(64(&+ 1)))
  4.   load := lag * (64(&+ 1)
  5.   if load == 0 {
  6.     return penalty
  7.   }
  8.   return load
  9. }

源码分析

如下源码会涉及go-zero和gRPC,请根据给出的代码路径进行区分

在gRPC中,Balancer和Resolver一样也可以自定义,同样也是通过Register方法进行注册

grpc-go/balancer/:53

  1. func Register(b Builder) {
  2.   m[strings.ToLower(b.Name())] = b
  3. }

Register的参数Builder为接口,在Builder接口中,Build方法的第一个参数ClientConn也为接口,Build方法的返回值Balancer同样也是接口,定义如下:

可以看出,要想实现自定义的Balancer的话,就必须要实现接口。

在了解了gRPC提供的Balancer的注册方式之后,我们看一下go-zero是在什么地方进行Balancer注册的

go-zero/zrpc/internal/balancer/p2c/:36

  1. func init() {
  2.   balancer.Register(newBuilder())
  3. }

在go-zero中并没有实现 接口,而是使用gRPC提供的 进行注册, 实现了 接口。创建baseBuilder的时候调用了 方法,需要传入 PickerBuilder 参数,PickerBuilder为接口,在go-zero中 p2c.p2cPickerBuilder 实现了该接口。

PickerBuilder接口Build方法返回值 也是一个接口,p2c.p2cPicker 实现了该接口。

grpc-go/balancer/base/:65

  1. func NewBalancerBuilder(name string, pb PickerBuilder, config Config)  {
  2.   return &baseBuilder{
  3.     name:          name,
  4.     pickerBuilder: pb,
  5.     config:        config,
  6.   }
  7. }

各结构之间的关系如下图所示,其中各结构模块对应的包为:

  • balancer:grpc-go/balancer

  • base:grpc-go/balancer/base

  • p2c: go-zero/zrpc/internal/balancer/p2c

在哪里获取已注册的Balancer?

通过上面的流程步骤,已经知道了如何自定义Balancer,以及如何注册自定义的Blancer。既然注册了肯定就会获取,接下来看一下是在哪里获取已经注册的Balancer的。

我们知道Resolver是通过解析DialContext的第二个参数target,从而得到Resolver的name,然后根据name获取到对应的Resolver的。获取Balancer同样也是根据名称,Balancer的名称是在创建gRPC Client的时候通过配置项传入的,这里的为注册Balancer时指定的名称 p2c_ewma ,如下:

go-zero/zrpc/internal/:50

  1. func NewClient(target string, opts ...ClientOption) (Client, error) {
  2.   var cli client
  3.   svcCfg := (`{"loadBalancingPolicy":"%s"}`, )
  4.   balancerOpt := WithDialOption((svcCfg))
  5.   opts = append([]ClientOption{balancerOpt}, opts...)
  6.   if err := (target, opts...); err != nil {
  7.     return nil, err
  8.   }
  9.   return &cli, nil
  10. }

在上一篇文章中,我们已经知道当创建gRPC客户端的时候,会触发调用自定义Resolver的Build方法,在Build方法内部获取到服务地址列表后,通过方法进行状态更新,后面当监听到服务状态变化的时候同样也会调用进行状态的更新,而这里的cc指的就是 ccResolverWrapper 对象,这一部分如果忘记的话,可以再去回顾一下讲解Resolver的那篇文章,以便能丝滑接入本篇:

go-zero/zrpc/resolver/internal/:51

  1. if err := ({
  2.   Addresses: addrs,
  3. }); err != nil {
  4.   logx.Error(err)
  5. }

这里有几个重要的模块对象,如下:

  • ClientConn:grpc-go/:464

  • ccResolverWrapper:grpc-go/resolver_conn_wrapper.go:36

  • ccBalancerWrapper:grpc-go/balancer_conn_wrappers.go:48

  • Balancer:grpc-go/internal/balancer/gracefulswitch/:46

  • balancerWrapper:grpc-go/internal/balancer/gracefulswitch/:247

当监听到服务状态的变更后(首次启动或者通过Watch监听变化)调用 触发更新状态的流程,各模块间的调用链路如下所示:

获取Balancer的动作是在 方法中触发的,代码如下所示:

grpc-go/balancer_conn_wrappers.go:266

  1. builder := balancer.Get(name)
  2. if builder == nil {
  3.   (logger, , "Channel switches to new LB policy %q, since the specified LB policy %q was not registered", PickFirstBalancerName, name)
  4.   builder = newPickfirstBuilder()
  5. else {
  6.   (logger, , "Channel switches to new LB policy %q", name)
  7. }
  8. if err := (builder); err != nil {
  9.   (logger, , "Channel failed to build new LB policy %q: %v", name, err)
  10.   return
  11. }
  12.  = ()

然后在 方法中,调用了自定义Balancer的Build方法:

grpc-go/internal/balancer/gracefulswitch/:121

newBalancer := builder.Build(bw, )

上文有提到Build方法的第一个参数为接口 ,而这里传入的为 balancerWrapper ,所以实现了该接口:

到这里我们已经知道了获取自定义Balancer是在哪里触达的,以及在哪里获取的自定义的Balancer,和的Build方法在哪里被调用。

通过上文可知这里的为baseBuilder,所以调用的Build方法为baseBuilder的Build方法,Build方法的定义如下:

grpc-go/balancer/base/:39

  1. func (bb *baseBuilder) Build(cc , opt )  {
  2.   bal := &baseBalancer{
  3.     cc:            cc,
  4.     pickerBuilder: ,
  5.     subConns: (),
  6.     scStates: make(map[]),
  7.     csEvltr:  &{},
  8.     config:   ,
  9.   }
  10.    = NewErrPicker()
  11.   return bal
  12. }

Build方法返回了baseBalancer,可以知道baseBalancer实现了接口:

再来回顾下这个流程,其实主要做了如下几件事:

  1. 在自定义的Resolver中监听服务状态的变更

  2. 通过UpdateState来更新状态

  3. 获取自定义的Balancer

  4. 执行自定义Balancer的Build方法获取Balancer

如何创建连接?

继续回到ClientConn的updateResolverState方法,在方法的最后调用方法更新客户端的连接状态:

grpc-go/:664

  1. uccsErr := (&{ResolverState: s, BalancerConfig: balCfg})
  2. if ret == nil {
  3. ret = uccsErr // prefer ErrBadResolver state since any other error is
  4. // currently meaningless to the caller.
  5. }

后面的调用链路如下图所示:

最终会调用方法:

grpc-go/balancer/base/:94

  1. func (b *baseBalancer) UpdateClientConnState(s ) error {
  2.   // .............
  3.    = nil
  4.   addrsSet := ()
  5.   for _, a := range  {
  6.     addrsSet.Set(a, nil)
  7.     if _, ok := .Get(a); !ok {
  8.       sc, err := ([]resolver.Address{a}, {HealthCheckEnabled: })
  9.       if err != nil {
  10.         (": failed to create new SubConn: %v", err)
  11.         continue
  12.       }
  13.       .Set(a, sc)
  14.       [sc] = 
  15.       (, )
  16.       ()
  17.     }
  18.   }
  19.   for _, a := range () {
  20.     sci, _ := .Get(a)
  21.     sc := sci.()
  22.     if _, ok := addrsSet.Get(a); !ok {
  23.       (sc)
  24.       .Delete(a)
  25.     }
  26.   }
  27.   // ................
  28. }

当第一次触发调用UpdateClientConnState的时候,如下代码中 ok 为 false:

_, ok := .Get(a);

所以会创建新的连接:

sc, err := .NewSubConn([]{a}, balancer.NewSubConnOptions{HealthCheckEnabled: })

这里的 即为 balancerWrapper,忘记的盆友可以往上翻看复习一下,也就是会调用 创建连接

grpc-go/internal/balancer/gracefulswitch/:328

  1. func (bw *balancerWrapper) NewSubConn(addrs []resolver.Address, opts ) (, error) {
  2.   // .............
  3.   sc, err := (addrs, opts)
  4.   if err != nil {
  5.     return nil, err
  6.   }
  7.   
  8.   // .............
  9.   
  10.   [sc] = true
  11.   
  12.   // .............
  13. }

即为ccBalancerWrapper,所以这里会调用创建连接:

grpc-go/balancer_conn_wrappers.go:299

  1. func (ccb *ccBalancerWrapper) NewSubConn(addrs []resolver.Address, opts ) (, error) {
  2.   if len(addrs) <= 0 {
  3.     return nil, ("grpc: cannot create SubConn with empty address list")
  4.   }
  5.   ac, err := (addrs, opts)
  6.   if err != nil {
  7.     (logger, , "acBalancerWrapper: NewSubConn: failed to newAddrConn: %v", err)
  8.     return nil, err
  9.   }
  10.   acbw := &acBalancerWrapper{ac: ac}
  11.   .Lock()
  12.    = acbw
  13.   .Unlock()
  14.   return acbw, nil
  15. }

最终返回的是acBalancerWrapper对象,acBalancerWrapper实现了接口:

调用流程图如下所示:

创建连接的默认状态为 :

grpc-go/:699

  1. func (cc *ClientConn) newAddrConn(addrs [], opts ) (*addrConn, error) {
  2.   ac := &addrConn{
  3.     state:        ,
  4.     cc:           cc,
  5.     addrs:        addrs,
  6.     scopts:       opts,
  7.     dopts:        ,
  8.     czData:       new(channelzData),
  9.     resetBackoff: make(chan struct{}),
  10.   }
  11.  
  12.   // ...........
  13. }

在gRPC中为连接定义了五种状态,分别如下:

  1. const (
  2.   // Idle indicates the ClientConn is idle.
  3.   Idle State = iota
  4.   // Connecting indicates the ClientConn is connecting.
  5.   Connecting
  6.   // Ready indicates the ClientConn is ready for work.
  7.   Ready
  8.   // TransientFailure indicates the ClientConn has seen a failure but expects to recover.
  9.   TransientFailure
  10.   // Shutdown indicates the ClientConn has started shutting down.
  11.   Shutdown
  12. )

在 **baseBalancer ** 中通过保存创建的连接,初始状态也为,之后通过()进行连接:

grpc-go/balancer/base/:112

  1. .Set(a, sc)
  2. [sc] = 
  3. (, )
  4. ()

这里调用的是acBalancerWrapper的Connect方法,可以看到这里创建连接是异步进行的:

grpc-go/balancer_conn_wrappers.go:406

  1. func (acbw *acBalancerWrapper) Connect() {
  2.   acbw.mu.Lock()
  3.   defer acbw.mu.Unlock()
  4.   go acbw.ac.connect()
  5. }

最后会调用方法:

grpc-go/:786

  1. func (ac *addrConn) connect() error {
  2.   .Lock()
  3.   if  ==  {
  4.     .Unlock()
  5.     return errConnClosing
  6.   }
  7.   if  !=  {
  8.     .Unlock()
  9.     return nil
  10.   }
  11.   (, nil)
  12.   .Unlock()
  13.   ()
  14.   return nil
  15. }

从connect开始的调用链路如下所示:

在baseBalancer的UpdateSubConnState方法的最后,更新了Picker为自定义的Picker:

grpc-go/balancer/base/:221

.UpdateState({ConnectivityState: , Picker: })

在addrConn方法的最后会调用()真正的进行连接的创建:

当连接已经创建好,处于Ready状态,最后调用方法,此时s==为true,而oldS == 为false,所以会调用()方法:

  1. if (s == ) != (oldS == ) ||
  2.      ==  {
  3.     ()
  4. }
  1. func (b *baseBalancer) regeneratePicker() {
  2.   if  ==  {
  3.      = NewErrPicker(())
  4.     return
  5.   }
  6.   readySCs := make(map[]SubConnInfo)
  7.   // Filter out all ready SCs from full subConn map.
  8.   for _, addr := range () {
  9.     sci, _ := .Get(addr)
  10.     sc := sci.()
  11.     if st, ok := [sc]; ok && st ==  {
  12.       readySCs[sc] = SubConnInfo{Address: addr}
  13.     }
  14.   }
  15.    = (PickerBuildInfo{ReadySCs: readySCs})
  16. }

在regeneratePicker中获取了处于状态可用的连接,同时更新了picker。还记得吗?为在go-zero中自定义实现的接口。

go-zero/zrpc/internal/balancer/p2c/:42

  1. func (b *p2cPickerBuilder) Build(info )  {
  2.   readySCs := 
  3.   if len(readySCs) == 0 {
  4.     return ()
  5.   }
  6.   var conns []*subConn
  7.   for conn, connInfo := range readySCs {
  8.     conns = append(conns, &subConn{
  9.       addr:    connInfo.Address,
  10.       conn:    conn,
  11.       success: initSuccess,
  12.     })
  13.   }
  14.   return &p2cPicker{
  15.     conns: conns,
  16.     r:     ((time.Now().UnixNano())),
  17.     stamp: (),
  18.   }
  19. }

最后把自定义的Picker赋值为 属性。

grpc-go/balancer_conn_wrappers.go:347

  1. func (ccb *ccBalancerWrapper) UpdateState(s ) {
  2.   ccb.cc.blockingpicker.updatePicker()
  3.   ccb.cc.csMgr.updateState()
  4. }

如何选择已创建的连接?

现在已经知道了如何创建连接,以及连接其实是在 中管理,当连接的状态发生变化,则会更新 ** ** 。那么接下来我们来看一下gRPC是如何选择一个连接进行请求的发送的。

当gRPC客户端发起调用的时候,会调用ClientConn的Invoke方法,一般不会主动使用该方法进行调用,该方法的调用一般是自动生成:

grpc-go/examples/helloworld/helloworld/helloworld_grpc.:39

  1. func (c *greeterClient) SayHello(ctx , in *HelloRequest, opts ...) (*HelloReply, error) {
  2.   out := new(HelloReply)
  3.   err := (ctx, "//SayHello", in, out, opts...)
  4.   if err != nil {
  5.     return nil, err
  6.   }
  7.   return out, nil
  8. }

如下为发起请求的调用链路,最终会调用方法获取连接,我们自定义的负载均衡算法一般都在Pick方法中实现,获取到连接之后,通过sendMsg发送请求。

grpc-go/:945

  1. func (a *csAttempt) sendMsg(m interface{}, hdr, payld, data []byte) error {
  2.   cs := 
  3.   if  != nil {
  4.     .Lock()
  5.     if  != nil {
  6.       (&payload{sent: true, msg: m}, true)
  7.     }
  8.     .Unlock()
  9.   }
  10.   if err := .Write(, hdr, payld, &transport.Options{Last: !}); err != nil {
  11.     if ! {
  12.       return nil
  13.     }
  14.     return 
  15.   }
  16.   if  != nil {
  17.     (, outPayload(true, m, data, payld, time.Now()))
  18.   }
  19.   if () {
  20.     ()
  21.   }
  22.   return nil
  23. }

源码分析到此就结束了,由于篇幅有限没法做到面面俱到,所以本文只列出了源码中的主要路径。

结束语

Balancer相关的源码还是有点复杂的,笔者也是读了好几遍才理清脉络,所以如果读了一两遍感觉没有头绪也不用着急,对照文章的脉络多读几遍就一定能搞懂。

如果有疑问可以随时找我讨论,在社区群中可以搜索dawn_zhou找到我。

希望本篇文章对你有所帮助,你的点赞是作者持续输出的最大动力。

项目地址

GitHub - zeromicro/go-zero: A cloud-native Go microservices framework with cli tool for productivity.

欢迎使用 go-zerostar 支持我们!

微信交流群

关注『微服务实践』公众号并点击 交流群 获取社区群二维码。