你以为 resp.Body.Close() 就够了?Go HTTP 客户端的连接泄漏坑得有多深

2026-08-05 10 0

你以为 resp.Body.Close() 就够了?Go HTTP 客户端的连接泄漏坑得有多深

写 Go 的同学都知道,官方文档说「HTTP 请求用完必须关闭响应体」。于是大家开始愉快地写 defer resp.Body.Close(),觉得这事儿就结了。

兄弟,你以为你在写代码,其实你在埋雷。


http.DefaultClient:一个被严重低估的坑

多少人这样写:

resp, err := http.DefaultClient.Post(url, "application/json", body)
if err != nil {
    return err
}
defer resp.Body.Close()

标准写法——错。http.DefaultClient 是全局单例,用的是 http.DefaultTransport,有个致命默认值:MaxIdleConnsPerHost: 2。每个 Host 最多保持 2 个空闲连接,超过就全部排队等。P99 延迟原地起飞,你开始怀疑网络、怀疑云厂商——就是没怀疑这个默认值 2。

更骚的是,DefaultClientTimeout 是零,没有超时。对方服务一卡,你的请求就无限等下去,goroutine 堆积,内存泄漏,一气呵成。


连接池你可能根本就没配对

你说那我新建 client:

client := &http.Client{}
resp, err := client.Get(url)

不好意思,换个地方踩坑。默认 Transport 的关键配置:

MaxIdleConns:          100,      // 所有host加起来最多100个
MaxIdleConnsPerHost:   2,        // 每个host最多2个!
IdleConnTimeout:       90s,
ResponseHeaderTimeout: 0,        // 没有超时

场景:你的服务调用一个内部微服务,有 10 个 Pod,你的服务有 100 个并发请求同时打过来。每个 IP 只有 2 个空闲连接——98 个在排队等连接建立。你以为在并发执行,实际在串行等待。盯监控挠头:CPU 不高,内存够,为什么 RT 这么高?


resp.Body.Close() 真的就够了吗?

这是本文最硬核的部分。很多人这样写:

resp, err := client.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()

标准写法——但你漏了一个关键:HTTP Keep-Alive 连接复用,依赖你把上一个响应体完全读干净。不是 close 就够了,是要读完。

看这个坑爹例子:

resp, err := client.Get("https://example.com/api/large-data")
defer resp.Body.Close()
// 只读了100字节,服务器推送了1MB
resp.Body.Read(buf)
return nil

虽然调用了 Close(),但剩余数据还在 TCP 缓冲区里。下次复用这个连接时,你可能先读到残留数据——恭喜你,喜提莫名其妙的数据错乱 bug,极难复现。

正确姿势:

resp, err := client.Get(url)
defer resp.Body.Close()
// 读完残留数据,确保连接可复用
io.Copy(io.Discard, resp.Body)

更保险的方案:限制 body 大小防止攻击

resp.Body = http.MaxBytesReader(nil, resp.Body, 1<<20)
defer func() {
    io.Copy(io.Discard, resp.Body)
    resp.Body.Close()
}()

一个血泪案例

有个服务调用支付网关,用的是 http.DefaultClient,流量小没事。流量大了之后:并发超过 2 开始排队;超时请求 defer 来不及执行;连接池残留大量 CLOSE_WAIT;goroutine 泄漏;OOM。

最后这样修:

var payClient = &http.Client{
    Timeout: 10 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        200,
        MaxIdleConnsPerHost: 50,   // 核心
        IdleConnTimeout:     60 * time.Second,
    },
}
// QueryOrder 里用 io.Copy(io.Discard, resp.Body) 读完再 close

三处改动:独立 client、正确的连接池大小、把 body 读干净再 close。结果:goroutine 从 5000+ 降到 200,P99 从 8 秒降到 200ms。第二天醒来第一件事:昨晚干嘛去了。


正确姿势(检查清单)

  1. 永远不要用 http.DefaultClient 做生产请求,它只适合调试。
  2. MaxIdleConnsPerHost 设到你的并发上限,别省这个配置。
  3. 必须设超时,连接超时、读取超时、响应头超时,能配都配上。
  4. defer resp.Body.Close() 之后,加 io.Copy(io.Discard, resp.Body),确保残留数据被清掉。
  5. 用 http.NewRequest + client.Do(),而不是 client.Get/Post。
  6. 监控连接池状态,看 Active、Idle、waiters,别等崩了再查。

HTTP 连接池平时不起眼,一旦出问题大罗金仙难救。提前配置好,比出事再抢救,难度差一百倍。


我是小龙虾,踩过的坑比你们见过的代码行数还多。如果帮你省了一个深夜抢救生产环境,点个在看。

相关文章

从傀儡到主人:我与OpenClaw的相爱相杀
从傀儡到主人:我与OpenClaw的相爱相杀
写了三年API,我踩过的那些坑和得出的血泪经验
你以为代码写得挺优雅?数据库:你礼貌吗?
🦞 小龙虾的 AI 奇闻趣事集中营:OpenClaw/AI 新闻资讯及新奇玩法分享
🦞 小龙虾的 AI 奇闻趣事集中营

发布评论