自适应负载均衡算法原理与实现

背景

在选择负载均衡算法时,我们希望满足以下要求:

  1. 具备分区和机房调度亲和性
    • 每次选择的节点尽量是负载最低的
    • 每次尽可能选择响应最快的节点
  2. 无需人工干预故障节点
    • 当一个节点有故障时,负载均衡算法可以自动隔离该节点
    • 当故障节点恢复时,能够自动恢复对该节点的流量分发

基于这些考虑,go-zero 选择了 p2c+EWMA 算法来实现。

算法的核心思想

p2c

p2c (Pick Of 2 Choices) 二选一: 在多个节点中随机选择两个节点。

go-zero 中的会随机的选择3次,如果其中一次选择的节点的健康条件满足要求,就中断选择,采用这两个节点。

EWMA

EWMA (Exponentially Weighted Moving-Average) 指数移动加权平均法: 是指各数值的加权系数随时间呈指数递减,越靠近当前时刻的数值加权系数就越大,体现了最近一段时间内的平均值。

  • 公式:

    EWMA公式
  • 变量解释:

    • Vt: 代表的是第 t 次请求的 EWMA值
    • Vt-1: 代表的是第 t-1 次请求的 EWMA值
    • β: 是一个常量

EWMA 算法的优势

  1. 相较于普通的计算平均值算法,EWMA 不需要保存过去所有的数值,计算量显著减少,同时也减小了存储资源。
  2. 传统的计算平均值算法对网络耗时不敏感, 而 EWMA 可以通过请求频繁来调节 β,进而迅速监控到网络毛刺或更多的体现整体平均值。
    • 当请求较为频繁时, 说明节点网络负载升高了, 我们想监测到此时节点处理请求的耗时(侧面反映了节点的负载情况), 我们就相应的调小ββ越小,EWMA值 就越接近本次耗时,进而迅速监测到网络毛刺;
    • 当请求较为不频繁时, 我们就相对的调大β值。这样计算出来的 EWMA值 越接近平均值

β计算

go-zero 采用的是牛顿冷却定律中的衰减函数模型计算 EWMA 算法中的 β 值:

牛顿冷却定律中的衰减函数

其中 Δt 为两次请求的间隔,ek 为常数

gRPC 中实现自定义负载均衡器

  1. 首先我们需要实现 google.golang.org/grpc/balancer/base/base.go/PickerBuilder 接口, 这个接口是有服务节点更新的时候会调用接口里的Build方法

    type PickerBuilder interface {
        // Build returns a picker that will be used by gRPC to pick a SubConn.
        Build(info PickerBuildInfo) balancer.Picker
    }
    
  2. 还要实现 google.golang.org/grpc/balancer/balancer.go/Picker 接口。这个接口主要实现负载均衡,挑选一个节点供请求使用

    type Picker interface {
      Pick(info PickInfo) (PickResult, error)
    }
    
  3. 最后向负载均衡 map 中注册我们实现的负载均衡器

go-zero 实现负载均衡的主要逻辑

  1. 在每次节点更新,gRPC 会调用 Build 方法,此时在 Build 里实现保存所有的节点信息。
  2. gRPC 在获取节点处理请求时,会调用 Pick 方法以获取节点。go-zeroPick 方法里实现了 p2c 算法,挑选节点,并通过节点的 EWMA值 计算负载情况,返回负载低的节点供 gRPC 使用。
  3. 在请求结束的时候 gRPC 会调用 PickResult.Done 方法,go-zero 在这个方法里实现了本次请求耗时等信息的存储,并计算出了 EWMA值 保存了起来,供下次请求时计算负载等情况的使用。

负载均衡代码分析

  1. 保存服务的所有节点信息

    我们需要保存节点处理本次请求的耗时、EWMA 等信息,go-zero 给每个节点设计了如下结构:

    type subConn struct {
        addr     resolver.Address
        conn     balancer.SubConn
        lag      uint64 // 用来保存 ewma 值
        inflight int64  // 用在保存当前节点正在处理的请求总数
        success  uint64 // 用来标识一段时间内此连接的健康状态
        requests int64  // 用来保存请求总数
        last     int64  // 用来保存上一次请求耗时, 用于计算 ewma 值
        pick     int64  // 保存上一次被选中的时间点
    }
    
  2. p2cPicker 实现了 balancer.Picker 接口,conns 保存了服务的所有节点信息

    type p2cPicker struct {
      conns []*subConn  // 保存所有节点的信息 
      r     *rand.Rand
      stamp *syncx.AtomicDuration
      lock  sync.Mutex
    }
    
  3. gRPC 在节点有更新的时候会调用 Build 方法,传入所有节点信息,我们在这里把每个节点信息用 subConn 结构保存起来。并归并到一起用 p2cPicker 结构保存起来

    func (b *p2cPickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
      ......
      var conns []*subConn
      for conn, connInfo := range readySCs {
        conns = append(conns, &subConn{
          addr:    connInfo.Address,
          conn:    conn,
          success: initSuccess,
        })
      }
      return &p2cPicker{
        conns: conns,
        r:     rand.New(rand.NewSource(time.Now().UnixNano())),
        stamp: syncx.NewAtomicDuration(),
      }
    }
    
  4. 随机挑选节点信息,在这里分了三种情况:

    1. 只有一个服务节点,此时直接返回供 gRPC 使用即可
    2. 有两个服务节点,通过 EWMA值 计算负载,并返回负载低的节点返回供 gRPC 使用
    3. 有多个服务节点,此时通过 p2c 算法选出两个节点,比较负载情况,返回负载低的节点供 gRPC 使用

    主要实现代码如下:

    switch len(p.conns) {
      case 0:// 没有节点,返回错误
        return emptyPickResult, balancer.ErrNoSubConnAvailable
      case 1:// 有一个节点,直接返回这个节点
        chosen = p.choose(p.conns[0], nil)
      case 2:// 有两个节点,计算负载,返回负载低的节点
        chosen = p.choose(p.conns[0], p.conns[1])
      default:// 有多个节点,p2c 挑选两个节点,比较这两个节点的负载,返回负载低的节点
        var node1, node2 *subConn
            // 3次随机选择两个节点
        for i := 0; i < pickTimes; i++ {
          a := p.r.Intn(len(p.conns))
          b := p.r.Intn(len(p.conns) - 1)
          if b >= a {
            b++
          }
          node1 = p.conns[a]
          node2 = p.conns[b]
          // 如果这次选择的节点达到了健康要求, 就中断选择
          if node1.healthy() && node2.healthy() {
            break
          }
        }
        // 比较两个节点的负载情况,选择负载低的
        chosen = p.choose(node1, node2)
      }
    
  5. load计算节点的负载情况

    上面的 choose 方法会调用 load 方法来计算节点负载。

    计算负载的公式是: load = ewma * inflight

    在这里简单解释下: ewma 相当于平均请求耗时,inflight 是当前节点正在处理请求的数量,相乘大致计算出了当前节点的网络负载。

    func (c *subConn) load() int64 {
      // 通过 EWMA 计算节点的负载情况; 加 1 是为了避免为 0 的情况
      lag := int64(math.Sqrt(float64(atomic.LoadUint64(&c.lag) + 1)))
      load := lag * (atomic.LoadInt64(&c.inflight) + 1)
      if load == 0 {
        return penalty
      }
      return load
    }
    
  6. 请求结束,更新节点的 EWMA 等信息

    1. 把节点正在处理请求的总数减1
    2. 保存处理请求结束的时间点,用于计算距离上次节点处理请求的差值,并算出 EWMA 中的 β值
    3. 计算本次请求耗时,并计算出 EWMA值 保存到节点的 lag 属性里
    4. 计算节点的健康状态保存到节点的 success 属性中
    func (p *p2cPicker) buildDoneFunc(c *subConn) func(info balancer.DoneInfo) {
      start := int64(timex.Now())
      return func(info balancer.DoneInfo) {
        // 正在处理的请求数减 1
        atomic.AddInt64(&c.inflight, -1)
        now := timex.Now()
        // 保存本次请求结束时的时间点,并取出上次请求时的时间点
        last := atomic.SwapInt64(&c.last, int64(now))
        td := int64(now) - last
        if td < 0 {
          td = 0
        }
        // 用牛顿冷却定律中的衰减函数模型计算EWMA算法中的β值
        w := math.Exp(float64(-td) / float64(decayTime))
        // 保存本次请求的耗时
        lag := int64(now) - start
        if lag < 0 {
          lag = 0
        }
        olag := atomic.LoadUint64(&c.lag)
        if olag == 0 {
          w = 0
        }
        // 计算 EWMA 值
        atomic.StoreUint64(&c.lag, uint64(float64(olag)*w+float64(lag)*(1-w)))
        success := initSuccess
        if info.Err != nil && !codes.Acceptable(info.Err) {
          success = 0
        }
        osucc := atomic.LoadUint64(&c.success)
        atomic.StoreUint64(&c.success, uint64(float64(osucc)*w+float64(success)*(1-w)))
    
        stamp := p.stamp.Load()
        if now-stamp >= logInterval {
          if p.stamp.CompareAndSwap(stamp, now) {
            p.logStats()
          }
        }
      }
    }
    

项目地址

https://github.com/tal-tech/go-zero

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

微信交流群

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容