一道看似基础、却很容易暴露工程习惯的 Go 面试题是:调用 http.Getclient.Do 拿到响应后,为什么总要写 defer resp.Body.Close()

有人会回答“防止内存泄漏”,也有人会回答“为了复用连接”。方向并非完全错误,但还不够准确:响应体本质上连接着网络数据流,关闭是调用方的责任;对于默认客户端使用的 HTTP/1.x 持久连接,想让连接更可能被下一次请求复用,通常还要把响应体读取到结束再关闭。

这个小细节会直接影响接口调用、爬虫采集和批处理任务的稳定性。

先看最常见的漏关写法

很多代码只关心状态码或解析结果,一不小心就忘记关闭响应体:

func ping(url string) error {
    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("status: %s", resp.Status)
    }
    return nil
}

这段代码请求成功后,resp.Body 无论状态码是多少都没有关闭。Body 的类型是 io.ReadCloser,它是一次 HTTP 响应的数据读取入口,并与传输层资源相关联。

net/http 官方文档给出的责任边界非常明确:当请求没有返回错误时,响应中的 Body 一定不为 nil,即使响应没有正文或长度为 0,也由调用方负责关闭。因此,获取响应成功后的第一件事,通常就应安排关闭:

resp, err := http.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
    return fmt.Errorf("status: %s", resp.Status)
}

defer 必须放在 err 判断之后。请求失败时,不能假设 resp 可以使用。这段代码只展示“成功得到响应后必须关闭”的底线;如果错误响应体很小且希望复用连接,还需要读取完成后再返回。

只关闭为什么还不够

关闭响应体能够完成必要的资源释放,但连接能否复用还有一个条件:响应数据是否已经读取完成。

Go 文档对 Client.Do 的说明是:如果响应体没有读取到 EOF 并关闭,底层 Transport 可能无法把这条持久 TCP 连接留给后续请求继续使用。换句话说,下列代码虽然不会遗漏 Close,却可能损失连接复用机会:

resp, err := http.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()
return nil // 响应体完全没有读取

在循环抓取、健康检查或调用下游 API 等高频路径中,连接不能复用就可能频繁重新建连,带来额外延迟和资源压力。业务本来就要读取小型 JSON 响应时,正常解析到结束并关闭即可;面对下载或未知大小响应,不能为了连接复用而无边界地读入内存。

不处理响应内容时怎么做

只需要确认请求是否成功时,如果接口契约确认响应体很小,可以将其丢弃读取后再关闭:

func finish(resp *http.Response) {
    _, _ = io.Copy(io.Discard, resp.Body)
    _ = resp.Body.Close()
}

这个写法读到 EOF,适合大小有约束的接口。对方可能返回大响应或长流时,不能盲目读取全部内容。

对于不可信或大小未知的错误响应,更稳妥的选择是只读取有限内容用于日志,然后关闭连接:

func errorDetail(resp *http.Response) string {
    defer resp.Body.Close()
    b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
    return string(b)
}

这里最多读取 4 KB。如果响应体超过限制而没有读到 EOF,这次连接就不应指望复用;相比复用,资源边界更重要。

循环请求中的 defer 陷阱

面试时常见的追问是:既然需要关闭,在循环里每次 defer resp.Body.Close() 可以吗?

defer 要等当前函数返回才执行。循环执行多次时,前面响应的资源会一直保留到循环结束。

下面这种写法不适合长循环:

for _, url := range urls {
    resp, err := client.Get(url)
    if err != nil {
        continue
    }
    defer resp.Body.Close() // 循环结束前不会执行
    process(resp.Body)
}

更合适的方式是把单次请求封装成函数,让每次迭代结束时立即触发 defer

func fetch(client *http.Client, url string) error {
    resp, err := client.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    return decode(resp.Body)
}

循环中调用 fetch,每次迭代都能及时关闭响应资源。如果还需要提高 HTTP/1.x 连接复用率,decode 也应完整消费该次响应体;只解析到一半便返回,仍不能依赖连接复用。此外,生产客户端还应设置合理超时;关闭响应体只能处理请求返回后的收尾,不能解决请求长期挂起的问题。

面试回答可以这样组织

遇到“为什么一定要关闭 resp.Body”这道题,可以分三层回答:

  • resp.Body 是由调用方管理的响应数据流,Do 成功返回后必须关闭。
  • 仅关闭并不等同于一定复用连接;对于 HTTP/1.x,响应体读取到 EOF 并关闭后,底层连接才更有机会继续用于后续请求。
  • 实际代码要区分场景:小响应读取或丢弃到 EOF;未知大小响应限制读取并关闭;循环请求不要把 defer 堆到外层函数结束。

这类回答既覆盖官方约定,也说明了工程上的取舍,比单纯背一句“防止资源泄漏”更完整。

写在最后

defer resp.Body.Close() 不是代码格式习惯,而是一次 HTTP 客户端调用必须完成的收尾动作。忘记关闭,可能让网络资源得不到及时释放;只关闭却不读取响应体,又可能让高频请求失去持久连接带来的效率。

处理小型 API 响应时,读取完成并关闭;处理未知响应时,限制资源消耗再关闭;循环调用则及时释放。掌握这些细节,既能回答面试题,也能让外部调用更稳健。