HTTPS 延迟由多个阶段组成,重复访问可以跳过其中一些阶段,却不一定是加密运算本身变快了。DNS、获取连接、TCP 建连、TLS 认证、服务端处理和响应传输,各自有不同的计时边界。分析前应先确认哪些阶段真的执行过;单看一次请求总耗时,无法区分复用既有连接与新连接上的 TLS 会话恢复。
消息会按批次传输
TLS 握手虽然包含多条消息,但并不是每条消息都需要单独一次往返,多条消息可以在同一批次中发送。通常的 TLS 1.3 完整握手在普通客户端应用数据之前需要一次 TLS 往返,HelloRetryRequest 会再增加一次。TCP 建连和之后的应用请求、响应,还属于另外的成本。
不使用 early data 的普通 TLS 1.3 会话恢复,通常也需要一次 TLS 往返。它能减少证书传输与认证工作,却仍可能执行新的临时密钥交换;恢复时派生的是新流量密钥,不是原封不动继续使用旧加密密钥。可选零往返数据有额外的重放安全限制,不能把一次快速重复请求直接叫作零往返。
HTTP 连接复用则跳过了建立另一条连接的步骤,因此该请求没有新的 TLS 握手。HTTP/2 多路复用也可以让多个流共享已建立连接。相反,关闭空闲连接再重新连接,即使缓存票据允许 TLS 恢复,仍然需要重新完成传输层建立,不能把两种节省混成同一个动作。
坏掉的样子
正常请求若每次创建新的 transport,就会丢掉连接池。响应正文没有读完或关闭,也可能妨碍复用。空闲超时、入口变化或并发需求都可能增加新连接数量,因此在把延迟变化归因于证书算法之前,应先按真实复用情况和握手事件分类,确认自己比较的是同一种路径。
尝试恢复后仍发生完整握手,并不必然意味着服务故障。票据可能过期或被拒绝,客户端也可能没有可用缓存。应保留普通证书验证,并观察最终选择的行为;为赢得性能数字而减少认证检查,改变的是安全约定,不是在同一个操作下获得了免费优化。
计时名称也要有定义。首字节时间包含的不只有 TLS,总时间还包括消费响应内容。跟踪回调可能来自不同协程,收集器必须正确同步。应比较有代表性的分布及连接类别,不能拿几次回环请求的微秒数去预测公网加速,也不能把偶然更快的一次执行写成稳定性能承诺。
快速验证
保存为 latency_probe.go,使用 Go 1.23 或更新版本运行 go run latency_probe.go。实验固定使用经过验证的 TLS 1.3 和 HTTP/1.1,服务端为接受的 TCP 连接编号。第一个 transport 同时保留 HTTP 连接池和 TLS 会话缓存,另一个独立 transport 刻意不配置会话缓存。
package main
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"runtime"
"strconv"
"sync"
"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 connections atomic.Int64
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Connection-ID", fmt.Sprint(r.Context().Value(connectionKey{})))
w.Header().Set("X-Resumed", strconv.FormatBool(r.TLS.DidResume))
fmt.Fprint(w, "ok")
}))
server.Config.ConnContext = func(ctx context.Context, _ net.Conn) context.Context {
return context.WithValue(ctx, connectionKey{}, connections.Add(1))
}
server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
server.StartTLS()
defer server.Close()
base := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
base.MinVersion, base.MaxVersion = tls.VersionTLS13, tls.VersionTLS13
check(!base.InsecureSkipVerify, "certificate verification must stay enabled")
cached := base.Clone()
cached.ClientSessionCache = tls.NewLRUClientSessionCache(4)
transport := &http.Transport{TLSClientConfig: cached}
defer transport.CloseIdleConnections()
fresh := &http.Transport{TLSClientConfig: base.Clone()}
defer fresh.CloseIdleConnections()
probe := func(label string, transport *http.Transport, wantID int64, wantReuse, wantResume bool) {
started := time.Now()
var mu sync.Mutex
var handshakeStarted time.Time
var handshakeError error
var observation struct {
got, starts, done, first int
reused bool
tlsTime, ttfb time.Duration
}
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
mu.Lock()
defer mu.Unlock()
observation.got++
observation.reused = info.Reused
},
TLSHandshakeStart: func() {
mu.Lock()
defer mu.Unlock()
handshakeStarted = time.Now()
observation.starts++
},
TLSHandshakeDone: func(_ tls.ConnectionState, err error) {
mu.Lock()
defer mu.Unlock()
observation.done++
observation.tlsTime = time.Since(handshakeStarted)
handshakeError = err
},
GotFirstResponseByte: func() {
mu.Lock()
defer mu.Unlock()
observation.first++
observation.ttfb = time.Since(started)
},
}
request, err := http.NewRequest(http.MethodGet, server.URL, nil)
must(err)
request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
response, err := client.Do(request)
must(err)
body, err := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(err)
must(closeError)
total := time.Since(started)
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 && response.TLS.DidResume == wantResume, "wrong connection or resumption")
check(response.Header.Get("X-Resumed") == strconv.FormatBool(wantResume), "peers disagree on resumption")
mu.Lock()
observed, tlsError := observation, handshakeError
mu.Unlock()
must(tlsError)
wantHandshakes := 1
if wantReuse {
wantHandshakes = 0
}
check(observed.got == 1 && observed.first == 1 && observed.reused == wantReuse, "wrong request trace")
check(observed.starts == wantHandshakes && observed.done == wantHandshakes, "wrong handshake count")
check(total >= observed.ttfb && observed.ttfb > 0, "invalid response timing")
fmt.Printf("%s: conn=%d reused=%t handshakes=%d connection_resumed=%t tls_us=%d ttfb_us=%d total_us=%d\n",
label, id, observed.reused, observed.done, response.TLS.DidResume,
observed.tlsTime.Microseconds(), observed.ttfb.Microseconds(), total.Microseconds())
}
probe("cold", transport, 1, false, false)
probe("reuse", transport, 1, true, false)
transport.CloseIdleConnections()
probe("resume", transport, 2, false, true)
probe("no cache", fresh, 3, false, false)
check(connections.Load() == 3, "four requests must use three TCP connections")
fmt.Println("timings are observations, not a speedup or wire-RTT assertion")
fmt.Println(runtime.Version())
}
不随运行耗时变化的结果如下:
| 请求 | TCP 编号 | 复用连接 | 新握手次数 | DidResume |
|---|---|---|---|---|
| cold | 1 | false | 1 | false |
| reuse | 1 | true | 0 | false |
| resume | 2 | false | 1 | true |
| no cache | 3 | false | 1 | false |
第二次请求仍使用连接一。第三次请求前关闭空闲连接,会强制建立另一条 TCP 连接,但不丢弃 TLS 缓存,因此可以恢复会话。第四个请求使用没有缓存的 transport,重新执行完整握手。双方报告的恢复标志必须一致,四个 HTTP 请求总共只使用三条已接受的 TCP 连接。
DidResume 描述的是这条连接最初如何建立,不代表当前 HTTP 请求又触发了一次恢复。如果复用的连接最初通过会话恢复建立,后续响应仍可能报告 true,却没有新握手。因此要把这个连接级标志与当前请求的复用和握手事件一起读,不能把每个 true 响应都计为一次新的恢复连接。
TLS 耗时从 TLSHandshakeStart 计到 TLSHandshakeDone;TTFB 从发起请求前开始,计到响应头首字节可用;总耗时则计到正文读完并关闭。复用那一行的 TLS 耗时为零,是因为没有观察到新握手事件,不代表握手异常快,更不是已经发送 TLS 零往返数据。
已核验的 macOS、Go 1.27.0 运行通过全部断言。程序消费并关闭响应正文,以互斥锁保护跟踪状态,每次请求限时两秒。会话状态只留在内存,不打印票据、私钥或流量机密。恢复连接中的已验证证书链状态属于已认证会话,不能据此推断网络上刚刚又传输了一份新证书。
程序故意不对“加速多少”作断言,计时数字也会随运行变化。它没有数包或测量线上的 RTT、DNS、HTTP/2 或 HTTP/3、early data、集群票据轮换、单独握手 CPU,或者生产延迟分布。跟踪类别可以帮助组织后续测量,但不能把这个本地实验当成那些测量已经完成的结果。
延伸阅读
来源
- RFC 8446:TLS 1.3,握手批次、会话恢复与 early data 边界,已核验 2026-09-09。
- Go net/http/httptrace,请求钩子、握手事件、计时边界和并发,已核验 2026-09-09。
- Go net/http,transport 复用与响应正文生命周期,已核验 2026-09-09。
- Go crypto/tls,会话缓存与协商后的恢复状态,已核验 2026-09-09。
- Go net/http/httptest,隔离 TLS 服务和可信测试客户端,已核验 2026-09-09。