土法炼钢兴趣小组的算法知识备份

HTTP/3 实战:从 QUIC 到 H3 的完整请求链路

目录

上两篇我们把 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: httpsuser-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):

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

注意几个被删掉的帧类型:

控制流(Control Stream)

HTTP/3 有一条特殊的单向流叫控制流。连接建立后,双方各打开一条控制流,用来交换 SETTINGSGOAWAY 等连接级别的帧。

控制流的作用:

  客户端 ──── 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 保留了这个功能但做了简化:

  1. 服务端在请求流上发送 PUSH_PROMISE 帧(而不是 HTTP/2 的 promised stream)
  2. 推送的响应在一条新的单向推送流上发送
  3. MAX_PUSH_ID 帧用来限制推送数量
  4. 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 开销确实更高,主要原因:

  1. 用户态加密:TCP 的 TLS 可以利用内核的 kTLS 和网卡的 offload,QUIC 全在用户态做
  2. UDP 系统调用开销:每个 QUIC 包是一个 UDP 包,系统调用次数更多(虽然有 sendmmsg/GSO 优化)
  3. 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 赢。

企业中间件兼容策略

对于必须穿越企业防火墙/代理的场景:

  1. 申请开放 UDP 443:最直接的方案。QUIC 用的是 UDP 443 端口,和 HTTPS 相同端口号,安全策略上容易审批
  2. 部署在防火墙前面:CDN 边缘节点天然在防火墙外侧,Cloudflare/Akamai 已全面支持 HTTP/3
  3. 检测并降级:客户端实现 QUIC 可用性探测,不通则降级到 HTTP/2,记录降级比例用于推动网络改造
  4. 透明代理兼容:一些中间件会篡改 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_conn

Chrome 的实际行为更精细:如果 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 内部主力,逐步推广

关键观察:

不同丢包率下的 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/3 在丢包和弱网环境下优势明显,正常网络下差距不大。部署的最大障碍是 UDP 防火墙——但随着 Cloudflare、Google、Meta 等大厂全面推 HTTP/3,企业网络也在逐步放开。

如果你还没看过 QUIC 的底层原理,建议回头读一下 QUIC 协议拆解(上):为什么 TCP 改不动了QUIC 协议拆解(下):用 C 实现一个最小 QUIC 握手。理解了 QUIC,HTTP/3 就是水到渠成的事。


延伸阅读


By .