Go 1.27 让 net/http 自动 drain:HTTP/1 连接复用从 710ms 到 150ms
这段代码几乎每个 Go 程序里都有:http.Get(url) 拿到 resp, err,检查 err,defer resp.Body.Close(),然后只取 resp.StatusCode 返回。body 从头到尾没人读。
用 GODEBUG=http2client=0 强制走 HTTP/1,对 index.golang.org 连续调用 100 次:Go 1.26 上约 710ms,Go 1.27 上约 150ms。代码没改,快出来的部分是连接复用。
新建一条连接要 3 个 RTT
Go 的 net/http 支持 HTTP/1.0、HTTP/1.1、HTTP/2,都跑在 TCP 上,对 HTTPS 端点通常还跑在 TLS 上。新建连接的成本是:
- TCP 握手 1 RTT(客户端可以在最后一个 ACK 里带上第一条 TLS 消息);
- TLS 握手 2 RTT(TLS 1.2);
- 之后才能发第一个 HTTP 请求。
合计 3 RTT。跨区通信单向延迟常见 50ms,也就是 RTT 100ms,那么连接建立要 300ms,而请求本身可能只占略多于 1 RTT。即便条件更好——index.golang.org 全球分布且支持 TLS 1.3,TLS 握手只需 1 RTT——差异依然明显。
复用条件与失败点
HTTP/1.0 里 Connection: keep-alive 是非规范但常见的做法;HTTP/1.1 默认持久连接,除非任一端发 Connection: close;HTTP/2 在单条 TCP 连接上多路复用。
关键是 HTTP/1 的线上行为:多个请求/响应在同一条连接上按序背靠背传输。http.Get 读取并解析状态行与头部、填好 net/http.Response 后就返回了,响应体还留在连接上没读,只有调用方去读 Response.Body 时才被消费。由于响应是背靠背发的,net/http 只有把当前响应体读到末尾(末尾由 Content-Length 或 Transfer-Encoding 声明决定)之后,才能找到下一个响应的起点。
所以上面那个函数从不读 resp.Body 时,到 Go 1.26 为止,resp.Body.Close 会发现有未读响应体,判断连接无法复用,直接丢弃现有连接,由后续请求重新建连,把 3 RTT 反复付一遍。
HTTP/2 没有这个问题,因为它在单连接上多路复用帧流,而不是严格背靠背发送响应,某个流上未读的响应体不会挡住其他请求共享连接。这也是「HTTP/2 不需要 drain」的由来——后来它被误读成了对所有协议都适用。
只差一个换行字节
这类退化常常写在看起来完全无害的代码里。客户端只关心状态码,端点却返回 {"success": true, "message": "update processed"} 这类用不上的详细信息,复用就失败了。
读了 body 也可能出问题:encoding/json.Decoder 一看到 JSON 对象的结尾 } 就停止读取。如果服务端发送的 JSON 以换行结尾,对象解析完之后那个换行仍未读,连接在消费掉这个换行之前无法复用于新请求。一条本来健康的连接就这样被丢弃,只差一个字节。
手写 drain 的四步演进
未读响应体是个长期存在的锐角,多年来的做法是关闭前先抽干:
func drainAndCloseBody(body io.ReadCloser) {
io.Copy(io.Discard, body)
body.Close()
}
响应小的时候可行。但未读体很大时,老老实实下载并丢弃剩余内容,可能比新建一条 TCP 连接还慢,所以要限制愿意抽干的字节数:
io.CopyN(io.Discard, body, 256<<10) // 最多 256KB
加这个上限是因为:抽干只是为了让连接可复用,超过 256KB 的剩余内容,直接换新连接的代价更低。
但服务端也可能缓慢滴流式发送响应体,字节数限制管不住时间,所以还要限制抽干耗时:起一个 goroutine 做 CopyN,select 等它完成或等 50ms 超时;超时后 body.Close 会中断 io.CopyN,避免 goroutine 泄漏。
最后一步:关闭响应体时也不该阻塞 50ms,于是把抽干放到后台异步做,让它与后续请求竞速——如果在下一个请求发出前抽干完了,连接就能复用;否则新建连接,当前连接进入空闲连接池,留给将来某个请求。
可见安全地抽干响应体出奇地棘手,而且随着这套做法传播,细节经常被丢掉,才产生了「对 HTTP/2 连接也去 drain」这种不必要的误解。
Go 1.27 的行为
从 Go 1.27 开始,net/http.Transport 自动完成上面这些事:异步抽干未读的 HTTP/1 响应体,最长 50ms 或最多 256KB。它还做了额外优化,比如响应带 Connection: close 时抽干必然是白费,就直接跳过。该行为在未来版本可能继续演进。
通用最佳实践因此退回最初的样子:不再需要响应体时直接关闭即可:
defer resp.Body.Close()
复杂性交给 net/http。
release notes 里的反向风险
Go 1.27 release notes 的 net/http 段补充了另一面:对多数程序这是 no-op 或性能提升,但少数情况下,如果程序本来就不从连接复用获益,又一直放任过多空闲连接滞留,可能出现性能退化。典型情形是把 Transport.MaxIdleConns 设为 0,或对不同请求使用不同 Client,从而绕过 MaxIdleConns 限制。
这种情况下可以设 Transport.DisableKeepAlives = true 关掉复用,但这类退化通常说明 Transport/Client 的配置或用法本来就不对,值得先查清楚,而不是拿一个开关盖住。
同一段里还有两条 HTTP/2 server 的变化:接受 RFC 9218 客户端优先级信号,Server.DisableClientPriority=true 可恢复旧的轮询行为;新增 Server.MaxHeaderValueCount 控制可接受的 header value 数量,未设置时用 DefaultMaxHeaderValueCount。
该不该删手写 drain
- 用 Go 1.27 及以上:手写的 drain 辅助函数可以删掉,
defer resp.Body.Close()照旧必须写——关闭本身没被省略,只是关闭时的抽干由 Transport 接管。 - 用低于 1.27 的版本,并且在做高频短请求的服务:才值得手写限时限量(256KB / 50ms)的 drain。低频、大 body 的调用直接 Close 就好。
参考资料
- Better HTTP/1 connection reuse — The Go Programming Language(Nicholas Husin、Damien Neil,2026 年 10 月 14 日)
- Go 1.27 Release Notes
- go.dev/issue/60240:讨论「到底什么时候需要 drain & close response body」,长期缺乏权威答案。