一個背景 goroutine 沒接住的 panic,把整台服務帶走了
Backend Engineering·2026年7月3日·8 分鐘閱讀

一個背景 goroutine 沒接住的 panic,把整台服務帶走了

L
Leo Wu

Leo Wu,主力 Go 的後端工程師,做過交易所撮合引擎與金流高併發系統,信奉「你 go 出去的每個 goroutine 都要自己負責」。

半夜三點,服務整個消失,但沒有任何一個請求對得上

那天凌晨的告警是「服務不可用」,不是「錯誤率上升」。這兩件事的差別,做過線上的人都懂。錯誤率上升,代表某些請求爛掉,但服務還活著;服務不可用,代表整台就這樣沒了。

我拉開監控,看到的是進程重啟曲線:撮合服務在 03:12 突然重啟了一次,接著 03:14、03:17,一路斷斷續續。每次重啟中間那幾秒,所有連進來的請求全部連不上。可是我翻 access log,翻到眼睛快脫窗,找不到任何一個 5xx 的請求可以對得上這幾個時間點。沒有慢查詢、沒有超時、沒有某個 handler 噴錯。就是——進程,安安靜靜地,死了,然後被 supervisor 重新拉起來。

我當下第一個念頭是:OOM。記憶體被吃爆,被系統 kill 掉,這最常見。結果我錯了,而且錯得離譜。

我一開始把方向想歪了

我花了快一個小時在查記憶體。看 RSS 曲線平穩,沒有暴衝;看 OOM killer 的 kernel log,乾乾淨淨什麼都沒有。我又懷疑是不是被部署系統誤觸、健康檢查配錯導致誤殺,甚至一度懷疑是機器本身有問題。

這一個小時基本上是白費的。因為我心裡有個很深的預設:Go 的 http server 不是會自動 recover 嗎?就算某個地方 panic 了,頂多那個請求回個 500,整台服務怎麼可能被一個 panic 帶走?我就是被這個「以為」害慘的。

直到我去翻進程被 kill 前最後那幾行 stdout,才看到那個熟悉又刺眼的東西:

panic: assignment to entry in nil map

後面跟著一長串 goroutine 的堆疊,最上面那一幀,是我們一個消費 Kafka 訊息、更新本地快取的背景工作。不是任何一個 http handler。

服務可用性:背景 goroutine 沒被 recover 的 panic 讓整個進程反覆崩潰重啟,修好根因並在入口補上 recover 後恢復穩定
服務可用性:背景 goroutine 沒被 recover 的 panic 讓整個進程反覆崩潰重啟,修好根因並在入口補上 recover 後恢復穩定

真正的根因:net/http 只救得了它自己那個 goroutine

這裡是整件事的核心,也是我那天真正補上的一課。

Go 的 net/http 確實有做 per-request 的 recover。你去看它的原始碼,conn.serve 裡面有一個 defer 包著 recover(),所以當某個 handler 在處理請求的過程中 panic,server 會接住它,記一筆 log,然後把那條連線斷掉,其他請求照常。這就是為什麼大家會有「Go server 很穩、panic 頂多掛一個請求」的印象。

但關鍵在於:這個 recover 只能救「處理該請求的那一個 goroutine」。

recover() 有兩條鐵律,缺一不可:

  • 它只能在 defer 的函式裡呼叫才有效;直接呼叫回傳 nil,什麼都攔不到。
  • 它只能攔截同一個 goroutine 裡往上冒的 panic。

第二條就是我踩的坑。當我自己寫下 go func() { ... }(),開了一條新的 goroutine 去消費佇列,這條 goroutine 跟 http server 那套 recover 機制完全沒有關係。它是我親手生出來的獨立執行流。它在裡面 panic,沒有任何人幫我在它的呼叫堆疊上放 defer recover()——因為那是該放的,不是 runtime 該幫我放的。

而 Go 的規則很殘酷也很乾脆:一個 goroutine 的 panic 一路往上冒,冒到頂端還沒被 recover,整個 process 就直接崩潰。不是這條 goroutine 自己死掉而已,是整個進程陪葬。

所以那晚的真相是:某批 Kafka 訊息裡有筆髒資料,讓背景工作走到一個沒被初始化的 map 去寫值,panic,沒人接,process 死,supervisor 拉起來,下一批訊息又踩到同一顆地雷,再死一次。監控上那條斷斷續續的重啟曲線,就是這樣來的。

為什麼我沒有選擇「全域無腦 recover」

找到根因之後,最偷懶的解法會浮現在腦海:那我在每個背景 goroutine 最外層都包一個 defer func(){ recover() }(),把所有 panic 通通吞掉,不就永遠不會 crash 了嗎?

我沒這樣做,而且我要很認真地說:這是最危險的想法。

recover() 把 panic 攔下來、讓程式繼續跑,聽起來很美好,但它的代價是——你把一個「程式已經進入未定義狀態」的訊號給硬生生壓掉了。panic 通常不是小事。以我這次的 nil map 為例,它代表我的初始化邏輯有洞;如果是 nil pointer,代表我對某個值的假設是錯的;如果是 map 的併發寫入觸發的 panic,那更是嚴重——那代表我有 data race,我的資料此刻可能已經處在錯亂狀態。

這種 panic,連 recover 都不該去掩蓋它。 你把它吞掉,程式看起來還活著,但它可能正拿著一份已經被破壞的記憶體繼續對外服務、繼續寫進資料庫。在金流跟撮合的場景,「悄悄地錯」遠比「大聲地死」可怕一百倍。進程 crash 至少誠實,資料錯亂是會賠錢的。

無腦全域 recover,本質上是拿一塊遮羞布蓋住真正該修的 bug,讓它以後在更難查的地方、用更貴的方式復仇。

我最後怎麼修

我分成兩層,順序不能顛倒。

第一層,先修根因。 那個 map 該在啟動時就初始化好,我補上了;訊息進來我加了型別與欄位的防禦性檢查,髒資料直接丟進死信佇列,不讓它有機會炸到核心邏輯。這一層是主角。

第二層,才是為背景 goroutine 補上入口的 defer-recover。 我在每一個長期存活的背景工作入口,包了一層這樣的東西(示意):

go func() { defer func() { if r := recover(); r != nil { /* 記錄堆疊、發告警、上報指標 */ } }(); run() }()

重點是這一層 recover 的定位:它是安全網,不是解決方案。它做三件事——把完整堆疊記下來、觸發一個會吵醒我的告警、然後讓這條背景工作能重新起來,避免單一髒訊息把整台服務反覆帶走。它絕對不是安靜地把錯誤吃掉。每一次它作動,對我來說都等於一張「這裡有 bug,去修」的工單。

另外那些明確代表記憶體已損壞的狀況,例如併發寫 map,我的態度是:那不該靠 recover 續命。正解是把併發存取用 mutex 或改用 sync.Map 之類的方式從源頭解決掉,讓它根本 panic 不起來。

第三件事,我把「進程重啟次數」變成一級監控指標。 以前我只盯錯誤率跟延遲,這次的教訓是:進程無聲無息地重啟,是不會出現在請求層 log 裡的,你不主動去數它,它就永遠是個盲區。現在只要某個服務的重啟次數在短時間內跳動,我立刻收到告警,不用再等到凌晨三點被「服務不可用」轟醒。

小結

這次事故對我最大的修正,是一句很樸素的話:你自己 `go` 出去的每一個 goroutine,它的 panic 都要你自己負責。

Go runtime 不會幫你兜底,net/http 的 recover 只保它自己那條請求 goroutine,保不到你親手開的那些。所以:

  • 每一個長期存活的背景 goroutine,入口都該有一層 defer-recover,但它的角色是安全網加告警,不是把 bug 掃到地毯底下。
  • 根因永遠優先修,recover 只是止血,不是治病。拿它當遮羞布,遲早在更貴的地方付帳。
  • 併發寫 map 這種代表記憶體已壞的 panic,該從源頭消滅,而不是 recover 掩蓋。
  • 把進程重啟次數納入監控。無聲的死亡,只有你主動去數,才看得見。

寫程式這麼多年,我還是會踩坑。差別只在於,這次之後,我對著螢幕上每一行 go func(),都會多問自己一句:這條 goroutine 要是炸了,誰接?如果答案是「沒有人」,那接的人就是我。

#Go#goroutine#panic#recover#事故覆盤#後端工程

留言討論

有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。

相關文章