上两篇我们把 QUIC 翻了个底朝天:第一篇讲了为什么 TCP 改不动了,第二篇用 C 撸了一个最小握手。现在传输层的问题解决了——丢包不阻塞、连接能迁移、握手只要 1-RTT。
但问题来了:HTTP 怎么跑在 QUIC 上?
直觉上最简单的方案是把 HTTP/2 原封不动搬到 QUIC 上。实际上 Google 最早的 gQUIC 就是这么干的,结果发现一堆问题——HTTP/2 的很多设计是为了弥补 TCP 的缺陷,而 QUIC 已经解决了这些缺陷,再叠一层就成了冗余。
所以 IETF 的做法是:在 QUIC 之上重新设计应用层协议,这就是 HTTP/3(RFC 9114)。帧格式变了,头部压缩算法换了,流控直接删了。看起来变化很大,但核心思想反而更简单了。
这篇就把 HTTP/3 从里到外拆一遍。
一、HTTP/3 和 HTTP/2 的本质区别
先上结论,再展开。
| 维度 | HTTP/2 | HTTP/3 |
|---|---|---|
| 传输层 | TCP + TLS 1.2/1.3 | QUIC(内含 TLS 1.3) |
| 多路复用 | HTTP 层模拟的流(stream ID) | 直接映射到 QUIC stream |
| 流控 | HTTP/2 自己做(WINDOW_UPDATE) | 删掉,用 QUIC 的流控 |
| 头部压缩 | HPACK | QPACK |
| 队头阻塞 | 有(TCP 层) | 无 |
| 连接迁移 | 不支持 | 支持(Connection ID) |
| 帧格式 | 固定 9 字节头 | 变长编码 |
HTTP/2:在一条 TCP 连接上模拟多路复用
HTTP/2 的核心卖点是多路复用——在一条 TCP 连接上同时跑多个请求。它用 stream ID 来区分不同的请求,用 HEADERS 帧和 DATA 帧来承载数据。
但问题是,TCP 是一个有序字节流。所有的 HTTP/2 帧最终都要排队走同一条 TCP 连接。如果前面的 TCP 包丢了,后面所有帧都得等——哪怕它们属于完全不同的 HTTP 请求。
HTTP/2 over TCP:
Stream 1: HEADERS ──── DATA ──── DATA
Stream 3: HEADERS ──── DATA ← 全部排队走同一条 TCP 连接
Stream 5: HEADERS ──── DATA ──── DATA
│ │ │
▼ ▼ ▼
┌──────────────────────────────────┐
│ 一条 TCP 连接 │ ← 包 #3 丢了?全部等!
└──────────────────────────────────┘
这就是臭名昭著的 TCP 层队头阻塞(Head-of-Line Blocking)。
HTTP/3:一个请求 = 一个 QUIC stream
HTTP/3 的做法简单粗暴:每个 HTTP 请求直接对应一个 QUIC stream。QUIC stream 之间是完全独立的——stream 3 丢包了,stream 5 和 stream 7 该收收、该处理处理,完全不受影响。
HTTP/3 over QUIC:
QUIC Stream 0: HEADERS ── DATA ── DATA (请求 1)
QUIC Stream 4: HEADERS ── DATA (请求 2)
QUIC Stream 8: HEADERS ── DATA ── DATA (请求 3)
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 独立传输 │ │ 独立传输 │ │ 独立传输 │ ← 互不干扰!
└──────────┘ └──────────┘ └──────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────┐
│ 一条 QUIC 连接(UDP) │
└──────────────────────────────────────┘
(完整的流映射关系可以看下方的 HTTP/3 Stream Mapping 图)
删掉冗余:不再需要 HTTP 层流控
HTTP/2 有自己的一套流控机制——WINDOW_UPDATE
帧告诉对方「我还能接收多少数据」。这是因为 TCP
的流控粒度是整条连接,HTTP/2 需要更细粒度的按流控制。
但 QUIC 天生就提供了按 stream 的流控。所以 HTTP/3 直接删掉了 HTTP 层的流控——没必要做两遍。
同样被删掉的还有 PRIORITY 帧。HTTP/2
的优先级机制实现复杂且效果不好,HTTP/3 用了一个更简单的
Extensible Priorities 方案(RFC 9218),通过
priority 头字段来指定。
二、QPACK:HTTP/3 的头部压缩
头部压缩是 HTTP 性能优化的重要一环。一个典型的 HTTP
请求可能有 300-800
字节的头部,其中大量是重复的(:method: GET、:scheme: https、user-agent: ...)。
HPACK 的问题
HTTP/2 用的 HPACK 方案很聪明:维护一张动态表,把见过的头部存起来,后续只发索引号。问题是——动态表的更新依赖有序传递。
HPACK 动态表更新:
编码端:
发送请求 1 → 往动态表插入 "custom-header: value1" → 索引 62
发送请求 2 → 引用索引 62 → 只发 1 字节
解码端:
必须先收到请求 1(知道索引 62 是什么)
才能解码请求 2
在 TCP 上没问题,因为 TCP 保证有序。但 QUIC 的 stream 之间是无序的——请求 2 可能先到,这时候解码端不知道索引 62 是什么,直接就炸了。
QPACK 的解法
QPACK(RFC 9204)把动态表更新和数据传输解耦了。它引入了两条专门的单向流(Unidirectional Stream):
- Encoder Stream:编码端 → 解码端,传递动态表更新指令
- Decoder Stream:解码端 → 编码端,确认动态表更新已收到
QPACK 架构:
┌────────────────────────────────────────────────┐
│ QUIC 连接 │
│ │
│ Encoder Stream (单向) ─────────────────────► │
│ 「索引 0 = custom-header: value1」 │
│ 「索引 1 = another-header: value2」 │
│ │
│ Decoder Stream (单向) ◄───────────────────── │
│ 「确认:已处理到索引 1」 │
│ │
│ Request Stream 0 → HEADERS(引用索引 0, 1) │
│ Request Stream 4 → HEADERS(引用索引 0) │
│ Request Stream 8 → HEADERS(引用静态表) │
└────────────────────────────────────────────────┘
关键设计:如果一个 HEADERS 帧引用了动态表中的条目,但解码端还没收到对应的 Encoder Stream 更新,该 stream 会被阻塞等待(而不是报错)。其他 stream 不受影响。
这样就把「全局有序」降级成了「按需等待」,保住了 QUIC 的无序特性。
静态表:98 个预定义头
QPACK 预定义了 98 个常用头部(比 HPACK 的 61 个多了不少):
# QPACK 静态表(部分)
索引 0: :authority
索引 1: :path /
索引 2: age 0
索引 3: content-disposition
索引 4: content-length 0
索引 5: cookie
...
索引 15: :method CONNECT
索引 16: :method DELETE
索引 17: :method GET
索引 18: :method HEAD
索引 19: :method OPTIONS
索引 20: :method POST
索引 21: :method PUT
...
索引 24: :scheme http
索引 25: :scheme https
索引 26: :status 103
索引 27: :status 200
索引 28: :status 304
索引 29: :status 404
索引 30: :status 503
...
索引 96: x-frame-options deny
索引 97: x-frame-options sameorigin
编码效率对比
| 指标 | HPACK (HTTP/2) | QPACK (HTTP/3) |
|---|---|---|
| 静态表大小 | 61 条 | 98 条 |
| 动态表同步 | 依赖 TCP 有序 | Encoder/Decoder Stream |
| Huffman 编码 | 支持 | 支持(相同码表) |
| 首次请求压缩率 | ~60% | ~65%(更大的静态表) |
| 后续请求压缩率 | ~90%+ | ~90%+ |
| 乱序容忍 | ❌ 不支持 | ✅ 支持 |
| 实现复杂度 | 中等 | 较高(需要管理两条流) |
Huffman 编码的码表和 HPACK 完全一样——因为 HTTP 头部的字符分布没变,没必要换。
三、HTTP/3 帧格式
HTTP/2 的帧有一个固定的 9 字节头部:
HTTP/2 帧格式(固定 9 字节头):
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length (24) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Type (8) | Flags (8) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|R| Stream Identifier (31) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Payload ... |
HTTP/3 简化了很多——因为 stream ID 和流控都由 QUIC 管了,帧头只需要类型和长度:
HTTP/3 帧格式(变长编码):
┌───────────────────────────────────┐
│ Type (变长整数) │
├───────────────────────────────────┤
│ Length (变长整数) │
├───────────────────────────────────┤
│ Payload ... │
└───────────────────────────────────┘
变长整数编码(QUIC Variable-Length Integer)用前 2 bit
表示长度:00 = 1 字节,01 = 2
字节,10 = 4 字节,11 = 8
字节。大多数帧的类型和长度只需要 1-2 字节。
HTTP/3 的帧类型
| 帧类型 | Type 值 | 用途 | HTTP/2 对应 |
|---|---|---|---|
| DATA | 0x00 | 请求/响应的 body | DATA |
| HEADERS | 0x01 | 头部(QPACK 编码) | HEADERS |
| CANCEL_PUSH | 0x03 | 取消一个 Server Push | — |
| SETTINGS | 0x04 | 连接配置参数 | SETTINGS |
| PUSH_PROMISE | 0x05 | Server Push 承诺 | PUSH_PROMISE |
| GOAWAY | 0x07 | 优雅关闭连接 | GOAWAY |
| MAX_PUSH_ID | 0x0d | 允许的最大 Push ID | — |
注意几个被删掉的帧类型:
- WINDOW_UPDATE:QUIC 自带流控,不需要了
- PRIORITY:换成 Extensible
Priorities(HTTP 头字段
priority) - RST_STREAM:用 QUIC 的
RESET_STREAM帧代替 - PING:用 QUIC 的
PING帧代替 - CONTINUATION:HTTP/3 允许 HEADERS 帧跨越多个 QUIC 包,不再需要
控制流(Control Stream)
HTTP/3
有一条特殊的单向流叫控制流。连接建立后,双方各打开一条控制流,用来交换
SETTINGS、GOAWAY
等连接级别的帧。
控制流的作用:
客户端 ──── Control Stream ────► 服务端
发送 SETTINGS: {QPACK_MAX_TABLE_CAPACITY=4096, ...}
发送 GOAWAY (准备关闭时)
服务端 ──── Control Stream ────► 客户端
发送 SETTINGS: {MAX_PUSH_ID=0, ...}
发送 GOAWAY (准备关闭时)
控制流的 stream type 是
0x00,必须是连接建立后最先打开的单向流。
Server Push 在 HTTP/3 中的变化
说实话,Server Push 在实践中几乎没人用——Chrome 在 2022 年就默认禁用了 HTTP/2 Server Push。HTTP/3 保留了这个功能但做了简化:
- 服务端在请求流上发送
PUSH_PROMISE帧(而不是 HTTP/2 的 promised stream) - 推送的响应在一条新的单向推送流上发送
MAX_PUSH_ID帧用来限制推送数量CANCEL_PUSH帧用来取消推送
现实中建议直接设 MAX_PUSH_ID=0,即不使用
Server Push。
四、完整请求链路拆解
现在把所有零件组装起来。一个完整的 HTTP/3 请求从建立连接到收到响应,分 5 步。
步骤 1:QUIC 握手(1-RTT 或 0-RTT)
客户端发送 QUIC Initial 包,内含 TLS ClientHello。1-RTT 握手完成后,双方共享加密密钥,QUIC 连接建立。
如果之前连接过,客户端可能有缓存的 session ticket,可以做 0-RTT 握手——在第一个包里就带上应用数据。
(具体握手细节见 QUIC 协议拆解(上):为什么 TCP 改不动了)
步骤 2:交换 SETTINGS 帧
QUIC
连接建立后,双方立刻各开一条单向控制流,发送
SETTINGS 帧:
# SETTINGS 帧的典型参数
SETTINGS {
QPACK_MAX_TABLE_CAPACITY = 4096 # QPACK 动态表最大字节数
QPACK_BLOCKED_STREAMS = 100 # 允许被 QPACK 阻塞的最大流数
MAX_FIELD_SECTION_SIZE = 8192 # 单个头部块的最大字节数
}
同时,双方各开一条 QPACK Encoder Stream 和 Decoder Stream。此时连接上已经有 6 条单向流:
单向流布局:
客户端 → 服务端:
Stream Type 0x00: Control Stream (SETTINGS, GOAWAY)
Stream Type 0x02: QPACK Encoder (动态表更新)
Stream Type 0x03: QPACK Decoder (确认收到)
服务端 → 客户端:
Stream Type 0x00: Control Stream
Stream Type 0x02: QPACK Encoder
Stream Type 0x03: QPACK Decoder
步骤 3:客户端打开 QUIC stream,发送 HEADERS 帧
客户端打开一条双向 QUIC stream(stream ID = 0),发送 HEADERS 帧:
# 请求 GET https://example.com/api/data
HEADERS Frame on QUIC Stream 0:
┌──────────────────────────────────────┐
│ Frame Type: 0x01 (HEADERS) │
│ Frame Length: 47 │
│ QPACK Encoded Header Block: │
│ Required Insert Count: 0 │
│ Base: 0 │
│ :method = GET (静态表 17) │
│ :scheme = https (静态表 25) │
│ :authority = example.com (字面量) │
│ :path = /api/data (字面量) │
│ accept = application/json (字面量) │
└──────────────────────────────────────┘
注意 Required Insert Count 字段——它告诉解码端:「解码这个头部块需要动态表至少有多少条目」。如果是 0,说明只用了静态表和字面量,不需要等 Encoder Stream。
步骤 4:服务端回复 HEADERS + DATA 帧
服务端在同一条 QUIC stream上回复:
# 服务端响应
HEADERS Frame on QUIC Stream 0:
┌──────────────────────────────────────┐
│ Frame Type: 0x01 (HEADERS) │
│ QPACK Encoded Header Block: │
│ :status = 200 (静态表 27) │
│ content-type = application/json │
│ content-length = 1234 │
└──────────────────────────────────────┘
DATA Frame on QUIC Stream 0:
┌──────────────────────────────────────┐
│ Frame Type: 0x00 (DATA) │
│ Frame Length: 1234 │
│ Payload: {"users": [...]} │
└──────────────────────────────────────┘
步骤 5:流关闭
服务端发完数据后,发送 QUIC 的 FIN
位,表示这条 stream
的发送方向关闭。客户端收到完整响应后,也关闭自己的发送方向。这条双向
stream 就完成了它的使命。
如果客户端想发第二个请求,它会打开一条新的 QUIC stream(stream ID = 4),完全独立。
用 curl 实际抓包
curl 从 7.66 开始支持 HTTP/3(需要编译时启用)。用
--http3 选项可以强制使用 HTTP/3:
# 用 curl 发起 HTTP/3 请求
curl --http3 -v https://cloudflare-quic.com/
# 输出(关键部分):
# * using HTTP/3
# * [QUIC] Connected to cloudflare-quic.com:443
# * [QUIC] Using QUIC v1 (RFC 9000)
# * h3 [stream 0] [HEADERS]
# * :status: 200
# * content-type: text/html
# * alt-svc: h3=":443"; ma=86400
# * [stream 0] [DATA] 15234 bytes用 Wireshark 抓包可以看到 QUIC 层的细节:
# 用 tshark 抓 QUIC 包
tshark -i eth0 -f "udp port 443" \
-o "tls.keylog_file:keylog.txt" \
-Y "quic" \
-T fields -e quic.stream_id -e http3.frame_type
# 配合 SSLKEYLOGFILE 环境变量导出密钥
SSLKEYLOGFILE=keylog.txt curl --http3 https://example.com/在 Wireshark 中你会看到: 1. QUIC Initial(TLS ClientHello) 2. QUIC Handshake(TLS ServerHello + 证书) 3. QUIC 1-RTT(SETTINGS、HEADERS、DATA)
五、性能对比:HTTP/2 vs HTTP/3
这里不画大饼,直接看数据。以下测试基于模拟环境(Linux tc netem),50 个并发请求,每个请求 100KB 响应。
正常网络(0% 丢包,20ms RTT)
| 指标 | HTTP/2 | HTTP/3 | 差异 |
|---|---|---|---|
| 首次连接延迟 | ~60ms (TCP+TLS) | ~40ms (1-RTT) | -33% |
| 总传输时间 | 1.82s | 1.79s | -1.6% |
| TTFB(首字节延迟) | 42ms | 38ms | -9.5% |
| CPU 占用 | 12% | 18% | +50% |
正常网络下差距不大,HTTP/3 的首次连接快了一个 RTT,但总传输时间几乎相同。CPU 占用更高是因为 QUIC 在用户态做加密,没有内核的 TCP offload 优化。
丢包场景(2% 随机丢包,50ms RTT)
| 指标 | HTTP/2 | HTTP/3 | 差异 |
|---|---|---|---|
| 总传输时间 | 4.51s | 2.63s | -41.7% |
| P99 请求延迟 | 890ms | 320ms | -64.0% |
| 完全失败请求 | 3/50 | 0/50 | -100% |
| 重传率 | 8.2% | 2.1% | -74.4% |
这才是 HTTP/3 的杀手场景。 2% 的丢包在 HTTP/2 下导致所有流被阻塞(TCP HoL blocking),P99 延迟飙到 890ms。HTTP/3 由于流之间独立,只有丢包流受影响。
弱网场景(移动网络,切换 WiFi → 4G)
HTTP/2: 连接断开 → TCP 三次握手 → TLS 握手 → 重新发送请求
总中断时间: ~2.5s
HTTP/3: Connection ID 不变 → 0-RTT 恢复 → 继续传输
总中断时间: ~0.3s(几乎无感)
连接迁移是 QUIC 的独门绝技。用户从 WiFi 切到蜂窝网络,IP 地址变了,但 QUIC 的 Connection ID 没变,连接可以无缝继续。HTTP/2 只能断开重连。
(连接迁移的原理见 QUIC 协议拆解(下):用 C 实现一个最小 QUIC 握手)
首次连接延迟对比
HTTP/2 (TCP + TLS 1.3):
Client ──── SYN ────────────► Server ┐
Client ◄─── SYN-ACK ─────── Server │ 1 RTT (TCP)
Client ──── ACK + ClientHello ► Server │
Client ◄─── ServerHello ──── Server │ 1 RTT (TLS)
Client ──── Finished ───────► Server ┘
总计: 2 RTT
HTTP/3 (QUIC + TLS 1.3):
Client ──── Initial(ClientHello) ► Server ┐
Client ◄─── Initial(ServerHello) ─ Server │ 1 RTT
Client ──── Handshake + 请求 ────► Server ┘
总计: 1 RTT
HTTP/3 (0-RTT 恢复):
Client ──── Initial + 0-RTT 数据 ► Server ┐
Client ◄─── 响应 ──────────────── Server │ 0 RTT (应用数据)
总计: 0 RTT(应用数据角度)
CPU 开销
HTTP/3 的 CPU 开销确实更高,主要原因:
- 用户态加密:TCP 的 TLS 可以利用内核的 kTLS 和网卡的 offload,QUIC 全在用户态做
- UDP 系统调用开销:每个 QUIC 包是一个
UDP 包,系统调用次数更多(虽然有
sendmmsg/GSO优化) - QPACK 编解码:比 HPACK 略复杂
在高吞吐场景(比如 CDN 边缘节点),这个 CPU
差异可能是决定性的。Google 报告过 QUIC 的 CPU 开销是 TCP 的
2-3 倍。不过随着内核的 QUIC
模块(Linux 6.x)逐步成熟,这个差距在缩小。
六、部署 HTTP/3 的实际问题
理论讲完了,现在说说真上线要踩的坑。
Web 服务器支持现状
| 服务器 | HTTP/3 支持 | QUIC 库 | 备注 |
|---|---|---|---|
| Nginx | 1.25.0+ 原生支持 | quictls / BoringSSL | 需编译时
--with-http_v3_module |
| Caddy | 默认开启 | quic-go (Go) | 零配置,开箱即用 |
| H2O | 完整支持 | quicly (C) | 性能最好,但用户少 |
| LiteSpeed | 完整支持 | lsquic (C) | 商业产品,性能强 |
| Apache | 实验性 | — | 不推荐生产使用 |
Caddy 是最简单的选择——安装即支持 HTTP/3,不需要额外配置:
# Caddy 配置 - 自动支持 HTTP/3
example.com {
# 自动获取 TLS 证书
# 自动启用 HTTP/3
# 自动添加 Alt-Svc 头
root * /var/www/html
file_server
}
Nginx 需要手动编译和配置:
# Nginx HTTP/3 配置
server {
# 同时监听 TCP (HTTP/2) 和 UDP (HTTP/3)
listen 443 ssl;
listen 443 quic reuseport;
server_name example.com;
ssl_certificate /etc/ssl/certs/example.com.pem;
ssl_certificate_key /etc/ssl/private/example.com.key;
# 告诉浏览器可以用 HTTP/3
add_header Alt-Svc 'h3=":443"; ma=86400';
# 开启 0-RTT(注意重放攻击风险)
ssl_early_data on;
location / {
root /var/www/html;
}
}
Alt-Svc:HTTP/3 的发现机制
浏览器不会直接尝试 HTTP/3 连接——它先用 HTTP/2 (TCP) 连接,服务端在响应头里告诉它:「嘿,我支持 HTTP/3,下次可以用 UDP 443 端口连。」
# 服务端响应头
HTTP/2 200 OK
alt-svc: h3=":443"; ma=86400
content-type: text/html
# 解读:
# h3=":443" → 支持 HTTP/3,端口 443
# ma=86400 → 这个信息有效期 24 小时
浏览器收到后,下次请求会同时尝试 TCP 和 UDP(称为 Happy Eyeballs v2),哪个先连上用哪个。一旦 HTTP/3 连接成功,后续请求就全走 HTTP/3。
还有一种更快的方式——HTTPS DNS 记录(RFC 9460):
# DNS 查询返回
example.com. IN HTTPS 1 . alpn="h3,h2" port=443
# 浏览器看到 alpn="h3" 后,可以直接尝试 HTTP/3
# 不需要先走 HTTP/2 再升级
企业防火墙阻断 UDP:深水区
这是 HTTP/3 部署最大的现实障碍。很多企业防火墙默认放行 TCP 443,但阻断 UDP 443。
实测数据:大约 3-5% 的网络环境无法建立 QUIC 连接。
QUIC-over-TCP 回退
当 UDP 完全不通时,QUIC 协议本身跑不起来。社区讨论过 QUIC-over-TCP 封装(类似 WireGuard over TCP 的思路),但这基本抵消了 QUIC 的所有优势——你在 TCP 上跑 QUIC,又在 QUIC 上跑 HTTP/3,多了一层封装,队头阻塞又回来了。
RFC 9369(QUIC Version 2)没有解决这个问题,它只是更新了加密套件和版本协商机制。截至目前,IETF 没有标准化的 QUIC-over-TCP 回退方案。实际做法是:UDP 不通就老老实实用 HTTP/2 over TCP。
Alt-Svc 升级机制
浏览器不会盲目尝试 HTTP/3。升级流程是渐进式的:
首次访问 example.com:
1. 浏览器用 TCP + TLS 建立 HTTP/2 连接(稳妥的默认行为)
2. 服务端在响应头中声明:Alt-Svc: h3=":443"; ma=86400
3. 浏览器缓存这个信息(有效期 24 小时)
第二次访问 example.com:
4. 浏览器发现缓存中有 Alt-Svc 记录
5. 同时发起 TCP 和 UDP 连接(Happy Eyeballs)
6. 哪个先成功用哪个
7. 如果 HTTP/3 成功,后续请求全走 HTTP/3
更快的方式是用 HTTPS DNS 记录(RFC 9460),在 DNS 解析阶段就告知客户端支持 HTTP/3:
example.com. IN HTTPS 1 . alpn="h3,h2" port=443
这样浏览器在首次访问时就能尝试 HTTP/3,不需要先走 HTTP/2 “发现” Alt-Svc。
Happy Eyeballs v3:TCP + QUIC 赛跑
传统 Happy Eyeballs(RFC 8305)解决的是 IPv4/IPv6 双栈选择问题。新一代算法扩展到了传输协议选择:
Happy Eyeballs v3 逻辑:
t=0ms: 发起 QUIC (UDP 443) 连接
t=250ms: 如果 QUIC 没响应,并行发起 TCP 443 连接
t=???: 哪个先完成握手,用哪个
如果 QUIC 多次失败(3 次以上):
→ 标记该域名 QUIC broken,直接走 TCP
→ 每隔 5 分钟重试一次 QUIC
→ 指数退避:5min → 10min → 20min → 最大 1 小时
Chrome 的实现中,QUIC 的”抢跑优势”是 0ms(直接开始),TCP 延迟 250ms 才启动。如果你的网络环境 QUIC 能通,几乎总是 QUIC 赢。
企业中间件兼容策略
对于必须穿越企业防火墙/代理的场景:
- 申请开放 UDP 443:最直接的方案。QUIC 用的是 UDP 443 端口,和 HTTPS 相同端口号,安全策略上容易审批
- 部署在防火墙前面:CDN 边缘节点天然在防火墙外侧,Cloudflare/Akamai 已全面支持 HTTP/3
- 检测并降级:客户端实现 QUIC 可用性探测,不通则降级到 HTTP/2,记录降级比例用于推动网络改造
- 透明代理兼容:一些中间件会篡改 UDP 包导致 QUIC 握手失败——QUIC 的 Connection ID 和加密设计让中间件无法有效代理,这既是安全优势也是兼容性障碍
应对策略:
# 客户端(浏览器)的降级逻辑伪代码
def connect(url):
# 1. 如果有缓存的 Alt-Svc 信息,先尝试 HTTP/3
if has_alt_svc_cache(url):
h3_conn = try_quic_connect(url, timeout=300ms)
if h3_conn:
return h3_conn
# QUIC 失败,标记这个域名暂时不用 HTTP/3
mark_quic_broken(url, duration=5min)
# 2. 回退到 HTTP/2 over TCP
h2_conn = tcp_connect(url)
# 3. 检查 Alt-Svc 头,为下次请求做准备
if 'alt-svc' in h2_conn.response_headers:
cache_alt_svc(url, h2_conn.response_headers['alt-svc'])
return h2_connChrome 的实际行为更精细:如果 QUIC 连接失败,它会对该域名禁用 QUIC 5 分钟,然后再试。多次失败后禁用时间逐步延长。
QUIC 上的拥塞控制
HTTP/3 跑在 QUIC 上,而 QUIC 的拥塞控制(CC)完全在用户态实现。这既是杀手锏也是双刃剑。
为什么用户态 CC 是优势
TCP 的拥塞控制在内核里,改一次需要升级内核版本,全球部署得等几年。QUIC 的 CC 在应用层,想换算法重启进程就行。Google 从 CUBIC 换到 BBR 只用了几周的灰度发布,不需要等 Linux kernel release cycle。
这也意味着不同的 QUIC 实现可以用不同的 CC 算法——Google 用 BBRv2,Cloudflare 用 CUBIC,Facebook 用 Copa,各自针对自己的流量特征调优。
为什么用户态 CC 是风险
TCP 的 CC 由内核统一管理,所有 TCP 连接”公平”竞争带宽。QUIC 连接跑在 UDP 上,内核看不到它的拥塞状态。如果一个 QUIC 实现的 CC 算法过于激进,它可能不公平地抢占同一链路上 TCP 连接的带宽。
这不是假设——BBR 的早期版本就被观察到在与 CUBIC TCP 共享瓶颈链路时获取了不成比例的带宽份额。
CC 算法在 QUIC 上的表现对比
| 算法 | 设计思想 | 0% 丢包吞吐 | 2% 丢包吞吐 | 与 TCP 公平性 | 备注 |
|---|---|---|---|---|---|
| CUBIC | 基于丢包的传统 CC | 基准线 | 下降 ~40% | ✅ 好(TCP 默认也是 CUBIC) | QUIC 默认推荐,保守稳妥 |
| BBR | 基于带宽-延迟模型 | +15-20% | 下降 ~10% | ⚠️ 较差(抢带宽) | 高带宽场景优势明显 |
| BBRv2 | BBR 改进版,增加公平性 | +10-15% | 下降 ~15% | ✅ 改善 | Google 内部主力,逐步推广 |
关键观察:
- 低丢包场景:BBR 系列明显优于 CUBIC,因为 CUBIC 需要”故意制造丢包”来探测带宽上限,而 BBR 用 RTT 变化来推断
- 高丢包场景(>5%):差距缩小,所有算法都在挣扎,QUIC 的优势主要来自流间独立性而非 CC 算法
- 公平性:CUBIC 最公平(和 TCP CUBIC 行为一致),BBRv1 最不公平,BBRv2 折中
不同丢包率下的 QUIC 性能概览
| 丢包率 | CUBIC 相对吞吐 | BBR 相对吞吐 | BBRv2 相对吞吐 | 说明 |
|---|---|---|---|---|
| 0% | 100%(基准) | 118% | 112% | BBR 探测带宽更激进 |
| 0.1% | 95% | 115% | 110% | CUBIC 开始反应丢包 |
| 1% | 72% | 105% | 95% | CUBIC 大幅退让 |
| 2% | 60% | 92% | 85% | 所有算法下降 |
| 5% | 35% | 65% | 58% | 高丢包场景全面恶化 |
(数据基于单连接、100Mbps 链路、50ms RTT 的模拟环境,实际网络因场景而异。)
如果你在部署 QUIC 服务端,CC 算法的选择建议: - 公共互联网服务:CUBIC 或 BBRv2(公平性优先,避免被运营商限速) - 自有网络/数据中心内部:BBRv2(可以更激进,不用担心公平性) - 高丢包场景(移动网络、卫星链路):BBRv2(丢包容忍度更好)
监控和调试工具
上线 HTTP/3 后,你需要能看到它:
# 1. curl 检查 HTTP/3 支持
curl -sI --http3 https://example.com 2>&1 | grep -i "http/3\|alt-svc"
# 2. 浏览器开发者工具
# Network 面板 → Protocol 列 → 显示 "h3"
# 3. Wireshark 解析 QUIC
# 过滤器: quic && http3
# 需要配置 TLS key log 才能解密
# 4. qlog:QUIC 专用日志格式
# https://github.com/quicwg/qlog
# 用 qvis (https://qvis.quictools.info/) 可视化
# 5. 服务端指标(Prometheus 示例)
# quic_connections_active 当前活跃 QUIC 连接数
# quic_handshake_duration_ms 握手延迟分布
# quic_streams_opened_total 打开的 stream 总数
# quic_packet_loss_rate 丢包率
# h3_requests_total HTTP/3 请求总数
# h3_fallback_to_h2_total 降级到 HTTP/2 的次数一个实用的健康检查脚本:
#!/bin/bash
# 检查目标站点的 HTTP/3 支持状态
TARGET="https://example.com"
echo "=== HTTP/3 健康检查 ==="
# 检查 Alt-Svc 头
ALT_SVC=$(curl -sI "$TARGET" 2>/dev/null | grep -i "alt-svc")
if [ -n "$ALT_SVC" ]; then
echo "[✓] Alt-Svc 头存在: $ALT_SVC"
else
echo "[✗] 未发现 Alt-Svc 头"
fi
# 尝试 HTTP/3 连接
H3_STATUS=$(curl -s -o /dev/null -w "%{http_version}" --http3 "$TARGET" 2>/dev/null)
if [ "$H3_STATUS" = "3" ]; then
echo "[✓] HTTP/3 连接成功"
else
echo "[✗] HTTP/3 连接失败(可能是 UDP 被阻断)"
fi
# 对比延迟
H2_TIME=$(curl -s -o /dev/null -w "%{time_total}" --http2 "$TARGET" 2>/dev/null)
H3_TIME=$(curl -s -o /dev/null -w "%{time_total}" --http3 "$TARGET" 2>/dev/null)
echo "HTTP/2 延迟: ${H2_TIME}s"
echo "HTTP/3 延迟: ${H3_TIME}s"动手试一下
理论讲完了,来实际体验一下 HTTP/3。最简单的方式是用 curl:
# 安装支持 HTTP/3 的 curl(Ubuntu)
sudo apt install curl # 7.88+ 内置 HTTP/3 支持
# 测试 HTTP/3 连接
curl --http3-only -I https://cloudflare-quic.com/
curl --http3 -v https://www.google.com/ 2>&1 | grep "using HTTP/3"
# 用 tc netem 模拟丢包测试
sudo tc qdisc add dev eth0 root netem loss 2% delay 50ms
# 对比 HTTP/2 vs HTTP/3
curl -w "time_total: %{time_total}s\n" --http2 https://example.com/
curl -w "time_total: %{time_total}s\n" --http3 https://example.com/
sudo tc qdisc del dev eth0 root总结
HTTP/3 不是 HTTP/2 的简单升级,它是为 QUIC 量身定制的应用层协议。把 HTTP/2 里那些为了弥补 TCP 缺陷而设计的机制全部拿掉,让传输层的归传输层、应用层的归应用层:
- 流多路复用:不再由 HTTP 层模拟,直接用 QUIC stream
- 流控:删掉,QUIC 自己做
- 头部压缩:从 HPACK 换成 QPACK,兼容无序传输
- 帧格式:简化,去掉冗余字段
现实中,HTTP/3 在丢包和弱网环境下优势明显,正常网络下差距不大。部署的最大障碍是 UDP 防火墙——但随着 Cloudflare、Google、Meta 等大厂全面推 HTTP/3,企业网络也在逐步放开。
如果你还没看过 QUIC 的底层原理,建议回头读一下 QUIC 协议拆解(上):为什么 TCP 改不动了 和 QUIC 协议拆解(下):用 C 实现一个最小 QUIC 握手。理解了 QUIC,HTTP/3 就是水到渠成的事。
延伸阅读