必须显式调用resp.body.close(),因为go的http连接复用依赖于完整消费并关闭响应体;否则连接无法归还池子,导致too many open files、time_wait堆积及goroutine泄漏。
为什么 resp.body.close() 必须显式调用
go 的 http.client 默认启用连接复用(keep-alive),但复用的前提是:响应体被读取并关闭。若只调用 http.get 或 client.do,拿到 resp 后不做任何处理,底层 tcp 连接就无法归还给连接池,会一直挂在 readloop goroutine 里等待读取——哪怕服务端早已发完全部数据。
常见错误现象包括:
-
socket: too many open files错误频繁出现 -
lsof -i :443 | wc -l持续上涨且不回落 - pprof 查看
/debug/pprof/goroutine?debug=1显示大量 goroutine 停在net.(*pollDesc).Wait或persistConn.readLoop
正确做法不是“偶尔 defer”,而是每次成功拿到 resp 后立即安排关闭:
resp, err := client.Get("https://api.example.com")
if err != nil {
return err
}
defer resp.Body.Close() // ✅ 放在 err 检查之后、任何读取之前
注意:defer 在函数退出时才执行;若该函数生命周期很长(如后台 worker 循环),应改用显式 resp.Body.Close(),避免堆积。
Transport 配置不当如何加剧连接堆积
即使你每次都关了 resp.Body,若 http.Transport 参数没调好,连接仍可能卡在 idle 状态不释放,或新建过多连接无法复用。
关键配置项及其影响:
-
MaxIdleConns和MaxIdleConnsPerHost:默认 100,高并发下易成为瓶颈;建议设为 500+,并与后端实例数匹配 -
IdleConnTimeout:空闲连接存活时间,设太长(如 90s)会让连接占着 fd 不放;设太短(如 5s)会导致频繁建连;推荐 30s -
TLSHandshakeTimeout和ResponseHeaderTimeout:防止 TLS 握手卡死或服务端迟迟不发 header 导致连接永久悬挂
典型错误写法是直接用 &http.Client{} 而不配 Transport,或复用 http.DefaultClient 却被第三方库悄悄修改了其 Transport 参数。
安全初始化示例:
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 500,
MaxIdleConnsPerHost: 500,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
},
}
403 等非 2xx 响应体也必须消费
很多人只在 StatusCode == 200 时读取 resp.Body,却忽略 403、401、500 等响应也可能携带非空 body(比如 HTML 错误页、JSON 提示、Content-Length: 345)。只要 body 没被读完或没被关闭,连接就不会释放。
更危险的是重试逻辑:若对 403 直接重试而不处理上一次的 resp.Body,等于每轮都泄漏一个连接。
可靠做法是:无论状态码如何,都确保 body 被消费并关闭:
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
<p>// 即使不需要内容,也要把 body 读完,否则连接无法复用
<em>, </em> = io.Copy(io.Discard, resp.Body)
</p>
这行 io.Copy(io.Discard, resp.Body) 很关键——它显式消费流,让 http.Transport 知道可以回收连接了。
全局复用 client 实例而非每次 new
最隐蔽的泄漏源之一是:在 HTTP handler 或循环中反复创建新 http.Client 实例。每个 client 自带独立 Transport 和连接池,新建即新增资源占用,旧的还可能因未关闭而滞留。
错误模式:
func handler(w http.ResponseWriter, r *http.Request) {
client := &http.Client{} // ❌ 每次请求都 new,连接池各自为政
resp, _ := client.Get("https://...")
}
正确方式是定义包级或全局变量复用:
var httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{ /* 如上配置 */ },
}
<p>func handler(w http.ResponseWriter, r *http.Request) {
resp, _ := httpClient.Get("https://...") // ✅ 复用同一实例
}
</p>
如果需要不同超时策略(如上传 vs 查询),应按用途分组定义多个 client 变量,而不是按请求动态构造。
真正容易被忽略的是:连接泄漏往往不是单点失误,而是 Body 未关 + Client 未复用 + Transport 未调优 三者叠加。线上一旦触发 too many open files,恢复窗口极短——因为新请求连 socket 都建不起来。











