目录

网络-18 HTTP/3 与 QUIC

前置阅读:HTTP 中的 HTTP/2 部分、TLS 1.3

1. 为什么需要 HTTP/3

HTTP/2 用"流 + 帧"解决了 HTTP 层的队头阻塞,但留下了一个它无力解决的问题:TCP 层的队头阻塞

TCP 提供的是有序字节流语义——它必须按发送顺序把数据交给应用层。一旦某个 TCP 段丢失,内核缓冲区里后续已经到达的数据只能干等着,直到丢失的那段重传成功。而 HTTP/2 把所有流都跑在同一条 TCP 连接上,于是:

HTTP/1.1 多连接:  流A ──连接1──►     一个包丢失只影响自己这条连接
                   流B ──连接2──►
                   流C ──连接3──►

HTTP/2 单连接:    流A ┐
                   流B ├─连接1──►    一个包丢失,A B C 全部阻塞
                   流C ┘              (即使丢的包只属于流 A)

HTTP/3 QUIC:      流A ┐
                   流B ├─连接1──►    一个包丢失只阻塞它所属的那个流
                   流C ┘              B C 继续正常交付

也就是说 HTTP/2 把问题从"一个慢响应堵住一条连接"变成了"一个丢包堵住连接上的全部流"。在丢包率较高的弱网环境(移动网络、跨境链路)下,HTTP/2 的实际表现有时甚至不如开了多条连接的 HTTP/1.1——这是个相当反直觉但被反复实测证实的结论。

这个问题在 TCP 之上无解,因为它是 TCP 有序交付语义的固有代价,与 HTTP 怎么设计无关。要绕过它,唯一的办法是换掉传输层。

但换传输层有个现实障碍:TCP 和 UDP 之外的新传输层协议(比如 SCTP)几乎不可能部署——中间设备(NAT、防火墙)只认 TCP 和 UDP,别的协议号一律丢弃。而 TCP 的实现在内核里,改一次要等操作系统普及,周期以年计。

于是 Google 选了一条务实的路:在 UDP 之上,用户态重新实现一套可靠传输。这就是 QUIC。

2. QUIC 是什么

QUIC(Quick UDP Internet Connections)不是 UDP 的简单封装,而是一个完整的传输层协议,只是把 UDP 当作穿越中间设备的载体。UDP 在这里的作用仅仅是"能被网络放行的信封"。

它于 2021 年 5 月由 IETF 标准化:

  • RFC 9000 — QUIC 传输协议本体
  • RFC 9001 — 用 TLS 1.3 保护 QUIC
  • RFC 9002 — 丢包检测与拥塞控制
  • RFC 9114 — HTTP/3(2022 年 6 月)
  • RFC 9204 — QPACK 头部压缩

所以 HTTP/3 = HTTP over QUIC。协议栈对比:

   HTTP/2                    HTTP/3
┌──────────┐            ┌──────────┐
│  HTTP/2  │            │  HTTP/3  │
├──────────┤            ├──────────┤
│   TLS    │            │          │  ← TLS 1.3 不再是独立的一层,
├──────────┤            │   QUIC   │    而是内嵌进 QUIC
│   TCP    │            │          │
├──────────┤            ├──────────┤
│    IP    │            │   UDP    │
└──────────┘            ├──────────┤
                        │    IP    │
                        └──────────┘

3. QUIC 的核心特性

1. 流级别的独立可靠性

这是 QUIC 存在的根本理由。QUIC 把"可靠传输"的粒度从连接级下沉到了流级:每个 stream 独立维护自己的序列号和重传状态。

流 A 的包丢了,QUIC 只会阻塞流 A 的交付,流 B、C 的数据照常向上交付。这才是真正消除了队头阻塞。

代价是复杂度——TCP 只需要维护一套发送状态,QUIC 要为每条流各维护一套。

2. 握手合并:1-RTT 甚至 0-RTT 建连

传统 HTTPS 建连要分两步:先 TCP 三次握手(1-RTT),再 TLS 握手(TLS 1.2 是 2-RTT,1.3 是 1-RTT)。

QUIC 把传输层握手和加密握手合并成了一次

HTTPS over TCP + TLS 1.3:   TCP 1-RTT  +  TLS 1-RTT  =  2-RTT
HTTP/3 over QUIC:                      1-RTT
HTTP/3 会话恢复:                        0-RTT

这是 QUIC 感知最明显的收益。移动网络下 RTT 常在 100ms 以上,省掉一个往返就是省掉 100ms 的首字节时间。

0-RTT 的重放风险和 TLS 1.3 完全一样——只应用于幂等请求,细节见 TLS 1.3 的 0-RTT 一节。

3. 连接迁移(Connection ID)

TCP 连接由四元组(源 IP、源端口、目的 IP、目的端口)唯一标识。手机从 WiFi 切到 5G,源 IP 变了,四元组就变了——TCP 连接必然断开,应用必须重新建连、重新握手。

QUIC 用一个独立的 Connection ID 来标识连接,与 IP 和端口无关。IP 变了,只要 Connection ID 不变,服务端就知道"还是刚才那个连接",会话直接继续。

对移动端体验的改善是实质性的:视频通话切换网络不再卡顿重连,下载不会中断。这也是 NAT 重绑定(NAT 设备超时后改了端口映射)不再导致断连的原因。

顺带一提,Connection ID 也带来了隐私考量——它在网络上是明文的,可以被用来跟踪用户。所以 QUIC 规定通信双方可以在连接过程中轮换 Connection ID。

4. 默认加密

QUIC 没有明文模式。TLS 1.3 是协议的强制组成部分,连大部分传输层头部字段(包号等)都被加密或至少受完整性保护。

这带来一个有争议的副作用:中间设备再也无法窥探和篡改传输层行为。运营商的流量整形、企业防火墙的深度包检测都失效了。这被称为"协议僵化(ossification)“的反制——TCP 之所以几十年难以演进,正是因为中间设备对它的每个字段都有假设,改任何一处都会被掐断。QUIC 干脆把一切都加密,让中间设备无从依赖。

5. 更精确的 RTT 测量

TCP 有个长期存在的歧义问题:重传的报文段沿用原来的序列号。收到 ACK 时无法确定它确认的是原始包还是重传包,RTT 估算因此失准(这就是 Karn 算法要解决的问题)。

QUIC 的包号(Packet Number)严格单调递增,永不重复——重传的内容会装在一个新的包号里发出。于是每个 ACK 都能明确对应到唯一的一次发送,RTT 测量和丢包检测都精确得多。

这个改动看起来很小,但它是 QUIC 拥塞控制能做得比 TCP 更好的基础。

4. QPACK:为什么不能沿用 HPACK

HTTP/2 用 HPACK 压缩头部,核心是双方维护一张同步的动态表,用索引号代替重复字符串。

问题在于:HPACK 的动态表更新依赖严格有序的传输。编码器说"把这个键值对存到索引 62”,解码器必须在处理后续引用之前先执行这条指令。TCP 保证了这个顺序,所以 HPACK 能工作。

但 QUIC 的各条流是独立且可能乱序到达的。如果流 B 的头部引用了流 A 刚插入动态表的条目,而流 A 的包还没到,流 B 就只能等——队头阻塞又回来了,只不过换到了头部压缩这一层。

QPACK 的解法是:

  • 把动态表的更新指令放到两条专门的单向流上(编码器流、解码器流);
  • 编码器可以选择只引用已经被确认收到的条目,这样就不会产生跨流依赖;
  • 想要更高压缩率就允许引用未确认条目(会引入阻塞风险),想要零阻塞就只用静态表和已确认条目——压缩率和队头阻塞之间的权衡交给实现者决定

5. 客户端怎么知道服务端支持 HTTP/3

浏览器不能上来就发 UDP 试探。目前有两种发现机制:

1. Alt-Svc 响应头(RFC 7838)

服务端在 HTTP/1.1 或 HTTP/2 的响应里告知:“我还支持 h3,你下次可以用”:

Alt-Svc: h3=":443"; ma=86400

ma 是缓存时间(秒)。如果客户端只依赖 Alt-Svc,必须先通过 HTTP/1.1 或 HTTP/2 收到这条响应,后续访问才会尝试 HTTP/3,这叫“first-flight problem”。下文的 DNS HTTPS 记录可以让客户端在第一次连接前就获知 h3 支持,因此“首次访问必然走 TCP”并非绝对。

2. DNS HTTPS 记录(RFC 9460)

把支持信息直接放进 DNS,客户端在解析域名时就知道了,省掉第一次的 TCP 回退:

dig cloudflare.com HTTPS +short
# 1 . alpn="h3,h2" ipv4hint=104.16.132.229 ...

6. 现实中的问题

QUIC 不是没有代价,实际部署要面对这几件事:

  • UDP 被屏蔽。不少企业网络、老旧防火墙默认丢弃 443 之外的 UDP,甚至连 443/UDP 也拦。所以HTTP/3 必须保留 TCP 回退路径,服务端要同时监听 TCP 443 和 UDP 443。这意味着运维成本不是替换而是叠加。
  • CPU 开销更高。TCP 的拥塞控制和重传跑在内核,还能用网卡的 TSO/GSO 卸载。QUIC 跑在用户态,每个包都要经过用户态加解密和系统调用,高吞吐场景下 CPU 消耗明显高于 TCP。UDP GSO、sendmmsg 等手段能缓解,但仍有差距。这也是为什么大文件下载等吞吐敏感场景,QUIC 未必优于 TCP——它的优势在高延迟、高丢包、多请求的场景。
  • 可观测性变差。传输层被加密后,tcpdump 抓到的 QUIC 包基本是密文,传统的网络排障手段大打折扣。调试要靠 qlog(QUIC 自己的结构化日志标准)或者在服务端导出密钥。
  • 实现分散。TCP 只有一个内核实现,QUIC 每家一套(Chrome 的、Cloudflare 的 quiche、Go 的 quic-go、Nginx 的 ngx_quic……),互操作性问题和性能差异都比 TCP 时代大。

7. Go 里怎么用

Go 标准库至今没有内置 QUICcrypto/tls 里有供 QUIC 使用的 QUICConn 接口,但传输层本身没有)。事实标准是 quic-go

服务端:

import "github.com/quic-go/quic-go/http3"

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        // r.Proto 这里会是 "HTTP/3.0"
        fmt.Fprintf(w, "hello from %s", r.Proto)
    })

    // 注意:ListenAndServeTLS 监听的是 UDP 443
    server := &http3.Server{
        Addr:    ":443",
        Handler: mux,
    }
    log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}

生产环境要同时提供 TCP 回退,并在 TCP 响应里通过 Alt-Svc 广告 h3:

h3 := &http3.Server{Addr: ":443", Handler: mux}

tcpSrv := &http.Server{
    Addr: ":443",
    Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 告诉客户端"我也支持 h3",下次它就会走 QUIC
        h3.SetQUICHeaders(w.Header())
        mux.ServeHTTP(w, r)
    }),
}

go h3.ListenAndServeTLS("cert.pem", "key.pem")  // UDP 443
log.Fatal(tcpSrv.ListenAndServeTLS("cert.pem", "key.pem"))  // TCP 443

客户端:

client := &http.Client{
    Transport: &http3.RoundTripper{},
}
resp, err := client.Get("https://cloudflare.com")

命令行验证:

# curl 需要编译时带 HTTP/3 支持,检查一下
curl --version | grep -o HTTP3

curl -v --http3 https://cloudflare.com -o /dev/null

8. 版本演进小结

版本 年份 传输层 解决的核心问题 遗留问题
HTTP/0.9 1991 TCP 验证 Web 可行性 只有 GET,无头部
HTTP/1.0 1996 TCP 头部、状态码、多类型文件 一连接一请求
HTTP/1.1 1999 TCP 长连接、Host、断点续传 HTTP 层队头阻塞
HTTP/2 2015 TCP 二进制分帧、多路复用、HPACK TCP 层队头阻塞
HTTP/3 2022 QUIC/UDP TCP 队头阻塞、连接迁移、握手延迟 UDP 被屏蔽、CPU 开销

可以看到一条清晰的主线:每一代都在解决上一代的队头阻塞,直到 HTTP/3 不得不把传输层一起换掉才算真正解决。

9. 本章面试题

HTTP/3 为什么要基于 UDP?(高频)

因为要解决的TCP 层队头阻塞在 TCP 之上无解——TCP 提供有序字节流语义,必须按序交付,一个段丢失就阻塞连接上所有 HTTP/2 流。这是 TCP 语义的固有代价。

而换传输层有现实障碍:

  1. 中间设备只认 TCP 和 UDP——部署全新协议(如 SCTP)会被防火墙/NAT 直接丢弃;
  2. TCP 实现在内核里,迭代周期以年计

所以选择在 UDP 之上用户态重建一套可靠传输QUIC 不是"UDP 的简单封装",而是一个完整的传输层协议,UDP 只是"能被网络放行的信封"。

QUIC 有哪些核心特性?
  1. 流级别独立可靠性——每个 stream 独立维护序列号和重传状态,一个流丢包只阻塞它自己(QUIC 存在的根本理由);
  2. 握手合并——传输层握手与 TLS 1.3 握手合并,1-RTT 建连(会话恢复 0-RTT),而 TCP+TLS1.3 需要 2-RTT;
  3. 连接迁移——用独立的 Connection ID 标识连接而非四元组,手机从 WiFi 切 5G 后 IP 变了连接不断
  4. 默认加密——没有明文模式,TLS 1.3 是协议的强制组成部分,连大部分传输层头部都受保护;
  5. 更精确的 RTT 测量——包号严格单调递增永不重复(重传用新包号),消除了 TCP 中"ACK 确认的是原包还是重传包"的歧义。
连接迁移是什么?为什么 TCP 做不到?

TCP 连接由四元组(源/目的 IP + 端口)唯一标识。手机从 WiFi 切到 5G,源 IP 变了,四元组就变了——TCP 连接必然断开,应用必须重连并重新握手。

QUIC 用一个独立的 Connection ID 标识连接,与 IP 和端口无关。IP 变了只要 Connection ID 不变,服务端就知道"还是刚才那个连接",会话直接继续。

这对移动端体验改善是实质性的:视频通话切网不卡顿、下载不中断。同时也解决了 NAT 重绑定(NAT 超时后改了端口映射)导致的断连。

副作用:Connection ID 在网络上是明文的,可被用于跟踪用户——所以 QUIC 规定双方可在连接过程中轮换 Connection ID。

为什么 HTTP/3 不能沿用 HPACK,要换成 QPACK?

HPACK 的动态表更新依赖严格有序的传输:编码器说"把这个键值对存到索引 62",解码器必须在处理后续引用前先执行这条指令。TCP 保证了顺序,所以 HPACK 能工作。

QUIC 的各条流独立且可能乱序到达。如果流 B 的头部引用了流 A 刚插入动态表的条目,而流 A 的包还没到,流 B 就只能等——队头阻塞又回来了,只是换到了头部压缩这一层。

QPACK 的解法:把动态表更新指令放到两条专门的单向流上;编码器可选择只引用已被确认的条目避免跨流依赖。压缩率与队头阻塞之间的权衡交给实现者决定。

HTTP/3 有什么代价?为什么不是所有场景都该用?

四个现实问题:

  1. UDP 可能被屏蔽——不少企业网络和老旧防火墙丢弃 UDP。所以必须保留 TCP 回退路径,服务端要同时监听 TCP 443 和 UDP 443,运维成本是叠加而非替换;
  2. CPU 开销通常更高——QUIC 跑在用户态并强制加密,传统 TCP 的 TSO、成熟内核路径等优化覆盖更广。QUIC 可以使用 UDP GSO、sendmmsg 等能力降低系统调用和分段成本,但硬件/内核支持并不总是等价。大文件下载等吞吐敏感场景 QUIC 未必优于 TCP
  3. 可观测性变差——传输层加密后 tcpdump 抓到的基本是密文,要靠 qlog 或导出密钥;
  4. 实现分散——TCP 只有一个内核实现,QUIC 每家一套(quiche、quic-go、ngx_quic),互操作性和性能差异更大。

QUIC 的优势场景是高延迟、高丢包、多请求(移动网络、跨国链路),不是所有场景的普适升级。

客户端怎么知道服务端支持 HTTP/3?

浏览器不能上来就发 UDP 试探,有两种发现机制:

  1. Alt-Svc 响应头——服务端在 HTTP/1.1 或 HTTP/2 响应里告知 Alt-Svc: h3=":443"; ma=86400,客户端记住后下次才用 HTTP/3。仅依赖 Alt-Svc 时首次访问要走 TCP,这叫 first-flight problem;
  2. DNS HTTPS 记录(RFC 9460)——把支持信息放进 DNS,客户端解析域名时就知道,省掉第一次的 TCP 回退:
dig cloudflare.com HTTPS +short
# 1 . alpn="h3,h2" ipv4hint=...

参考资料:

  • RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport
  • RFC 9114 — HTTP/3
  • RFC 9204 — QPACK: Field Compression for HTTP/3
  • RFC 9460 — Service Binding and Parameter Specification via the DNS (SVCB/HTTPS RR)