微服務治理之如何優雅應對突發流量洪峰

kevinwan發表於2021-11-23

為什麼需要降載

微服務叢集中,呼叫鏈路錯綜複雜,作為服務提供者需要有一種保護自己的機制,防止呼叫方無腦呼叫壓垮自己,保證自身服務的高可用。

最常見的保護機制莫過於限流機制,使用限流器的前提是必須知道自身的能夠處理的最大併發數,一般在上線前通過壓測來得到最大併發數,而且日常請求過程中每個介面的限流引數都不一樣,同時系統一直在不斷的迭代其處理能力往往也會隨之變化,每次上線前都需要進行壓測然後調整限流引數變得非常繁瑣。

那麼有沒有一種更加簡潔的限流機制能實現最大限度的自我保護呢?

什麼是自適應降載

自適應降載能非常智慧的保護服務自身,根據服務自身的系統負載動態判斷是否需要降載。

設計目標:

  1. 保證系統不被拖垮。
  2. 在系統穩定的前提下,保持系統的吞吐量。

那麼關鍵就在於如何衡量服務自身的負載呢?

判斷高負載主要取決於兩個指標:

  1. cpu 是否過載。
  2. 最大併發數是否過載。

以上兩點同時滿足時則說明服務處於高負載狀態,則進行自適應降載。

同時也應該注意高併發場景 cpu 負載、併發數往往波動比較大,從資料上我們稱這種現象為毛刺,毛刺現象可能會導致系統一直在頻繁的進行自動降載操作,所以我們一般獲取一段時間內的指標均值來使指標更加平滑。實現上可以採用準確的記錄一段時間內的指標然後直接計算平均值,但是需要佔用一定的系統資源。

統計學上有一種演算法:滑動平均(exponential moving average),可以用來估算變數的區域性均值,使得變數的更新與歷史一段時間的歷史取值有關,無需記錄所有的歷史區域性變數就可以實現平均值估算,非常節省寶貴的伺服器資源。

滑動平均演算法原理 參考這篇文章講的非常清楚。

變數 V 在 t 時刻記為 Vt,θt 為變數 V 在 t 時刻的取值,即在不使用滑動平均模型時 Vt=θt,在使用滑動平均模型後,Vt 的更新公式如下:

Vt=β⋅Vt−1+(1−β)⋅θt

  • β = 0 時 Vt = θt
  • β = 0.9 時,大致相當於過去 10 個 θt 值的平均
  • β = 0.99 時,大致相當於過去 100 個 θt 值的平均

程式碼實現

接下來我們來看下 go-zero 自適應降載的程式碼實現。

core/load/adaptiveshedder.go

自適應降載介面定義:

// 回撥函式
Promise interface {
    // 請求成功時回撥此函式
    Pass()
    // 請求失敗時回撥此函式
    Fail()
}

// 降載介面定義
Shedder interface {
    // 降載檢查
    // 1. 允許呼叫,需手動執行 Promise.accept()/reject()上報實際執行任務結構
    // 2. 拒絕呼叫,將會直接返回err:服務過載錯誤 ErrServiceOverloaded
    Allow() (Promise, error)
}

介面定義非常精簡意味使用起來其實非常簡單,對外暴露一個`Allow()(Promise,error)。

go-zero 使用示例:

業務中只需調該方法判斷是否降載,如果被降載則直接結束流程,否則執行業務最後使用返回值 Promise 根據執行結果回撥結果即可。

func UnarySheddingInterceptor(shedder load.Shedder, metrics *stat.Metrics) grpc.UnaryServerInterceptor {
    ensureSheddingStat()

    return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler) (val interface{}, err error) {
        sheddingStat.IncrementTotal()
        var promise load.Promise
        // 檢查是否被降載
        promise, err = shedder.Allow()
        // 降載,記錄相關日誌與指標
        if err != nil {
            metrics.AddDrop()
            sheddingStat.IncrementDrop()
            return
        }
        // 最後回撥執行結果
        defer func() {
            // 執行失敗
            if err == context.DeadlineExceeded {
                promise.Fail()
            // 執行成功
            } else {
                sheddingStat.IncrementPass()
                promise.Pass()
            }
        }()
        // 執行業務方法
        return handler(ctx, req)
    }
}

介面實現類定義 :

主要包含三類屬性

  1. cpu 負載閾值:超過此值意味著 cpu 處於高負載狀態。
  2. 冷卻期:假如服務之前被降載過,那麼將進入冷卻期,目的在於防止降載過程中負載還未降下來立馬加壓導致來回抖動。因為降低負載需要一定的時間,處於冷卻期內應該繼續檢查併發數是否超過限制,超過限制則繼續丟棄請求。
  3. 併發數:當前正在處理的併發數,當前正在處理的併發平均數,以及最近一段內的請求數與響應時間,目的是為了計算當前正在處理的併發數是否大於系統可承載的最大併發數。
// option引數模式
ShedderOption func(opts *shedderOptions)

// 可選配置引數
shedderOptions struct {
    // 滑動時間視窗大小
    window time.Duration
    // 滑動時間視窗數量
    buckets int
    // cpu負載臨界值
    cpuThreshold int64
}

// 自適應降載結構體,需實現 Shedder 介面
adaptiveShedder struct {
    // cpu負載臨界值
    // 高於臨界值代表高負載需要降載保證服務
    cpuThreshold int64
    // 1s內有多少個桶
    windows int64
    // 併發數
    flying int64
    // 滑動平滑併發數
    avgFlying float64
    // 自旋鎖,一個服務共用一個降載
    // 統計當前正在處理的請求數時必須加鎖
    // 無損併發,提高效能
    avgFlyingLock syncx.SpinLock
    // 最後一次拒絕時間
    dropTime *syncx.AtomicDuration
    // 最近是否被拒絕過
    droppedRecently *syncx.AtomicBool
    // 請求數統計,通過滑動時間視窗記錄最近一段時間內指標
    passCounter *collection.RollingWindow
    // 響應時間統計,通過滑動時間視窗記錄最近一段時間內指標
    rtCounter *collection.RollingWindow
}

自適應降載構造器:

func NewAdaptiveShedder(opts ...ShedderOption) Shedder {
    // 為了保證程式碼統一
    // 當開發者關閉時返回預設的空實現,實現程式碼統一
    // go-zero很多地方都採用了這種設計,比如Breaker,日誌元件
    if !enabled.True() {
        return newNopShedder()
    }
    // options模式設定可選配置引數
    options := shedderOptions{
        // 預設統計最近5s內資料
        window: defaultWindow,
        // 預設桶數量50個
        buckets:      defaultBuckets,
        // cpu負載
        cpuThreshold: defaultCpuThreshold,
    }
    for _, opt := range opts {
        opt(&options)
    }
    // 計算每個視窗間隔時間,預設為100ms
    bucketDuration := options.window / time.Duration(options.buckets)
    return &adaptiveShedder{
        // cpu負載
        cpuThreshold:    options.cpuThreshold,
        // 1s的時間內包含多少個滑動視窗單元
        windows:         int64(time.Second / bucketDuration),
        // 最近一次拒絕時間
        dropTime:        syncx.NewAtomicDuration(),
        // 最近是否被拒絕過
        droppedRecently: syncx.NewAtomicBool(),
        // qps統計,滑動時間視窗
        // 忽略當前正在寫入視窗(桶),時間週期不完整可能導致資料異常
        passCounter: collection.NewRollingWindow(options.buckets, bucketDuration,
            collection.IgnoreCurrentBucket()),
        // 響應時間統計,滑動時間視窗
        // 忽略當前正在寫入視窗(桶),時間週期不完整可能導致資料異常
        rtCounter: collection.NewRollingWindow(options.buckets, bucketDuration,
            collection.IgnoreCurrentBucket()),
    }
}

降載檢查 Allow()

檢查當前請求是否應該被丟棄,被丟棄業務側需要直接中斷請求保護服務,也意味著降載生效同時進入冷卻期。如果放行則返回 promise,等待業務側執行回撥函式執行指標統計。

// 降載檢查
func (as *adaptiveShedder) Allow() (Promise, error) {
    // 檢查請求是否被丟棄
    if as.shouldDrop() {
        // 設定drop時間
        as.dropTime.Set(timex.Now())
        // 最近已被drop
        as.droppedRecently.Set(true)
        // 返回過載
        return nil, ErrServiceOverloaded
    }
    // 正在處理請求數加1
    as.addFlying(1)
    // 這裡每個允許的請求都會返回一個新的promise物件
    // promise內部持有了降載指標物件
    return &promise{
        start:   timex.Now(),
        shedder: as,
    }, nil
}

檢查是否應該被丟棄 shouldDrop()

// 請求是否應該被丟棄
func (as *adaptiveShedder) shouldDrop() bool {
    // 當前cpu負載超過閾值
    // 服務處於冷卻期內應該繼續檢查負載並嘗試丟棄請求
    if as.systemOverloaded() || as.stillHot() {
        // 檢查正在處理的併發是否超出當前可承載的最大併發數
        // 超出則丟棄請求
        if as.highThru() {
            flying := atomic.LoadInt64(&as.flying)
            as.avgFlyingLock.Lock()
            avgFlying := as.avgFlying
            as.avgFlyingLock.Unlock()
            msg := fmt.Sprintf(
                "dropreq, cpu: %d, maxPass: %d, minRt: %.2f, hot: %t, flying: %d, avgFlying: %.2f",
                stat.CpuUsage(), as.maxPass(), as.minRt(), as.stillHot(), flying, avgFlying)
            logx.Error(msg)
            stat.Report(msg)
            return true
        }
    }
    return false
}

cpu 閾值檢查 systemOverloaded()

cpu 負載值計算演算法採用的滑動平均演算法,防止毛刺現象。每隔 250ms 取樣一次 β 為 0.95,大概相當於歷史 20 次 cpu 負載的平均值,時間週期約為 5s。

// cpu 是否過載
func (as *adaptiveShedder) systemOverloaded() bool {
    return systemOverloadChecker(as.cpuThreshold)
}

// cpu 檢查函式
systemOverloadChecker = func(cpuThreshold int64) bool {
        return stat.CpuUsage() >= cpuThreshold
}

// cpu滑動平均值
curUsage := internal.RefreshCpu()
prevUsage := atomic.LoadInt64(&cpuUsage)
// cpu = cpuᵗ⁻¹ * beta + cpuᵗ * (1 - beta)
// 滑動平均演算法
usage := int64(float64(prevUsage)*beta + float64(curUsage)*(1-beta))
atomic.StoreInt64(&cpuUsage, usage)

檢查是否處於冷卻期 stillHot:

判斷當前系統是否處於冷卻期,如果處於冷卻期內,應該繼續嘗試檢查是否丟棄請求。主要是防止系統在過載恢復過程中負載還未降下來,立馬又增加壓力導致來回抖動,此時應該嘗試繼續丟棄請求。

func (as *adaptiveShedder) stillHot() bool {
    // 最近沒有丟棄請求
    // 說明服務正常
    if !as.droppedRecently.True() {
        return false
    }
    // 不在冷卻期
    dropTime := as.dropTime.Load()
    if dropTime == 0 {
        return false
    }
    // 冷卻時間預設為1s
    hot := timex.Since(dropTime) < coolOffDuration
    // 不在冷卻期,正常處理請求中
    if !hot {
        // 重置drop記錄
        as.droppedRecently.Set(false)
    }

    return hot
}

檢查當前正在處理的併發數highThru()

一旦 當前處理的併發數 > 併發數承載上限 則進入降載狀態。

這裡為什麼要加鎖呢?因為自適應降載時全域性在使用的,為了保證併發數平均值正確性。

為什麼這裡要加自旋鎖呢?因為併發處理過程中,可以不阻塞其他的 goroutine 執行任務,採用無鎖併發提高效能。

func (as *adaptiveShedder) highThru() bool {
    // 加鎖
    as.avgFlyingLock.Lock()
    // 獲取滑動平均值
    // 每次請求結束後更新
    avgFlying := as.avgFlying
    // 解鎖
    as.avgFlyingLock.Unlock()
    // 系統此時最大併發數
    maxFlight := as.maxFlight()
    // 正在處理的併發數和平均併發數是否大於系統的最大併發數
    return int64(avgFlying) > maxFlight && atomic.LoadInt64(&as.flying) > maxFlight
}

如何得到正在處理的併發數與平均併發數呢?

當前正在的處理併發數統計其實非常簡單,每次允許請求時併發數 +1,請求完成後 通過 promise 物件回撥-1 即可,並利用滑動平均演算法求解平均併發數即可。

type promise struct {
    // 請求開始時間
    // 統計請求處理耗時
    start   time.Duration
    shedder *adaptiveShedder
}

func (p *promise) Fail() {
    // 請求結束,當前正在處理請求數-1
    p.shedder.addFlying(-1)
}

func (p *promise) Pass() {
    // 響應時間,單位毫秒
    rt := float64(timex.Since(p.start)) / float64(time.Millisecond)
    // 請求結束,當前正在處理請求數-1
    p.shedder.addFlying(-1)
    p.shedder.rtCounter.Add(math.Ceil(rt))
    p.shedder.passCounter.Add(1)
}

func (as *adaptiveShedder) addFlying(delta int64) {
    flying := atomic.AddInt64(&as.flying, delta)
    // 請求結束後,統計當前正在處理的請求併發
    if delta < 0 {
        as.avgFlyingLock.Lock()
        // 估算當前服務近一段時間內的平均請求數
        as.avgFlying = as.avgFlying*flyingBeta + float64(flying)*(1-flyingBeta)
        as.avgFlyingLock.Unlock()
    }
}

得到了當前的系統數還不夠 ,我們還需要知道當前系統能夠處理併發數的上限,即最大併發數。

請求通過數與響應時間都是通過滑動視窗來實現的,關於滑動視窗的實現可以參考 自適應熔斷器那篇文章。

當前系統的最大併發數 = 視窗單位時間內的最大通過數量 * 視窗單位時間內的最小響應時間。

// 計算每秒系統的最大併發數
// 最大併發數 = 最大請求數(qps)* 最小響應時間(rt)
func (as *adaptiveShedder) maxFlight() int64 {
    // windows = buckets per second
    // maxQPS = maxPASS * windows
    // minRT = min average response time in milliseconds
    // maxQPS * minRT / milliseconds_per_second
    // as.maxPass()*as.windows - 每個桶最大的qps * 1s內包含桶的數量
    // as.minRt()/1e3 - 視窗所有桶中最小的平均響應時間 / 1000ms這裡是為了轉換成秒
    return int64(math.Max(1, float64(as.maxPass()*as.windows)*(as.minRt()/1e3)))
}    

// 滑動時間視窗內有多個桶
// 找到請求數最多的那個
// 每個桶佔用的時間為 internal ms
// qps指的是1s內的請求數,qps: maxPass * time.Second/internal
func (as *adaptiveShedder) maxPass() int64 {
    var result float64 = 1
    // 當前時間視窗內請求數最多的桶
    as.passCounter.Reduce(func(b *collection.Bucket) {
        if b.Sum > result {
            result = b.Sum
        }
    })

    return int64(result)
}

// 滑動時間視窗內有多個桶
// 計算最小的平均響應時間
// 因為需要計算近一段時間內系統能夠處理的最大併發數
func (as *adaptiveShedder) minRt() float64 {
    // 預設為1000ms
    result := defaultMinRt

    as.rtCounter.Reduce(func(b *collection.Bucket) {
        if b.Count <= 0 {
            return
        }
        // 請求平均響應時間
        avg := math.Round(b.Sum / float64(b.Count))
        if avg < result {
            result = avg
        }
    })

    return result
}

參考資料

Google BBR 擁塞控制演算法

滑動平均演算法原理

go-zero 自適應降載

專案地址

https://github.com/zeromicro/go-zero

歡迎使用 go-zerostar 支援我們!

微信交流群

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

相關文章