导语:你的 HTTP 客户端可能每次都在建连
很多人写 Go HTTP 客户端时 http.Get 一把梭,结果压测一上来延迟飙升、time_wait 连接堆积到几十万。每次新建 TCP 连接的成本是三次握手 + TLS 握手,几乎是毫秒级延迟的全部来源。连接池的价值就是把"建连"摊薄成"复用",但 Go 的连接池参数有它自己的讲究。
一句话总结:http.Client 性能九成在 Transport 连接池——配置 MaxIdleConnsPerHost、Keep-Alive 和超时,比任何业务优化都值钱。
1. Transport 是连接池的真正主角
1.1 Client 只是壳,Transport 才是连接管理
// 很多人只配置了 Client,却忘了最关键的 Transport
client := &http.Client{
Timeout: 10 * time.Second, // 整个请求总超时
Transport: &http.Transport{
// —— 连接池三剑客 ——
MaxIdleConnsPerHost: 100, // 每个 host 可保留的空闲连接数
MaxConnsPerHost: 200, // 每个 host 的连接总数上限
IdleConnTimeout: 90 * time.Second, // 空闲连接回收时间
// —— 绕过代理直连 ——
Proxy: http.ProxyFromEnvironment,
// —— 建立连接超时 ——
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // 建立 TCP 连接超时
KeepAlive: 30 * time.Second, // TCP keepalive
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second, // TLS 握手超时
ResponseHeaderTimeout: 5 * time.Second, // 等待响应头超时
},
}
1.2 关键参数含义
| 参数 | 默认 | 作用 | 过大/过小的后果 |
|---|---|---|---|
| MaxIdleConns | 100 | 全局空闲连接数 | 太小→频繁建连 |
| MaxIdleConnsPerHost | 2(默认) | 每 host 空闲复用 | 默认太小,必改 |
| MaxConnsPerHost | 0(不限) | 每 host 总连接 | 过大→资源耗尽 |
| IdleConnTimeout | 90s | 空闲回收 | 过小→连接被回收需重建 |
| ResponseHeaderTimeout | 0(不限) | 等响应头超时 | 过小→慢接口被打断 |
| DisableKeepAlives | false | 是否禁用保活 | true→每次都建连 |
一句话总结:http.Client 的
MaxIdleConnsPerHost默认只有 2——高并发爬虫/网关不改它,连接池就是摆设。
2. Keep-Alive 与连接复用生命周期
2.1 一次复用发生了什么
复用流程(第二次请求):
1. 从池取出空闲连接
2. Reuse = true,不做三次握手 & TLS
3. 请求完成后归还池中,等待下次复用
4. 若 IdleConnTimeout 到期,连接被关闭回收
不复用的情况:
- 每次 url 不同 host(PerHost 上限)
- 请求显式 Connection: close
- 连接被服务端超时关闭
2.2 连接泄漏:最常见的坑
// ❌ 忘了 body,连接池无法复用,连接泄漏
resp, err := http.Get("https://api.example.com")
defer resp.Body.Close() // ← 必须关闭,否则连接不归还池
// ✅ 正确的读与关闭
resp, err := http.Get(url)
if err != nil { /* 处理 */ }
defer resp.Body.Close()
// 即使不用 body,也要 drain 再关,让连接可复用
_, _ = io.Copy(io.Discard, resp.Body)
一句话总结:每个 resp.Body 都必须 Close;不读完就关会断连,忘了关则连接泄漏——这是连接池最大的隐性杀手。
3. DNS 缓存与超时配置
3.1 DNS 解析与缓存
// Go 的 net/http 默认会缓存 DNS 解析结果(Dialer 的 Resolver)
// 关键:resolver 缓存存活时间(LookupHost 结果缓存 30s 左右)
// 频繁变动的域名(故障转移/多活)要关掉缓存:
transport := &http.Transport{
DialContext: (&net.Dialer{
Resolver: &net.Resolver{
PreferGo: true, // 用 Go 自己的解析器
},
// 每次 Dial 强制重新解析:
// 也可配置 resolver 不缓存
}).DialContext,
}
DNS 缓存要点:
- 默认有缓存 → 域名切换后旧 IP 仍被复用一段时间
- 需要即时故障转移时关闭缓存或缩短 TTL
- 连接池是建立在"域名 → IP"之上的,缓存过期会重建连接
3.2 shared Transport 全局复用
// 全局共用一个 Transport(连接池共享,而不是每个请求新建)
var httpClient = &http.Client{
Transport: sharedTransport(),
}
func sharedTransport() *http.Transport {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConnsPerHost = 100
transport.IdleConnTimeout = 90 * time.Second
return transport
}
// 别再:
// for { resp, _ := &http.Client{}.Get(...) } // 每个都新建池
3.3 超时等级
// 三层超时,层层兜底
// 1. DialContext → 建立 TCP 连接超时
// 2. TLSHandshakeTimeout → TLS 握手超时
// 3. ResponseHeaderTimeout → 等响应超时
// 4. Client.Timeout → 整个请求总超时
client.Timeout = 10 * time.Second
一句话总结:全局共享一个 Transport,把连接池复用到极致;四层超时写清楚,别让慢上游拖垮你的调用方。
4. 生产排查与避坑
| 症状 | 可能原因 | 对策 |
|---|---|---|
| 大量 TIME_WAIT | 每请求新建连接,DisableKeepAlive | 全局 Transport + KeepAlive |
| connection refused | MaxConnsPerHost 打满 | 提高上限或限流 |
| 请求很慢但 CPU 低 | 连接池打满在排队 | 扩 MaxConns + 连接复用 |
| net/http: response body closed by server | 未读 body 复用连接失败 | io.Copy(io.Discard, resp.Body) |
| TLS 握手频繁 | 连接没复用 | 检查 IdleConnTimeout 过长 |
| 大量 map 内存 | 大量 host 各自建连接 | MaxIdleConns 全局调优 |
生产避坑:
□ 永远 defer resp.Body.Close(),读完才分批连接
□ 用全局共享 Transport,别每个 goroutine 都建 Client
□ 有 HTTP/2(h2c/h2)时连接复用更高效(多路复用)
□ 关闭 KeepAlive 只在流量极低时考虑
□ 用监控看 TIME_WAIT 与连接数,反映池健康
□ 注意代理与连接池的相互作用,别让代理绕过复用
□ 网关类服务把 MaxIdleConnsPerHost 调到几百
5. 总结
HTTP 连接池是 Go 客户端性能的命门:
| 层级 | 关键动作 |
|---|---|
| 连接池 | MaxIdleConnsPerHost/MaxIdleConns/IdleConnTimeout |
| 复用 | 共享 Transport、defer Body.Close、读尽 body |
| 超时 | Dial/TLS/Header/总超时四层 |
| 健康 | 监控 TIME_WAIT、连接数、队列等待 |
落地记住五件事:共享 Transport、开 Keep-Alive、调 MaxIdleConnsPerHost、defer 关门、四层超时。连接池配置正确,你的 HTTP 客户端性能会立刻上一个台阶,time_wait 与建连开销一键消失。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。