自適應微服務治理背後的演算法

kevwan發表於2021-05-31

前言

go-zero 群裡經常有同學問:

服務監控是通過什麼演算法實現的?

滑動視窗是怎麼工作的?能否講講這塊的原理?

熔斷演算法是怎麼設計的?為啥沒有半開半閉狀態呢?

本篇文章,來分析一下 go-zero 中指標統計背後的實現演算法和邏輯。

指標怎麼統計

這個我們直接看 breaker

type googleBreaker struct {
  k     float64
  stat  *collection.RollingWindow
  proba *mathx.Proba
}

go-zero 中預設的 breaker 是以 google SRE 做為實現藍本。

breaker 在攔截請求過程中,會記錄當前這類請求的成功/失敗率:

func (b *googleBreaker) doReq(req func() error, fallback func(err error) error, acceptable Acceptable) error {
  ...
  // 執行實際請求函式
  err := req()
  if acceptable(err) {
    // 實際執行:b.stat.Add(1)
    // 也就是說:內部指標統計成功+1
    b.markSuccess()
  } else {
    // 原理同上
    b.markFailure()
  }

  return err
}

所以其實底層說白了就是:請求執行完畢,會根據錯誤發生次數,內部的統計資料結構會相應地加上統計值(可正可負)。同時隨著時間遷移,統計值也需要隨時間進化。

簡單來說:時間序列記憶體資料庫【也沒資料庫這麼猛,就是一個儲存,只是一個記憶體版的】

下面就來說說這個時間序列用什麼資料結構組織的。

滑動視窗

我們來看看 rollingwindow 定義資料結構:

type RollingWindow struct {
    lock          sync.RWMutex
    size          int
    win           *window
    interval      time.Duration
    offset        int
    ignoreCurrent bool
    lastTime      time.Duration
  }

上述結構定義中,window 就儲存指標記錄屬性。

在一個 rollingwindow 包含若干個桶(這個看開發者自己定義):

每一個桶儲存了:Sum 成功總數,Count 請求總數。所以在最後 breaker 做計算的時候,會將 Sum 累計加和為 accepts,Count 累計加和為 total,從而可以統計出當前的錯誤率。

滑動是怎麼發生的

首先對於 breaker 它是需要統計單位時間(比如1s)內的請求狀態,對應到上面的 bucket 我們只需要將單位時間的指標資料記錄在這個 bucket 即可。

那我們怎麼保證在時間前進過程中,指定的 Bucket 儲存的就是單位時間內的資料?

第一個想到的方式:後臺開一個定時器,每隔單位時間就建立一個 bucket ,然後當請求時當前的時間戳落在 bucket 中,記錄當前的請求狀態。週期性建立桶會存在臨界條件,資料來了,桶還沒建好的矛盾。

第二個方式是:惰性建立 bucket,當遇到一個資料再去檢查並建立 bucket。這樣就有時有桶有時沒桶,而且會大量建立 bucket,我們是否可以複用呢?

go-zero 的方式是:rollingwindow 直接預先建立,請求的當前時間通過一個演算法確定到bucket ,並記錄請求狀態。

下面看看 breaker 呼叫 b.stat.Add(1) 的過程:

func (rw *RollingWindow) Add(v float64) {
  rw.lock.Lock()
  defer rw.lock.Unlock()
  // 滑動的動作發生在此
  rw.updateOffset()
  rw.win.add(rw.offset, v)
}

func (rw *RollingWindow) updateOffset() {
  span := rw.span()
  if span <= 0 {
    return
  }

  offset := rw.offset
  // 重置過期的 bucket
  for i := 0; i < span; i++ {
    rw.win.resetBucket((offset + i + 1) % rw.size)
  }

  rw.offset = (offset + span) % rw.size
  now := timex.Now()
  // 更新時間
  rw.lastTime = now - (now-rw.lastTime)%rw.interval
}

func (w *window) add(offset int, v float64) {
  // 往執行的 bucket 加入指定的指標
  w.buckets[offset%w.size].add(v)
}

上圖就是在 Add(delta) 過程中發生的 bucket 發生的視窗變化。解釋一下:

  1. updateOffset 就是做 bucket 更新,以及確定當前時間落在哪個 bucket 上【超過桶個數直接返回桶個數】,將其之前的 bucket 重置
    • 確定當前時間相對於 bucket interval的跨度【超過桶個數直接返回桶個數】
    • 將跨度內的 bucket 都清空資料。reset
    • 更新 offset,也是即將要寫入資料的 bucket
    • 更新執行時間 lastTime,也給下一次移動做一個標誌
  2. 由上一次更新的 offset,向對應的 bucket 寫入資料

而在這個過程中,如何確定確定 bucket 過期點,以及更新時間。滑動視窗最重要的就是時間更新,下面用圖來解釋這個過程:

bucket 過期點,說白就是 lastTime 即上一個更新時間跨越了幾個 buckettimex.Since(rw.lastTime) / rw.interval


這樣,在 Add() 的過程中,通過 lastTimenowTime 的標註,通過不斷重置來實現視窗滑動,新的資料不斷補上,從而實現視窗計算。

總結

本文分析了 go-zero 框架中的指標統計的基礎封裝、滑動視窗的實現 rollingWindow。當然,除此之外,store/redis 也存在指標統計,這個裡面的就不需要滑動視窗計數了,因為本身只需要計算命中率,命中則對 hit +1,不命中則對 miss +1 即可,分指標計數,最後統計一下就知道命中率。

滑動視窗適用於流控中對指標進行計算,同時也可以做到控流。

關於 go-zero 更多的設計和實現文章,可以關注『微服務實踐』公眾號。

專案地址

github.com/tal-tech/go-zero

歡迎使用 go-zero 並 star 支援我們!

微信交流群

關注『微服務實踐』公眾號並點選 交流群 獲取社群群二維碼。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章