我不会起名字322· 后端 / 算法 / 数据库
📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL
文章目录
- Go 接口偶发超时与 502:net/http 连接池的 4 个参数与 3 类踩坑
- 一、先看实测:默认配置和调过的配置差多少
- 二、Go 的连接池复用了什么
- 三、3 类真实踩坑
- 坑 1:Body 没读完就 Close,连接直接报废
- 坑 2:只设了一个 `Client.Timeout`,超时根本没分层
- 坑 3:池子容量和并发不匹配,连接被丢弃或堆积
- 四、4 个必须调的参数
- 五、命令清单
- 六、配置模板(可直接抄)
- 七、4 步排查顺序
- 八、总结
Go 接口偶发超时与 502:net/http 连接池的 4 个参数与 3 类踩坑
有个 Golang 写的接口,QPS 长期只有两位数,高峰期也不过两三百,但每隔十几分钟就会冒出一批超时,客户端看到的是502 或者context deadline exceeded。平均耗时很好看——30ms;P99 却时不时冲到 3 秒以上。
CPU、GC、数据库全都正常,慢查询日志里一条相关记录都没有。最后问题出在一个谁都没改过的地方:http.Client用的是默认连接池。
这篇文章把 net/http 连接池最容易咬人的 3 类坑和必须调的 4 个参数讲清楚,附一份我在本机跑的实测数据(200 请求 / 50 并发,HTTP/1.1)。
一、先看实测:默认配置和调过的配置差多少
我在本机起了一个极简 echo 服务,用不同的 Transport 配置各打 200 次请求(并发 50),统计新建 TCP 连接的次数:
| 场景 | 新建 TCP 连接数 | 耗时 |
|---|---|---|
| 默认(MaxIdleConnsPerHost=2)+ 读完 body | 94 | 16ms |
| PerHost=50 + 读完 body | 56 | 10ms |
| PerHost=50 +不读 body 就 Close | 200 | 25ms |
第三行是重点:200 次请求建了 200 条连接,一条都没复用。而前两行的差别只有MaxIdleConnsPerHost这一个参数。
本机 echo 服务的连接成本几乎为零,所以耗时差距只有几毫秒。放到真实环境里——跨机房、带 TLS 握手、请求体几 MB——每多建一条连接就是一次完整的 TCP 三次握手 + TLS 握手,几十毫秒起步,而且这些开销只出现在 P99 上,平均值完全看不出来。
二、Go 的连接池复用了什么
先建立一个正确的心智模型,不然后面的参数全是死记硬背。
http.Client └── http.Transport(连接池真正住在它这里) ├── idleConn[host] ← 空闲连接,按 host 分桶 │ 每条都是一个 keep-alive 的 TCP 连接 ├── 一个请求来了:先找 idleConn[host] 里有没有能用的 │ ├── 有 → 直接复用(零握手) │ └── 没有 → Dial 建新连接 → 用完放回(如果还能放) └── 放回条件:读完了响应体 + 池子没满 + 连接仍然健康两个容易记错的点:
- 连接池在 Transport 上,不在 Client 上。每次
&http.Client{}用默认 Transport 等于共享同一个池;但如果你写http.Client{Transport: &http.Transport{...}},那就是一个全新的池。池子建太多 = 连接数翻倍,这是很常见的资源放大来源。 - 连接能不能放回池子,取决于你把响应体读完了没有。没读完就
Close(),Go 会直接丢弃这条连接(因为它没法保证连接处于干净状态)。这就是上面第三行 200 次新建连接的原因。
三、3 类真实踩坑
坑 1:Body 没读完就 Close,连接直接报废
这是最常见、也最隐蔽的一个。典型代码长这样:
// ❌ 错误示范:只读了状态码就 Close,body 里还有数据没读resp,err:=client.Get(url)iferr!=nil{returnerr}deferresp.Body.Close()// 此时 body 没读完 → 连接不会被复用ifresp.StatusCode==http.StatusOK{// 只用了状态码,body 一个字节都没碰returnnil}只要服务端返回了响应体(哪怕只有几十字节),这条连接就会被丢弃。正确做法是把 body 读完(或显式丢弃)再 Close:
// ✅ 正确:哪怕不用 body,也要把它读干净resp,err:=client.Get(url)iferr!=nil{returnerr}deferresp.Body.Close()// 不需要内容时:丢弃即可,这样连接才能回到池子里_,_=io.Copy(io.Discard,resp.Body)判断自己有没有踩这个坑:看服务的TIME_WAIT数量,或者直接看实测里第三行那种「请求数 ≈ 新建连接数」的比例。
坑 2:只设了一个Client.Timeout,超时根本没分层
http.Client.Timeout是整个请求的 deadline,从发起算到响应体读完。它管不住这些情况:
- 服务端接受连接后迟迟不发响应头(慢查询、死循环、下游阻塞)→ 你会一直等到总超时才失败,期间连接被占住;
- TLS 握手卡住 → 同样要等总超时;
- 连接池排队等连接的时间也算在里面,最后报错时你分不清卡在哪一步。
超时应该分层设置(第六节给完整模板):
| 阶段 | 参数 | 说明 |
|---|---|---|
| 建连 | Dialer.Timeout | TCP 握手超时,内网 1~3 秒足够 |
| TLS | TLSHandshakeTimeout | 1~3 秒 |
| 等响应头 | ResponseHeaderTimeout | ⭐ 最容易被漏掉的一个,等于「服务端多久必须开始回答」 |
| 整体 | Client.Timeout | 兜底 deadline |
坑 3:池子容量和并发不匹配,连接被丢弃或堆积
MaxIdleConnsPerHost的默认值是2(http.DefaultTransport里是MaxIdleConns=100、MaxIdleConnsPerHost=2)。
这意味着:并发 50 个请求打到同一个域名时,只有 2 条连接能被留下来复用,剩下 48 条用完即弃——然后下一波请求再重新建。表现出来就是实测第一行的94 次新建连接,放到生产上就是不断的握手开销和TIME_WAIT堆积。
反过来调得太大也不好:每条空闲连接都占着对端的一个 socket,池子开到几千会让对端(以及中间的 LB)连接数暴涨。经验值:MaxIdleConnsPerHost≥ 稳态并发数,且不超过对端能承受的量级。
还有个容易被忽略的:IdleConnTimeout(默认 90 秒)。如果你的调用是低频的(比如每 2 分钟一次),空闲连接在 90 秒时就被关掉了,下一次还是得重新握手——这种场景要么调大这个值,要么接受它。
另一个方向的坑是CLOSE_WAIT 堆积:对端已经关了连接,我方应用层没关 socket,连接会停在CLOSE_WAIT。在 Go 里这通常不是 Transport 的问题,而是你自己管理的长连接/裸 socket 忘了关,或者用了反向代理之类的组件却没配好。
四、4 个必须调的参数
| 参数 | 默认值 | 建议值 | 不调会怎样 |
|---|---|---|---|
MaxIdleConns | 100 | 按总并发量设,200~500 | 全局空闲连接不够,跨多域名时被挤掉 |
MaxIdleConnsPerHost | 2 | ≥ 稳态并发(如 50~100) | 单域名下几乎不复用,P99 抖动 |
IdleConnTimeout | 90s | 高频调用保持 90s;低频调用可调大 | 低频调用每次都重新握手 |
ResponseHeaderTimeout | 0(不限) | 按服务端 P99 设,如 5s | 服务端假装活着时,连接被长时间占住 |
补充两个:
MaxConnsPerHost:单 host 的总连接数上限(含活跃)。想保护下游时设它,超出的请求会排队而不是无限建连。ForceAttemptHTTP2:用自定义 Transport 且没有 TLS 配置时,默认不会启用 HTTP/2。走 HTTP/2 网关时要注意这一点。
五、命令清单
| 命令 | 作用 | 看什么 |
|---|---|---|
ss -tan state time-wait | wc -l | 统计 TIME_WAIT | 几千以上就要警惕短连接风暴 |
ss -tan state close-wait | wc -l | 统计 CLOSE_WAIT | 堆积说明应用层没关 socket |
ss -s | 连接总数概览 | 一眼看总量是否异常 |
ss -tanp | grep <目标端口> | 看与下游的连接 | 验证是否复用(数量是否稳定) |
netstat -an | awk '/TIME_WAIT/{print $5}' | sort | uniq -c | sort -rn | head | 按对端聚合 | 定位是哪个下游在产生短连接 |
curl -w "%{time_connect} %{time_starttransfer} %{time_total}\n" -o /dev/null -s <url> | 拆握手/首字节/总耗时 | time_connect大 = 在建连上浪费 |
curl -v <url> 2>&1 | grep -i "re-using|connected" | 验证复用 | 复用会打印Re-using existing connection |
tcpdump -i any -nn host <ip> and port 443 | 抓握手包 | 看 SYN 的频率 |
GODEBUG=http2debug=2 ./app | HTTP/2 调试 | 判断是否真的走了 h2 |
go test -bench . -benchmem -cpuprofile cpu.out | 压测并采样 | 配合 pprof 一起看 |
六、配置模板(可直接抄)
packagemainimport("context""io""log""net""net/http""time")// newTransport 返回一个生产可用的 TransportfuncnewTransport()*http.Transport{return&http.Transport{Proxy:http.ProxyFromEnvironment,// ① 建连与保活DialContext:(&net.Dialer{Timeout:3*time.Second,// TCP 握手超时KeepAlive:30*time.Second,// TCP keepalive 探测间隔}).DialContext,// ② 池容量:按稳态并发来配,不要照抄MaxIdleConns:200,// 全局空闲连接MaxIdleConnsPerHost:100,// ⭐ 单 host 空闲连接,默认是 2MaxConnsPerHost:200,// 单 host 总连接上限(含活跃),保护下游IdleConnTimeout:90*time.Second,// ③ 分层超时TLSHandshakeTimeout:3*time.Second,ResponseHeaderTimeout:5*time.Second,// ⭐ 服务端必须在这个时间内给出响应头ExpectContinueTimeout:1*time.Second,}}// newClient 组装一个带整体兜底超时的 ClientfuncnewClient()*http.Client{return&http.Client{Transport:newTransport(),Timeout:15*time.Second,// 整体兜底:从发起到 body 读完}}// getDiscard 正确地读完并丢弃响应体,保证连接回到池子里funcgetDiscard(ctx context.Context,client*http.Client,urlstring)(int,error){req,err:=http.NewRequestWithContext(ctx,http.MethodGet,url,nil)iferr!=nil{return0,err}resp,err:=client.Do(req)iferr!=nil{return0,err}deferresp.Body.Close()// ⭐ 关键:不管用不用 body,都要读完,否则连接不会复用if_,err:=io.Copy(io.Discard,resp.Body);err!=nil{returnresp.StatusCode,err}returnresp.StatusCode,nil}funcmain(){client:=newClient()// 全局复用一份,池子也只有这一份fori:=0;i<100;i++{ctx,cancel:=context.WithTimeout(context.Background(),10*time.Second)code,err:=getDiscard(ctx,client,"https://example.com/api")cancel()iferr!=nil{log.Printf("第 %d 次失败: %v",i,err)continue}_=code}}三条使用纪律,比参数本身更重要:
http.Client全局复用一份(var client = newClient()),不要每次请求&http.Client{};- 每次请求都要带
context.WithTimeout,并且cancel()一定要执行(用defer cancel()); - 不管用不用响应体,都要读完。
七、4 步排查顺序
第 1 步:确认超时发生在哪一段。
用curl -w "%{time_connect} %{time_starttransfer} %{time_total}"打一次:
time_connect特别大 → 建连慢(DNS/TCP/TLS),回到连接池问题;time_starttransfer大、而time_connect正常 → 服务端处理慢,不是客户端的锅;- 两个都正常但偶发超时 → 大概率是排队等连接,或者瞬时的 GC/调度抖动。
第 2 步:看连接是不是真的复用。ss -tanp | grep <下游端口>观察连接数是否稳定在一个小范围;如果跟着 QPS 线性增长,就是没复用。也可以用curl -v看有没有Re-using existing connection。
第 3 步:查TIME_WAIT/CLOSE_WAIT。
- TIME_WAIT 多 → 短连接风暴:检查
MaxIdleConnsPerHost和「有没有读完 body」; - CLOSE_WAIT 多 → 应用层没关连接:查自己管理的裸连接、反向代理配置;
- 两者都多 → 通常是上游在疯狂重连,配合第 1 步一起看。
第 4 步:给超时分层,再压测验证。
按第六节模板设置 4 个超时,然后用真实并发压一轮,记录 P99。重点看超时错误的类型分布:分层之后,你应该能清楚区分「连接超时」「等响应头超时」「整体超时」三类,而不是清一色的context deadline exceeded。
八、总结
- 默认
MaxIdleConnsPerHost = 2是很多偶发超时的元凶:并发一上来,绝大部分请求都要重新握手,开销只体现在 P99 上。实测里 200 请求 / 50 并发,默认配置新建了 94 条连接,调到 50 后降到 56 条。 Body不读完就Close(),连接几乎不会被复用(实测:200 请求建了 200 条连接)。哪怕不用响应内容,也要io.Copy(io.Discard, resp.Body)。- 超时必须分层:
Dialer.Timeout/TLSHandshakeTimeout/ResponseHeaderTimeout/Client.Timeout各管一段,只设一个总超时会让故障定位变成猜谜。 http.Client要全局复用,每个请求 new 一个等于每次开一个新池子,连接数直接放大。- 排查顺序:先
curl -w分段定位 → 再看连接是否复用 → 然后查TIME_WAIT/CLOSE_WAIT→ 最后改参数并压测验证。别一上来就拍脑袋把连接池调大。
本文实测数据来自本机 echo 服务(Go 1.25,200 请求 / 50 并发,HTTP/1.1)。真实环境下握手成本更高,差距会比这几毫秒显著得多。