先看一个用 http.Get 请求 url 并返回 HTTP 状态码的函数。在 GODEBUG=http2client=0 强制使用 HTTP/1 时,作者的机器上用 Go 语言(Go) 1.26 对 index.golang.org 调用它 100 次约需 710 毫秒,Go 1.27 上同样程序约需 150 毫秒,快了近 5 倍。要弄清原因,得看新建 HTTP 连接的过程:Go 的 net/http 包支持 HTTP/1.0、HTTP/1.1 和 HTTP/2,它们都跑在 TCP 上,访问 HTTPS 时通常还要做 TLS;新连接先花 1 个 RTT 完成 TCP 握手,再花 2 个 RTT 完成 TLS 握手。
正文
看下面这个函数:
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)下,在我的机器上用 Go 1.26 对 index.golang.org 调用这个函数 100 次大约需要 710 毫秒。而在 Go 1.27 上,同样的程序只需要大约 150 毫秒。
这几乎快了 5 倍!为什么会有这么大的性能差异?
建立 HTTP 连接的成本
要理解这个差异,先看看发出 HTTP 请求时网络上发生了什么。
Go 的 net/http 包支持 HTTP/1.0、HTTP/1.1 和 HTTP/2。这三种协议都运行在 TCP 之上,与 HTTPS 端点通信时通常还要加上 TLS。通过新连接发出请求的过程大致如下:
- TCP 握手(1 RTT):客户端发送 SYN 包,服务器回复 SYN-ACK,客户端再回复 ACK。因为客户端可以在最后一个 ACK 的同时发送第一条 TLS 消息,所以建立 TCP 连接需要 1 个往返时间(RTT)。
- TLS 握手(2 RTT):接着进行 TLS 握手,交换加密密钥并验证服务器身份;使用 TLS 1.2 时,这需要 2 个 RTT。
- HTTP 请求:密钥交换完成后,HTTP 请求终于可以在总共 3 个 RTT 之后发送出去。
这些往返(加上 TLS 握手消耗的一些 CPU 时间)累积得很快。在跨区域通信中,单向延迟经常达到或超过上图中显示的 50 毫秒(100 毫秒 RTT)。与仅仅建立连接就花掉的 300 毫秒相比,HTTP 请求本身的服务成本可能很低——在带宽充足或 HTTP 响应很小的情况下,只需要 1 个多 RTT。
即使在更有利的条件下,打开新连接也可能很昂贵。我们开头的基准测试查询的是 index.golang.org,它在全球都有部署,并支持 TLS 1.3。这意味着延迟相对较低,TLS 握手实际上可以在 1 个 RTT 内完成。即便如此,性能差异仍然非常明显。
连接复用
为了避免每次请求都付出这个建立连接的成本,HTTP 客户端和服务器会尽可能地在多个请求之间复用现有连接。具体机制因 HTTP 版本而异:
- HTTP/1.0:虽然不是官方规范的一部分,但客户端和服务器通常通过发送
Connection: keep-alive头部来选择启用连接复用。复用会一直持续,直到某一端不再发送该头部,或者发送Connection: close头部。 - HTTP/1.1:连接默认是持久的,可以用于后续的请求和响应,除非某一方通过发送
Connection: close头部另行声明。 - HTTP/2:多个请求和响应可以通过单条 TCP 连接多路复用。
连接复用失败的情况
在 HTTP/1(包括 HTTP/1.0 和 HTTP/1.1)中,同一连接上的多个请求和响应在网络上是按顺序依次发送的。如果向 index.golang.org 通过同一 TCP 连接发送两个 GET 请求,响应会作为单个字节流到达:
如上图所示,net/http.Get 会从字节流中读取并解析状态行和头部,填充到 net/http.Response 中,然后立即返回。响应体仍未读取,仍留在线上,只有在调用者从 net/http.Response.Body 读取时才会被消费。
因为响应是在同一个流上背靠背发送的,net/http 只有在当前响应体被读到最末尾(由 Content-Length 或 Transfer-Encoding 头部确定)之后,才能找到下一个响应的开头。
现在回头看我们的示例程序,其中我们从来没有读取 resp.Body:
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 }
在 Go 1.26 及之前,当调用 resp.Body.Close 时,net/http 发现还有一个未读取的响应体。它意识到无法复用该连接,于是放弃现有连接,并为下一个请求创建新连接,导致它反复承受建立新连接的成本。
(HTTP/2 没有这个问题。因为它在单条连接上多路复用帧流,而不是严格地背靠背发送响应,所以某个流上未读取的响应体不会阻止其他请求共享该连接。)
连接复用失败带来的影响
HTTP/1 连接复用的这个微妙之处,可能会在一些看似无害的代码中导致无声的性能下降。
就像我们开头的例子一样,客户端可能只关心 API 响应的状态码。然而,它正在通信的 API 端点可能会发送提供更多详细信息的响应体,比如 {"success": true, "message": "update processed"},而客户端最终并不会使用这些信息。不幸的是,这会导致连接复用失败。
即使是确实读取了响应体的程序,如果它只读到离响应体末尾差一点点的地方,也会遇到这个问题。例如,encoding/json.Decoder 一看到 JSON 对象的结束 } 就会停止读取。如果服务器发送的 JSON 响应以换行符结尾,解码 JSON 对象就会把换行符留作未读状态。在这个换行符被消费之前,连接无法被新请求复用。因此,一个健康的连接可能仅仅因为差一个字节就能复用,却被放弃了。
手动解决方案
未读取响应体的问题是一个长期存在的棘手边缘情况。多年来,许多 Go 开发者想出了自己的变通方法。最常见的做法是在关闭响应体之前先把它抽干:
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) // 在关闭前抽干响应体 body.Close() }
虽然这对小响应有效,但如果未读取的响应体很大,建立新的 TCP 连接可能比下载并丢弃剩余响应体更快。更好的做法是限制我们愿意抽干的字节数:
func drainAndCloseBody(body io.ReadCloser) { io.CopyN(io.Discard, body, 256<<10) // 最多只抽干 256 KB body.Close() }
然而,服务器可能会慢慢涓流发送响应体,所以我们还需要限制抽干所花费的时间:
func drainAndCloseBody(body io.ReadCloser) { drainDone := make(chan struct{}, 1) go func() { io.CopyN(io.Discard, body, 256<<10) // 最多只抽干 256 KB drainDone <- struct{}{} }() // 尝试抽干响应体最多只花 50 毫秒 select { case <-drainDone: case <-time.After(50 * time.Millisecond): // 如果达到 50 毫秒超时,body.Close 会运行并中断 io.CopyN,防止 goroutine 泄漏 } body.Close() }
当然,我们也不希望在关闭响应体时阻塞长达 50 毫秒。相反,我们可以在后台异步抽干响应体,让它与后续请求竞争:
func drainAndCloseBody(body io.ReadCloser) { drainDone := make(chan struct{}, 1) go func() { io.CopyN(io.Discard, body, 256<<10) // 最多只抽干 256 KB drainDone <- struct{}{} }() go func() { // 尝试抽干响应体最多只花 50 毫秒 select { case <-drainDone: // 如果抽干能在发出新请求前完成,连接就会被复用;否则会创建新连接, // 当前连接会被放入空闲连接池,将来可能用于另一个请求。 case <-time.After(50 * time.Millisecond): // 如果达到 50 毫秒超时,body.Close 会运行并中断 io.CopyN,防止 goroutine 泄漏 } body.Close() }() }
正如我们所见,安全地抽干响应体出人意料地棘手且麻烦。更糟糕的是,随着抽干响应体的做法传播开来,一些细微之处经常丢失。这导致了一些误解,比如试图为 HTTP/2 连接抽干响应体,而这样做其实是不必要的。
现状与结论
从 Go 1.27 开始,net/http.Transport 会自动完成我们上面代码中实现的所有操作:它会异步抽干未读取的 HTTP/1 响应体,最多花 50 毫秒或 256 KB。它还实现了额外的优化;例如,当响应包含 Connection: close 头部时,抽干响应体注定是白费功夫,因此它会跳过这一步。请注意,随着未来发现其他可能的增强功能,这一行为在未来的 Go 版本中可能会演进。
作为通用的最佳实践,我们现在建议用户在不再需要 HTTP 响应体时直接关闭它们,就像我们最初的例子那样:
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 }
Go 追求的就是简洁。让你的代码保持干净简单,让 net/http 替你处理这些复杂问题。
评论
讨论气氛是认可与担忧并存:不少人接受用最多 50ms 排空响应体来换取连接复用,认为这避免了被大量取消请求拖垮;但很多人也质疑这是 net/http 中未充分文档化的隐式行为变更,并担心它拿走了过去直接关闭连接、跳过下载的能力。分歧主轴集中在:为了复用而短暂排空 body 是否值得、没有强制关闭连接的逃生舱该怎么办,以及 HTTP/2、反向代理等真实环境下会出什么问题。
认为有限排空换取连接复用是合理折衷
- 以前关闭 Response.Body 可以完全跳过下载,现在行为变了;但新实现只最多排空很短时间(约 50ms),即便网络跟不上被取消的 body,也只会多等一点流量,不会被拖垮。有人因此能退休自己写的排空辅助函数。(认可度:较多人) > 以前关闭 body 就能完全跳过下载,但现在最多只会多读 50 毫秒的流量,这个折衷我喜欢。(@nasretdinov)
认为这是未文档化的隐式行为变更,且缺少强制关闭连接的手段
- Close 的这种行为很反直觉,而且 net/http 文档里似乎没有写清楚,很多人把它当成用 Go 写 HTTP 客户端时的常见问题。真正的痛点在于:如果调用方就是想立刻丢弃连接、根本不打算复用,却仍要无谓地排空 socket,这不合理;有人问能否伪造 Connection: close,或者绕过响应体去拿底层 socket。(认可度:较多人) > Close 的行为这么出人意料,应该在 net/http 文档里写清楚;如果我想直接掐掉连接、不打算复用,难道只能伪造头部或去挖底层 socket 吗?(@jerf)
担心反向代理、nginx 与浏览器下出现 5xx 或状态码 0
- 文档末尾说 Connection: close 时不会排空 body,但这在反向代理后是否仍成立存疑。有人遇到 nginx 在服务端不消费 body 时仍报输入输出错误,并把 5xx 抛给客户端;Firefox 的 XHR 甚至返回状态码 0。相比之下,Chrome 和 curl 早关闭且不消费 body 的行为符合预期。(认可度:少数人) > 我在反向代理后遇到 nginx 仍报输入输出错误并把 5xx 抛给客户端,Firefox 的 XHR 甚至返回状态码 0。(@prussian)
认为基准强制 HTTP/1 的做法让人困惑,HTTP/2 是否生效不明
- 示例基准用 GODEBUG=http2client=0 强制 HTTP/1,100 次调用从约 710ms 降到约 150ms;有人问这是不是意味着必须禁用 HTTP/2 才有效,还是反而说明 HTTP/2 根本没被用上。也有人直接质疑 2026 年还有什么理由继续用 HTTP/1。(认可度:少数人) > 示例强制 GODEBUG=http2client=0 后快了很多,这到底是要我禁用 HTTP/2,还是说明 HTTP/2 根本没被用上?(@donatj)
认为这是 Go 团队在“保持简单”与真实 HTTP/2 场景之间妥协的结果
- 有人觉得好笑又同情:Go 团队原本一直不承诺自动连接复用,直到内部折腾出几版越来越长的排空函数,从 drainAndCloseBody 到 drainAndCloseBodyWithTimer,再到 drainAsyncAndCloseBodyWithTimer,直到有人在 Google 把它用到 HTTP/2 连接上才像终于划下界限。第三版虽然丑,但在这种常见却棘手的边缘场景里说得通,也体现了 Go 坚持简单的使命有多难维持。(认可度:少数人) > 直到有人在 Google 把它用到 HTTP/2 连接上,团队才终于划下界限吧。(@gandreani)
有价值的补充
- 用 json.Decoder 读完 body 后,若响应末尾有换行,仍可能导致连接无法复用:Decoder 只按固定字节数(约 512)读取,若结构正好填满这些字节,结尾换行会残留;建议在 Decode 后调用 Decoder.More 排掉空白,避免连接被浪费。