Go 1.27 显著优化 HTTP/1 连接复用
看一下下面的函数:
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 耗时约 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 请求本身的响应成本相对较低,在带宽充足或响应体较小的情况下仅需略多于 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 发送两个 GET 请求并共用同一个 TCP 连接,响应数据会作为单一字节流到达:
如图所示,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,防止协程泄漏。
}
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 代为处理复杂性。
上一篇文章:Go 中的架构特定 SIMD
博客索引