微服務過載保護原理與實戰

Kevin Wan發表於2020-12-16

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

比如,服務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的使用率

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

寫作不易,如果覺得文章不錯,歡迎 github star ?

專案地址:https://github.com/tal-tech/go-zero

專案地址:
https://github.com/tal-tech/go-zero

相關文章