過載保護原理與實戰

zhoushuguang發表於2020-12-12

在微服務中由於服務間相互依賴很容易出現連鎖故障,連鎖故障可能是由於整個服務鏈路中的某一個服務出現故障,進而導致系統的其他部分也出現故障。例如某個服務的某個例項由於過載出現故障,導致其他例項負載升高,從而導致這些例項像多米諾骨牌一樣一個個全部出現故障,這種連鎖故障就是所謂的雪崩現象

比如,服務 A 依賴服務 C,服務 C 依賴服務 D,服務 D 依賴服務 E,當服務 E 過載會導致響應時間變慢甚至服務不可用,這個時候呼叫方 D 會出現大量超時連線資源被大量佔用得不到釋放,進而資源被耗盡導致服務 D 也過載,從而導致服務 C 過載以及整個系統雪崩

service_dependency

某一種資源的耗盡可以導致高延遲、高錯誤率或者相應資料不符合預期的情況發生,這些的確是在資源耗盡時應該出現的情況,在負載不斷上升直到過載時,伺服器不可能一直保持完全的正常。而 CPU 資源的不足導致的負載上升是我們工作中最常見的,如果 CPU 資源不足以應對請求負載,一般來說所有的請求都會變慢,CPU 負載過高會造成一系列的副作用,主要包括以下幾項:

  • 正在處理的 (in-flight) 的請求數量上升
  • 伺服器逐漸將請求佇列填滿,意味著延遲上升,同時佇列會用更多的記憶體
  • 執行緒卡住,無法處理請求
  • cpu 死鎖或者請求卡主
  • rpc 服務呼叫超時
  • cpu 的快取效率下降

由此可見防止伺服器過載的重要性不言而喻,而防止伺服器過載又分為下面幾種常見的策略:

  • 提供降級結果
  • 在過載情況下主動拒絕請求
  • 呼叫方主動拒絕請求
  • 提前進行壓測以及合理的容量規劃

今天我們主要討論的是第二種防止伺服器過載的方案,即在過載的情況下主動拒絕請求,下面我統一使用” 過載保護 “來表述,過載保護的大致原理是當探測到伺服器已經處於過載時則主動拒絕請求不進行處理,一般做法是快速返回 error

fail_fast

很多微服務框架中都內建了過載保護能力,本文主要分析go-zero中的過載保護功能,我們先通過一個例子來感受下 go-zero 的中的過載保護是怎麼工作的

首先,我們使用官方推薦的goctl生成一個 api 服務和一個 rpc 服務,生成服務的過程比較簡單,在此就不做介紹,可以參考官方文件,我的環境是兩臺伺服器,api 服務跑在本機,rpc 服務跑在遠端伺服器

遠端伺服器為單核 CPU,首先通過壓力工具模擬伺服器負載升高,把 CPU 打滿

stress -c 1 -t 1000

此時通過 uptime 工具檢視伺服器負載情況,-d 引數可以高亮負載的變化情況,此時的負載已經大於 CPU 核數,說明伺服器正處於過載狀態

watch -d uptime

19:47:45 up 5 days, 21:55,  3 users,  load average: 1.26, 1.31, 1.44

此時請求 api 服務,其中 ap 服務內部依賴 rpc 服務,檢視 rpc 服務的日誌,級別為 stat,可以看到 cpu 是比較高的

"level":"stat","content":"(rpc) shedding_stat [1m], cpu: 986, total: 4, pass: 2, drop: 2"

並且會列印過載保護丟棄請求的日誌,可以看到過載保護已經生效,主動丟去了請求

adaptiveshedder.go:185 dropreq, cpu: 990, maxPass: 87, minRt: 1.00, hot: true, flying: 2, avgFlying: 2.07

這個時候呼叫方會收到 "service overloaded" 的報錯

通過上面的試驗我們可以看到當伺服器負載過高就會觸發過載保護,從而避免連鎖故障導致雪崩,接下來我們從原始碼來分析下過載保護的原理,go-zero 在 http 和 rpc 框架中都內建了過載保護功能,程式碼路徑分別在 go-zero/rest/handler/sheddinghandler.go 和 go-zero/zrpc/internal/serverinterceptors/sheddinginterceptor.go 下面,我們就以 rpc 下面的過載保護進行分析,在 server 啟動的時候回 new 一個 shedder 程式碼路徑: go-zero/zrpc/server.go:119, 然後當收到每個請求都會通過 Allow 方法判斷是否需要進行過載保護,如果 err 不等於 nil 說明需要過載保護則直接返回 error

promise, err = shedder.Allow()
if err != nil {
  metrics.AddDrop()
  sheddingStat.IncrementDrop()
  return
}

實現過載保護的程式碼路徑為: go-zero/core/load/adaptiveshedder.go,這裡實現的過載保護基於滑動視窗可以防止毛刺,有冷卻時間防止抖動,當 CPU>90% 的時候開始拒絕請求,Allow 的實現如下

func (as *adaptiveShedder) Allow() (Promise, error) {
    if as.shouldDrop() {
        as.dropTime.Set(timex.Now())
        as.droppedRecently.Set(true)

        return nil, ErrServiceOverloaded  // 返回過載錯誤
    }

    as.addFlying(1) // flying +1

    return &promise{
        start:   timex.Now(),
        shedder: as,
    }, nil
}

sholdDrop 實現如下,該函式用來檢測是否符合觸發過載保護條件,如果符合的話會記錄 error 日誌

func (as *adaptiveShedder) shouldDrop() bool {
    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 是否達到預設值,預設 90%

systemOverloadChecker = func(cpuThreshold int64) bool {
    return stat.CpuUsage() >= cpuThreshold
}

CPU 的負載統計程式碼如下,每隔 250ms 會進行一次統計,每一分鐘沒記錄一次統計日誌

func init() {
    go func() {
        cpuTicker := time.NewTicker(cpuRefreshInterval)
        defer cpuTicker.Stop()
        allTicker := time.NewTicker(allRefreshInterval)
        defer allTicker.Stop()

        for {
            select {
            case <-cpuTicker.C:
                threading.RunSafe(func() {
                    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)
                })
            case <-allTicker.C:
                printUsage()
            }
        }
    }()
}

其中 CPU 統計實現的程式碼路徑為: go-zero/core/stat/internal,在該路徑下使用 linux 結尾的檔案,因為在 go 語言中會根據不同的系統編譯不同的檔案,當為 linux 系統時會編譯以 linux 為字尾的檔案

func init() {
    cpus, err := perCpuUsage()
    if err != nil {
        logx.Error(err)
        return
    }

    cores = uint64(len(cpus))
    sets, err := cpuSets()
    if err != nil {
        logx.Error(err)
        return
    }

    quota = float64(len(sets))
    cq, err := cpuQuota()
    if err == nil {
        if cq != -1 {
            period, err := cpuPeriod()
            if err != nil {
                logx.Error(err)
                return
            }

            limit := float64(cq) / float64(period)
            if limit < quota {
                quota = limit
            }
        }
    }

    preSystem, err = systemCpuUsage()
    if err != nil {
        logx.Error(err)
        return
    }

    preTotal, err = totalCpuUsage()
    if err != nil {
        logx.Error(err)
        return
    }
}

在 linux 中,通過/proc 虛擬檔案系統向使用者控制元件提供了系統內部狀態的資訊,而/proc/stat 提供的就是系統的 CPU 等的任務統計資訊,這裡主要原理就是通過/proc/stat 來計算 CPU 的使用率

本文主要介紹了過載保護的原理,以及通過實驗觸發了過載保護,最後分析了實現過載保護功能的程式碼,相信通過本文大家對過載保護會有進一步的認識,過載保護不是萬金油,對服務來說是有損的,所以在服務上線前我們最好是進行壓測做好資源規劃,儘量避免服務過載

專案地址

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

框架地址

https://github.com/tal-tech/go-zero/tree/master/core/load

文件地址

https://www.yuque.com/tal-tech/go-zero/rhakzy

微信交流群

更多原創文章乾貨分享,請關注公眾號
  • 過載保護原理與實戰
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章