默认不复用TCP连接是因为复用前提被破坏:未共享Transport、服务端返回Connection: close、手动设Connection: close、host不一致、MaxIdleConnsPerHost过小或DNS轮询导致IP变化。

为什么默认的 http.Client 有时不复用 TCP 连接
Go 的 http.DefaultClient 确实启用了连接复用,但实际是否复用,取决于你是否无意中破坏了复用前提。常见破坏点包括:
• 每次请求都新建一个独立的 http.Client 实例(没共享 Transport)
• 请求头里手动设置了 Connection: close
• 服务端返回了 Connection: close 或使用了 HTTP/1.0 且未显式声明 keep-alive
• URL 域名解析结果不稳定(比如 DNS 轮询导致底层 net.Conn 目标 IP 频繁变化)
• Transport.MaxIdleConnsPerHost 设得太小,空闲连接被过早回收
如何配置 Transport 实现稳定复用
关键不是“开启复用”,而是确保复用条件持续满足。推荐显式构造带调优参数的 http.Transport:
tr := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
client := &http.Client{Transport: tr}注意:
• MaxIdleConnsPerHost 必须 ≥ 1(默认是 2),设为 0 会禁用复用
• IdleConnTimeout 控制空闲连接存活时间,太短会导致频繁重建,太长可能占用过多 fd
• 不要设置 ForceAttemptHTTP2: false(除非明确需要降级),HTTP/2 天然支持多路复用,复用效率更高
• 若服务端不支持 HTTP/2,Go 会自动回落到 HTTP/1.1 的 keep-alive 复用逻辑
怎么验证连接是否真的被复用了
最直接的方式是观察底层 TCP 连接数变化,而不是依赖响应头。可用以下方法确认:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 启动服务端后,用
lsof -i :8080 | grep ESTABLISHED | wc -l(Linux/macOS)观察客户端发起多个请求时该数值是否稳定 - 在
Transport中启用调试日志:tr.DialContext = func(ctx context.Context, netw, addr string) (net.Conn, error) { log.Printf("dial %s", addr); return (&net.Dialer{}).DialContext(ctx, netw, addr) },看重复请求是否跳过 dial 日志 - 抓包观察:连续两个请求之间没有
TCP SYN,且第二个请求复用了同一个 socket 的源端口
注意:不要只检查响应头是否有 Connection: keep-alive——Go 客户端即使收到它,也可能因本地配置或超时已关闭连接。
哪些情况会让复用彻底失效
即便 Transport 配置正确,以下场景仍会强制新建连接:
- 请求的
URL.Host解析出不同 IP(例如 DNS 返回多个 A 记录且客户端轮询)——Go 将其视为不同 host,各自维护 idle 连接池 - 使用自定义
DialContext但每次返回新连接且未做连接池管理 - 请求上下文(
context.Context)在连接建立中途取消,导致该连接被标记为不可复用 - 服务端主动发送
GOAWAY(HTTP/2)或提前关闭 TCP 连接(HTTP/1.1) - 调用
client.CloseIdleConnections()后所有空闲连接被立即关闭
真正影响复用效果的,往往不是 Transport 参数本身,而是整个请求生命周期中连接状态的连续性。DNS 缓存、服务端健康状态、甚至中间代理的行为,都可能让看似相同的请求落到不同的底层连接上。

















