Go 团队近日发布了关于 HTTP/1 连接复用优化的技术说明,指出在 Go 1.27 版本中,通过改进 net/http 包对未读取响应体的处理机制,显著提升了 HTTP/1 请求的性能。根据官方基准测试,在禁用 HTTP/2(设置 GODEBUG=http2client=0)的情况下,向 index.golang.org 发起 100 次 GET 请求,Go 1.26 耗时约 710 毫秒,而 Go 1.27 仅需约 150 毫秒,性能提升近 5 倍。

HTTP 连接建立的开销
理解这一性能差异的关键在于分析建立新 HTTP 连接的成本。Go 的 net/http 包支持 HTTP/1.0、HTTP/1.1 和 HTTP/2。在与 HTTPS 端点通信时,这些协议均运行于 TCP 之上,通常还涉及 TLS 加密层。建立一个全新的连接主要包含以下阶段:
- TCP 握手:客户端发送 SYN 包,服务器回复 SYN-ACK,客户端再发送 ACK。由于客户端可在最后的 ACK 中携带首个 TLS 消息,此过程需消耗 1 个往返时间(RTT)。
- TLS 握手:交换加密密钥并验证服务器身份。使用 TLS 1.2 标准时,这一步骤需要额外的 2 个 RTT。
- HTTP 请求发送:完成上述握手后,HTTP 请求方可发出。总计需经历 3 个 RTT。
这些往返延迟加上 TLS 握手的 CPU 计算开销累积迅速。在跨区域网络环境中,单向延迟常达到或超过 50ms(即 100ms RTT)。仅建立连接就可能耗费约 300ms,相比之下,若带宽充足且响应数据较小,HTTP 请求本身的传输时间可能不足 1 个 RTT。即便在延迟较低且支持 TLS 1.3(握手仅需 1 RTT)的理想条件下,如访问全球分布的 index.golang.org,频繁新建连接带来的性能损耗依然显著。
连接复用机制与失效场景
为避免重复支付连接建立成本,HTTP 客户端和服务器倾向于复用现有连接。不同版本的复用机制如下:
- HTTP/1.0:非官方规范,但常通过
Connection: keep-alive头部启用复用,直至收到Connection: close或对方忽略该头部。 - HTTP/1.1:默认持久连接,除非任一方发送
Connection: close声明关闭。 - HTTP/2:支持在单个 TCP 连接上多路复用多个请求和响应流。
问题出现在 HTTP/1 的连接复用失效场景中。在 HTTP/1 中,同一连接上的请求和响应是串行发送的字节流。net/http.Get 函数解析完状态行和头部后即返回,此时响应体尚未被完全读取。只有当调用者从 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() 被调用时,系统检测到仍有未读的响应体数据。由于无法确定下一个响应的起始位置(依赖 Content-Length 或 Transfer-Encoding),当前连接被视为不可用而被丢弃,后续请求不得不重新建立连接,导致性能大幅下降。值得注意的是,HTTP/2 不受此影响,因为其多路复用特性允许一个流的未读数据不阻塞其他流共享连接。
手动优化方案的复杂性
这种隐蔽的性能陷阱曾困扰许多开发者。常见的解决方案是在关闭前“排空”(drain)响应体:
func drainAndCloseBody(body io.ReadCloser) {
io.Copy(io.Discard, body) // Drain the response body before closing it.
body.Close()
}
然而,对于大体积响应,下载并丢弃数据的时间可能超过新建连接的成本。因此,更优的策略是限制排空的字节数和时间:
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()
}
为了进一步降低延迟,可将排空操作异步化,使其与后续请求并行执行:
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 响应体,限制为最多 50 毫秒或 256 KB。同时,系统还会识别特定场景,例如当响应头包含 Connection: close 时,跳过排空步骤以避免无用功。随着技术发展,这一行为可能会在未来版本中进一步演进。
基于此改进,Go 团队建议开发者回归简洁的代码风格,无需手动处理复杂的排空逻辑,只需确保正确关闭 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 语言“简单性”的核心设计哲学:将底层复杂性封装在标准库中,让应用层代码保持清晰高效。





