一道看似基础、却很容易暴露工程习惯的 Go 面试题是:调用 http.Get 或 client.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 响应时,读取完成并关闭;处理未知响应时,限制资源消耗再关闭;循环调用则及时释放。掌握这些细节,既能回答面试题,也能让外部调用更稳健。