Backend Engineering·2026年6月14日·15 分鐘閱讀

凌晨三點的 OOM:我用 pprof 抓出 Go 服務裡四隻漏了三個月的 goroutine

L
Leo Wu

Go 後端工程師,在高併發服務裡抓過不少記憶體與 goroutine 洩漏。

那張鋸齒狀的記憶體圖

那是一個負責推播行情的 Go 服務,把交易所撮合引擎吐出來的成交、掛單變動,透過 WebSocket 廣播給幾萬個前端連線。它跑得好好的,直到某天 PagerDuty 在凌晨三點十七分把我吵醒。

告警內容是 OOMKilled。Pod 的 memory limit 設 2GB,被 Kubernetes 砍掉重啟。我半睡半醒地打開 Grafana,看到那張我這輩子都忘不了的圖:RSS 從每天早上重啟後的 180MB 開始,像爬樓梯一樣往上長,大概每小時漲 70 到 90MB,到了第三天凌晨就撞到 2GB 的天花板,然後啪一聲掉回 180MB,重新開始爬。

一個非常標準、非常經典的鋸齒(sawtooth)。

當下我做了所有人都會做的事:把 limit 從 2GB 調到 4GB,回去睡覺。這當然不是修好,只是把 OOM 的週期從三天拉長到六天而已。問題從來沒有消失,我只是花錢買了三天的安寧。隔了一週又被吵醒的時候,我才認真承認:這是 leak,而且是會無限長大的那種。記憶體只進不出,重啟之所以「修好」,是因為重啟把所有洩漏掉的東西全部清乾淨了。

先分清楚:是 heap 漏,還是 goroutine 漏

Go 是有 GC 的語言,照理說你不該煩惱記憶體。但 GC 只能回收「沒有人再引用」的東西。只要還有任何一條路徑指得到某塊記憶體,GC 就會乖乖把它留著。在 Go 服務裡,記憶體無限長大幾乎只有兩個來源:

第一種是 heap 漏。你把東西一直塞進某個 map 或 slice,卻從來不刪。這塊記憶體有人引用(那個 map 本身),GC 不會碰它,於是它就一直長。

第二種是 goroutine 漏,這也是 Go 特有、最陰險的一種。每開一條 goroutine,runtime 至少配給它 8KB 的 stack,而且這條 goroutine 持有的所有區域變數、它 capture 的 closure、它卡住的那個 channel 背後的 buffer,全部都還活著、全部都不會被回收。如果你不斷開新的 goroutine,而舊的因為某種原因永遠卡住、永遠不結束,那它們就會像殭屍一樣越積越多。一萬條殭屍 goroutine 光 stack 就是 80MB,再加上它們各自抓著的東西,輕鬆破 GB。

要分辨是哪一種,第一步根本不用 pprof,看一個數字就好:goroutine 的總數。

我在服務裡早就埋了一行最便宜的監控:

runtime.NumGoroutine()

把它定時丟到 metrics 裡。打開那條曲線一看,答案就出來了——goroutine 數量跟 RSS 是同一個形狀的鋸齒。重啟後大概是 300 條,然後一路爬,撞牆前大概是四萬多條。

四萬條。一個正常的廣播服務,連線數乘以每連線該有的 goroutine 數,估算下來頂多應該是六七千條。多出來的三萬多條,就是我的兇手。確定是 goroutine 漏了。

把 pprof 掛上去

如果你的 Go 服務還沒接 pprof,現在就接。它幾乎是零成本,而且救命。

標準做法是 import net/http/pprof,它會在 init 的時候把一堆 handler 註冊到 DefaultServeMux。但生產環境我從來不把它跟業務的 HTTP server 混在同一個 port、同一個對外的 mux,太危險。我習慣另外開一個只綁內網的 debug server:

import _ "net/http/pprof"

go func() { log.Println(http.ListenAndServe("127.0.0.1:6060", nil)) }()

只綁 127.0.0.1,外面打不進來。要用的時候,我從 Pod 裡 port-forward 出來:

kubectl port-forward pod/quote-pusher-xxxx 6060:6060

接著就能抓 profile。對 goroutine 漏,最直接的兩個端點是 goroutine profile 跟 heap profile。

最快速的肉眼檢查,是直接看 goroutine 的完整 stack dump。debug=2 會把每一條 goroutine 現在卡在哪一行、卡了多久全部印出來:

curl "http://127.0.0.1:6060/debug/pprof/goroutine?debug=2" > goroutine.txt

我打開那個檔案,三萬多條 goroutine 的 stack。一開始看了頭很痛,但很快就發現規律:絕大多數的 stack 長得一模一樣,而且最上面那行都寫著同一件事——

goroutine 48213 [chan send, 47 minutes]:

chan send,卡了 47 分鐘。下面那條卡了 52 分鐘,再下面卡了 1 小時 3 分鐘。卡的時間長短不一,但卡的位置全部指向我自己寫的同一個函數。這時候已經幾乎破案了。

第一隻:永遠等不到讀者的 channel

把 stack 指到的那段 code 撈出來看,邏輯大概長這樣(我簡化過)。每一個 WebSocket 連線進來,我會幫它開一條 writer goroutine,這條 goroutine 從一個 channel 收訊息然後寫進 socket。而廣播的那一端,是把訊息往每個連線的 channel 送:

func (h *Hub) broadcast(msg []byte) { for _, c := range h.clients { c.send <- msg } }

問題出在 c.send 這個 channel 的 buffer 是有限的,我設了 256。正常情況下 writer goroutine 收得很快,buffer 不會滿。但如果某個前端連線網路很爛、或者乾脆人就跑掉了(手機切背景、地鐵斷網),那條 writer goroutine 在 socket write 上會阻塞甚至卡死,它就不再從 c.send 讀東西了。

於是 buffer 很快被塞滿 256 則訊息,接著 broadcast 裡的 c.send <- msg 這一行——因為沒人讀、buffer 又滿——就永遠卡在那裡。而 broadcast 是在主廣播迴圈裡跑的,一卡住整個推播都會延遲,這是另一個事故了,但更糟的是:每次行情變動都會嘗試送,每一個卡住的連線都讓對應的廣播路徑多一條卡死的 goroutine。連線斷了卻沒被清掉,writer goroutine 也沒退出,殭屍就這樣一隻一隻長出來。

根因是兩個經典錯誤疊在一起:一是往 channel 送東西時用了會阻塞的寫法,沒有任何退路;二是連線生命週期結束時,沒有確實把對應的 goroutine 收掉。

修法是 channel 送訊息一定要給自己留後路。用 select 加 default,送不出去就放棄這一則、甚至直接斷開這個慢得要命的連線,而不是陪它一起卡死:

func (h *Hub) broadcast(msg []byte) { for _, c := range h.clients { select { case c.send <- msg: default: // 這個 client 太慢,buffer 滿了,直接踢掉 h.unregister <- c } } }

對行情推播來說,丟掉一個慢連線幾則訊息完全可以接受,總比拖垮整台服務好。改完之後,那一類 chan send 卡死的 goroutine 直接歸零。

第二隻:少了一個 context 取消

第一隻修掉,鋸齒的斜率變平緩了,但沒有歸零,還是慢慢在漲。回去再抓一次 goroutine dump,這次最上面那行變成:

goroutine 21984 [select, 88 minutes]:

select 卡住,指向一個我為每個連線啟動的背景任務。這個任務負責定時去後端拉一次該使用者的訂閱設定,邏輯裡有一個 for-select,select 裡監聽一個 ticker 跟一個 done channel。

問題是這個 goroutine 接收的是 context,但我傳的是 context.Background()。也就是說,這條 goroutine 根本沒有任何辦法被取消。連線斷了,沒有人去通知它「可以結束了」,它就抱著那個 ticker 永遠 select 下去。

這是我看過最常見的 Go leak 模式:開了一條長命 goroutine,卻沒有把一個會被取消的 context 帶進去。正確的做法是,連線一建立就用 context.WithCancel 派生一個子 context,連線結束時呼叫 cancel,goroutine 裡的 select 一定要有一個 case 在等 ctx.Done():

func (c *Client) loop(ctx context.Context) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: c.refreshSubscriptions() } } }

兩個關鍵:select 裡一定有 ctx.Done() 這條路可以退出,以及那個 defer ticker.Stop()。

第三隻:永遠不會停的 ticker

順著上面這段 code,我才發現第三隻就藏在這裡。原本舊版本根本沒有 defer ticker.Stop()。

很多人以為 ticker 是個小東西,goroutine 結束了它自然就沒了。錯。time.NewTicker 背後是 runtime 的計時器堆,只要你不呼叫 Stop,那個 ticker 就會一直被 runtime 持有、一直定時往它的 channel 送,相關的記憶體永遠不會釋放。就算你的 goroutine 已經 return 了,這個 ticker 還活著。

這隻在 heap profile 上看得特別清楚。我抓兩份快照來比:

curl "http://127.0.0.1:6060/debug/pprof/heap" > heap1.prof # 等 30 分鐘 curl "http://127.0.0.1:6060/debug/pprof/heap" > heap2.prof go tool pprof -base heap1.prof heap2.prof

那個 -base 參數是整套流程裡我覺得最好用的東西。它把第一份快照當基準,只顯示這 30 分鐘之間「多出來」的記憶體。輸出裡用 top 一看,最大的成長來源直接指向 time.NewTicker 跟它相關的 runtime.timer 結構,配著 web 指令畫出火焰圖,那條長大的路徑一目了然。

加上 defer ticker.Stop() 之後解決。順帶一提,如果只是要一個會延遲的定時器、用完即丟,優先考慮 time.After 也要小心——在 for-select 裡每圈都 time.After 一樣會堆計時器,這也是個常見坑。

第四隻:一個只進不出的 map

修到這裡,goroutine 數已經穩定了,重啟後就維持在六千上下,不再爬。但 RSS 還是會非常緩慢地漲,一天大概漲一兩百 MB。這次不是 goroutine 漏了,是真正的 heap 漏。

再抓一次 heap 的 -base diff,這次最大的增長指向一個我用來做去重的 map。我為了避免同一筆成交被重複推播,拿一個 map[string]time.Time 記下每筆成交 ID 跟它的時間。寫的時候很順手:

h.seen[tradeID] = time.Now()

寫得很開心,但我從來沒有刪過任何一個 key。成交 ID 是單調遞增、永不重複的,於是這個 map 就變成一個只進不出的黑洞,一天幾百萬筆,記憶體當然往上漲。這種 unbounded cache 是 heap leak 裡最常見的長相。

修法看你需求。我這個情境其實只需要去重「最近幾秒內」的重複,根本不需要記住一整天的成交,所以我換成一個有 TTL、會自動淘汰舊 key 的結構,定期把超過時間窗的 entry 清掉。如果是真的要當快取,就該用有容量上限、會 LRU 淘汰的實作。原則只有一條:任何一個會被持續寫入的 map,你都必須回答「它什麼時候會變小」,答不出來,它就是一顆未爆彈。

確認真的修好,以及怎麼讓它別再發生

四隻都修掉之後,我盯著 Grafana 看了整整一週。那條 RSS 曲線從鋸齒變成一條幾乎水平的線,穩穩停在 220MB 上下,goroutine 數穩定在六千,再也沒有半夜的 OOM 告警。limit 我也從 4GB 調回了 1.5GB,還比原本省。

但我不想再用「盯一週」這種方式來確認。事後我做了幾件預防的事。

最有效的是替關鍵路徑寫 goroutine 洩漏測試。用 uber-go/goleak 這個套件,在測試結束時斷言沒有意外殘留的 goroutine:

func TestClientNoLeak(t *testing.T) { defer goleak.VerifyNone(t) // 建立一個 client、送幾筆訊息、然後關閉它 // 如果關閉後還有 goroutine 沒退出,這個測試就會紅 }

這種測試把問題擋在 CI,比生產環境的告警早了好幾個數量級。

再來是把幾條原則寫進團隊的 code review checklist:每一條 go 出去的 goroutine,都要能回答「它怎麼結束」;任何長命 goroutine 一律吃 context,select 裡一律要有 ctx.Done();每個 NewTicker 旁邊就要配 Stop,最好寫成 defer 緊跟著;往 channel 送東西在廣播這種場景一律用 select default,不要裸寫阻塞送;每個會持續寫入的 map 都要有淘汰機制。

監控面,runtime.NumGoroutine 跟 RSS 兩條線常駐 dashboard,而且設「goroutine 數量持續上升超過一小時」的告警。leak 最怕的是慢,慢到你以為是正常成長。有了趨勢告警,下次它在還只有八千條的時候我就會知道,而不是等到四萬條撞牆。

寫在最後

回頭看,這四隻 bug 沒有一隻是「難」的。chan send 阻塞、忘了傳 context、ticker 沒 Stop、map 不淘汰——每一條單獨拿出來都是教科書第一章的內容,我面試別人的時候甚至會拿來當考題。但它們在生產環境疊在一起、藏在幾萬條長得一模一樣的 stack 裡、用一條三天爬一次的鋸齒慢慢殺你的時候,就完全是另一回事了。

我學到的其實不是什麼高深的 pprof 技巧。pprof 很好用,那個 -base 比較兩份快照的功能我會用一輩子,但它終究只是把答案攤在你面前的工具。真正讓我半夜被吵醒四次的,是我寫每一條 goroutine 的時候,腦子裡沒有同時想著它怎麼死。在 Go 裡開一條 goroutine 太簡單了,一個 go 關鍵字而已,簡單到你會忘記你正在創造一個需要被負責任地結束掉的生命。從那次之後,我每次打出 go 這個字,手都會停一下,問自己一句:這傢伙,到底要怎麼收場。

#Go#pprof#記憶體洩漏#goroutine#效能調校

相關文章