计算密集型服务 性能优化实战始末
背景
項目背景
worker 服務數據鏈路圖
worker 服務消費上游數據(工作日高峰期產出速度達近 200 MB/s,節假日高峰期可達 300MB/s 以上),進行中間處理后,寫入多個下游。在實踐中結合業務場景,基于快慢隔離的思想,以三個不同的 consumer group 消費同一 Topic,隔離三種數據處理鏈路。
面對問題
worker 服務在高峰期時 CPU Idle 會降至 60%,因其屬于數據處理類計算密集型服務,CPU Idle 過低會使服務吞吐降低,在數據處理上產生較大延時,且受限于 Kafka 分區數,無法進行橫向擴容;
對上游數據的采樣率達 **30%**,業務方對數據的完整性有較大訴求,但系統 CPU 存在瓶頸,無法滿足;
性能優化
針對以上問題,開始著手對服務 CPU Idle 進行優化;抓取服務 pprof profile 圖如下:go tool pprof -http=:6061 http://「ip:port」/debug/pprof/profile
優化前的 pprof profile 圖
服務與存儲之間置換壓力
背景
worker 服務消費到上游數據后,會先寫全部寫入 Apollo 的數據庫,之后每分鐘定時撈取處理,但消息體大小 P99 分位達近 1.5M,對于 Apollo 有較嚴重的大 Key 問題,再結合 RocksDB 特有的寫放大問題,會進一步加劇存儲壓力。在這個背景下,我們采用 zlib 壓縮算法,對消息體先進行壓縮后再寫入 Apollo,減緩讀寫大 Key 對 Apollo 的壓力。
優化
在 CPU 的優化過程中,我們發現服務在壓縮操作上占用了較多的 CPU,于是對壓縮等級進行調整,以減小壓縮率、增大下游存儲壓力為代價,減少壓縮操作對服務 CPU 的占用,提升服務 CPU 。這一操作本質上是在服務與存儲之間進行壓力置換,是一種空間換時間的做法。
關于壓縮等級
這里需要特別注意的是,在壓縮等級的設置上可能存在較為嚴重的邊際效用遞減問題。在進行基準測試時發現,將壓縮等級由 BestCompression 調整為 DefaultCompression 后,壓縮率只有近 1? 的下降,但壓縮方面的 CPU 占用卻相對提高近 **50%**。此結論不能適用于所有場景,需從實際情況出發,但在使用時應注意這個問題,選擇相對最優的壓縮方式。
zlib 可設置的壓縮等級
使用更高效的序列化庫
背景
worker 服務在設計之初基于快慢隔離的思想,使用三個不同的 consumer group 進行分開消費,導致對同一份數據會重復消費三次,而上游產出的數據是在 PB 序列化之后寫入 Kafka,消費側亦需要進行 PB 反序列化方能使用,因此導致了 PB 反序列化操作在 CPU 上的較大開銷。
優化
經過探討和調研后發現,gogo/protobuf 三方庫相較于原生的 golang/protobuf 庫性能更好,在 CPU 上占用更低,速度更快,因此采用 gogo/protobuf 庫替換掉原生的 golang/protobuf 庫。
gogo/protobuf 為什么快
通過對每一個字段都生成代碼的方式,取消了對反射的使用;
采用預計算方式,在序列化時能夠減少內存分配次數,進而減少了內存分配帶來的系統調用、鎖和 GC 等代價。
用過去或未來換現在的時間:頁面靜態化、池化技術、預編譯、代碼生成等都是提前做一些事情,用過去的時間,來降低用戶在線服務的響應時間;另外對于一些在線服務非必須的計算、存儲的耗時操作,也可以異步化延后進行處理,這就是用未來的時間換現在的時間。出處:https://mp.weixin.qq.com/s/S8KVnG0NZDrylenIwSCq8g
關于序列化庫
這里只列舉的了 PB 序列化庫的優化 Case,但在 JSON 序列化方面也存在一樣的優化手段,如 json-iterator、sonic、gjson 等等,我們在 Feature 服務中先后采用了 json-iterator 與 gjson 庫替換原有的標準庫 JSON 序列化方式,均取得了顯著效果。JSON 庫調研報告:https://segmentfault.com/a/1190000041591284
調整壓縮等級與更換 PB 序列化庫之后
數據攢批 減少調用
背景
在觀察 pprof 圖后發現寫 hbase 占用了近 50% 的相對 CPU,經過進一步分析后,發現每次在序列化一個字段時 Thrift 都會調用一次 socket->syscall,帶來頻繁的上下文切換開銷。
優化
閱讀代碼后發現, 原代碼中使用了 Thrift 的 TTransport 實現,其功能是包裝 TSocket,裸調 Syscall,每次 Write 時都會調用 socket 寫入進而調用 Syscall。這與通常我們的編碼習慣不符,認為應該有一個 buffer 充當中間層進行數據攢批,當 buffer 寫完或者寫滿后再向下層寫入。于是進一步閱讀 Thrift 源碼,發現其中有多種 Transport 實現,而 TTBufferedTransport 是符合我們編碼習慣的。
對 Thrift 調用進行優化,使用帶 buffer 的 transport,大大減少對 Syscall的調用
更換 transport 之后,對 HBase 的調用消耗只剩最右側的一條了
對 HBase 的訪問耗時大幅下降
Thrift Client 部分源碼分析
Transport 使用了裝飾器模式
| TTransport | 包裝 TSocket,裸調 Syscall,每次 Write 都會調用 syscall; |
| TTBufferedTransport | 需要提前聲明 buffer 的大小,在調用 Socket 之前加了一層 buffer,寫滿或者寫完之后再調用 Syscall; |
| TFramedTransport | 與 TTBufferedTransport 類似,但只會在全部寫入 buffer 后,再調用 Syscall。數據格式為:size+content,客戶端與服務端必須都使用該實現,否則會因為格式不兼容報錯; |
| streamTransport | 傳入自己實現的 IO 接口; |
| TMemoryBufferTransport | 純內存交換,不與網絡交互; |
| TBinaryProtocol | 直接的二進制格式; |
| TCompactProtocol | 緊湊型、高效和壓縮的二進制格式; |
| TJSONProtocol | JSON 格式; |
| TSimpleJSONProtocol | SimpleJSON 產生的輸出適用于 AJAX 或腳本語言,它不保留Thrift的字段標簽,不能被 Thrift 讀回,它不應該與全功能的 TJSONProtocol 相混淆;https://cwiki.apache.org/confluence/display/THRIFT/ThriftUsageJava |
關于數據攢批
數據攢批:將數據先寫入用戶態內存中,而后統一調用 syscall 進行寫入,常用在數據落盤、網絡傳輸中,可降低系統調用次數、利用磁盤順序寫特性等,是一種空間換時間的做法。有時也會犧牲一定的數據實時性,如 kafka producer 側。相似優化可見:https://mp.weixin.qq.com/s/ntNGz6mjlWE7gb_ZBc5YeA
語法調整
除在對庫的使用上進行優化外,在 GO 語言本身的使用上也存在一些優化方式;
slice、map 預初始化,減少頻繁擴容導致的內存拷貝與分配開銷;
字符串連接使用 strings.builder(預初始化) 代替 fmt.Sprintf();
buffer 修改返回 string([]byte) 操作為 []byte,減少內存 []byte -> string 的內存拷貝開銷;
string <-> []byte 的另一種優化,需確保 []byte 內容后續不會被修改,否則會發生 panic;
關于語法調整
更多語法調整,見以下文章
https://www.bacancytechnology.com/blog/golang-performance
https://mp.weixin.qq.com/s/Lv2XTD-SPnxT2vnPNeREbg
GC 調優
背景
在上次優化完成之后,系統已經基本穩定,CPU Idle 高峰期也可以維持在 80% 左右,但后續因業務訴求對上游數據采樣率調整至 100%,CPU.Idle 高峰期指標再次下降至近 70%,且由于定時任務的問題,存在 CPU.Idle 掉 0 風險;
優化
經過對 pprof 的再次分析,發現 runtime.gcMarkWorker 占用不合常理,達到近 30%,于是開始著手對 GC 進行優化;
GC 優化前 pprof 圖
方法一:使用 sync.pool()
通常來說使用 sync.pool() 緩存對象,減少對象分配數,是優化 GC 的最佳方式,因此我們在項目中使用其對 bytes.buffer 對象進行緩存復用,意圖減少 GC 開銷,但實際上線后 CPU Idle 卻略微下降,且 GC 問題并無緩解。原因有二:
sync.pool 是全局對象,讀寫存在競爭問題,因此在這方面會消耗一定的 CPU,但之所以通常用它優化后 CPU 會有提升,是因為它的對象復用功能對 GC 帶來的優化,因此 sync.pool 的優化效果取決于鎖競爭增加的 CPU 消耗與優化 GC 減少的 CPU 消耗這兩者的差值;
GC 壓力的大小通常取決于 inuse_objects,與 inuse_heap 無關,也就是說與正在使用的對象數有關,與正在使用的堆大小無關;
本次優化時選擇對 bytes.buffer 進行復用,是想做到減少堆大小的分配,出發點錯了,對 GC 問題的理解有誤,對 GC 的優化因從 pprof heap 圖 inuse_objects 與 alloc_objects 兩個指標出發。
甚至沒有依賴經驗, 只是單純的想當然了🤦?♂?
方法二:設置 GOGC
原理:GOGC 默認值是 100,也就是下次 GC 觸發的 heap 的大小是這次 GC 之后的 heap 的一倍,通過調大 GOGC 值(gcpercent)的方式,達到減少 GC 次數的目的;
公式:gc_trigger = heap_marked * (1+gcpercent/100) gcpercent:通過 GOGC 來設置,默認是 100,也就是當前內存分配到達上次存活堆內存 2 倍時,觸發 GC;heap_marked:上一個 GC 中被標記的(存活的)字節數;
問題:GOGC 參數不易控制,設置較小提升有限,設置較大容易有 OOM 風險,因為堆大小本身是在實時變化的,在任何流量下都設置一個固定值,是一件有風險的事情。這個問題目前已經有解決方案,Uber 發表的文章中提到了一種自動調整 GOGC 參數的方案,用于在這種方式下優化 GO 的 GC CPU 占用,不過業界還沒有開源相關實現
設置 GOGC 至 1000% 后,GC 占用大幅縮小
內存利用率接近 100%
方法三:GO ballast 內存控制
ballast 壓艙物--航海。為提供所需的吃水和穩定性而臨時或永久攜帶在船上的重型材料。來源:Dictionary.com
原理:仍然是從利用了下次 GC 觸發的 heap 的大小是這次 GC 之后的 heap 的一倍這一原理,初始化一個生命周期貫穿整個 Go 應用生命周期的超大 slice,用于內存占位,增大 heap_marked 值降低 GC 頻率;實際操作有以下兩種方式
公式:gc_trigger = heap_marked * (1+gcpercent/100) gcpercent:通過 GOGC 來設置,默認是 100,也就是當前內存分配到達上次存活堆內存 2 倍時,觸發 GC;heap_marked:上一個 GC 中被標記的(存活的)字節數;
方式一
方式二
兩種方式都可以達到同樣的效果,但是方式一會實際占用物理內存,在可觀測性上會更舒服一點,方式二并不會實際占用物理內存。
原因:Memory in ‘nix (and even Windows) systems is virtually addressed and mapped through page tables by the OS. When the above code runs, the array the ballast slice points to will be allocated in the program’s virtual address space. Only if we attempt to read or write to the slice, will the page fault occur that causes the physical RAM backing the virtual addresses to be allocated. 引用自:https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/
優化后 GC 頻率由每秒 5 次降低到了每秒 0.1 次
使用 ballast 內存控制后,GC 占用縮小至紅框大小
使用方式一后,內存始終穩定在 25% -30%,即 3G 大小
相比于設置 GOGC 的優勢
安全性更高,OOM 風險小;
效果更好,可以從 pprof 圖看出,后者的優化效果更大;
負面考量問:雖然通過大切片占位的方式可以有效降低 GC 頻率,但是每次 GC 需要掃描和回收的對象數量變多了,是否會導致進行 GC 的那一段時間產生耗時毛刺?答:不會,GC 有兩個階段 mark 與 sweep,unused_objects 只與 sweep 階段有關,但這個過程是非??焖俚?#xff1b;mark 階段是 GC 時間占用最主要的部分,但其只與當前的 inuse_objects 有關,與 unused_objects 無太大關系;因此,綜上所述,降低頻率確實會讓每次 GC 時的 unused_objects 有所增長,但并不會對 GC 增加太多負擔;
關于 ballast 內存控制更詳細的內容請看:https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/
關于 GC 調優
GC 優化手段的優先級:設置 GOGC、GO ballast 內存控制等操作是一種治標不治本略顯 trick 的方式,在做 GC 優化時還應先從對象復用、減少對象分配角度著手,在確無優化空間或優化成本較大時,再選擇此種方式;
設置 GOGC、GO ballast 內存控制等操作本質上也是一種空間換時間的做法,在內存與 CPU 之間進行壓力置換;
在 GC 調優方面,還有很多其他優化方式,如 bigcache 在堆內定義大數組切片自行管理、fastcache 直接調用 syscall.mmap 申請堆外內存使用、offheap 使用 cgo 管理堆外內存等等。
優化效果
黃色線:調整壓縮等級與更換 PB 序列化庫;綠色線:Thrift 序列化更換帶 buffer 的 transport;
藍色曲線抖動是因為上游業務放量,后又做垂直伸縮將 CPU 由 8 核提至 16 核 GC 優化(紅框部分)
先后將 CPU 提升 **25%、10%**(假設不做伸縮);
支持上游數據 100% 放量;
通過對 CPU 瓶頸的解決,順利合并服務,下掉 70 臺容器。
總結
經驗分享
做性能優化經驗很重要,其次在優化之前掌握一部分前置知識更好;
平時多看一些資料學習,有優化機會就抓住實踐,避免書到用時方恨少;
仔細觀察 pprof 圖,分析大塊部分;
觀察問題點的 api 使用,可能具有更高效的使用方式
記錄優化過程和優化效果,以后分享、吹逼用的上;
最好可以構建穩定的基準環境,驗證效果;
空間換時間是萬能鑰匙,工程問題不要只 case by case 的看,很多解決方案都是同一種思想的不同落地,嘗試去總結和掌握這種思想,最后達到遷移復用的效果;
多和大佬討論,非常重要,以上多項優化都出自與大佬(特別鳴謝 @李小宇@曹春暉)討論后的實踐;
參考
gogo/protobuf:https://jishuin.proginn.com/p/763bfbd4f993
改善 Go 語言編程質量的 50 個有效實踐:第 15 章 注意Go 字符串是原生類型
字節跳動 Go RPC 框架 KiteX 性能優化實踐:https://mp.weixin.qq.com/s/Xoaoiotl7ZQoG2iXo9_DWg
某高并發服務 GOGC 及 UDP Pool 優化:https://mp.weixin.qq.com/s/EuJ3Pw0s24Nr1h2edn5Sgg
性能優化 | Go Ballast 讓內存控制更加絲滑:https://mp.weixin.qq.com/s/gc34RYqmzeMndEJ1-7sOwg
Go 自底向上的性能優化實踐:https://www.bilibili.com/video/BV1GT4y127SC?spm_id_from=333.999.0.0
Go memory ballast: How I learnt to stop worrying and love the heap:https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/
總結
以上是生活随笔為你收集整理的计算密集型服务 性能优化实战始末的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 写了 30 多个 Go 常用文件操作的示
- 下一篇: Go 之父:聊聊我眼中的 Go 语言和环