IO 密集型服务 性能优化实战记录
背景
項目背景
Feature 服務作為特征服務,產出特征數據供上游業務使用。服務壓力:高峰期 API 模塊 10wQPS,計算模塊 20wQPS。服務本地緩存機制:
計算模塊有本地緩存,且命中率較高,最高可達 50% 左右;
計算模塊本地緩存在每分鐘第 0 秒會全部失效,而在此時流量會全部擊穿至下游 Codis;
Codis 中 Key 名 = 特征名 + 地理格子 Id + 分鐘級時間串;
? ? ? ? ? ? ? ? ? ? ? ? ? ? ?Feature 服務模塊圖
面對問題
服務 API 側存在較嚴重的 P99 耗時毛刺問題(固定出現在每分鐘第 0-10s),導致上游服務的訪問錯誤率達到 1‰ 以上,影響到業務指標;目標:解決耗時毛刺問題,將 P99 耗時整體優化至 15ms 以下;? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?API 模塊返回上游 P99 耗時圖
解決方案
服務 CPU 優化
背景
偶然的一次上線變動中,發現對 Feature 服務來說 CPU 的使用率的高低會較大程度上影響到服務耗時,因此從提高服務 CPU Idle 角度入手,對服務耗時毛刺問題展開優化。
優化
通過對 Pprof profile 圖的觀察發現 JSON 反序列化操作占用了較大比例(50% 以上),因此通過減少反序列化操作、更換 JSON 序列化庫(json-iterator)兩種方式進行了優化。
效果
收益:CPU idle 提升 5%,P99 耗時毛刺從 30ms 降低至 20 ms 以下。
優化后的耗時曲線(紅色與綠色線)關于 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/developer/article/1064753
調用方式優化 - 對沖請求
背景
Feature 服務 API 模塊訪問計算模塊 P99 顯著高于 P95;API 模塊訪問計算模塊 P99 與 P95 耗時曲線
經觀察計算模塊不同機器之間毛刺出現時間點不同,單機毛刺呈偶發現象,所有機器聚合看呈規律性毛刺;計算模塊返回 API P99 耗時曲線(未聚合)計算模塊返回 API P99 耗時曲線(均值聚合)
優化
針對 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》
調研
閱讀論文 Google《The Tail at Scale》;
開源實現:BRPC、RPCX;
工業實踐:百度默認開啟、Grab LBS 服務(下游純內存型數據庫)效果非常明顯、谷歌論文中也有相關的實踐效果闡述;
落地實現:修改自 RPCX 開源實現
package?backuprequestimport?("sync/atomic""time""golang.org/x/net/context" )var?inflight?int64//?call?represents?an?active?RPC. type?call?struct?{Name??stringReply?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?contextreturn?nil,?ctx.Err()case?c?:=?<-callCh:t.Stop()return?c.Reply,?c.Errorcase?<-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?contextreturn?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)
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%百分位數延遲的一半。專注于這些慢速異常值的技術可以使整體服務性能大幅降低。
同樣,由于消除所有的變異性來源也是不可行的,因此正在為大規模服務開發尾部容忍技術。盡管解決特定的延遲變異來源的方法是有用的,但最強大的尾部容錯技術可以重新解決延遲問題,而不考慮根本原因。這些尾部容忍技術允許設計者繼續為普通情況進行優化,同時提供對非普通情況的恢復能力。
對沖請求原理
對沖請求典型場景其原理是從概率角度出發,利用下游服務的耗時模型,在這條耗時曲線上任意取兩個點,其中一個小于x的概率,這個概率遠遠大于任意取一個點小于x的概率,所以可以極大程度降低耗時;
但如果多發的請求太多了,比如說1倍,會導致下游壓力劇增,耗時曲線模型產生惡化,達不到預期的效果,如果控制比如說在5%以內,下游耗時曲線既不會惡化,也可以利用他95分位之前的那個平滑曲線,因此對沖請求超時時間的選擇也是一個需要關注的點;
當超過95分位耗時的時候,再多發一個請求,這時候這整個請求剩余的耗時就取決于在這整個線上任取一點,和在95分位之后的那個線上任取一點,耗時是這兩點中小的那個,從概率的角度看,這樣95分位之后的耗時曲線,會比之前平滑相當多;
這個取舍相當巧妙,只多發5%的請求,就能基本上直接干掉長尾情況;
局限性
如同一個 mget 接口查 100 個 key 與查 10000 個 key 耗時一定差異很大,這種情況下對沖請求時無能為力的,因此需要保證同一個接口請求之間質量是相似的情況下,這樣下游的耗時因素就不取決于請求內容本身;
如 Feature 服務計算模塊訪問 Codis 緩存擊穿導致的耗時毛刺問題,在這種情況下對沖請求也無能為力,甚至一定情況下會惡化耗時;
請求需要冪等,否則會造成數據不一致;
總得來說對沖請求是從概率的角度消除偶發因素的影響,從而解決長尾問題,因此需要考量耗時是否為業務側自身固定因素導致,舉例如下:
對沖請求超時時間并非動態調整而是人為設定,因此極端情況下會有雪崩風險,解決方案見一下小節;
名稱來源 backup request 好像是 BRPC 落地時候起的名字,論文原文里叫 Hedged requests,簡單翻譯過來是對沖請求,GRPC 也使用的這個名字。
關于雪崩風險
對沖請求超時時間并非動態調整,而是人為設定,因此極端情況下會有雪崩風險;
摘自《Google SRE》 如果不加限制確實會有雪崩風險,有如下解法
BRPC 實踐:對沖請求會消耗一次對下游的重試次數;
bilibili 實踐:
對 retry 請求下游會阻斷級聯;
本身要做熔斷;
在 middleware 層實現窗口統計,限制重試總請求占比,比如 1.1 倍;
服務自身對下游實現熔斷機制,下游服務對上游流量有限流機制,保證不被打垮。從兩方面出發保證服務的穩定性;
Feature 服務實踐:對每個對沖請求在發出和返回時增加 atmoic 自增自減操作,如果大于某個值(請求耗時 ?? QPS ?? 5%),則不發出對沖請求,從控制并發請求數的角度進行流量限制;
語言 GC 優化
背景
在引入對沖請求機制進行優化后,在耗時方面取得了突破性的進展,但為從根本上解決耗時毛刺,優化服務內部問題,達到標本兼治的目的,著手對服務的耗時毛刺問題進行最后的優化;
優化
第一步:觀察現象,初步定位原因對 Feature 服務早高峰毛刺時的 Trace 圖進行耗時分析后發現,在毛刺期間程序 GC pause 時間(GC 周期與任務生命周期重疊的總和)長達近 50+ms(見左圖),絕大多數 goroutine 在 GC 時進行了長時間的輔助標記(mark assist,見右圖中淺綠色部分),GC 問題嚴重,因此懷疑耗時毛刺問題是由 GC 導致;
第二步:從原因出發,進行針對性分析
根據觀察計算模塊服務平均每 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/go-gc-keypoint-and-monitor/
第三步:按照分析結論,設計優化操作從減少對象分配數角度出發,對 Pprof heap 圖進行觀察
在 inuse_objects 指標下 cache 庫占用最大;
在 alloc_objects 指標下 json 序列化占用最大;
但無法確定哪一個是真正使分配內存增大的因素,因此著手對這兩點進行分開優化;
通過對業界開源的 json 和 cache 庫調研后(調研報告:https://segmentfault.com/a/1190000041591284),采用性能較好、低分配的 GJSON 和 0GC 的 BigCache 對原有庫進行替換;效果
更換 JSON 序列化庫 GJSON 庫優化無效果;
更換 Cache 庫 BigCache 庫效果明顯,inuse_objects 由 200-300w 下降到 12w,毛刺基本消失;
關于 Golang GC
在通俗意義上常認為,GO GC 觸發時機為堆大小增長為上次 GC 兩倍時。但在 GO GC 實際實踐中會按照 Pacer 調頻算法根據堆增長速度、對象標記速度等因素進行預計算,使堆大小在達到兩倍大小前提前發起 GC,最佳情況下會只占用 25% CPU 且在堆大小增長為兩倍時,剛好完成 GC。
關于 Pacer 調頻算法:https://golang.design/under-the-hood/zh-cn/part2runtime/ch08gc/pacing/
但 Pacer 只能在穩態情況下控制 CPU 占用為 25%,一旦服務內部有瞬態情況,例如定時任務、緩存失效等等,Pacer 基于穩態的預判失效,導致 GC 標記速度小于分配速度,為達到 GC 回收目標(在堆大小到達兩倍之前完成 GC),會導致大量 Goroutine 被招募去執行 Mark Assist 操作以協助回收工作,從而阻礙到 Goroutine 正常的工作執行。因此目前 GO GC 的 Marking 階段對耗時影響時最為嚴重的。
關于 gc pacer 調頻器
引用自:https://go.googlesource.com/proposal/+/a216b56e743c5b6b300b3ef1673ee62684b5b63b/design/44167-gc-pacer-redesign.md最終效果
API 模塊 P99 耗時從 20-50ms 降低至 6ms,訪問錯誤率從 1‰ 降低到 1?。
API 返回上游服務耗時統計圖總結
當分析耗時問題時,觀察監控或日志后,可能會發現趨勢完全匹配的兩種指標,誤以為是因果關系,但卻有可能這兩者都是外部表現,共同受到第三變量的影響,相關但不是因果;
相對于百毫秒耗時服務,低延時服務的耗時會較大程度上受到 CPU 使用率的影響,在做性能優化時切勿忽視這點;(線程排隊、調度損耗、資源競爭等)
對于高并發、低延時服務,耗時方面受到下游的影響可能只是一個方面,服務自身開銷如序列化、GC 等都可能會較大程度上影響到服務耗時;
性能優化因從提高可觀測性入手,如鏈路追蹤、標準化的 Metric、go pprof 工具等等,打好排查基礎,基于多方可靠數據進行分析與猜測,最后著手進行優化、驗證,避免盲人摸象似的操作,妄圖通過碰運氣的方式解決問題;
了解一些簡單的建模知識對耗時優化的分析與猜測階段會有不錯的幫助;
理論結合實際問題進行思考;多看文章、參與分享、進行交流,了解更多技術,擴展視野;每一次的討論和質疑都是進一步深入思考的機會,以上多項優化都出自與大佬(特別鳴謝 @李心宇@劉琦@龔勛)的討論后的實踐;
同為性能優化,耗時優化不同于 CPU、內存等資源優化,更加復雜,難度較高,在做資源優化時 Go 語言自帶了方便易用的 PProf 工具,可以提供很大的幫助,但耗時優化尤其是長尾問題的優化非常艱難,因此在進行優化的過程中一定要穩住心態、耐心觀察,行百里者半九十。
關注請求之間共享資源的爭用導致的耗時問題,不僅限于下游服務,服務自身的 CPU、內存(引發 GC)等也是共享資源的一部分;
總結
以上是生活随笔為你收集整理的IO 密集型服务 性能优化实战记录的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Go 之父:聊聊我眼中的 Go 语言和环
- 下一篇: 图解 Go GC