必须显式设置http.Client超时或使用context控制请求生命周期,共享client复用连接池,且每次请求后必须关闭resp.Body并检查StatusCode。

用 http.Client 发起并发请求时,必须手动控制超时
Go 的 http.DefaultClient 默认没有设置超时,一旦后端响应慢或挂死,goroutine 会永久阻塞,导致连接泄漏和内存持续增长。这不是并发问题,而是客户端配置缺失。
正确做法是显式构造带超时的 http.Client:
client := &http.Client{
Timeout: 5 * time.Second,
}更稳妥的方式是使用 context.Context 控制单次请求生命周期,尤其在需要取消或传递 deadline 时:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel()req, _ := http.NewRequestWithContext(ctx, "GET", "https://www.php.cn/link/46b315dd44d174daf5617e22b3ac94ca", nil) resp, err := client.Do(req)
-
client.Timeout控制整个请求(DNS + 连接 + 写入 + 读取)的总耗时 -
context.WithTimeout可在请求中途主动取消,比如用户关闭页面、上游服务已放弃等待 - 不要混用两者——
context优先级更高,会覆盖client.Timeout
多个 goroutine 共享一个 http.Client 是安全且推荐的
常见误区是为每个请求新建 http.Client,以为“隔离更安全”。实际上 http.Client 是并发安全的,内部复用连接池(http.Transport),新建实例反而导致:
立即学习“go语言免费学习笔记(深入)”;
- 重复创建
http.Transport,浪费文件描述符 - 无法复用 TCP 连接,增加 TLS 握手开销
- 连接池参数(如
MaxIdleConns)失效,容易触发too many open files
全局复用一个 client 即可:
var httpClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 30 * time.Second,
},
Timeout: 5 * time.Second,
}注意:http.Transport 的默认值偏保守(如 MaxIdleConns=100),高并发场景需按压测结果调优。
错误处理不能只看 err != nil,还要检查 resp.StatusCode
HTTP 请求成功返回不代表业务成功。比如后端返回 503 Service Unavailable 或 429 Too Many Requests,err 仍为 nil,但 resp 已存在。
典型错误写法:
resp, err := client.Do(req)
if err != nil {
log.Println("request failed:", err)
return
}
// 忘记检查 resp.StatusCode,直接 resp.Body.Read —— 可能拿到错误页 HTML应统一检查状态码:
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Printf("HTTP %d for %s", resp.StatusCode, req.URL.String())
return
}-
http.Client只在连接失败、TLS 握手失败、超时等网络层问题时返回err -
4xx/5xx属于 HTTP 协议内正常响应,err为nil,必须靠resp.StatusCode判断 - 建议封装一个
doRequest辅助函数,统一处理超时、重试、状态码校验
goroutine 泄漏比想象中更容易发生
异步发起 HTTP 请求后,若不消费 resp.Body,底层连接无法释放,goroutine 和连接都会堆积。即使加了 defer resp.Body.Close(),如果 resp 是 nil(比如 err != nil),就会 panic 或跳过关闭。
安全写法必须覆盖所有分支:
resp, err := client.Do(req)
if err != nil {
log.Println("request error:", err)
return
}
defer resp.Body.Close() // 此处 resp 不为 nil
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Printf("bad status: %d", resp.StatusCode)
return
}
body, _ := io.ReadAll(resp.Body)
// ... use body
更严谨的做法是用 io.Copy(io.Discard, resp.Body) 显式丢弃不需要的响应体,避免大响应体阻塞连接复用。
真正难排查的是:goroutine 数量缓慢上涨,日志里看不到明显错误,最后发现是某处 if err != nil { return } 后漏掉了 resp.Body.Close() —— 这种细节在并发场景下会被指数级放大。










