怎樣優化一個Go服務以減少40%的CPU使用率?
通過優化一個 Go 服務,Coralogix 公司的工程師成功減少了 40% 的 CPU 使用率。
10 年前,谷歌遇到一個由 C++ 編譯時間過長造成的嚴重瓶頸,他們需要全新的解決方案。為應對這一挑戰,谷歌工程師創建了一種叫 Go(又名 Golang)的新編程語言。Go 語言借鑒了 C++ 的優點(比如其性能和安全特性),同時結合了 Python 的開發速度,這讓其能快速使用多個核心,實現并發計算。
在 Coralogix,我們解析客戶日志來為它們提供相應的實時分析、警報和元數據等。為做到這一點,解析階段必須非???,但解析階段卻又非常復雜,并需要為每一個日志行服務加載大量規則。這是我們覺得采用 Golang 的原因之一。
這項新服務全天候運行在我們的生產環境中。雖然結果很不錯,但是它仍然需要運行在高性能的機器上。這個 Go 服務運行在一臺 8 核 CPU 和 36GB 內存的 AWS m4.2xlarge 實例上,每天解析超過數百億的日志。這個階段,一切運行正常,可以將就,但這不是我們的風格,我們希望用更少的 AWS 實例來提供更多的功能,比如性能等。為實現這一目標,我們需要了解瓶頸的本質,以及如何能減少或完全消除它們。
1. 問題分析
我們決定在服務上運行一些性能分析,并檢查是什么導致 CPU 的高消耗,看看是否可以做些優化工作。
升級版本
首先,我們將 GO 升級到最新的穩定版本(軟件生命周期中的關鍵一步)。此前,我們的版本是 1.12.4,現在用的是 1.13.8。
根據官方文檔,1.13 版本在 runtime library 和一些對內存使用有很大影響的組件進行了重大改進。不管怎么說,使用最新的穩定版本很有意義,并且為我們節省了很多工作。
https://golang.org/doc/devel/release.html
因此,內存消耗也從 800MB 左右優化到 180MB 左右。
分析開始
其次,為更好地理解我們的工作流程,并了解我們在哪里花費時間和資源,我們開始進行分析。
分析不同的服務和編程語言可能看起來非常復雜并令人生畏,但是在 Go 中,它實際上非常簡單,僅用幾個命令就可以實現。Go 有一個叫”pprof”的專門工具,可以通過監聽路由(默認端口為 6060)在應用程序中啟用該工具,并使用 Go 包來管理 HTTP 連接
- import _ "net/http/pprof"
然后,在主函數或者路由包中啟用如下操作:
- go func() {
- log.Println(http.ListenAndServe("localhost:6060",nil))
- }()
現在我們可以啟動服務并連接到
- Http://localhost:6060/debug/pprof
完整的 Go 文檔可以參閱此處。
https://golang.org/pkg/net/http/pprof
pprof 的默認配置是每隔 30 秒對 CPU 使用率進行采樣。我們可以調整一些配置從而實現對 CPU 使用率、heap usage 等參數的采樣。我們主要關注的是 CPU 的使用情況,因此在生產階段中,我們采取一個 30 秒間隔的性能采樣,并發現下圖中的顯示內容(注意:這是在我們升級了 Go 版本并將 Go 的內部組件降到最低之后的結果):
Go profiling
如你所見,我們發現很多與運行時庫(runtime package )相關的活動,其中需要特別指出是 GC(垃圾收集):幾乎 29% 的 CPU 被 GC 使用,這還只是消耗最多的前 20 個對象。由于 Go 的 GC 已經非常快并做了很大優化,最好的實踐就是不要去改變或修改它。由于我們的內存消耗非常低(與前一個 Go 版本相比),所以主要問題變成了高對象分配率。
如果是這樣的話,我們可以做兩件事情:
- 調整 Go GC 活動來適應我們的服務行為,也就是說,我們需要延遲 GC 的觸發來減少其運行頻率。作為代價,我們將不得不消耗更多的內存。
- 找出代碼中分配了太多對象的函數、區域或行。
觀察一下實例類型,我們有大量的空閑內存,而 CPU 數量則被機器類型所限制。因此我們需要調整這個比率。從 Golang 早期開始,就有一個閥值(flag)被大多數開發人員所忽視:GOGC。該閥值的缺省值為 100,它的主要工作就是告訴系統何時觸發 GC。當堆達到其初始大小的 100% 時,默認值將觸發 GC 進程。將默認值更改為更高數字則延遲 GC 觸發,反之,將更快地觸發 GC。我們開始針對不同的數值進行基準測試,最終發現當 GOGC=2000 時,我們能獲得最佳性能。
這將我們的內存使用量從 200 MB 立刻增加到 2.7 GB(這還是在我們更新 Go 版本減少了內存消耗之后),并將我們的 CPU 使用率降低 10%。
下面的截圖展示了這些基準測試的結果:
Gogc=2000 的結果
CPU 使用率排名前 4 的函數變成了我們的服務函數,這才說得過去?,F在,總的 GC 使用量變成了約 13%,比之前的一半還少。
繼續深入
我們本可以就此打住,但我們還是決定繼續去調查分配這么多對象的位置以及原因。很多時候,分配對象都有一個很好的理由(例如在流處理的情況下,我們為每條消息都創建了很多新對象,因為它與下一條消息無關,需要移除它),但在某些情況下,有一種簡單的方法可以優化并極大地減少對象創建。
首先,讓我們運行一個和之前相同的命令,只做一個小小的變動,采用 heap dump:
- Http://localhost:6060/debug/pprof/heap
為了查詢結果文件,我們可以在代碼文件目錄中運行如下命令來分析調試結果:
- go tool pprof -allocobjects <HEAP.PROFILE.FILE>
我們的截圖看起來是這樣的:
除第三行外,一切似乎都很合理。第三行是一個監控函數,在每個 Coralogix 規則解析階段的末尾向我們的 Promethes 導出者(exporter)輸出報告。為獲取進一步的信息,我們運行以下命令:
- list <FunctionName>
例如:
- list reportRuleExecution
然后,我們得到以下結果:
這兩個對 WithLabelValues 的調用其實是針對度量的 Prometheus 調用(我們讓產品來決定是否真的需要它)。此外,我們看到第一行創建了大量對象(占該函數總分配對象的 10%)。通過進一步研究,我們發現這是一個將客戶 ID 從 int 轉換為 string 的過程。該過程非常重要,但考慮到數據庫中的客戶數量有限,我們不應該為迎合 Prometheus 而將變量作為字符串接收。
因此,我們沒有在每次創建一個新字符串并在函數結束時丟棄它(浪費了分配時間和 GC 的更多工作),而是在對象初始化時定義了一個映射,映射了從 1 到 10 萬之間的所有數字以及一個相對應的”get”操作。
我們運行了一個新的性能分析會話來驗證上述論點,結果證明它是正確的(我們可以看到這部分不再分配對象了):
這并不是一個非常大的改動,但總的來說,這為我們節省了另一個 GC 活動,更具體地說,大約 1% 的 CPU 使用率。
最終狀態顯示在下面的截圖中:
2. 最終結果
內存使用:~1.3GB -> ~2.7GB
CPU 使用:~2.55 均值和~5.05 峰值 -> ~2.13 均值和~2.9 峰值。
Golang 優化之前的 CPU 使用率:
Golang 優化之后的 CPU 使用率:
總的來說,我們的改進主要體現在峰值時間段,每秒處理的日志數量提升了。這意味著我們的基礎設施不再需要針對異常值進行調優,而且變得更加穩定。
3. 總結
通過分析 Go 解析服務,我們能定位出問題區域,更好地理解我們的服務,并決定在哪里(如果有的話)投入時間來做改進工作。大多數性能分析工作最終都會參照用戶使用情況對閥值或配置進行調優,從而獲得更好的性能。