代码 Go 1.27 的 Transport 自动排空未读响应体:getStatus 从 710ms 降到 150ms

2026-10-21 20:02:32

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,未读168µs2.049s
JSON,json.Decoder11µs2.045s
JSON,换行晚 5ms157µs2.047s
chunked 64 KiB,未读1703µs2.036s
chunked 300 KiB,未读20189µs2.051s
无限流,读 1 KiB20144µs2.04s
慢 body,间隔 30ms130µs2.041s
慢 body,间隔 80ms2018µs2.047s
Connection: close20110µs2.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,未读230µs2ms
慢 body,间隔 30ms20134µs8ms
4 KiB,先读到 EOF10s2ms
慢 body,先读到 EOF11µs636ms

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,再 Closeread 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,不要靠排空去关连接。

复制全文 生成海报 Go net http 性能优化 HTTP

推荐文章

程序员茄子在线接单