Go 應用優化指北

爲什麼要做優化

這是一個速度決定一切的時代,我們的生活在不斷地數字化,線下的流程依然在持續向線上轉移,轉移過程中,作爲工程師,我們會碰到各種各樣的性能問題。

互聯網公司本質是將用戶共通的行爲流程進行了集中化管理,通過中心化的信息交換達到效率提升的目的,同時用規模效應降低了數據交換的成本。

用人話來講,公司希望的是用盡量少的機器成本來賺取儘量多的利潤。利潤的提升與業務邏輯本身相關,與技術關係不大。而降低成本則是與業務無關,純粹的技術話題。這裏面最重要的主題就是 “性能優化”。

如果業務的後端服務規模足夠大,那麼一個程序員通過優化幫公司節省的成本,就可以負擔他十年的工資了。

優化的前置知識

從資源視角出發來對一臺服務器進行審視的話,CPU、內存、磁盤與網絡是後端服務最需要關注的四種資源類型。

對於計算密集型的程序來說,優化的主要精力會放在 CPU 上,要知道 CPU 基本的流水線概念,知道怎麼樣在使用少的 CPU 資源的情況下,達到相同的計算目標。

對於 IO 密集型的程序 (後端服務一般都是 IO 密集型) 來說,優化可以是降低程序的服務延遲,也可以是提升系統整體的吞吐量。

IO 密集型應用主要與磁盤、內存、網絡打交道。因此我們需要知道一些基本的與磁盤、內存、網絡相關的基本數據與常見概念:

優化越靠近應用層效果越好

Performance tuning is most effective when done closest to where the work is performed. For workloads driven by applications, this means within the application itself.

我們在應用層的邏輯優化能夠幫助應用提升幾十倍的性能,而最底層的優化則只能提升幾個百分點。

這個很好理解,我們可以看到一個 GTA Online 的新聞:rockstar thanks gta online player who fixed poor load times[2]。

簡單來說,GTA online 的遊戲啓動過程讓玩家等待時間過於漫長,經過各種工具分析,發現一個 10M 的文件加載就需要幾十秒,用戶 diy 進行優化之後,將加載時間減少 70%,並分享出來:how I cut GTA Online loading times by 70%[3]。

這就是一個非常典型的案例,GTA 在商業上取得了巨大的成功,但不妨礙它局部的代碼是一坨屎。我們只要把這裏的重複邏輯幹掉,就可以完成三倍的優化效果。同樣的案例,如果我們去優化磁盤的讀寫速度,則可能收效甚微。

優化是與業務場景相關的

不同的業務場景優化的側重也是不同的。

優化的工作流程

  1. 建立評估指標,例如固定 QPS 壓力下的延遲或內存佔用,或模塊在滿足 SLA 前提下的極限 QPS

  2. 通過自研、開源壓測工具進行壓測,直到模塊無法滿足預設性能要求: 如大量超時,QPS 不達預期,OOM

  3. 通過內置 profile 工具尋找性能瓶頸

  4. 本地 benchmark 證明優化效果

  5. 集成 patch 到業務模塊,回到 2

可以使用的工具

pprof

memory profiler

Go 內置的內存 profiler 可以讓我們對線上系統進行內存使用採樣,有四個相應的指標:

網關類應用因爲海量連接的關係,會導致進程消耗大量內存,所以我們經常看到相關的優化文章,主要就是降低應用的 inuse_space。

而兩個對象數指標主要是爲 GC 優化提供依據,當我們進行 GC 調優時,會同時關注應用分配的對象數、正在使用的對象數,以及 GC 的 CPU 佔用的指標。

GC 的 CPU 佔用情況可以由內置的 CPU profiler 得到。

cpu profiler

The builtin Go CPU profiler uses the setitimer(2) system call to ask the operating system to be sent a SIGPROF signal 100 times a second. Each signal stops the Go process and gets delivered to a random thread’s sigtrampgo() function. This function then proceeds to call sigprof() or sigprofNonGo() to record the thread’s current stack.

Go 語言內置的 CPU profiler 使用 setitimer 系統調用,操作系統會每秒 100 次向程序發送 SIGPROF 信號。在 Go 進程中會選擇隨機的信號執行 sigtrampgo 函數。該函數使用 sigprof 或 sigprofNonGo 來記錄線程當前的棧。

Since Go uses non-blocking I/O, Goroutines that wait on I/O are parked and not running on any threads. Therefore they end up being largely invisible to Go’s builtin CPU profiler.

Go 語言內置的 cpu profiler 是在性能領域比較常見的 On-CPU profiler,對於瓶頸主要在 CPU 消耗的應用,我們使用內置的 profiler 也就足夠了。

如果碰到的問題是應用的 CPU 使用不高,但接口的延遲卻很大,那麼就需要用上 Off-CPU profiler,遺憾的是官方的 profiler 並未提供該功能,我們需要藉助社區的 fgprof。

fgprof

fgprof is implemented as a background goroutine that wakes up 99 times per second and calls runtime.GoroutineProfile. This returns a list of all goroutines regardless of their current On/Off CPU scheduling status and their call stacks.

fgprof 是啓動了一個後臺的 goroutine,每秒啓動 99 次,調用 runtime.GoroutineProfile 來採集所有 gorooutine 的棧。

雖然看起來很美好:

 1func GoroutineProfile(p []StackRecord) (n int, ok bool) {
 2    .....
 3 stopTheWorld("profile")
 4
 5 for _, gp1 := range allgs {
 6  ......
 7 }
 8
 9 if n <= len(p) {
10  // Save current goroutine.
11  ........
12  systemstack(func() {
13   saveg(pc, sp, gp, &r[0])
14  })
15
16  // Save other goroutines.
17  for _, gp1 := range allgs {
18   if isOK(gp1) {
19    .......
20    saveg(^uintptr(0), ^uintptr(0), gp1, &r[0])
21                .......
22   }
23  }
24 }
25
26 startTheWorld()
27
28 return n, ok
29}
30
31

但調用 GoroutineProfile 函數的開銷並不低,如果線上系統的 goroutine 上萬,每次採集 profile 都遍歷上萬個 goroutine 的成本實在是太高了。所以 fgprof 只適合在測試環境中使用。

trace

一般情況下我們是不需要使用 trace 來定位性能問題的,通過壓測 + profile 就可以解決大部分問題,除非我們的問題與 runtime 本身的問題相關。

比如 STW 時間比預想中長,超過百毫秒,向官方反饋問題時,才需要出具相關的 trace 文件。比如類似 long stw[4] 這樣的 issue。

採集 trace 對系統的性能影響還是比較大的,即使我們只是開啓 gctrace,把 gctrace 日誌重定向到文件,對系統延遲也會有一定影響,因爲 gctrace 的日誌 print 是在 stw 期間來做的:gc trace 阻塞調度 [5]。

perf

如果應用沒有開啓 pprof,在線上應急時,我們也可以臨時使用 perf:

perf demo

微觀性能優化

編寫 library 時會關注關鍵函數的性能,這時可以脫離系統去探討性能優化,Go 語言的 test 子命令集成了相關的功能,只要我們按照約定來寫 Benchmark 前綴的測試函數,就可以實現函數級的基準測試。我們以常見的二維數組遍歷爲例:

 1package main
 2
 3import "testing"
 4
 5var x = make([][]int, 100)
 6
 7func init() {
 8 for i := 0; i < 100; i++ {
 9  x[i] = make([]int, 100)
10 }
11}
12
13func traverseVertical() {
14 for i := 0; i < 100; i++ {
15  for j := 0; j < 100; j++ {
16   x[j][i] = 1
17  }
18 }
19}
20
21func traverseHorizontal() {
22 for i := 0; i < 100; i++ {
23  for j := 0; j < 100; j++ {
24   x[i][j] = 1
25  }
26 }
27}
28
29func BenchmarkHorizontal(b *testing.B) {
30 for i := 0; i < b.N; i++ {
31  traverseHorizontal()
32 }
33}
34
35func BenchmarkVertical(b *testing.B) {
36 for i := 0; i < b.N; i++ {
37  traverseVertical()
38 }
39}
40
41
42

執行 go test -bench=.

1BenchmarkHorizontal-12       102368      10916 ns/op
2BenchmarkVertical-12          66612      18197 ns/op
3
4

可見橫向遍歷數組要快得多,這提醒我們在寫代碼時要考慮 CPU 的 cache 設計及局部性原理,以使程序能夠在相同的邏輯下獲得更好的性能。

除了 CPU 優化,我們還經常會碰到要優化內存分配的場景。只要帶上 -benchmem 的 flag 就可以實現了。

舉個例子,形如下面這樣的代碼:

1logStr := "userid :" + userID + "; orderid:" + orderID
2
3

你覺得代碼寫的很難看,想要優化一下可讀性,就改成了下列代碼:

1logStr := fmt.Sprintf("userid: %v; orderid: %v", userID, orderID)
2
3

這樣的修改方式在某公司的系統中曾經導致了 p2 事故,上線後接口的超時俱增至 SLA 承諾以上。

我們簡單驗證就可以發現:

1BenchmarkPrin-12       7168467        157 ns/op       64 B/op        3 allocs/op
2BenchmarkPlus -12     43278558         26.7 ns/op        0 B/op        0 allocs/op
3
4

使用 + 進行字符串拼接,不會在堆上產生額外對象。而使用 fmt 系列函數,則會造成局部對象逃逸到堆上,這裏是高頻路徑上有大量逃逸,所以導致線上服務的 GC 壓力加重,大量接口超時。

出於謹慎考慮,修改高併發接口時,拿不準的儘量都應進行簡單的線下 benchmark 測試。

當然,我們不能指望靠寫一大堆 benchmark 幫我們發現系統的瓶頸。

實際工作中還是要使用前文提到的優化工作流來進行系統性能優化。也就是儘量從接口整體而非函數局部考慮去發現與解決瓶頸。

宏觀性能優化

接口類的服務,我們可以使用兩種方式對其進行壓測:

壓測過程中需要採集不同 QPS 下的 CPU profile,內存 profile,記錄 goroutine 數。與歷史情況進行 AB 對比。

Go 的 pprof 還提供了 --base 的 flag,能夠很直觀地幫我們發現不同版本之間的指標差異:用 pprof 比較內存使用差異 [6]。

總之記住一點,接口的性能一定是通過壓測來進行優化的,而不是通過硬啃代碼找瓶頸點。關鍵路徑的簡單修改往往可以帶來巨大收益。如果只是啃代碼,很有可能將 1% 優化到 0%,優化了 100% 的局部性能,對接口整體影響微乎其微。

尋找性能瓶頸

在壓測時,我們通過以下步驟來逐漸提升接口的整體性能:

  1. 使用固定 QPS 壓測,以階梯形式逐漸增加壓測 QPS,如 1000 -> 每分鐘增加 1000 QPS

  2. 壓測過程中觀察系統的延遲是否異常

  3. 觀察系統的 CPU 使用情況

  4. 如果 CPU 使用率在達到一定值之後不再上升,反而引起了延遲的劇烈波動,這時大概率是發生了阻塞,進入 pprof 的 web 頁面,點擊 goroutine,查看 top 的 goroutine 數,這時應該有大量的 goroutine 阻塞在某處,比如 Semacquire

  5. 如果 CPU 上升較快,未達到預期吞吐就已經過了高水位,則可以重點考察 CPU 使用是否合理,在 CPU 高水位進行 profile 採樣,重點關注火焰圖中較寬的 “平頂山”

一些優化案例

gc mark 佔用過多 CPU

在 Go 語言中 gc mark 佔用的 CPU 主要和運行時的對象數相關,也就是我們需要看 inuse_objects。

定時任務,或訪問流量不規律的應用,需要關注 alloc_objects。

優化主要是下面幾方面:

減少變量逃逸

儘量在棧上分配對象,關於逃逸的規則,可以查看 Go 編譯器代碼中的逃逸測試部分:

查看某個 package 內的逃逸情況,可以使用 build + 全路徑的方式,如:

go build -gcflags="-m -m" github.com/cch123/elasticsql

需要注意的是,逃逸分析的結果是會隨着版本變化的,所以去背誦網上逃逸相關的文章結論是沒有什麼意義的。

使用 sync.Pool 複用堆上對象

sync.Pool 用出花兒的就是 fasthttp 了,可以看看我之前寫的這一篇:fasthttp 爲什麼快 [7]。

最簡單的複用就是複用各種 struct,slice,在複用時 put 時,需要判斷 size 是否已經擴容過頭,小心因爲 sync.Pool 中存了大量的巨型對象導致進程佔用了大量內存。

調度佔用過多 CPU

goroutine 頻繁創建與銷燬會給調度造成較大的負擔,如果我們發現 CPU 火焰圖中 schedule,findrunnable 佔用了大量 CPU,那麼可以考慮使用開源的 workerpool 來進行改進,比較典型的 fasthttp worker pool[8]。

如果客戶端與服務端之間使用的是短連接,那麼我們可以使用長連接來減少連接創建的開銷,這裏就包含了 goroutine 的創建與銷燬。

進程佔用大量內存

當前大多數的業務後端服務是不太需要關注進程消耗的內存的。

我們經常看到做 Go 內存佔用優化的是在網關 (包括 mesh)、存儲系統這兩個場景。

對於網關類系統來說,Go 的內存佔用主要是因爲 Go 獨特的抽象模型造成的,這個很好理解:

海量的連接加上海量的 goroutine,使網關和 mesh 成爲 Go OOM 的重災區。所以網關側的優化一般就是優化:

很多項目都有相關的分享,這裏就不再贅述了。

對於存儲類系統來說,內存佔用方面的不少努力也是在優化各種 buffer,比如 dgraph 使用 cgo + jemalloc 來優化他們的產品內存佔用 [9]。

堆外內存不會在 Go 的 GC 系統裏進行管轄,所以也不會影響到 Go 的 GC Heap Goal,所以不會因爲分配大量對象造成 Go 的 Heap Goal 被推高,系統整體佔用的 RSS 也被推高。

鎖衝突嚴重,導致吞吐量瓶頸

我在 幾個 Go 系統可能遇到的鎖問題 [10] 中分享過實際的線上 case。

進行鎖優化的思路無非就一個 “拆” 和一個 “縮” 字:

timer 相關函數佔用大量 CPU

同樣是在網關和海量連接的應用中較常見,優化手段:

模擬真實工作負載

在前面的論述中,我們對問題進行了簡化。真實世界中的後端系統往往不只一個接口,壓測工具、平臺往往只支持單接口壓測。

公司的業務希望知道的是後端系統整體性能,即這些系統作爲一個整體,在限定的資源條件下,能夠承載多少業務量 (如併發創建訂單) 而不崩潰。

雖然大家都在講微服務,但單一服務往往也不只有單一功能,如果一個系統有 10 個接口 (已經算是很小的服務了),那麼這個服務的真實負載是很難靠人肉去模擬的。

這也就是爲什麼互聯網公司普遍都需要做全鏈路壓測。像樣點的公司會定期進行全鏈路壓測演練,以便知曉隨着系統快速迭代變化,系統整體是否出現了嚴重的性能衰退。

通過真實的工作負載,我們才能發現真實的線上性能問題。講全鏈路壓測的文章也很多,本文就不再贅述了。

當前性能問題定位工具的侷限性

本文中幾乎所有優化手段都是通過 Benchmark 和壓測來進行的,但真實世界的軟件還會有下列場景:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/J1PdpX4Mr6crR6cZMAqecQ