沒關 response body,我把服務的檔案描述符慢慢耗光了
Backend Engineering·2026年7月1日·9 分鐘閱讀

沒關 response body,我把服務的檔案描述符慢慢耗光了

L
Leo Wu

Leo Wu,主力 Go 的後端工程師,做過撮合引擎與金流高併發系統,這次栽在一個沒關的 response body 上。

半夜三點,那個每隔四小時就掛掉的服務

那陣子我過得很痛苦。我們有一個負責串接下游風控服務的 Go 服務,白天上線都好好的,可是每隔三到四個小時,它就會突然開始大量拒絕連線,健康檢查掛掉,然後被 orchestrator 重啟。重啟完又活蹦亂跳,再過幾個小時又倒。

最經典的是,它幾乎都挑半夜倒。我被 on-call 叫起來好幾次,睡眼惺忪地打開 dashboard,看到的畫面是:這服務又在噴 dial tcp: connect: cannot assign requested addresstoo many open files,重啟一下就好。當下腦袋根本轉不動,重啟完看它恢復正常,就又爬回去睡。隔天精神好一點想查,它偏偏又一切正常。這種「重啟就好、白天抓不到」的 bug,是最折磨人的那種。

我一開始賭錯了兩個方向

第一個直覺是流量。我想說一定是尖峰時段連線暴增,把服務打爆了。於是我調大了 pod 數量、加了限流。結果呢?該掛的時間點還是掛,而且掛掉的當下 QPS 根本不高,這條路直接被打臉。

第二個直覺是記憶體洩漏。too many open files 嘛,通常會讓人聯想到「某種東西一直漏、一直漲」。我掛上 pprof 盯了半天 heap,RSS 確實有緩慢上升,但幅度不大,離 OOM 還遠得很,根本解釋不了為什麼是「連線」先崩。我卡在這裡繞了快一個小時,一直在記憶體那邊鑽牛角尖,完全走錯棚。

真正讓我清醒的是一句話:too many open files 講的是檔案描述符(file descriptor)耗盡,而在 Linux 上,每一條 TCP 連線都是一個 fd。我一直盯著記憶體,卻忘了 fd 才是那個被慢慢榨乾的資源。

開啟中的檔案描述符數量:沒關 response body 導致 fd 只漲不跌,撞上 ulimit 上限後服務崩潰,補上 defer Close 後回穩
開啟中的檔案描述符數量:沒關 response body 導致 fd 只漲不跌,撞上 ulimit 上限後服務崩潰,補上 defer Close 後回穩

用 fd 數量把兇手釘在牆上

方向對了,排查就快了。我直接進到快要掛掉的那個 pod 裡,開始數它的 fd:

  • ls /proc/<pid>/fd | wc -l 看總量,發現它從剛啟動的幾百個,一路爬到一萬多,逼近 ulimit -n 的上限。
  • lsof -p <pid> 攤開來看,滿滿都是連到下游服務 IP 的 socket,而且絕大多數狀態是 ESTABLISHEDCLOSE_WAIT
  • CLOSE_WAIT 這個狀態是關鍵線索:它代表對端已經關了連線,但我方遲遲沒有 close。這根本就是在指著鼻子說「你有連線沒收乾淨」。

我把 fd 數量接到 metrics 上畫成一條線,圖一出來就破案了:那是一條幾乎筆直、只漲不跌的斜線,斜率跟請求量成正比。每處理一個下游請求,就漏掉一個 fd,永不回收。掛掉的時間點,正好就是這條線撞到 fd 上限的時候。跟流量尖峰無關,跟記憶體無關,純粹是「漏連線漏到死」。

根因:body 沒關,client 還每次新建

回到程式碼,我盯著那段呼叫下游的 function,當場臉就綠了。它長得大概像這樣:

go
func callDownstream(payload []byte) (*Result, error) {
    client := &http.Client{}
    resp, err := client.Post(url, "application/json", bytes.NewReader(payload))
    if err != nil {
        return nil, err
    }
    var r Result
    json.NewDecoder(resp.Body).Decode(&r)
    return &r, nil
}

短短幾行,藏了兩個致命傷。

第一,resp.Body 從頭到尾沒有 Close()。在 Go 裡,resp.Body 是一個需要你主動關閉的 io.ReadCloser,它背後綁著那條底層的 TCP 連線。你不 Close(),那條連線就不會被放回連線池、也不會被釋放,fd 就這樣一個一個卡死在那裡。這就是我那條只漲不跌的斜線。

第二,每次呼叫都 &http.Client{} 新建一個。http.Client 底層的 Transport 才是管理連線池與 keep-alive 的地方。每次 new 一個新 client,就等於每次都用一套全新的、空的連線池,前一個請求好不容易建立的連線根本沒機會被下一個請求重用。就算 body 有關好,這種寫法也會讓連線一直建一直拆,白白浪費。

keep-alive 的細節:光關還不夠,要讀完才能重用

修的時候我才把這件事真正搞懂,這裡有個很多人(包括當時的我)會漏掉的細節。

Go 的 Transport 預設會做 HTTP keep-alive:一個請求處理完,如果連線還健康,它會被放回連線池,下一個打到同一個 host 的請求就能直接複用,省掉三次握手。但這個「放回池子」有前提:

  • 你必須 Close()resp.Body,否則連線根本不會被回收。
  • 你必須把 resp.Body 讀到 EOF,這條連線才能被重用。如果你只讀了一半就 Close()(例如 decode 到你要的欄位就中途 return),Transport 沒辦法確定連線上還殘留多少沒讀完的資料,為了安全,它會選擇直接把整條連線關掉,而不是放回池子。

換句話說,「有 Close 但沒讀完」不會漏 fd,但會讓連線無法重用、一直重建;「連 Close 都沒有」則是直接漏 fd,就是把我搞掛的那種。兩個坑要一起避開,連線池才會真的運作起來。

修法:成對申請釋放,client 全域共用

修正其實不難,難的是觀念要對。改完的版本大概是這樣:

go
var client = &http.Client{
    Timeout: 5 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 20,
        IdleConnTimeout:     90 * time.Second,
    },
}

func callDownstream(payload []byte) (*Result, error) {
    resp, err := client.Post(url, "application/json", bytes.NewReader(payload))
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    var r Result
    if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
        return nil, err
    }
    io.Copy(io.Discard, resp.Body) // 把殘料讀乾,讓連線能被重用
    return &r, nil
}

關鍵動作有幾個:

  • `client` 提到全域、只建一次,讓所有請求共用同一個 Transport 跟連線池。
  • 拿到 `resp` 且 `err == nil` 後,第一件事就 `defer resp.Body.Close()`,確保任何 return 路徑都會關。順序很重要:一定要先判斷 err,再 defer,因為出錯時 resp 可能是 nil。
  • 用 `io.Copy(io.Discard, resp.Body)` 把 body 剩下的資料倒掉,湊足「讀到 EOF」的條件,連線才回得了池子。
  • 設 `MaxIdleConnsPerHost` 跟各種 timeout。預設的 MaxIdleConnsPerHost 只有 2,對高併發下游呼叫太小,容易讓連線頻繁重建;Timeout 則是避免某個慢下游把連線卡住不放。

上線後,那條只漲不跌的 fd 曲線,終於變成一條在低檔平穩起伏的線。半夜的電話也不再響了。

小結

這次事故給我的教訓,其實跟語言、跟框架都沒什麼關係,是一條更底層的紀律:

  • 資源要成對申請與釋放。開了 body 就要關,就跟開檔案、拿鎖、開 transaction 一樣,defer 是你最好的朋友。
  • 每一個對外呼叫,都要問自己一句「這條連線怎麼回收」。HTTP client 看起來人畜無害,但它背後是實實在在的 TCP 連線跟 fd,會漏、會爆。
  • `too many open files` 不是玄學,它精準地在告訴你 fd 漏了,別像我一樣一頭栽進記憶體那邊找兇手。
  • client 跟 Transport 要重用,body 要讀完,keep-alive 才會真的幫你省連線,而不是變成漏洞來源。

說到底,會踩這個坑不是因為技術多難,而是因為那幾行 code「看起來能跑」。它白天真的能跑,只是每跑一次,就悄悄偷走一個 fd。

#Go#HTTP#連線池#資源洩漏#事故覆盤#檔案描述符

留言討論

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

相關文章