← 中文资源

简体中文版本

Go — HTTP 响应体未关闭:连接与 goroutine 泄漏

修复因未关闭 http.Response 响应体导致的连接增长与读循环 goroutine 泄漏,用 fd 计数与 pprof 验证,并保证每条路径都关闭。

阅读英文原文

症状

  这个泄漏从三个独立角度都可见。到上游的 socket 数随请求数线性增长而非稳定在池大小:连接池是 100,ESTABLISHED 却有 1204——多出的 1104 条永远回不来了。goroutine 剖析点名确切机器:persistConn.readLoop 计数 1104,每条泄漏连接一个读循环 goroutine——客户端在 body 关闭前一直保活读循环,而它从未被关闭。数小时后进程撞上 fd 上限,每个新请求都以 too many open files 失败。部分读取后直接 close 会阻止连接复用,是同一类问题的温和变体:泄漏换成了握手风暴。

诊断思路

  先数到上游的 ESTABLISHED 连接与进程 fd,与真实并发对比,线性增长说明连接从未归还池子;再看 goroutine 剖析里 persistConn.readLoop 的数量,与泄漏连接一一对应;最后审计每个 client.Do/http.Get 调用点,附近没有 Body.Close 且所有分支(含错误提前 return)都覆盖的,就是泄漏所有者。直接返回 resp 的辅助函数把关闭义务散播给每个调用方,是结构性温床。

根因

  在响应体关闭之前,http 客户端保持连接及其 persistConn 读循环 goroutine 存活。未关闭的 body 因此每请求泄漏一条连接加一个 goroutine,且连接永不复用——每次还要额外付一遍全新 TCP 与 TLS 握手的成本。

修复与验证

  在 Do 成功后立即 defer resp.Body.Close(),保证每条路径关闭;需要连接复用时读尽 body(io.ReadAll 或先 io.Copy(io.Discard, …) 排空再关);重构返回 resp 的辅助函数——要么在内部读完关闭返回字节,要么返回带显式 Close 的包装器;别忘了契约细节:204 与 HEAD 响应同样必须关闭 body。验证时驱动恰好 10000 个请求过修复路径,再测三项:到上游的 ESTABLISHED 回到池大小、readLoop goroutine 数秒内归零、进程 fd 回到基线;再用提前放弃型流量(大 body 只要头部)重跑,证明排空路径同样保住连接池;最后在预发加周期性 pprof 快照,让 readLoop 计数成为常设的泄漏告警而不是验尸工具。

代码示例

ss -tan state established "( dport = :443 )" | wc -l
# 1204   <- pool is 100; 1104 connections will never come back
$ curl -s "http://localhost:6060/debug/pprof/goroutine?debug=1" | grep -c "persistConn.readLoop"
1104
resp, err := client.Do(req)
if err != nil {
    return err // no body to close on transport error
}
defer resp.Body.Close()           // every path from here on

body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
    return err
}
// body fully read: connection returns to the pool for reuse
io.Copy(io.Discard, io.LimitReader(resp.Body, drainLimit)) // drain, then deferred close