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

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

文章导航

分类入口
network
标签入口
#http3#quic#qpack#http2#head-of-line-blocking#alt-svc#web-performance#协商

目录

阅读路径(TLS 1.3 ↔︎ QUIC ↔︎ HTTP/3)

文章 分工
TLS 1.3 工程实践 RFC 8446 部署:1-RTT/0-RTT、early_data 重放风险
TLS 1.3 最小实现 握手与 HKDF 源码级拆解(与工程文二选一打底)
QUIC 上 设计动机;RFC 9000 传输语义
QUIC 下 Initial→1-RTT 实现;RFC 9001 TLS 映射
本文 RFC 9114 应用层:QPACK、流映射、H3 协商(Alt-Svc)

建议顺序:TLS 1.3(工程或 crypt)→ QUIC 上/下 → 本文。下文假定已理解 QUIC 流、Connection ID 与 TLS 1.3 内嵌握手。

QUIC 上QUIC 下 把传输层拆完:丢包不阻塞单流、连接可迁移、握手可合并为 1-RTT(RFC 9000, Section 7RFC 9001, Section 4)。加密语义与 TLS 1.3 工程实践 / 最小实现 同源。

但问题来了:HTTP 怎么跑在 QUIC 上?

直觉上最简单的方案是把 HTTP/2 原封不动搬到 QUIC 上。实际上 Google 最早的 gQUIC 就是这么干的,结果发现一堆问题——HTTP/2 的很多设计是为了弥补 TCP 的缺陷,而 QUIC 已经解决了这些缺陷,再叠一层就成了冗余。

所以 IETF 的做法是:在 QUIC 之上重新设计应用层协议,这就是 HTTP/3(RFC 9114)。帧格式变了(RFC 9114, Section 7),头部压缩换成 QPACK(RFC 9204),HTTP 层流控删掉——由 QUIC 承担(RFC 9114, Section 4.1)。看起来变化很大,但职责划分反而更清晰。

这篇就把 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 层) 单流内无;连接仍共享 CC(RFC 9002
连接迁移 不支持 支持(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 的做法(RFC 9114, Section 6):每个 HTTP 请求/响应交换映射到一条 QUIC 双向流。QUIC stream 之间独立——stream 3 丢包重传时,stream 5 与 stream 7 仍可继续(传输层 HoL 消除见 RFC 9000, Section 2.2)。注意:单条 QUIC 连接内仍共享拥塞控制(RFC 9002),「应用层无 HoL」不等于「连接级带宽无限」。

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 层的流控——没必要做两遍。

同样被删掉的还有 HTTP/2 的 PRIORITY 帧。HTTP/3 改用 Extensible Priorities(RFC 9218),通过 priority 头字段表达优先级——浏览器与 CDN 的实际调度效果仍在演进,不宜假设与 HTTP/2 优先级树等价。


二、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)把动态表更新和数据传输解耦。它引入两条专门的单向流(RFC 9204, Section 4.2):

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(引用静态表)  │
  └────────────────────────────────────────────────┘

关键设计(RFC 9204, Section 4.4):若 HEADERS 引用的动态表条目尚未经 Encoder Stream 同步,该 stream 阻塞等待QPACK_BLOCKED_STREAMS 上限见 RFC 9114, Section 7.2.4),而非直接解码失败;其他 stream 不受影响。

这样就把「全局有序」降级成了「按需等待」,保住了 QUIC 的无序特性。

静态表:98 个预定义头

QPACK 预定义 98 个常用头部(RFC 9204, Appendix A;HPACK 静态表见 RFC 7541, Appendix B):

# 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

编码效率对比(机制层,非 benchmark)

指标 HPACK (HTTP/2) QPACK (HTTP/3)
静态表大小 61 条(RFC 7541) 98 条(RFC 9204)
动态表同步 依赖 TCP 有序 Encoder/Decoder Stream
Huffman 编码 支持 支持(相同码表,RFC 9204 Section 4.3)
乱序容忍 是(单流可阻塞)
实现复杂度 中等 较高(需管理两条控制流 + blocked stream)

压缩率随站点头部分布变化极大;不宜引用未标注出处的「首次 ~60% / 后续 ~90%」类百分比——对比应在本机 trace 上自测,或引用带 workload 说明的第三方报告。


三、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)


五、性能:机制收益与引用数据

本节不包含本站自测 QPS/延迟表。 可核对的部分来自 RFC 定义的 RTT 步数;端到端吞吐、TTFB、P99 随页面大小、CDN 拓扑、丢包与 CPU offload 能力变化——下文只写机制推论带来源的外部报告,复现请用文末 curl + tc netem 在本环境自测。

5.1 握手 RTT(规范层)

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([RFC 8446](https://www.rfc-editor.org/rfc/rfc8446) + TCP 三次握手)

HTTP/3 (QUIC + TLS 1.3):
  Client ──── Initial(ClientHello) ► Server  ┐
  Client ◄─── Initial(ServerHello) ─ Server  │ 1 RTT
  Client ──── Handshake + 请求 ────► Server  ┘
  总计: 1 RTT([RFC 9001, Section 4](https://www.rfc-editor.org/rfc/rfc9001#section-4))

HTTP/3 (0-RTT 恢复):
  Client ──── Initial + 0-RTT 数据 ► Server  ┐
  Client ◄─── 响应 ──────────────── Server  │ 0 RTT(应用数据角度)
  总计: 0 RTT([RFC 8446, Section 2.3](https://www.rfc-editor.org/rfc/rfc8446#section-2.3);[RFC 9001, Section 4.6](https://www.rfc-editor.org/rfc/rfc9001#section-4.6))

冷启动节省 1 RTT 在跨洋链路上可直接换算为毫秒级延迟差;高带宽、短连接场景下这一差值往往小于连接建立后的传输时间,因此「HTTP/3 一定更快」不成立——需按业务 trace 测量。

5.2 多路复用、丢包与尾延迟

机制:HTTP/2 所有帧共享一条 TCP 字节流,丢包触发 TCP 重传时会阻塞同连接上全部 stream(RFC 9113 over TCP 的固有限制)。HTTP/3 把请求映射到独立 QUIC stream(RFC 9114, Section 6),丢包重传粒度在 stream 级(RFC 9000, Section 2.2)。

外部引用(Google 生产部署,非通用 benchmark):Langley et al., The QUIC Transport Protocol: Design and Internet-Scale Deployment, SIGCOMM 2017 报告,在 Google 搜索与 YouTube 客户端上 QUIC 相对 TCP+TLS 的延迟与卡顿率有个位数到两位数百分比量级的改善(具体数值依赖 Google 内部 workload 与客户端版本,见该文 Section 6 实测)。公网长期观测见 Kakhki et al., Taking a Long Look at QUIC, IMC 2017——结论之一是 QUIC 收益在高丢包、移动网络子集上更明显,干净链路差距缩小。

工程含义:若你的用户主要在低丢包数据中心或企业内网,HTTP/3 相对 HTTP/2 的吞吐优势可能不显著;若面向移动网、跨境 Wi‑Fi,更值得投入 UDP 443 可达性与 Alt-Svc 发现链路。

5.3 连接迁移

IP 变更时 HTTP/2 必须重建 TCP+TLS;QUIC 用 Connection ID 维持连接(RFC 9000, Section 9),配合 0-RTT 可跳过完整握手。原理见 QUIC 下恢复时间取决于路径 RTT、ticket 缓存与服务器 Early-Data 策略TLS 1.3 工程实践 第五节),不宜写死「× 秒」类数字。

5.4 CPU 与用户态栈

QUIC 加密、ACK、CC 多在用户态完成(RFC 9000),相对内核 TCP + kTLS/offload 路径 CPU 开销更高——Langley et al. (SIGCOMM 2017) 在 Google 边缘曾报告 QUIC 相对 TCP 的 CPU 成本上升,并通过批量发送(如 GSO/sendmmsg)缓解。Linux 6.x 起内核 QUIC 模块与 DPDK/XDP 等路径仍在演进,边缘节点容量规划必须按本机 QPS 压测,不能套用未标注硬件的倍数。

QPACK 编解码比 HPACK 多两条控制流(RFC 9204),在头部极大或动态表频繁更新的 API 上可能放大 CPU 占比——同样需 profile 验证。

5.5 本地对比方法(需自测)

# 模拟丢包/延迟后对比 time_total(结果仅对本机、本站点有效)
sudo tc qdisc add dev eth0 root netem loss 2% delay 50ms
curl -w "h2 time_total: %{time_total}s\n" --http2 -o /dev/null -s https://example.com/
curl -w "h3 time_total: %{time_total}s\n" --http3 -o /dev/null -s https://example.com/
sudo tc qdisc del dev eth0 root

并发、页面大小、CDN 缓存状态都会改变结论;不要把单次 curl 输出写成普适性能排名


六、部署 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(H3)的协商与发现机制

浏览器默认不直接猜 UDP 443——先用 TCP 建连,服务端在响应头声明 HTTP/3 能力,完成从 HTTP/2 到 H3 的协商发现(RFC 9114, Section 3.1;Alt-Svc 语法见 RFC 7838):

# 服务端响应头
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 RRRFC 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。

引用数据:Kakhki et al. (IMC 2017) 对公网 QUIC 可达性做过长期测量,显示中间盒与路径策略会导致部分客户端无法完成 QUIC 握手;Cloudflare、Google 边缘博客亦多次讨论企业网 UDP 过滤——具体失败比例因地区与 ASN 而异,本站未复现,上线应监控 h3_fallback_to_h2 类指标

QUIC-over-TCP 回退

当 UDP 完全不通时,QUIC 协议本身跑不起来。社区讨论过 QUIC-over-TCP 封装(类似 WireGuard over TCP 的思路),但这基本抵消了 QUIC 的所有优势——你在 TCP 上跑 QUIC,又在 QUIC 上跑 HTTP/3,多了一层封装,队头阻塞又回来了。

RFC 9369(QUIC Version 2)只更新版本协商与加密套件,不提供 QUIC-over-TCP 标准化回退(RFC 9369)。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:TCP + QUIC 并行

RFC 8305 解决地址族选择;Chromium 等实现将其扩展到「QUIC 与 TCP 赛跑」——典型行为是优先尝试 QUIC,若在实现定义的超时内无响应则并行发起 TCP(具体毫秒数随版本变化,以浏览器源码为准,不宜写死为 universal 常量)。QUIC 多次失败后会对域名临时禁用 HTTP/3 并指数退避重试。

企业中间件兼容策略

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

  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

Chromium 等实现会在 QUIC 反复失败后对域名启用临时 QUIC 禁用与指数退避(具体时长为实现细节,以目标浏览器版本为准)。

QUIC 上的拥塞控制

HTTP/3 继承 QUIC 连接级 CC(RFC 9002),在用户态实现——迭代算法不需等内核发版,但同一瓶颈链路上可能与 TCP 竞争带宽。

为什么用户态 CC 是优势

TCP CC 在内核全局生效;QUIC 栈可在进程内切换 NewReno、CUBIC、BBR 等(RFC 9002, Appendix B 列举参考算法)。Google 在 SIGCOMM 2017 文中描述了生产环境快速灰度 CC 的能力。

为什么用户态 CC 是风险

多 QUIC 流与 TCP 流共享瓶颈时,若 QUIC 侧 CC 更激进,可能不公平抢占带宽——Cardwell et al., BBR: Congestion-Based Congestion Control, ACM Queue 2016 及后续 BBRv2 工作讨论了与 CUBIC TCP 共存时的公平性问题;RFC 9002, Section 7 亦要求实现文档说明与 TCP 的互操作考量。

本站不提供 CC 算法吞吐排名表——不同丢包率、缓冲、bulk 与 web 短流混合下结论相反。部署建议(工程判断,非 benchmark):

监控和调试工具

上线 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"

七、开放问题

  1. QPACK blocked stream 与优先级:动态表同步阻塞单 stream(RFC 9204, Section 4.4)与 RFC 9218 优先级调度如何协同、是否引入新的尾延迟——浏览器与 CDN 实现分歧大,缺统一基准。
  2. 发现路径:Alt-Svc(RFC 7838)与 HTTPS RR(RFC 9460)并存,首次访问仍常需 TCP 探路;「零 RTT 发现 HTTP/3」尚无单一 IETF 强制 profile。
  3. 0-RTT + HTTP 语义:TLS Early Data(RFC 8446, Section 8)与 HTTP 幂等/重放边界在 API 网关层仍靠各厂自行约束——与 TLS 1.3 工程实践 第七节同源未决问题。
  4. QUIC–TCP 公平性与运营商策略:用户态 CC(RFC 9002)在混合瓶颈上的公平性无跨实现标准答案;部分网络对 UDP 443 限速/过滤的原因与趋势仍缺公开 longitudinal 数据(可跟进 IMC/SIGCOMM 网络测量 track)。
  5. Server Push 去留RFC 9114 保留 PUSH 帧,但 Chrome 已默认禁用 HTTP/2 Push;HTTP/3 Push 是否会在 CDN 缓存预填充之外找到产品化场景,社区倾向 pessimistic。

动手试一下

理论讲完了,来实际体验一下 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 在高丢包、连接迁移频繁的场景更有机制优势;低丢包、长连接 bulk 传输下差距可能不明显。部署障碍主要是 UDP 可达性与发现链路——需在本用户群上度量降级率,而非假设「大厂已推即全网畅通」。

同主题延伸QUIC 上 · QUIC 下 · TLS 1.3 工程实践 · TLS 1.3 最小实现


参考资料

规范

核心论文 / 部署报告

源码 / 工具

实验 / 自测

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · network

网络工程索引

汇总本站网络工程系列文章,覆盖分层模型、以太网、IP、TCP、DNS、TLS、HTTP/2/3、CDN、BGP 与故障诊断。

2025-08-04 · network

【网络工程】QUIC 生态与工程部署:从实验到生产

QUIC 已经不是实验性协议——HTTP/3 标准化后,CDN、浏览器和主流服务端框架都在推进 QUIC 支持。本文从工程视角对比主流 QUIC 库的成熟度和性能特征,讲解 CDN/负载均衡器的 QUIC 适配方案、从 TCP 迁移到 QUIC 的渐进路径、QUIC 调试工具链,以及生产环境的部署陷阱和性能调优实践。

2025-07-26 · network

【网络工程】可靠 UDP 框架:KCP、ENet 与 QUIC 的设计对比

TCP 的可靠传输是一种固定策略——全量有序、丢包即重传、拥塞窗口统一管理。但很多场景需要'可定制的可靠性':游戏要低延迟重传、视频要部分可靠、RPC 要多路复用无队头阻塞。本文深入对比 KCP、ENet、QUIC 三个在 UDP 上构建可靠传输的框架,剖析它们的 ARQ 策略、流控设计和工程取舍。


By .