Go 1.27 的 HTTP/1 连接复用:不读完 resp.Body 也不会再丢连接,代价是 50ms/256KB 的后台排空
Go Blog 2026 年 10 月 14 日的文章《Better HTTP/1 connection reuse》,作者 Nicholas Husin 和 Damien Neil。原文:
基准:100 次请求 710ms → 150ms
先看这段代码:
func getStatus(url string) (int, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}
用 GODEBUG=http2client=0 强制走 HTTP/1,对 index.golang.org 调用 100 次:Go 1.26 上约 710ms,Go 1.27 上约 150ms,快了近 5 倍。
差的这几百毫秒从哪来
新建连接的代价在 HTTP 请求发出之前就已经付掉了:TCP 握手 1 个 RTT,TLS 握手 TLS 1.2 要 2 个 RTT、TLS 1.3 要 1 个 RTT。这些做完才能发第一个请求。
复用规则按版本不同:HTTP/1.0 需要 Connection: keep-alive 才复用;HTTP/1.1 默认持久连接,除非服务端回 Connection: close;HTTP/2 直接在一条连接上多路复用。
问题出在 HTTP/1 上——请求和响应在连接上是串行的。http.Get 解析完状态行和头部就返回了,响应 body 还躺在连接里没读。net/http 必须按 Content-Length 或 Transfer-Encoding 把当前 body 读到结尾,才能确定下一个响应从哪里开始。Go 1.26 及之前,resp.Body.Close() 时 body 没读完,net/http 就放弃这条连接,下次请求重新 dial。
两个典型中招场景
一是只看状态码。客户端根本不关心 body,但接口返回的是 {"success": true, "message": "update processed"},body 一个字节没读,连接直接作废。
二是用了 body reader 也躲不掉。encoding/json.Decoder 一旦看到闭合的 } 就停下——如果服务端在 JSON 后面补了个换行,这个换行就是未读的,连接只差一个字节没能复用。
手写 drain 的演进
社区方案是一路加约束堆出来的:
io.Copy(io.Discard, body)再Close,把 body 读完。- 换成
io.CopyN(io.Discard, body, 256<<10),给读的字节数封顶,避免超大 body 拖时间。 - 加 50ms 超时:
select监听time.After,超时后Close。Close会打断io.CopyN,所以不会漏 goroutine。 - 把排空扔进后台 goroutine,和后续请求竞争,这样
Close不必阻塞最多 50ms。
drain 本身很琐碎,细节容易传丢,比如有人连 HTTP/2 的 body 也去排空——多路复用下完全没这个必要。
Go 1.27 的做法
net/http.Transport 把这件事接管了:对未读的 HTTP/1 响应 body 异步排空,上限 50ms 或 256KB,谁先到算谁。响应头带 Connection: close 时跳过排空,因为这条连接本来就要关。官方说明该行为后续可能还会调整。
所以调用侧只剩一件事:老老实实 defer resp.Body.Close(),剩下的交给 net/http。
可操作的两条
- 删掉手写的 drain 逻辑,尤其是针对 HTTP/2 body 的那些。升级到 Go 1.27 后它们不但没用,还会和 Transport 的排空重复。
- 想验证效果就用
GODEBUG=http2client=0跑同一段基准,对比 Go 1.26 和 1.27 的耗时与连接建立次数。