IO 密集型服務 耗時優化

Jesse發表於2022-04-01

背景

專案背景

Feature 服務作為特徵服務,產出特徵資料供上游業務使用。
服務壓力:高峰期 API 模組 10wQPS,計算模組 20wQPS。
服務本地快取機制:

  • 計算模組有本地快取,且命中率較高,最高可達 50% 左右;
  • 計算模組本地快取在每分鐘第 0 秒會全部失效,而在此時流量會全部擊穿至下游 Codis;
  • Codis 中 Key 名 = 特徵名 + 地理格子 Id + 分鐘級時間串;

image.png

                                Feature 服務模組圖

面對問題

服務 API 側存在較嚴重的 P99 耗時毛刺問題(固定出現在每分鐘第 0-10s),導致上游服務的訪問錯誤率達到 1‰ 以上,影響到業務指標;
目標:解決耗時毛刺問題,將 P99 耗時整體優化至 15ms 以下;
image.png

                            API 模組返回上游 P99 耗時圖

解決方案

服務 CPU 優化

背景

偶然的一次上線變動中,發現對 Feature 服務來說 CPU 的使用率的高低會較大程度上影響到服務耗時,因此從提高服務 CPU Idle 角度入手,對服務耗時毛刺問題展開優化。

優化

通過對 Pprof profile 圖的觀察發現 JSON 反序列化操作佔用了較大比例(50% 以上),因此通過減少反序列化操作、更換 JSON 序列化庫(json-iterator)兩種方式進行了優化。

效果

收益:CPU idle 提升 5%,P99 耗時毛刺從 30ms 降低至 20 ms 以下
image.png

                            優化後的耗時曲線(紅色與綠色線)

關於 CPU 與耗時

為什麼 CPU Idle 提升耗時會下降

  • 反序列化時的開銷減少,使單個請求中的計算時間得到了減少;
  • 單個請求的處理時間減少,使同時併發處理的請求數得到了減少,減輕了排程切換、協程/執行緒排隊、資源競爭的開銷;

關於 json-iterator 庫

json-iterator 庫為什麼快
標準庫 json 庫使用 reflect.Value 進行取值與賦值,但 reflect.Value 不是一個可複用的反射物件,每次都需要按照變數生成 reflect.Value 結構體,因此效能很差。
json-iterator 實現原理是用 reflect.Type 得出的型別資訊通過「物件指標地址+欄位偏移」的方式直接進行取值與賦值,而不依賴於 reflect.Value,reflect.Type 是一個可複用的物件,同一型別的 reflect.Type 是相等的,因此可按照型別對 reflect.Type 進行 cache 複用。
總的來說其作用是減少記憶體分配反射呼叫次數,進而減少了記憶體分配帶來的系統呼叫、鎖和 GC 等代價,以及使用反射帶來的開銷。

詳情可見:https://cloud.tencent.com/dev...

呼叫方式優化 - 對衝請求

背景

  1. Feature 服務 API 模組訪問計算模組 P99 顯著高於 P95;
    image.png

                         API 模組訪問計算模組 P99 與 P95 耗時曲線
  2. 經觀察計算模組不同機器之間毛刺出現時間點不同,單機毛刺呈偶發現象,所有機器聚合看呈規律性毛刺;
    image.png

                         計算模組返回 API P99 耗時曲線(未聚合)

    image.png

                         計算模組返回 API P99 耗時曲線(均值聚合)
    

優化

  1. 針對 P99 高於 P95 現象,提出對衝請求方案,對毛刺問題進行優化;

    對衝請求:把對下游的一次請求拆成兩個,先發第一個,n毫秒超時後,發出第二個,兩個請求哪個先返回用哪個;
    Hedged requests.
    A simple way to curb latency variability is to issue the same request to multiple replicas and use the results from whichever replica responds first. We term such requests “hedged requests” because a client first sends one request to the replica be- lieved to be the most appropriate, but then falls back on sending a secondary request after some brief delay. The cli- ent cancels remaining outstanding re- quests once the first result is received. Although naive implementations of this technique typically add unaccept- able additional load, many variations exist that give most of the latency-re- duction effects while increasing load only modestly.
    One such approach is to defer send- ing a secondary request until the first request has been outstanding for more than the 95th-percentile expected la- tency for this class of requests. This approach limits the additional load to approximately 5% while substantially shortening the latency tail. The tech- nique works because the source of la- tency is often not inherent in the par- ticular request but rather due to other forms of interference.
    摘自:論文《The Tail at Scale》
  2. 調研

    • 閱讀論文 Google《The Tail at Scale》;
    • 開源實現:BRPC、RPCX;
    • 工業實踐:百度預設開啟、Grab LBS 服務(下游純記憶體型資料庫)效果非常明顯、谷歌論文中也有相關的實踐效果闡述;
  3. 落地實現:修改自 RPCX 開源實現
package backuprequest


import (
    "sync/atomic"
    "time"

    "golang.org/x/net/context"
)


var inflight int64

// call represents an active RPC.
type call struct {
    Name  string
    Reply interface{} // The reply from the function (*struct).
    Error error       // After completion, the error status.
    Done  chan *call  // Strobes when call is complete.
}


func (call *call) done() {
    select {
    case call.Done <- call:
    default:
        logger.Debug("rpc: discarding Call reply due to insufficient Done chan capacity")
    }
}


func BackupRequest(backupTimeout time.Duration, fn func() (interface{}, error)) (interface{}, error) {
    ctx, cancelFn := context.WithCancel(context.Background())
    defer cancelFn()
    callCh := make(chan *call, 2)
    call1 := &call{Done: callCh, Name: "first"}
    call2 := &call{Done: callCh, Name: "second"}


    go func(c *call) {
        defer helpers.PanicRecover()
        c.Reply, c.Error = fn()
        c.done()
    }(call1)


    t := time.NewTimer(backupTimeout)
    select {
    case <-ctx.Done(): // cancel by context
        return nil, ctx.Err()
    case c := <-callCh:
        t.Stop()
        return c.Reply, c.Error
    case <-t.C:
        go func(c *call) {
            defer helpers.PanicRecover()
            defer atomic.AddInt64(&inflight, -1)
            if atomic.AddInt64(&inflight, 1) > BackupLimit {
                metric.Counter("backup", map[string]string{"mark": "limited"})
                return
            }

            metric.Counter("backup", map[string]string{"mark": "trigger"})
            c.Reply, c.Error = fn()
            c.done()
        }(call2)
    }


    select {
    case <-ctx.Done(): // cancel by context
        return nil, ctx.Err()
    case c := <-callCh:
        metric.Counter("backup_back", map[string]string{"call": c.Name})
        return c.Reply, c.Error
    }
}

效果

收益:P99 耗時整體從 20-60ms 降低至 6ms,毛刺全部幹掉;(backupTimeout=5ms)
image.png

                            API 模組返回上游服務耗時統計圖

《The Tail at Scale》論文節選及解讀

括號中內容為個人解讀
為什麼存在變異性?(高尾部延遲的響應時間)

  • 導致服務的個別部分出現高尾部延遲的響應時間的變異性(耗時長尾的原因)可能由於許多原因而產生,包括:
  • 共享的資源。機器可能被不同的應用程式共享,爭奪共享資源(如CPU核心、處理器快取、記憶體頻寬和網路頻寬)(在雲上環境中這個問題更甚,如不同容器資源爭搶、Sidecar 程式影響);在同一個應用程式中,不同的請求可能爭奪資源。
  • 守護程式。後臺守護程式可能平均只使用有限的資源,但在安排時可能產生幾毫秒的中斷。
  • 全域性資源共享。在不同機器上執行的應用程式可能會爭奪全球資源(如網路交換機和共享檔案系統(資料庫))。
  • 維護活動。後臺活動(如分散式檔案系統中的資料重建,BigTable等儲存系統中的定期日誌壓縮(此處指 LSM Compaction 機制,基於 RocksDB 的資料庫皆有此問題),以及垃圾收集語言中的定期垃圾收集(自身和上下游都會有 GC 問題 1. Codis proxy 為 GO 語言所寫,也會有 GC 問題;2. 此次 Feature 服務耗時毛刺即時因為服務本身 GC 問題,詳情見下文)會導致週期性的延遲高峰;以及排隊。中間伺服器和網路交換機的多層排隊放大了這種變化性。

減少元件的可變性

  • 後臺任務可以產生巨大的CPU、磁碟或網路負載;例子是面向日誌的儲存系統的日誌壓縮和垃圾收集語言的垃圾收集器活動。
  • 通過節流、將重量級的操作分解成較小的操作(例如 GO、Redis rehash 時漸進式搬遷),並在整體負載較低的時候觸發這些操作(例如某資料庫將 RocksDB Compaction 操作放在凌晨定時執行),通常能夠減少後臺活動對互動式請求延遲的影響。

關於消除變異源

  • 消除大規模系統中所有的延遲變異源是不現實的,特別是在共享環境中。
  • 使用一種類似於容錯計算的方法(此處指對衝請求),容尾軟體技術從不太可預測的部分中形成一個可預測的整體(對下游耗時曲線進行建模,從概率的角度進行優化)。
  • 一個真實的谷歌服務的測量結果,該服務在邏輯上與這個理想化的場景相似;根伺服器通過中間伺服器將一個請求分發到大量的葉子伺服器。該表顯示了大扇出對延遲分佈的影響。在根伺服器上測量的單個隨機請求完成的第99個百分點的延遲是10ms。然而,所有請求完成的第99百分位數延遲是140ms,95%的請求完成的第99百分位數延遲是70ms,這意味著等待最慢的5%的請求完成的時間佔總的99%百分位數延遲的一半。專注於這些慢速異常值的技術可以使整體服務效能大幅降低。
  • 同樣,由於消除所有的變異性來源也是不可行的,因此正在為大規模服務開發尾部容忍技術。儘管解決特定的延遲變異來源的方法是有用的,但最強大的尾部容錯技術可以重新解決延遲問題,而不考慮根本原因。這些尾部容忍技術允許設計者繼續為普通情況進行優化,同時提供對非普通情況的恢復能力。

對衝請求原理

image.png

                                對衝請求典型場景
  • 其原理是從概率角度出發,利用下游服務的耗時模型,在這條耗時曲線上任意取兩個點,其中一個小於x的概率,這個概率遠遠大於任意取一個點小於x的概率,所以可以極大程度降低耗時;
  • 但如果多發的請求太多了,比如說1倍,會導致下游壓力劇增,耗時曲線模型產生惡化,達不到預期的效果,如果控制比如說在5%以內,下游耗時曲線既不會惡化,也可以利用他95分位之前的那個平滑曲線,因此對衝請求超時時間的選擇也是一個需要關注的點;
  • 當超過95分位耗時的時候,再多發一個請求,這時候這整個請求剩餘的耗時就取決於在這整個線上任取一點,和在95分位之後的那個線上任取一點,耗時是這兩點中小的那個,從概率的角度看,這樣95分位之後的耗時曲線,會比之前平滑相當多;
  • 這個取捨相當巧妙,只多發5%的請求,就能基本上直接幹掉長尾情況;
  • 侷限性

    • 請求需要冪等,否則會造成資料不一致;
    • 總得來說對衝請求是從概率的角度消除偶發因素的影響,從而解決長尾問題,因此需要考量耗時是否為業務側自身固定因素導致,舉例如下:

      • 如同一個 mget 介面查 100 個 key 與查 10000 個 key 耗時一定差異很大,這種情況下對衝請求時無能為力的,因此需要保證同一個介面請求之間質量是相似的情況下,這樣下游的耗時因素就不取決於請求內容本身;
      • 如 Feature 服務計算模組訪問 Codis 快取擊穿導致的耗時毛刺問題,在這種情況下對衝請求也無能為力,甚至一定情況下會惡化耗時;
    • 對衝請求超時時間並非動態調整而是人為設定,因此極端情況下會有雪崩風險,解決方案見一下小節;
名稱來源
backup request 好像是 BRPC 落地時候起的名字,論文原文裡叫 Hedged requests,簡單翻譯過來是對衝請求,GRPC 也使用的這個名字。

關於雪崩風險

對衝請求超時時間並非動態調整,而是人為設定,因此極端情況下會有雪崩風險;
image.png

                                摘自《Google SRE》

如果不加限制確實會有雪崩風險,有如下解法

  • BRPC 實踐:對衝請求會消耗一次對下游的重試次數;
    image.png
  • bilibili 實踐:

    • 對 retry 請求下游會阻斷級聯;
    • 本身要做熔斷;
    • 在 middleware 層實現視窗統計,限制重試總請求佔比,比如 1.1 倍;
  • 服務自身對下游實現熔斷機制,下游服務對上游流量有限流機制,保證不被打垮。從兩方面出發保證服務的穩定性;
  • Feature 服務實踐:對每個對衝請求在發出和返回時增加 atmoic 自增自減操作,如果大於某個值(請求耗時 ✖️ QPS ✖️ 5%),則不發出對衝請求,從控制併發請求數的角度進行流量限制;

語言 GC 優化

背景

在引入對衝請求機制進行優化後,在耗時方面取得了突破性的進展,但為從根本上解決耗時毛刺,優化服務內部問題,達到標本兼治的目的,著手對服務的耗時毛刺問題進行最後的優化;

優化

第一步:觀察現象,初步定位原因
對 Feature 服務早高峰毛刺時的 Trace 圖進行耗時分析後發現,在毛刺期間程式 GC pause 時間(GC 週期與任務生命週期重疊的總和)長達近 50+ms(見左圖),絕大多數 goroutine 在 GC 時進行了長時間的輔助標記(mark assist,見右圖中淺綠色部分),GC 問題嚴重,因此懷疑耗時毛刺問題是由 GC 導致;
image.pngimage.png

第二步:從原因出發,進行鍼對性分析

  • 根據觀察計算模組服務平均每 10 秒發生 2 次 GC,GC 頻率較低,但在每分鐘前 10s 第一次與第二次的 GC 壓力大小(做 mark assist 的 goroutine 數)呈明顯差距,因此懷疑是在每分鐘前 10s 進行第一次 GC 時的壓力過高導致了耗時毛刺。
  • 根據 Golang GC 原理分析可知,G 被招募去做輔助標記是因為該 G 分配堆記憶體太快導致,而 計算模組每分鐘快取失效機制會導致大量的下游訪問,從而引入更多的物件分配,兩者結合互相印證了為何在每分鐘前 10s 的第一次 GC 壓力超乎尋常;
關於 GC 輔助標記 mark assist
為了保證在Marking過程中,其它G分配堆記憶體太快,導致Mark跟不上Allocate的速度,還需要其它G配合做一部分標記的工作,這部分工作叫輔助標記(mutator assists)。在Marking期間,每次G分配記憶體都會更新它的”負債指數”(gcAssistBytes),分配得越快,gcAssistBytes越大,這個指數乘以全域性的”負載匯率”(assistWorkPerByte),就得到這個G需要幫忙Marking的記憶體大小(這個計算過程叫revise),也就是它在本次分配的mutator assists工作量(gcAssistAlloc)。
引用自:https://wudaijun.com/2020/01/...

第三步:按照分析結論,設計優化操作
從減少物件分配數角度出發,對 Pprof heap 圖進行觀察

  • 在 inuse_objects 指標下 cache 庫佔用最大;
  • 在 alloc_objects 指標下 json 序列化佔用最大;

但無法確定哪一個是真正使分配記憶體增大的因素,因此著手對這兩點進行分開優化;
image.png
image.png
通過對業界開源的 json 和 cache 庫調研後(調研報告:https://segmentfault.com/a/11...),採用效能較好、低分配的 GJSON 和 0GC 的 BigCache 對原有庫進行替換;

效果

  • 更換 JSON 序列化庫 GJSON 庫優化無效果;
  • 更換 Cache 庫 BigCache 庫效果明顯,inuse_objects 由 200-300w 下降到 12w,毛刺基本消失;

image.png

                    計算模組耗時統計圖(淺色部分:GJSON,深色部分:BigCache)

image.png

                            API 模組返回上游耗時統計圖

關於 Golang GC

在通俗意義上常認為,GO GC 觸發時機為堆大小增長為上次 GC 兩倍時。但在 GO GC 實際實踐中會按照 Pacer 調頻演算法根據堆增長速度、物件標記速度等因素進行預計算,使堆大小在達到兩倍大小前提前發起 GC,最佳情況下會只佔用 25% CPU 且在堆大小增長為兩倍時,剛好完成 GC。

關於 Pacer 調頻演算法:https://golang.design/under-t...

但 Pacer 只能在穩態情況下控制 CPU 佔用為 25%,一旦服務內部有瞬態情況,例如定時任務、快取失效等等,Pacer 基於穩態的預判失效,導致 GC 標記速度小於分配速度,為達到 GC 回收目標(在堆大小到達兩倍之前完成 GC),會導致大量 Goroutine 被招募去執行 Mark Assist 操作以協助回收工作,從而阻礙到 Goroutine 正常的工作執行。因此目前 GO GC 的 Marking 階段對耗時影響時最為嚴重的。

關於 gc pacer 調頻器
image.png
引用自:https://go.googlesource.com/p...

最終效果

API 模組 P99 耗時從 20-50ms 降低至 6ms,訪問錯誤率從 1‰ 降低到 1‱。
image.png

                            API 返回上游服務耗時統計圖

總結

  1. 當分析耗時問題時,觀察監控或日誌後,可能會發現趨勢完全匹配的兩種指標,誤以為是因果關係,但卻有可能這兩者都是外部表現,共同受到第三變數的影響,相關但不是因果
  2. 相對於百毫秒耗時服務,低延時服務的耗時會較大程度上受到 CPU 使用率的影響,在做效能優化時切勿忽視這點;(執行緒排隊、排程損耗、資源競爭等)
  3. 對於高併發、低延時服務,耗時方面受到下游的影響可能只是一個方面,服務自身開銷如序列化、GC 等都可能會較大程度上影響到服務耗時;
  4. 效能優化因從提高可觀測性入手,如鏈路追蹤、標準化的 Metric、go pprof 工具等等,打好排查基礎,基於多方可靠資料進行分析與猜測,最後著手進行優化、驗證,避免盲人摸象似的操作,妄圖通過碰運氣的方式解決問題;
  5. 瞭解一些簡單的建模知識對耗時優化的分析與猜測階段會有不錯的幫助;
  6. 理論結合實際問題進行思考;多看文章、參與分享、進行交流,瞭解更多技術,擴充套件視野;每一次的討論和質疑都是進一步深入思考的機會,以上多項優化都出自與大佬(特別鳴謝 @李心宇@劉琦@龔勳)的討論後的實踐;
  7. 同為效能優化,耗時優化不同於 CPU、記憶體等資源優化,更加複雜,難度較高,在做資源優化時 Go 語言自帶了方便易用的 PProf 工具,可以提供很大的幫助,但耗時優化尤其是長尾問題的優化非常艱難,因此在進行優化的過程中一定要穩住心態、耐心觀察,行百里者半九十
  8. 關注請求之間共享資源的爭用導致的耗時問題,不僅限於下游服務,服務自身的 CPU、記憶體(引發 GC)等也是共享資源的一部分;

參考

均為內網文章,略。。

相關文章