Golang服務的請求調(diào)度的實現(xiàn)
1. 寫在前面
最近在看相關的Go服務的請求調(diào)度的時候,發(fā)現(xiàn)在gin中默認提供的中間件中,不含有請求調(diào)度相關的邏輯中間件,去github查看了一些服務框架,發(fā)現(xiàn)在go-zero中,有一個SheddingHandler的中間件來幫助服務請求進行調(diào)度,防止在流量徒增的時候,服務出現(xiàn)滾雪球進一步惡化,導致最后服務不可用的現(xiàn)象出現(xiàn)。
SheddingHandler中間件存在的意義就是盡量保證服務可用的情況下盡可能多的處理請求,而在流量突增的時候,丟棄部分請求以確保服務可用,防止服務因為流量過大而崩潰。
2. SheddingHandler的實現(xiàn)原理
SheddingHandler簡單來說就是維持了一套指標,在每個請求進入系統(tǒng)的時候,利用指標進行計算,判斷當前的請求是否允許被進入系統(tǒng),如果允許則請求通過中間件繼續(xù)向下被服務處理,如果不被允許則在中間件層面就丟棄掉(正是這個丟棄,保證了在流量突增時服務的穩(wěn)定)。
具體看源碼:
// SheddingHandler returns a middleware that does load shedding. func SheddingHandler(shedder load.Shedder, metrics *stat.Metrics) func(http.Handler) http.Handler { if shedder == nil { return func(next http.Handler) http.Handler { return next } } ensureSheddingStat() // 負責每分鐘打印shedding相關的數(shù)據(jù) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sheddingStat.IncrementTotal() promise, err := shedder.Allow() // 判斷是否允許此請求進入下一步 if err != nil { metrics.AddDrop() // drop掉請求,在中間件層面就拒絕了請求 sheddingStat.IncrementDrop() logx.Errorf("[http] dropped, %s - %s - %s", r.RequestURI, httpx.GetRemoteAddr(r), r.UserAgent()) w.WriteHeader(http.StatusServiceUnavailable)// 返回503,提示服務不可用 return } cw := response.NewWithCodeResponseWriter(w) defer func() { if cw.Code == http.StatusServiceUnavailable { promise.Fail() // 相關指標記錄 } else { sheddingStat.IncrementPass() promise.Pass() // 相關指標記錄 } }() next.ServeHTTP(cw, r) }) } }
可以看到請求是否可以繼續(xù)向下,取決于Allow()
這個方法,這個方法的實現(xiàn)如下:
// Allow implements Shedder.Allow. func (as *adaptiveShedder) Allow() (Promise, error) { if as.shouldDrop() {// 判斷是否應該丟棄 as.droppedRecently.Set(true) return nil, ErrServiceOverloaded// 丟棄 } as.addFlying(1) // 通過校驗 return &promise{ start: timex.Now(), shedder: as, }, nil }
繼續(xù)看shouldDrop()
方法:
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 } func (as *adaptiveShedder) systemOverloaded() bool { if !systemOverloadChecker(as.cpuThreshold) { // 校驗CPU的負載是否超出設定值 return false } as.overloadTime.Set(timex.Now())// 超出設定值,記錄當前的時間(這主要是為了后續(xù)流量減小,系統(tǒng)的恢復用) return true } func (as *adaptiveShedder) stillHot() bool { if !as.droppedRecently.True() {// 如果這個請求之前有請求被drop這里值為true,反之為false return false// 之前的請求沒有被drop表示系統(tǒng)可能沒有遇到過載的問題,返回false } overloadTime := as.overloadTime.Load()// 如果之前有請求被drop,表示存在過載 if overloadTime == 0 {// 看看是否有記錄過載的時間 return false } if timex.Since(overloadTime) < coolOffDuration {// 如果小于冷卻時間,表示系統(tǒng)依然是過載狀態(tài) return true } as.droppedRecently.Set(false)// 表示CPU過載,上一次過載過了冷卻器,這個請求可以繼續(xù)執(zhí)行,設置為false return false }
可以看到請求被drop的前置條件有兩個:
- 系統(tǒng)的CPU負載超出了設定值,目前go-zero設置的默認值為90%,即系統(tǒng)CPU負載達到90%后,就意味著系統(tǒng)過載了,只要是過載,請求會被直接拒絕;否則判斷第二個條件
- 因為過載可能會隨著流量減小而恢復,或者丟棄的請求太多,系統(tǒng)CPU會慢慢的恢復正常水平(90%以下),所以需要看一下過載時間,如果超過了冷卻時間,而第一個條件又表示系統(tǒng)CPU負載正常,此時我們會認定系統(tǒng)恢復了,這個請求可以處理。
滿足上述任一條件,此請求就會進入最后的highThru()
方法判斷環(huán)節(jié),如果滿足了,此請求就會被丟棄。
從上面我們可以得到,我們判斷服務是否過載,是依靠CPU的使用率去判斷的,那么我們?nèi)绾蝿討B(tài)的計算CPU的使用率呢?
在go-zero里面,采用的是直接獲取linux機器上的cpu的相關文件,然后通過代碼邏輯將相關的文件進行解析并計算出CPU使用率。可以參考:[cgroup_linux.go]
這里為了效率問題,并不是實時去計算的,而是在啟動的時候,啟動了一個goroutine每250ms進行以此CPU使用率數(shù)據(jù)的刷新。
const ( // 250ms and 0.95 as beta will count the average cpu load for past 5 seconds cpuRefreshInterval = time.Millisecond * 250 allRefreshInterval = time.Minute // moving average beta hyperparameter beta = 0.95 ) var cpuUsage int64 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() // 刷新CPU使用率數(shù)據(jù) prevUsage := atomic.LoadInt64(&cpuUsage) // cpu = cpu??1 * beta + cpu? * (1 - beta) usage := int64(float64(prevUsage)*beta + float64(curUsage)*(1-beta)) atomic.StoreInt64(&cpuUsage, usage) }) case <-allTicker.C: if logEnabled.True() { printUsage() } } } }() }
最后再來看highThru()
方法,這個方法相對來說比較復雜:
func (as *adaptiveShedder) addFlying(delta int64) { flying := atomic.AddInt64(&as.flying, delta)// 請求通過檢驗進入后會加1,請求被服務處理完后會減1 if delta < 0 { as.avgFlyingLock.Lock() // 平均請求數(shù)計算為當前平均請求數(shù)*0.9 + 當前運行請求數(shù)*0.1 as.avgFlying = as.avgFlying*flyingBeta + float64(flying)*(1-flyingBeta) as.avgFlyingLock.Unlock() } } func (as *adaptiveShedder) highThru() bool { as.avgFlyingLock.Lock() avgFlying := as.avgFlying // 運行中的平均請求數(shù) as.avgFlyingLock.Unlock() maxFlight := as.maxFlight()// 運行的最大的請求數(shù) // 如果運行的平均請求數(shù)>最大的請求數(shù)且當前運行的請求數(shù)>最大的請求數(shù),表示依舊高負載 return int64(avgFlying) > maxFlight && atomic.LoadInt64(&as.flying) > maxFlight } func (as *adaptiveShedder) maxFlight() int64 { // windows = buckets per second // maxQPS = maxPASS * windows // minRT = min average response time in milliseconds // maxQPS * minRT / milliseconds_per_second // 最大的運行數(shù)的計算為最大請求數(shù)*窗口的長度*最小的處理時間 return int64(math.Max(1, float64(as.maxPass()*as.windows)*(as.minRt()/1e3))) }
上面關于flying的計算,在SheddingHandler中有兩個count統(tǒng)計器在統(tǒng)計這通過的總請求數(shù)以及請求的平均耗時。默認會在5s的時間內(nèi)啟動50個大小的bucket來循環(huán)滾動,即每個bucket統(tǒng)計100ms內(nèi)的請求數(shù)。
這里利用窗口統(tǒng)計請求數(shù)大小的判斷主要是為了規(guī)避在負載的情況下,丟棄了太多的請求導致系統(tǒng)實際運行的請求數(shù)減少的太多,所以加了這一層判斷,這個可以保證在系統(tǒng)高負載丟棄了大量的請求的情況下,系統(tǒng)盡可能多的處理更多的請求,而不是負載一高就直接丟棄。
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 { 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 }
3. 相關方案的對比
在調(diào)度請求這一塊,go-zero的方案確實很棒,結合了CPU使用率和過載冷缺以及請求數(shù)大小因素,不僅保證了系統(tǒng)高負載下服務的正常,還確保了系統(tǒng)能夠盡可能多的處理請求。
但從我們目前的調(diào)度模式以及執(zhí)行單元的狀態(tài)角度出發(fā),我們會發(fā)現(xiàn)服務接收到一個請求后會解析請求讀取請求的內(nèi)容,然后調(diào)度此請求給到執(zhí)行單元,這個執(zhí)行單元可能是一個線程或者一個Goroutine,從執(zhí)行單元的角度來看,以線程為例,線程的生命周期會有如下圖所示的幾個階段:
- 新建
- 就緒
- 運行
- 阻塞
- 死亡
我們再從系統(tǒng)服務的限制方面考慮,一般系統(tǒng)的限制包括I/O限制和CPU限制,I/O限制指代I/O密集型的應用程序的限制,而CPU限制則是CPU密集型應用程序的限制:
- I/O密集型:表示服務需要進行大量的I/O操作,如磁盤讀寫、網(wǎng)絡傳輸?shù)?,這類服務不需要進行大量的計算,但需要等待I/O操作完成,所以一般CPU占用率很低。
- CPU密集型:表示服務需要進行大量的CPU操作,如數(shù)據(jù)處理、圖像處理、加密解密等,這類服務需要進行大量的計算,但不需要進行太多I/O相關的操作,所以I/O等待時間短,CPU占用率高。
在目前的服務應用中,絕大部分的應用程序是CPU密集型。
而CPU密集型服務,要想最大限度的利用CPU,最理想的情況所有的執(zhí)行單元都處于運行和等待的狀態(tài),但等待和運行之間有個就緒的中間態(tài),這也就意味著,如果想讓所有的執(zhí)行單元都處于運行和代碼狀態(tài),我們就需要最小化就緒的執(zhí)行單元數(shù)量。而就緒單元一旦獲取到CPU資源(時間片)就會進入Running狀態(tài)。
如果處于就緒的單元不斷增多,在某種意義上意味著程序的CPU資源不足,即CPU過負載。從這個角度出發(fā),我們可以利用執(zhí)行單元處于就緒態(tài)的數(shù)量來判斷服務是否過載。
在Golang的GMP模型中,P的數(shù)量是一定的,M的數(shù)量最多不超過10000個,而Goroutine的數(shù)量幾乎是不定的。從上面利用就緒態(tài)(在Golang中是GRunnable狀態(tài))的數(shù)量來判斷系統(tǒng)過載,也給我們提供了一個新的方案:判斷系統(tǒng)所有P上(本地隊列)的Goroutine處于GRunnable的數(shù)量,如果數(shù)量超過一個界定值,表示CPU資源不足,即過載。
4. 小結
在剛開始接觸到服務的請求調(diào)度的時候,就想著看看是否有開源的方案來解決這個問題,果不其然,你能夠想到的,大家曾經(jīng)都想到過并付諸了時間和精力去給出了具體的方案設計,無論是SheddingHandler的設計,還是利用Goroutine的狀態(tài)來判斷系統(tǒng)是否過載,它們都有各自的理論為依托,但從精確度來說go-zero的SheddingHandler的設計相對來說更為準確,因為從CPU的真實數(shù)據(jù)出發(fā),得到具體的CPU是否負載是最為可靠直觀的。
判斷Goroutine的就緒態(tài)數(shù)量這個方案,在最開始的接觸中,自己是不太理解的,但從具體理論出發(fā),包括后續(xù)自己也進行了相關的壓測,以及Golang的trace.out文件的分析,在某種程度上,這種方案也是可行的,不禁感嘆自己還是太弱了,還是要多學習,加油!
到此這篇關于Golang服務的請求調(diào)度的實現(xiàn)的文章就介紹到這了,更多相關Golang服務請求調(diào)度內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Go?實戰(zhàn)單隊列到優(yōu)先級隊列實現(xiàn)圖文示例
這篇文章主要為大家介紹了Go?實戰(zhàn)單隊列到優(yōu)先級隊列圖文示例實現(xiàn)詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-07-07CentOS 32 bit安裝golang 1.7的步驟詳解
Go是Google開發(fā)的一種編譯型,并發(fā)型,并具有垃圾回收功能的編程語言。在發(fā)布了6個rc版本之后,Go 1.7終于正式發(fā)布了。本文主要介紹了在CentOS 32 bit安裝golang 1.7的步驟,文中給出了詳細的步驟,相信對大家的學習和理解具有一定的參考借鑒價值,下面來一起看看吧。2016-12-12golang定時器Timer的用法和實現(xiàn)原理解析
這篇文章主要介紹了golang定時器Ticker,本文主要來看一下Timer的用法和實現(xiàn)原理,需要的朋友可以參考以下內(nèi)容2023-04-04