持久连接允许多次请求和响应共享同一条连接,减少重复的 TCP 建立和 TLS 协商。但这是协议提供的能力,不是某个套接字永远可用的承诺。下一次请求能否真正复用,还取决于消息边界是否完整、客户端连接池策略、服务端行为和连接当前状态;单看请求成功或一次响应较快,都不足以证明走了复用路径。
默认允许,不代表永不关闭
HTTP/1.1 默认允许持久连接,客户端通常不需要发送 Connection: keep-alive 才能启用它。Connection: close 则表示当前交换之后不再继续使用这条连接。HTTP/1.0 的持久连接规则不同,不能把旧版本的协商方式复制到所有现代请求里,也不能把“没有 keep-alive 字段”直接判成配置错误。
复用首先需要明确的消息结束位置。准确的 Content-Length、分块编码,或无正文响应规则,都可以界定边界。若响应只能依靠关闭连接来表示结束,自然无法留下这条连接继续使用。接收方必须先消费完当前消息,再把后续字节解释成下一条;只收到 HTTP 200 状态,还不能证明响应正文已经完整到达。
已建立的 HTTPS 连接被复用时,不会重新进行 TLS 握手;使用缓存的 TLS 会话建立另一条连接,则仍然需要握手。TCP keepalive 又是另一回事,它的空闲探测不会刷新 HTTP 消息边界,也不能否定应用主动关闭连接的决定。HTTP/2 同样可以复用连接,但并不使用 HTTP/1.x 的 Connection 字段语义。
分别管理连接池与一次操作
空闲套接字仍占用描述符、缓冲区和实现相关的状态。Go 的 MaxIdleConnsPerHost 控制每个主机保留多少空闲连接,MaxConnsPerHost 则同时限制正在拨号、活动和空闲的连接总数。前者不是总并发上限。可复用连接池由共享的 Transport 持有,连续调用若各自建立无关的 transport,就会丢掉原本可以复用的连接资源。
IdleConnTimeout 限制一条空闲连接可以保留多久,Client.Timeout 则限制单次请求,包括获取连接和读取响应正文。两个计时边界不能混用。反向代理还分别持有下游与上游连接,因此浏览器到代理的复用和超时观察,不会自动说明代理到源站的连接池也采取相同策略;需要针对实际关心的那一跳取得证据。
坏掉的样子
客户端准备复用空闲连接时,服务器可能恰好关闭它。这类竞态必须能够处理,但连接重置不证明服务端没有执行操作。缺少业务层幂等约定时,自动重发一次有副作用的请求可能重复扣款或创建记录。增加空闲连接保留时间,不能消除这种“请求是否已经生效”的不确定性。
未消费的正文可能持续占用资源、推迟复用,或者导致连接关闭。应按照客户端库的契约完成或明确关闭每个响应。某些实现会自行排空被放弃的正文,但适用范围和大小限制各不相同,不能假设提前关闭就一定立即得到可复用连接。反过来,观察到一次复用失败,也不构成全局禁用持久连接的充分理由。
快速验证
以下 TLS 套接字集成原样保留,本轮没有重跑。文中的 macOS 观察属于历史记录,不是新一轮复用结果。
保存为 persistence_probe.go,使用 Go 1.23 或更新版本运行 go run persistence_probe.go。实验通过经过验证的 TLS 1.3 提供 HTTP/1.1 服务,为接受的 TCP 连接编号,并返回明确标注长度的两字节正文。它不修改公共服务或系统信任库;一个 transport 允许复用,另一个独立克隆则禁用复用。
package main
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"runtime"
"strconv"
"sync/atomic"
"time"
)
type connectionKey struct{}
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
var accepted, handled atomic.Int64
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
w.Header().Set("X-Connection-ID", fmt.Sprint(r.Context().Value(connectionKey{})))
w.Header().Set("X-Request-Connection", r.Header.Get("Connection"))
w.Header().Set("Content-Length", "2")
if r.URL.Path == "/close" {
w.Header().Set("Connection", "close")
}
fmt.Fprint(w, "ok")
}))
server.Config.ConnContext = func(ctx context.Context, _ net.Conn) context.Context {
return context.WithValue(ctx, connectionKey{}, accepted.Add(1))
}
server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
server.StartTLS()
defer server.Close()
config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
check(!config.InsecureSkipVerify, "certificate verification must stay enabled")
transport := &http.Transport{
TLSClientConfig: config, MaxConnsPerHost: 1, IdleConnTimeout: 10 * time.Second,
}
defer transport.CloseIdleConnections()
noReuse := transport.Clone()
noReuse.DisableKeepAlives = true
defer noReuse.CloseIdleConnections()
probe := func(label, route string, tr *http.Transport, wantID int64, wantReuse bool) {
var got atomic.Int64
var reused atomic.Bool
trace := &httptrace.ClientTrace{GotConn: func(info httptrace.GotConnInfo) {
got.Add(1)
reused.Store(info.Reused)
}}
request, err := http.NewRequest(http.MethodGet, server.URL+route, nil)
must(err)
request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
client := &http.Client{Transport: tr, Timeout: 2 * time.Second}
response, err := client.Do(request)
must(err)
body, readError := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.StatusCode == 200 && string(body) == "ok", "wrong response")
check(response.Proto == "HTTP/1.1", "fixture requires HTTP/1.1")
check(response.TLS.Version == tls.VersionTLS13 && len(response.TLS.VerifiedChains) > 0, "verified TLS 1.3 required")
id, err := strconv.ParseInt(response.Header.Get("X-Connection-ID"), 10, 64)
must(err)
check(id == wantID && got.Load() == 1 && reused.Load() == wantReuse, "wrong connection reuse")
check(response.Close == (route == "/close" || tr.DisableKeepAlives), "wrong close indication")
wantHeader := ""
if tr.DisableKeepAlives {
wantHeader = "close"
}
check(response.Header.Get("X-Request-Connection") == wantHeader, "unexpected request Connection header")
fmt.Printf("%s: conn=%d reused=%t closing=%t\n", label, id, reused.Load(), response.Close)
}
probe("cold", "/ok", transport, 1, false)
probe("reuse", "/ok", transport, 1, true)
probe("server close", "/close", transport, 1, true)
probe("after server close", "/ok", transport, 2, false)
transport.CloseIdleConnections()
probe("after idle retirement", "/ok", transport, 3, false)
probe("reuse disabled one", "/ok", noReuse, 4, false)
probe("reuse disabled two", "/ok", noReuse, 5, false)
check(accepted.Load() == 5 && handled.Load() == 7, "seven requests must use five TCP connections")
fmt.Println(runtime.Version())
}
前三次请求都使用连接一,包括第三次明确告知服务端即将关闭的响应。因此,下一次请求使用连接二。随后主动回收空闲连接,会让再下一次请求建立连接三。禁用复用的 transport 则为它的两次请求分别建立连接四和连接五。总计七次应用请求,对应五条被服务端接受的 TCP 连接。
每次请求的 GotConn 事件还会核验复用标志。服务器回显收到的 Connection 字段:普通请求没有这个字段,却仍能复用套接字;禁用复用时,请求携带 close。这样验证的是实际连接、协议选择、证书检查、完整正文和关闭指示,而不是只查看内存中的配置值,就把配置存在当作行为生效。
已核验的 macOS、Go 1.27.0 运行通过全部断言。正文均读取完毕并关闭,每次请求限时两秒,两套 transport 也会清理。虽然设置了 IdleConnTimeout,但实验没有等待它到期,而是通过显式回收得到确定结果。它不测量提速、TLS 会话恢复、TCP 探活、流水线、生产资源上限或代理行为;这些结论仍需各自独立的测量支持。
延伸阅读
来源
- RFC 9112:HTTP/1.1,持久连接、消息边界、关闭和重试的规范依据,已核验 2026-10-15。
- MDN:HTTP/1.x 连接管理,逐跳管理以及持久连接与流水线的区别,已核验 2026-10-15。
- Go net/http,transport 连接池、连接数量限制、响应生命周期和超时语义,已核验 2026-10-15。