问题通常不是网络,而是连接池
很多 Go 服务把外发 HTTP 请求当成一件特别简单的事:http.Get 一发,读一下 body,完事。脚本和低频任务这么写没什么问题,但一旦服务持续向上游发请求,尤其是高 QPS 场景,连接池策略就会从不起眼的角落变成瓶颈。

比较典型的表现是:服务运行一段时间后开始报 too many open files,或者 ss 一看全是 TIME_WAIT;上游偶尔慢一下,客户端这边就大量超时;明明上游状态正常,日志里却出现间歇性 EOF。很多人会去调内核参数,或者怀疑负载均衡,其实问题往往出在 Go 客户端自己的连接池配置上。
在 Go 的 net/http 里,http.Client 只是一个门面,负责超时、重定向、cookie 这些高层语义。真正管理连接的是 http.Transport。它内部维护了一个按 host 分组的空闲连接池,请求结束后连接不会立刻销毁,而是被放回池子里,等下一个请求复用。我们聊的 KeepAlive 和 IdleConnTimeout,本质就是围绕这个池子的生命周期做控制。
拆开 KeepAlive、IdleConnTimeout 和 MaxIdleConnsPerHost
第一次看 Transport 配置的人容易掉进一个坑:代码里明明写了 KeepAlive: 30 * time.Second,但 http.Transport 结构体里并没有一个叫 KeepAlive 的字段。这个值属于 net.Dialer,是 TCP 层的 keepalive,作用是让系统每隔一段时间发一个探测包,确认对端还活着。它和 HTTP 连接的复用没有直接关系。
HTTP 层的 keep-alive 由 DisableKeepAlives 控制。默认是 false,也就是允许连接复用。只要连接没被服务端关闭、没达到空闲超时,就可以留给下一个请求。如果你把这个值改成 true,每个请求都会新建连接,连接池就形同虚设。
IdleConnTimeout 才是连接池真正关心的值:一条连接在空闲状态下最多保留多长时间。Go 的默认值是 90 秒,超过之后 Transport 会主动把它关闭。注意它只作用于空闲连接,对请求执行期间的活跃连接没有任何影响。
MaxIdleConns 和 MaxIdleConnsPerHost 分别控制全局和单个 host 的空闲连接数量上限。前者默认 100,后者默认 2。这两个值和 MaxConnsPerHost 不同:后者控制的是一个 host 的总连接数,包括正在使用的连接。
| 参数 | 默认值 | 作用 | 建议起点 |
|---|---|---|---|
| MaxIdleConns | 100 | 全局空闲连接上限 | 200~500 |
| MaxIdleConnsPerHost | 2 | 单个 host 空闲连接上限 | 20~50 |
| IdleConnTimeout | 90s | 空闲连接回收时间 | 30~60s |
| MaxConnsPerHost | 0(不限) | 单个 host 总连接上限 | 视上游而定 |
| Dialer.KeepAlive | 30s | TCP 层探活间隔 | 保持默认 |
默认配置为什么不够用
DefaultTransport 的参数是为命令行工具这类低频场景设计的,不是为高并发服务准备的。尤其是 MaxIdleConnsPerHost=2,很容易让线上服务产生连接抖动。
很多人把这个值理解成“同一个 host 最多同时只能建立 2 个连接”,这是最常见的误区。它限制的只是空闲状态。换句话说,如果你的服务经常有 20 个并发请求打向同一个上游,高峰期确实会有 20 条连接在工作,但请求结束后只有 2 条会被保留,其余 18 条会被关闭,等下一波请求来了再重新建立。
假设一个服务的 QPS 是 1000,平均响应时间 20ms,瞬时并发大约 20。如果空闲连接上限只有 2,每个请求结束后几乎都会关闭连接,下一个请求再重建。TCP 三次握手、TLS 握手、慢启动全部重新来一遍。延迟和负载都被这个不起眼的默认值吃掉了。
这种情况在高并发时还会带来另一个副作用:客户端所在主机出现大量 TIME_WAIT。主动关闭连接的一方要承担 TIME_WAIT 状态,Go 客户端在关闭多余空闲连接时,自己是主动关闭方。连接越多,TIME_WAIT 越多,最终可能耗尽本地端口。
IdleConnTimeout 要和上游 keepalive 对齐
既然 IdleConnTimeout 是空闲连接回收时间,那是不是设得越小越好?不是。如果设为 5 秒,请求稍微一间隔就重建,连接池等于没设。设为 0 表示不超时,又会占用大量空闲连接。真正合适的值,要跟上游的 keepalive 策略对齐。
很多服务通过 Nginx 或云负载均衡转发。Nginx 默认的 keepalive_timeout 一般是 75 秒,一些平台可能调成 60 秒。Go 的 DefaultTransport 是 90 秒。这就出现一个错位:客户端以为连接还能用,但服务端或中间层早就把连接关了。下一次请求复用这条连接,轻则多一次 EOF 重试,重则直接报错。Go 对幂等请求可能在复用连接失败后自动换一条连接重试,但第一次尝试已经浪费了时间;带 body 且没有设置 GetBody 的请求则可能直接失败。
比较稳妥的做法是,把客户端的 IdleConnTimeout 设置成略小于上游 keepalive 超时。比如上游是 60 秒,你就设 45 到 50 秒,让连接在服务端主动关闭之前先被客户端回收,尽量避免使用半开连接。
一份可落地的 Transport 配置
下面是一组适合单上游、高 QPS 内网服务起步的配置,具体数值需要根据业务的并发模型和上游能力调整。
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 20,
MaxConnsPerHost: 50,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
}
这里把 DialContext 里的 Timeout 设成 5 秒,避免上游不可达时长时间卡住;TLSHandshakeTimeout 单独设成 5 秒,跟连接超时区分开;MaxConnsPerHost 设为 50,相当于给这个上游画了一条水位线。注意 MaxConnsPerHost 把活跃连接和空闲连接一起计算,如果某条连接长时间不释放,新的请求会在 Transport 内部排队,不会无限创建连接。
如果不希望从零写一份 Transport,更稳妥的做法是拿到默认配置再改:clone := http.DefaultTransport.(*http.Transport).Clone(),然后修改你想覆盖的字段。这样可以保留 Proxy、HTTP/2 这些容易遗漏的默认行为。
另外,如果你的请求是 HTTPS 并且启用 HTTP/2,情况会略有不同:HTTP/2 的一条连接可以承载多个并发流,MaxIdleConnsPerHost 的意义会被弱化,你更需要关心 MaxConnsPerHost 和上游的并发限制。
几个容易被忽略的坑
- response body 没有读完就 Close。Go 的 Transport 会检查 body 是否被完整读取。如果请求结束前没有读完,它不会把连接放回池里,而是直接关闭。你配置的池子再大也没用。正确做法是至少
io.Copy(io.Discard, resp.Body)后再 Close,或者确保业务代码把 body 读完。 - 把 IdleConnTimeout 调得特别大。以为这样能提高复用,但如果上游和中间层不允许,反而更容易拿到已经失效的连接。它要和两端的超时链路配合,不是越大越好。
- 自定义 Transport 时丢掉了 Proxy 和 HTTP/2。有些内网环境需要走代理,有些服务依赖 HTTP/2。从
DefaultTransportClone()一份再改,是最省心的方式。 - 把 MaxConnsPerHost 设成拍脑袋的小值。这个参数会限制总连接数,设太小会让请求在 Transport 内部排队,叠加慢响应时容易形成新的瓶颈。
连接池参数不是压测时的加分项,而是高 QPS 服务的底层配置。先确认默认值是否适合你的业务,再谈调优。
怎么确认配置真的生效
调整完参数后,别急着看平均耗时。先观察客户端到上游的连接状态,用 ss -tan | grep :8080 | awk '{print $1}' | sort | uniq -c 统计 ESTABLISHED 和 TIME_WAIT 的数量。如果调整后 ESTABLISHED 数量趋近于你的 MaxIdleConnsPerHost,TIME_WAIT 明显下降,说明连接确实在复用。
如果你想从单次请求维度确认,可以用 net/http/httptrace 的 GotConn 回调,查看 conn.Reused 是否为 true。这个值能直接告诉你当前请求是不是用了一条池子里捞出来的连接。
Go net/http 的连接池管理,说到底就是管理连接的生命周期。TCP KeepAlive 保证链路不会在静默中死掉,HTTP keep-alive 决定连接能不能被复用,IdleConnTimeout 决定连接在池子里待多久,MaxIdleConnsPerHost 决定我们愿意为短时并发预留多少余地。把这些参数和上游的超时策略对齐,很多看似诡异的网络问题,其实在客户端代码里就能解决。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/732/