Go 1.27 的 HTTP/1 连接复用改进解决了什么,怎么实测

Go 1.27 的 net/http 让 Transport 自己异步排空 HTTP/1 的未读响应体,最多 50 毫秒或者 256 KB。过去只要调用方没把响应体读到末尾就关闭,net/http 就认为这条连接没法再复用,只能丢掉重建。现在排空由标准库接管,调用方继续写 defer resp.Body.Close() 就能拿到复用收益。对大量发小请求、又用 JSON 解码后立刻返回的 Go 服务来说,这个改动会减少握手次数和 TIME_WAIT 堆积。适合先把连接指标采出来再决定要不要动代码的团队。

旧行为为什么会浪费连接

HTTP/1.1 的一条 TCP 连接同一时间只能承载一个请求。响应结束后这条连接能不能给下一个请求用,取决于两端是否都认为它停在干净的边界上。Transport 归还连接前要做一次收尾,把响应体剩余部分读完,确认没有残留数据,否则下一条请求的响应会跟在残留字节后面,解析就乱了。

问题在于过去这件事被推给了调用方。net/http 只读完状态行和头部就返回,响应体留在连接上,要等调用方读到末尾,下一个响应的起点才找得到。调用方用 json.Decoder 解完一个对象就 return,或者只关心状态码压根不读响应体,收尾就不会发生,这条连接只能被放弃。社区为此流传过一段手动排空的写法,开协程按 256 KB 上限拷贝,最多等 50 毫秒再关闭,既啰嗦又容易被抄错。

1.27 把排空这件事收回标准库

改动集中在谁来负责收尾。Transport 现在会自动异步排空未读的 HTTP/1 响应体,上限还是 50 毫秒和 256 KB,超时或超量就放弃复用。它顺带做了几处优化,比如响应里带了 Connection close 时排空必定白做,就直接跳过。

实际收益取决于流量形状。响应体在几百字节到几 KB 的小请求受益最明显,整个排空在一次网络读取里就完成了。响应体远超 256 KB 时行为跟老版本差别不大,仍然会被判定为不值得排空。

实测要看的四个指标

  1. 在客户端侧记录每秒新建连接数。可以用 Transport 的 DialContext 计数,或者看内核里该端口的 SYN 数量。这个数字下降才说明复用真的生效。
  2. 采集处于 TIME_WAIT 的套接字数量。连接被频繁关闭又重建时,这个数会持续升高,是复用失效最直观的旁证。
  3. 同时记录请求延迟的 P50 和 P99。复用改善的是建连开销,对稳定的长连接流量几乎没有影响。
  4. 对比升级前后的同一段压测脚本,并发数、请求体大小和压测时长都保持不变。

什么时候不用管它

  • 服务端在响应里带了 Connection close 指示,客户端收到就该关,Transport 不做复用尝试。
  • 用的是 HTTP/2 或 HTTP/3,多路复用由协议自己处理,跟 HTTP/1 的连接回收无关。
  • 请求本来就走长连接、并发很低,建连开销在总延迟里占比很小,优化空间有限。

容易踩的两个坑

第一个是手写的排空逻辑还在跑。有些人早年为绕开复用失效,在关闭响应体前自己挂了拷贝协程,升到 1.27 之后这段代码最好删掉,标准库已经做了同一件事,留着只是多一个协程和一处出错点。

第二个是把复用当成性能问题的万能解释。连接数高有时是因为连接池上限设得太小,有时是服务端主动断开,还有时是负载均衡器清理空闲连接的时刻恰好落在请求间隔上。先定位再改。

常见问题

升级 SDK 需要改业务代码吗。多数情况不用,复用的判断在标准库里,调用方保持原有的 defer resp.Body.Close() 写法即可。

能不能在旧版本上手动绕过。可以自己读完剩余字节再关闭,效果接近,代价是要在每一处调用点重复这段收尾逻辑,容易漏。