Go 1.27 的 Transport 自动排空未读响应体:getStatus 从 710ms 降到 150ms
getStatus 只取状态码:
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
}
它向 url 发 GET,返回 HTTP 状态码。用 HTTP/1(GODEBUG=http2client=0)对 index.golang.org 调用 100 次,Go 1.26 上约 710ms,Go 1.27 上约 150ms。差异来自 net/http.Transport 对未读 HTTP/1 响应体的处理。背景见 Better HTTP/1 connection reuse。
三种 HTTP 版本的连接复用规则
- HTTP/1.0:客户端和服务端通常通过
Connection: keep-alive显式开启复用。任一端省略该 header,或发送Connection: close,复用结束。 - HTTP/1.1:连接默认持久,除非任一侧发送
Connection: close。 - HTTP/2:多个请求和响应可以在一条 TCP 连接上多路复用。
未读 body 为什么让连接无法复用
HTTP/1 的多个请求和响应在同一条连接上按顺序传输。net/http.Get 从字节流里读取并解析状态行和 headers,填充 net/http.Response,然后立即返回。响应体仍留在连接上,只有调用方从 net/http.Response.Body 读取时才会被消费。
响应在同一个流上背靠背发送,net/http 必须把当前响应体读到真正结尾,才能找到下一个响应的起点;这个结尾由 Content-Length 或 Transfer-Encoding header 决定。
到 Go 1.26 为止,调用 resp.Body.Close 时,如果 net/http 发现还有未读响应体,就知道连接不能复用,于是丢弃现有连接,为后续请求新建连接。每次新建连接都要重新付出建连成本。
HTTP/2 没有这个问题。它把帧流多路复用到一条连接上,而不是严格背靠背发送响应,所以某个流上的未读 body 不会阻止其他请求共享连接。
两个让连接被丢弃的写法
一种客户端只关心 API 的状态码。但端点可能返回更详细、最终没人使用的 body,例如 {"success": true, "message": "update processed"}。只要 body 没读完,连接复用就会失败。
另一种是程序确实读了 body,但停在结尾前一点。encoding/json.Decoder 看到 JSON 对象的闭括号 } 就停止读取。如果服务端返回的 JSON 以换行结尾,解码 JSON 对象后换行仍未读。连接要等到这个换行被消费后才能复用。一条健康的连接可能只差一个字节就被丢弃。
手工排空:从无界 io.Copy 到异步 50ms
最常见的修法是在关闭前把响应体排空:
func getStatus(url string) (int, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
drainAndCloseBody(resp.Body)
return resp.StatusCode, nil
}
func drainAndCloseBody(body io.ReadCloser) {
io.Copy(io.Discard, body) // Drain the response body before closing it.
body.Close()
}
响应小时这没问题。未读 body 很大时,新建 TCP 连接可能比下载并丢弃剩余 body 更快。更好的做法是限制愿意排空的字节数:
func drainAndCloseBody(body io.ReadCloser) {
io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
body.Close()
}
服务端也可能慢慢吐响应体,所以还要限制排空时间:
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
drainDone <- struct{}{}
}()
// Only spend at most 50 ms trying to drain the response body.
select {
case <-drainDone:
case <-time.After(50 * time.Millisecond):
// If we reach the 50 ms timeout, body.Close will run and interrupt the
// io.CopyN, preventing goroutine leaks.
}
body.Close()
}
关闭响应体时也不该阻塞最多 50ms。可以改为后台异步排空,让它和后续请求竞争:
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
drainDone <- struct{}{}
}()
go func() {
// Only spend at most 50 ms trying to drain the response body.
select {
case <-drainDone:
// If the drain can finish before a new request is sent, the
// connection will be reused; otherwise, a new connection is
// created, and the current connection will be put in a pool of
// idle connections, potentially being reused for another future
// request.
case <-time.After(50 * time.Millisecond):
// If we reach the 50 ms timeout, body.Close will run and interrupt
// the io.CopyN, preventing goroutine leaks.
}
body.Close()
}()
}
安全地排空响应体比看上去麻烦。排空做法扩散后,一些细节也容易丢,例如给 HTTP/2 连接排空——这没有必要。
Go 1.27 的行为
从 Go 1.27 开始,net/http.Transport 自动完成上面这些逻辑:异步排空未读的 HTTP/1 响应体,最多 50ms 或 256KB。它还做了额外优化,例如响应带有 Connection: close 时,排空注定是白费,直接跳过。这个行为在未来版本中可能继续演化。
改动来自 issue 77370(golang/go#77370)。限制是两个常量:
const maxPostCloseReadBytes = 256 << 10
const maxPostCloseReadTime = 50 * time.Millisecond
在未读到 EOF 时调用 Close,连接的 read loop 先检查 alive && resp.ContentLength <= maxPostCloseReadBytes。随后让你的 Close 返回,并启动 goroutine,最多向 io.Discard 复制 256 KiB + 1 字节,同时最多等 50ms。只有复制在计时器触发前命中 EOF,连接才会复用。其他情况——字节太多、太慢、读错误——都会关闭连接,和 Go 1.26 每次提前 Close 的表现一样。
chunked 响应的 ContentLength 是 -1,能通过检查。chunked body 和普通 body 一样排空,256 KiB 限制针对的是仍未读的部分。响应本身带 Connection: close 时,alive 已经是 false,不会排空。transport 替你解压 gzip 时,排空读取的是 body.body,也就是 gzip reader 下面的原始流,所以限制统计的是线上的压缩字节。
实测矩阵:Go 1.27.2 与 Go 1.26.9
数据来自 importstatic.com 的实测(go-drain-http-response-body)。测试环境 darwin/arm64,httptest server 走 loopback。
测试程序起一个 plain HTTP/1.1 的 loopback server,每个用例在新的 Transport 上发 20 个顺序请求,统计服务端的 http.StateNew。一条连接意味着 20 个请求全部复用;20 条意味着一个都没复用。请求之间 sleep 100ms。
每个用例的循环体:
resp, err := client.Do(req)
if err != nil { panic(err) }
// ... read nothing, part of the body, or decode JSON, depending on the case
t0 := time.Now()
resp.Body.Close()
if d := time.Since(t0); d > res.maxClose { res.maxClose = d }
cancel()
if gap > 0 { time.Sleep(gap) }
Go 1.27.2:
| 用例 | 连接数(/20) | 最慢 Close | 总耗时 |
|---|---|---|---|
| 4 KiB,未读 | 1 | 68µs | 2.049s |
JSON,json.Decoder | 1 | 1µs | 2.045s |
| JSON,换行晚 5ms | 1 | 57µs | 2.047s |
| chunked 64 KiB,未读 | 1 | 703µs | 2.036s |
| chunked 300 KiB,未读 | 20 | 189µs | 2.051s |
| 无限流,读 1 KiB | 20 | 144µs | 2.04s |
| 慢 body,间隔 30ms | 1 | 30µs | 2.041s |
| 慢 body,间隔 80ms | 20 | 18µs | 2.047s |
Connection: close | 20 | 110µs | 2.05s |
Go 1.26.9 上,表中每个用例都开了 20 条连接,只有两个 json.Decoder 已读到 EOF 的用例例外。
“慢 body”用例发送一个 2 KiB、带 Content-Length 的 body,分两半发送,中间暂停。30ms 暂停落在 50ms 窗口内,连接复用;80ms 时排空放弃。“无限流”handler 每 10ms 写 1 KiB,持续两秒,排空永远看不到 EOF,50ms 后关闭连接。每个用例的 Close 都远小于 1ms,因为调用 goroutine 不等排空完成。
Go 1.26.9 上,小 JSON body、同一 body 以 chunked 发送、100 KiB body,用 json.Decoder 解码并关闭时都复用了——decoder 最后一次 Read 把换行和 body 结尾一起读了进来。
背靠背请求的竞态
排空在连接自己的 goroutine 里、Close 返回后运行。如果发往同一 host 的下一个请求立刻开始,连接可能还在忙。Transport 不会等它,而是拨一条新连接。Go 1.27.2 上请求之间不加暂停:
| 用例 | 连接数(/20) | 最慢 Close | 总耗时 |
|---|---|---|---|
| 4 KiB,未读 | 2 | 30µs | 2ms |
| 慢 body,间隔 30ms | 20 | 134µs | 8ms |
| 4 KiB,先读到 EOF | 1 | 0s | 2ms |
| 慢 body,先读到 EOF | 1 | 1µs | 636ms |
4 KiB body 通常在下一次拨号完成前就排空,所以跨多次运行会看到 2 或 3 条连接。尾部晚到 30ms 的 body 永远来不及,每个请求都拨号。排空完成后,连接进入 idle pool;默认每个 host 只保留两个(DefaultMaxIdleConnsPerHost = 2),其余关闭。
自己读到 EOF 没有这个竞态。Read 返回 io.EOF 时,连接已经回到池里。慢速用例因此总耗时 636ms。
想主动断连的三种写法
resp.Body.Close() 从来不会自己关闭 TCP 连接。要丢弃连接而不是排空:
| 做法 | 效果 |
|---|---|
发送前设置 req.Close = true | 请求带 Connection: close,连接不复用 |
取消请求的 context,再 Close | read loop 看到取消,关闭连接 |
Transport.DisableKeepAlives = true | 该 transport 上所有请求都不复用;release notes 点名的 opt-out |
对一个 200 KiB body,Go 1.27 上普通 Close 会复用连接;上述三种写法在两个版本上都是 20 个请求 20 条连接。
结论
目标版本是 Go 1.27 及以后,普通 HTTP/1 请求直接 Close 即可,删掉调用点手写的 drainAndCloseBody。Transport 会异步排空,最多 50ms 或 256KB,遇到 Connection: close 会跳过。
如果代码还要在 Go 1.26 及更早版本运行,连接复用又重要,保留有界排空:256KB 加 50ms,不要用无界的 io.Copy。升级到 Go 1.27 后可以移除。
HTTP/2 不适用这套逻辑。它多路复用流,未读 body 不会阻塞其他请求共享连接,也不需要为 HTTP/2 加排空。想主动断连时,用 req.Close、取消 context 或 DisableKeepAlives,不要靠排空去关连接。