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
cold1false1false
reuse1true0false
resume2false1true
no cache3false1false

第二次请求仍使用连接一。第三次请求前关闭空闲连接,会强制建立另一条 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。