第11章 QUIC 与 HTTP/3
叙事密度:中高 | 主语言:Python | Java/C++ 差异窗 | Vol 4·计算机网络
元数据卡
| 属性 | 内容 |
|---|---|
| 卷号 | Vol 4 — 计算机网络 |
| 章节 | 第11章:QUIC 与 HTTP/3 |
| 前置 | 第3章(TCP 深度剖析)、第4章(HTTP 与 Web 服务器)、第5章(HTTPS 与 TLS) |
| 后置 | 第12章(网络调试) |
| 理论深度 | (3/5) |
| Python 相关度 | ≈90%;aioquic 库实现 QUIC 客户端/服务器 |
| 核心模型 | QUIC 连接生命周期、0-RTT 握手、无 HOL 多路复用、QPACK |
| 代码量 | ~150 行 |
你的进度
"TCP 很好,但它的咒语来自 1980 年代的驿道世界。2020 年代的魔法驿道需要更快、更灵活、不担心队列阻塞的方案——QUIC 和 HTTP/3 是答案。它们抛弃了 TCP,直接在 UDP 魔力信鸽上重新构建可靠的传送。"
前面几章你深入理解了 TCP 的一切:三次法力握手建立可靠连接,TLS 加密法阵保障安全,拥塞控制在驿道拥挤时协调减速。这一套组合拳从 1990 年代开始统治驿道——HTTPS = TCP + TLS,这个等式天经地义。
但你有没有想过:
TCP + TLS = 两轮魔力握手(1.5 RTT TCP + 1-2 RTT TLS),延迟在长距离驿道上动辄几百毫秒。一道 HTTP 法术请求还没发出去,光是建立安全联盟就花了将近 3 个回咒。
这还不算 TCP 的队列头阻塞(HOL blocking)——一个 TCP 传送咒内,丢失一道法术信函,后面所有信函都得等着。即使它们属于完全不相关的信函流。
2013 年,Google 驿道大法师 Jim Roskind 提出了一个"疯狂"的想法:把 HTTP 的传送层从 TCP 换成 UDP 魔力信鸽。不,不是直接用 UDP 裸传——而是在 UDP 之上重新实现一个"TCP++",把这个新传送层叫做 QUIC(Quick UDP 驿道连接)。
6 年后,RFC 9000 发布了 QUIC 1.0。HTTP/3 也随之诞生——TCP 的 40 年统治正式迎来终结的开始。
本章分层
- 必读:TCP 有什么根本性问题导致 QUIC 诞生、QUIC 的四大设计决策(UDP、0-RTT、无 HOL、连接迁移)、HTTP/3 与 HTTP/2 的核心区别
- 选读:QUIC 包格式、QPACK 工作机制、aioquic 代码实现
- 进阶:QUIC 加密层细节(Initial/Handshake 包的保护机制)、QUIC Loss Detection 规范、多路径 QUIC
本章不会要求你掌握
- QUIC 的精确包编码(VARINT、Packet Header 的位级别解析)
- QUIC 拥塞控制的精确实现(它与 TCP 共享 AIMD 框架)
- QPACK 编码表的状态同步细节
你的任务
学完本章,你应当:
- 说清 TCP 队头阻塞和连接建立延迟为什么是 Web 性能的根本瓶颈
- 解释 QUIC 为什么选择 UDP 作为底层——"在应用层重新实现 TCP"
- 理解 0-RTT 握手如何工作以及它带来的安全风险
- 用 Python + aioquic 写一个 QUIC 客户端请求 HTTPS 资源
- 对比 HTTP/1.1、HTTP/2、HTTP/3 在传输层上的差异
- 知道哪些网站/CDN 实际部署了 QUIC & HTTP/3
破局 · 溯源
第1战:问题驱动——TCP 的三宗罪
问题: 你的法师观测镜要加载一个现代传送咒网页。这个页面有 1 道 HTML 法术令牌、5 道 CSS 样式咒、10 道 JS 脚本卷轴、20 张魔导图片——一共 36 个资源。法师观测镜第一次连这个信标塔。
第一宗罪:连接建立延迟
TCP 三次握手: SYN → 1 RTT (约 50ms)
← SYN-ACK
ACK →
TLS 1.3 握手: ClientHello → 1 RTT (约 50ms,合并在一个 RTT 内完成)
← ServerHello + Finished
Finished →
总耗时: ≈ 2 RTT = 100ms(即使信标塔就在同一座法师城)
如果服务器在另一个大洲(RTT 200ms):= 400ms在这 100-400ms 内,你的浏览器什么都做不了——连接还没建立。
第二宗罪:队头阻塞(Head-of-Line Blocking)
HTTP/2 用多路复用解决了 HTTP/1.1 的"一个连接同时只能发一个请求"的问题——多个流可以共享一个 TCP 连接。
但问题来了:TCP 的可靠性是字节流层面的:
TCP 连接 ↓
┌─────────────────────────────────────────────┐
│ 流1: [包1][包2][包3][包4]... │
│ 流2: [包A][包B][包C][包D]... │
│ 流3: [包α][包β][包γ][包δ]... │
└─────────────────────────────────────────────┘
TCP 传输层
↓
┌─────────────────────────────────────────────┐
│ [包1][包2][包3][包4][包A][包B][包C][包D]... │ ← 字节流
└─────────────────────────────────────────────┘如果 包3 丢了(属于流1),TCP 会阻塞所有后续包的递交——包括流2的 包C 和 包D,即使它们完全不相关。
时间轴:
包1 → 收到
包2 → 收到
包3 → 丢了 ← 流1 的一个包丢了
包4 → 收到但等待包3重传... ← 流1的其他包也卡住
包A → 收到但等待包3重传... ← 流2的包白到了!
包B → 收到但等待包3重传... ← 流2继续白等
...这就是 TCP 的队头阻塞。HTTP/2 的多路复用在 TCP 上被这个底层设计彻底架空了——只要丢一个包,后面的所有流都得等。
第三宗罪:连接迁移不存在
你正在驿道传送阵上看法术影像 → 走出法师塔 → 法术通讯石切到 4G 魔力信号 → TCP 连接断了 → 域名解析法阵重新解析 → 重新 TCP 三次法力握手 → 重新 TLS 封印握手 → 重新 HTTP 法术请求 → 法术影像法阵缓冲转圈。
WiFi (IP=10.0.0.5:44321) → 连接建立
走动...
切到 4G (IP=10.227.4.131:51234) → TCP 连接断了!TCP 用 (src_ip, src_port, dst_ip, dst_port) 四元组标识一个连接。换 IP = 连接终结。
第2战:QUIC 选择 UDP——"拆了 TCP,重新造轮子"
问题: TCP 有三宗罪——队列头阻塞、法力握手延迟、不能迁移。但你不想在驿道守护法阵内核里改 TCP——因为那意味着等全世界的驿道信标塔、护城法阵、守护法阵升级内核,需要十年以上。
QUIC 的答案简单到令人震惊:不碰内核,用 UDP。
为什么是 UDP?
UDP 是操作系统给你最小的网络抽象——给你一个
sendto()和一个recvfrom(),包能不能到、顺序对不对、会不会重复——统统不管。QUIC 就在这一层之上,在应用空间(userspace) 重新实现了 TCP 的所有功能(可靠传输、流量控制、拥塞控制)外加加密、多路复用、连接迁移——且不需要改一行内核代码。
传统: [应用层 HTTP/2]
[传输层 TCP] ← 内核空间,更新需要操作系统升级
[网络层 IP]
[链路层]
QUIC: [应用层 HTTP/3]
[QUIC (在应用空间)] ← 用户空间,更新 = 库升级/浏览器更新
[UDP] ← 操作系统只需要支持 UDP
[IP]
[链路层]"在用户空间重新实现 TCP" 意味着:
- 更新速度快:Chrome 可以随浏览器更新 QUIC 实现,不需要等 Windows/macOS/Linux 内核更新
- 灵活性强:可以试验新的拥塞控制算法、新的丢包检测机制
- 加密内建:不像 TCP+TLS 是两个独立协议拼装——QUIC 把加密写进了协议骨子里
C 差异窗:传统 TCP 栈在内核(Linux
net/ipv4/tcp_*.c),修改需要系统调用接口或加载内核模块。QUIC 的用户空间实现让开发者可以用 C/C++(如 lsquic、ngtcp2、quiche)或任何语言实现自己的 QUIC 栈,不需要接触内核代码。
QUIC 如何用 UDP 实现可靠传输?
TCP 用序列号(seq number)来保证有序交付和重传检测。QUIC 也用自己的序列号系统——但做了一点改良:
QUIC Packet Header:
┌─────────────────────────────────────────────┐
│ 连接ID (8-18字节) ← 不是四元组,是连接ID │
│ 包号 (1-8字节) ← 单调递增,永不重复 │
│ 负载 (加密的) ← 帧的集合 │
└─────────────────────────────────────────────┘关键差异:
| TCP 序列号 | QUIC 包号 | |
|---|---|---|
| 含义 | 字节流中的位置 | 严格单调递增的编号 |
| 重传时 | 重传用相同序列号 | 重传用新包号(彻底消除重传歧义) |
| RTT 计算 | 重传时无法区分是原始 ACK 还是重传 ACK | 每个包独一无二,精确计算 RTT |
TCP 重传时的"重传歧义问题"——发送端发送 seq=5 的包,丢失后重传,收到 ACK=11——你分不清这个 ACK 是针对原始包还是重传包的。QUIC 用不同的包号解决了这个问题:原始包号=5 丢失,重传包号=17——收到的 ACK=17 明确告知"重传的那个包到了"。
QUIC 的 Stream 机制——真正的无阻塞多路复用
QUIC 在内部维护了多条独立的流(Stream),每条流有自己独立的可靠序列号空间:
# 概念模型:QUIC 内部的独立流
class QUICStream:
"""一条 QUIC 流,有自己的可靠字节序列"""
def __init__(self, stream_id: int):
self.stream_id = stream_id
self.offset = 0 # 流内的偏移
self.buffer = {} # 收到的失序数据
self.next_expected = 0 # 下一个期望的偏移
class QUICConnection:
"""一条 QUIC 连接可以承载多个独立流"""
def __init__(self):
self.streams = {} # stream_id → QUICStream
def on_packet_received(self, packet_data: bytes):
# 解析出 QUIC 帧列表(帧可以携带流数据)
frames = parse_quic_frames(packet_data)
for frame in frames:
if frame.type == "STREAM":
stream = self.streams[frame.stream_id]
stream.buffer[frame.offset] = frame.data
# 只有这条流需要等 retransmit,不影响其他流!如果流1丢了一个包:QUIC 只会阻塞流1的数据递交。流2和流3可以继续愉快地递送数据——因为每条流有自己的序列号空间。
QUIC 连接 ↓(一个 UDP socket,内部多个流)
┌─────────────────────────────────────────────┐
│ 流1: [包1][包2][包3(丢了)][包4(等待中)] │ ← 只有流1卡住
│ 流2: [包A][包B][包C][包D] → 正常递交 │
│ 流3: [包α][包β][包γ][包δ] → 正常递交 │
└─────────────────────────────────────────────┘这才是 HTTP/2 承诺的"多路复用"在没有 TCP 队头阻塞时的真正形态。
第3战:0-RTT 连接建立——第一次握手还是需要,但之后不用了
问题: 每次 HTTP 法术请求都需要一个新的 TCP + TLS 连接(尽管 HTTP/2 和 HTTP/1.1 保持存活可以复用,但第一次连接或者长时间断开后重连还是慢)。在移动法术设备上,法师频繁切换驿道、法术卷轴后台被杀再重连——每次 2 RTT 的握手指数太贵了。
QUIC 的 0-RTT:首次连接 1-RTT,后续连接 0-RTT
首次连接(1-RTT)
客户端 服务器
│ │
│────── Initial (ClientHello) ──────→│ 1-RTT
│←── Handshake (ServerHello + Cert)─│
│────── Handshake (Finished) ──────→│
│══════════════ 1-RTT ══════════════│
│────── 1-RTT (HTTP 请求) ─────────→│ 可以发数据了这比 TCP + TLS 1.3 的 2-RTT 少了整整 1 个 RTT——因为 QUIC 在握手的同时就开始了加密通信(不需要像 TCP 那样先建裸连接再加密)。
后续连接(0-RTT)
关键在于:服务器在首次握手完成后,给客户端发了一个"未来的通行证"——会话票据(Session Ticket)。
# 0-RTT 握手概念示意
class QUICClient:
def __init__(self):
self.session_ticket = None # 首次连接后缓存
self.server_config = None
def first_connect(self, host: str):
"""首次连接:需要 1-RTT"""
# 交换密钥,建立连接
# 服务器返回加密的 Session Ticket
self.session_ticket = perform_handshake(host)
print("首次连接完成,缓存了 Session Ticket")
def reconnect(self, host: str):
"""再次连接同一服务器:0-RTT!"""
if self.session_ticket:
# 立刻发加密的 HTTP 请求 + Session Ticket
# 服务器验证 Ticket 后直接处理请求
http_response = send_early_data(
host=host,
session_ticket=self.session_ticket,
request="GET / HTTP/3"
)
print(f"0-RTT 连接成功!响应: {http_response[:50]}...")
return
# 没有缓存 → 正常 1-RTT
self.first_connect(host)工作流程:
第二次连接(0-RTT):
客户端 服务器
│ │
│────── 0-RTT 包 (HTTP 请求) ──────→│ 同时到达!不需要等握手
│────── Initial (ClientHello) ─────→│
│←── Handshake (ServerHello) ──────│
│══════════ 0-RTT ════════════════│
│←── HTTP 响应 ←─────────────────-│ 服务器已经处理完请求了客户端一发送 UDP 包,里面就包含了加密的 HTTP 请求。服务器验证 Session Ticket 有效,直接处理请求。不需要任何网络往返就能开始传数据。
第4战:连接迁移——从 WiFi 切到蜂窝,连接不断
问题: 换驿道网络,法术坐标变了,TCP 连接就死了。
QUIC 的解法:连接不再用 IP:Port 标识,而是用 64/128 位的连接 ID(Connection ID)。
class QUICConnectionMigration:
def __init__(self):
self.dst_connection_id = b"\x00\x01\x02\x03\x04\x05\x06\x07"
self.src_connection_id = b"\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11"
def on_network_change(self, new_ip: str, new_port: int):
"""WiFi 切到蜂窝:IP 变了,但连接ID不变"""
old_socket = self.socket
# 创建一个新的 UDP socket 绑定到新 IP
self.socket = create_new_udp_socket(new_ip, new_port)
# 用相同的连接ID继续通信
self.send_packet(
data="继续视频流数据...",
dest_id=self.dst_connection_id # 服务器靠这个认出客户端
)
print(f"连接迁移成功!IP 变了但连接ID={self.dst_connection_id.hex()}")WiFi: (192.168.1.5:44321) ─────→ 服务器 (1.2.3.4:443)
连接ID = 0x01234567
↓ 走出门
4G: (10.0.0.5:51234) ─────→ 服务器 (1.2.3.4:443)
连接ID = 0x01234567 ← 不变!服务器收到包,看到连接ID 0x01234567,从连接表里找到对应的连接状态(cwnd、加密密钥、未 ACK 的包列表)——继续处理,就像什么都没发生一样。
现实效果:Google 的内部数据显示,在移动设备上使用 QUIC 连接迁移,视频播放的"转圈等待"时间减少了 30% 以上。YouTube 使用 QUIC 后用户重连延迟从 >1000ms 降到了 <100ms。
第5战:TLS 1.3 内建——不是拼装,是一体化设计
问题: TCP + TLS 是"先建裸连接,再包一层加密"的两个独立协议。这意味着:
- TLS 握手的数据要经过 TCP 的可靠传输层 → 增加延迟
- TLS 的 Change Cipher Spec 需要额外的协议交互
- 加密只保护数据内容,不保护头部(TCP 头部可见)
- 中间件(如代理、负载均衡器)可以读 TCP 头部
QUIC 的设计哲学: 加密不是附加层,而是协议的核心组成部分。
TCP/TLS 视图:
┌──────────────────────┐
│ HTTP/2 数据 │
├──────────────────────┤
│ TLS 记录层 │ ← 应用数据才加密
├──────────────────────┤
│ TCP 头部 + 数据 │ ← TCP 头部明文可见
├──────────────────────┤
│ IP 头部 │
└──────────────────────┘
QUIC 视图:
┌──────────────────────┐
│ QUIC 头部 (部分加密)│ ← 只有连接ID部分可见
├──────────────────────┤
│ 帧: STREAM / CRYPTO │
│ (全部加密) │ ← 数据 + 控制帧全部加密
├──────────────────────┤
│ UDP 头部 │
├──────────────────────┤
│ IP 头部 │
└──────────────────────┘具体差异:
| TCP + TLS | QUIC | |
|---|---|---|
| 握手开销 | TCP 1.5 RTT + TLS 1-2 RTT = 2-3 RTT | 首次 1 RTT,后续 0 RTT |
| 头部可见性 | TCP seq/ack 明文可见(中间件能干扰) | 大部分头部加密 |
| 认证范围 | TLS 只认证数据内容 | QUIC 认证整个包(包括头部)——防篡改 |
| 协议层数 | 两个独立协议拼装 | 一个一体化协议 |
Java 差异窗:Java 20+ 引入了虚拟线程和
DatagramSocket的改进,但原生 Java 没有 QUIC 标准 API。你需要引入第三方库如 Netty + quic-incubator(https://github.com/netty/netty-incubator-codec-quic)或 direct 调用 C QUIC 栈(通过 JNI/JNR)。相比之下,Python 的aioquic和 Rust 的s2n-quic等库更贴近 QUIC 应用开发。
第6战:HTTP/3——HTTP 语义不变,但传输层换了
问题: HTTP/2 的多路复用被 TCP 的队列头阻塞坑了。解决方案只有一个——把传送层从 TCP 换成 QUIC。
HTTP/3 就是 HTTP over QUIC。
HTTP/1.1: HTTP/1.1 ← TCP ← IP
HTTP/2: HTTP/2 ← TCP ← IP (HPACK 压缩头部)
HTTP/3: HTTP/3 ← QUIC ← UDP ← IP (QPACK 压缩头部)HTTP/3 的协议层变化:
| 组件 | HTTP/2 | HTTP/3 |
|---|---|---|
| 传输层 | TCP | QUIC |
| 多路复用 | TCP 内部多流(受 HOL 影响) | QUIC 独立流(无 HOL) |
| 头部压缩 | HPACK(依赖 TCP 有序性) | QPACK(适配失序递交) |
| 服务器推送 | 支持 | 支持(通过 QUIC 流实现) |
| 流优先级 | 基于层的树状优先级 | 基于可扩展的优先级(RFC 9218) |
QUIC 上实现了 HTTP 的"帧"映射:
QUIC 流的基本框架:
┌─────────────────────────────────────────────┐
│ QUIC Packet │
│ ├─ Stream ID=0: CONTROL (单向) ← 控制帧 │
│ │ ├─ SETTINGS │
│ │ ├─ GOAWAY │
│ │ └─ PRIORITY_UPDATE │
│ ├─ Stream ID=2: RESPONSE (单向) ← 推送 │
│ │ └─ HEADERS + DATA │
│ ├─ Stream ID=3: REQUEST 1 (双向) │
│ │ └─ HEADERS + DATA │
│ └─ Stream ID=5: REQUEST 2 (双向) │
│ └─ HEADERS + DATA │
└─────────────────────────────────────────────┘关键:HTTP/3 每条 HTTP 请求/响应使用独立的 QUIC 流(Stream ID 递增)。一条流的丢包阻塞不会影响其他请求——这才是真正无队头阻塞的多路复用。
第7战:QPACK——为失序设计的头部压缩
问题: HPACK(HTTP/2 的头部压缩)依赖 TCP 的有序递交——如果两个流共享同一张压缩表,流A写了表项N,流B写了表项N+1,压缩表在有序的 TCP 流上正确前进。但 QUIC 的流可能失序到达——表项N+1可能比表项N先到!
QPACK 的解法:把压缩表分成两个独立空间——编码器表(Encoder Table)和解码器表(Decoder Table),并用额外的指令流同步。
HTTP/2 HPACK: HTTP/3 QPACK:
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 共享压缩表 │ │ 编码器表 │ │ 解码器表 │
│ (所有流共享)│ │ (发HEADERS) │ │ (收HEADERS) │
└────────────┘ └────────────┘ └────────────┘
│ ↑
└── 指令流 ────┘
(QUIC 单向流)
简化模型:
# QPACK 的工作原理(概念层面)
class QPACKEncoder:
"""服务器侧的 QPACK 编码器"""
def __init__(self):
self.static_table = {":method: GET": 0, "accept: */*": 1, ...}
self.dynamic_table = {} # 编码器自己的表
self.instruction_stream = QUICStream(stream_id=0, unidirectional=True)
def encode_headers(self, headers: dict) -> bytes:
"""编码头部:可能引用动态表项或发送字面值"""
encoded = b""
for name, value in headers.items():
key = f"{name}: {value}"
if key in self.static_table:
# 引用静态表(索引 = 0x00-0x3F)
encoded += encode_index(self.static_table[key])
elif key in self.dynamic_table:
# 引用动态表(索引 = 0x40-0x7F)
encoded += encode_dynamic_index(self.dynamic_table[key])
else:
# 插入动态表 + 发送字面值
idx = len(self.dynamic_table)
self.dynamic_table[key] = idx
encoded += encode_literal(name, value)
# 同步:通过指令流通知解码器"我在索引idx插入了新表项"
self.instruction_stream.send(encode_insertion(idx, name, value))
return encoded
class QPACKDecoder:
"""客户端侧的 QPACK 解码器"""
def __init__(self):
self.static_table = {0: ":method: GET", 1: "accept: */*", ...}
self.dynamic_table = {}
self.instruction_buffer = []
def receive_instruction(self, data: bytes):
"""从指令流接收编码器的表更新指令"""
# 可能失序到达!但指令流本身是 QUIC 独立流 → 流内有序
for instruction in parse_instructions(data):
if instruction.type == "INSERT":
self.dynamic_table[instruction.idx] = instruction.valueQPACK 的核心思想: 压缩表的同步不依赖 HTTP 请求/响应流的到达顺序——指令流是独立的 QUIC 单向流。编码器通过这个流通知解码器"我在动态表里插入了什么"。解码器收到 HEADERS 帧时,如果引用了还没到达的指令,就阻塞当前流(不影响其他流)。
对比 HPACK 的"所有流共享一个有序表" → 失序会直接破坏共享表的状态。QPACK 解耦了"头部数据"和"表同步"两个通道。
第8战:用 Python + aioquic 写 QUIC 客户端
下面我们用 Python 的 aioquic 库写一个真实的 QUIC 客户端。这不是模拟——这是真正的 QUIC 协议实现。
# quic_client.py — 真正的 QUIC 客户端
# 安装: pip install aioquic
# 运行: python quic_client.py https://cloudflare-quic.com/
import asyncio
import ssl
from urllib.parse import urlparse
from aioquic.asyncio.client import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3Connection
from aioquic.h3.events import HeadersReceived, DataReceived
class HttpClient(QuicConnectionProtocol):
"""使用 aioquic 的 QUIC + HTTP/3 客户端"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.http = None
self.response_data = b""
def on_handshake_complete(self):
"""连接握手完成后,发起 HTTP/3 请求"""
if not self.http:
self.http = H3Connection(self._quic)
# 发送 GET 请求
self.http.send_headers(
stream_id=self._quic.get_next_available_stream_id(),
headers=[
(b":method", b"GET"),
(b":path", b"/"),
(b":authority", self._authority.encode()),
(b":scheme", b"https"),
],
)
def on_connection_lost(self, exc):
asyncio.get_event_loop().stop()
async def send_early_data(self, authority: str):
"""0-RTT: 客户端在握手前就发送数据"""
self._authority = authority
def quic_event_received(self, event):
"""处理 QUIC 事件(头部、数据等)"""
if isinstance(event, HeadersReceived):
status = dict(event.headers).get(b":status", b"").decode()
print(f"HTTP/3 响应: 状态码={status}")
elif isinstance(event, DataReceived):
self.response_data += event.data
if event.stream_ended:
print(f"响应完成,共 {len(self.response_data)} 字节")
print("前 200 字节:")
print(self.response_data[:200].decode("utf-8", errors="replace"))
async def quic_get(url: str):
url = urlparse(url)
assert url.scheme == "https", "QUIC 只支持 HTTPS"
# 连接参数
host = url.hostname
port = url.port or 443
# 不需要证书验证(测试用)
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
print(f"连接到 {host}:{port} via QUIC...")
async with connect(
host=host,
port=port,
configuration=QuicConfiguration(
alpn_protocols=H3_ALPN,
is_client=True,
verify_mode=ssl.CERT_NONE,
),
create_protocol=HttpClient,
) as client:
# 握手完成后自动发送请求
await asyncio.sleep(2) # 等待响应
print("QUIC 请求完成")
return client.response_data
# 主程序:如果对方支持 HTTP/3 就用 QUIC,否则提示不支持
if __name__ == "__main__":
import sys
url = sys.argv[1] if len(sys.argv) > 1 else "https://cloudflare-quic.com/"
asyncio.run(quic_get(url))预期输出(连接到 cloudflare-quic.com——Cloudflare 的 QUIC 测试站点):
连接到 cloudflare-quic.com:443 via QUIC...
HTTP/3 响应: 状态码=200
响应完成,共 12345 字节
前 200 字节:
<!DOCTYPE html>
<html>
...Java 差异窗:Java 用
aioquic风格的库不多。推荐 Netty 的 QUIC 孵化器:java// Maven: io.netty.incubator:netty-incubator-codec-quic QuicChannel.newBootstrap(...) `.handler(new ChannelInitializer<QuicChannel>()` { protected void initChannel(QuicChannel ch) { ch.pipeline().addLast(new Http3ClientCodec()); } }) .connect(host, port);或者使用 Cloudflare 的 C 库 quiche 的 Java 绑定。相比之下 Python + aioquic 对新手友好得多。
进阶:QUIC 包格式与丢失检测
以下内容属于协议细节的深入层,主线不要求掌握。如果你在做网络协议实现或分析抓包,再看这里。
QUIC 包的四层保护
QUIC 对不同阶段的包使用不同的加密级别:
| 包类型 | 加密级别 | 用途 |
|---|---|---|
| Initial | Initial 密钥(来自公共参数) | 首次握手的 ClientHello/ServerHello |
| Handshake | Handshake 密钥 | 握手阶段的证书、密钥确认 |
| 0-RTT | 0-RTT 密钥(来自前次连接) | 重连时的早期数据 |
| 1-RTT | 应用数据密钥 | 正常的 HTTP 数据 |
为什么这样设计? 因为服务器在握手完成前不能直接识别客户端——但 Initial 包可以被任何第一次连接的客户端发送。用公开的 Initial 密钥保护它,防止中间人随意注入但又不依赖服务器的预知。
QUIC 的 Loss Detection
与 TCP 不同,QUIC 有两套确认机制:
- ACK 帧:类似 TCP,确认收到某个包号范围
- ECN(Explicit Congestion Notification):利用 IP 层的 ECN 标记
关键差异:QUIC 的 RTT 计算没有重传歧义问题
# 伪代码——无歧义的 RTT 计算
class QUICLossDetection:
def __init__(self):
self.sent_packets = {} # packet_number → sent_time
self.rtt_samples = []
def on_ack_received(self, acked_packet_numbers: list):
for pn in acked_packet_numbers:
sent_time = self.sent_packets.pop(pn, None)
if sent_time:
# 这个包号只被发送一次!(重传用新号)
rtt = time.now() - sent_time
self.rtt_samples.append(rtt)
def on_packet_sent(self, data: bytes) -> int:
packet_number = self.next_packet_number
self.next_packet_number += 1 # 单调递增,永不重复
self.sent_packets[packet_number] = time.now()
return packet_numberTCP 的序列号重传是"同一个序列号发两次";QUIC 的包号是"每个包唯一 ID"——简单、精确、没有歧义。
HTTP/1.1 vs HTTP/2 vs HTTP/3 对比
| 特性 | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| 传输层 | TCP | TCP | QUIC (UDP) |
| 连接复用 | 顺序复用(一次一个请求) | 多路复用 | 无队头阻塞的多路复用 |
| 队头阻塞 | 严重(一个连接一个请求) | TCP 级 HOL(致命) | 无(QUIC 独立流) |
| 握手延迟 | TCP 1.5 RTT | TCP 1.5 RTT + TLS 1-2 RTT | 首次 1 RTT,后续 0 RTT |
| 头部压缩 | 无 | HPACK(依赖 TCP 有序) | QPACK(面向失序) |
| 连接迁移 | 换网络不断连 | ||
| 二进制协议 | 文本 | 二进制帧 | 二进制帧 |
| 服务器推送 | |||
| 加密内建 | (HTTPS = TCP + TLS 附加) | (HTTPS = TCP + TLS 附加) | (QUIC 协议层内建) |
| 协议魔改 | 需要等网络设备升级 | 需要等网络设备升级(TCP 可见) | 用户空间可迭代更新 |
| 协议头部可见性 | 完全明文 | 部分明文 | 绝大部分加密 |
| OS 支持 | 任何 OS | 任何 OS | 需要 OS 无限制发送 UDP |
| CDN 支持 | 100% | 100% | 主流均已支持 |
实际部署:谁在用 QUIC & HTTP/3
截至 2026 年,QUIC/HTTP/3 的部署情况:
| 类型 | 支持情况 |
|---|---|
| 全栈部署:Chrome、YouTube、Gmail、Search。Google 内部甚至有自己的 QUIC 变种(gQUIC)经验才催生了标准化 | |
| Cloudflare | 所有客户站点默认启用 HTTP/3。free 套餐即有 |
| Facebook/Meta | Facebook、Instagram App 使用 QUIC |
| Akamai | 支持 HTTP/3 边缘加速 |
| Fastly | 支持 HTTP/3 |
| Apple | iOS/macOS 的 URLSession 原生支持 HTTP/3 |
| Chrome | 默认尝试 QUIC(如服务器支持),降级到 HTTP/2 或 HTTP/1.1 |
| Firefox | 支持 HTTP/3,默认开启 |
| Safari | 支持 HTTP/3 |
| Edge | 支持 HTTP/3 |
| Nginx | 从 1.25+ 开始实验性支持 HTTP/3 |
| curl | curl --http3(需编译时开启 quiche) |
| Node.js | 实验性 QUIC 支持(Node 21+) |
如何测试一个网站是否支持 HTTP/3?
# 方法1:curl
curl --http3 -I https://www.google.com/
# 方法2:用 dig 检查 Alt-Svc 记录
dig google.com ANY | grep -A2 h3
# 方法3:浏览器开发者工具→网络面板→协议列(显示 h3 表示 HTTP/3)抓包看 QUIC:
# tcpdump 抓 UDP 包(QUIC 在 UDP 443 端口)
sudo tcpdump -i any "udp port 443" -c 10 -X
# wireshark 过滤:quicQUIC 数据是加密的所以你看到的是乱码——但 Wireshark 能解析出 Packet Header 的字段(连接ID、包号等)。
常见陷阱
陷阱1:0-RTT 重放攻击
场景: 你发送了 0-RTT 请求"转账 1000 元到账户A"。这个请求被中间人截获了。
问题: 0-RTT 请求的加密数据在握手完成前就被发送了——服务器还没验证客户端身份。这意味着攻击者可以截获 0-RTT 包并多次重放给服务器。
# 0-RTT 重放攻击示意
# 攻击者截获了客户端的 0-RTT 包,连续重放 10 次
for i in range(10):
server.handle_0rtt_packet(sniffed_0rtt_request)
# 服务器执行了 10 次转账操作!QUIC 的应对:
- 0-RTT 幂等性要求:客户端不应该在 0-RTT 中发送非幂等操作(如 POST/PUT 中的状态变更)。RFC 要求服务器只对安全幂等的方法(如 GET、HEAD、OPTIONS)处理 0-RTT 数据
- Anti-Replay 机制:服务器可以限制 0-RTT 窗口(如只接受 10 秒内的 0-RTT 请求),或使用重复检测缓存
- 实际使用建议:支付/登录等敏感操作不要配 0-RTT
陷阱2:UDP 被某些网络阻塞
场景: 你的 App 使用 QUIC,但在某个企业 WiFi 上连接失败——退回到了 HTTP/1.1。
问题: 有些企业网络、公共 WiFi、或者某些 ISP 会限制或完全阻断 UDP 流量。QUIC 运行在 UDP 上,UDP 被阻 = QUIC 不能工作。
客户端 (QUIC) ── UDP 443 →── 企业防火墙 ──→ 服务器
│
├── UDP 规则: DROP
│ 理由:"UDP 不安全 / 用于 P2P"
│
└──→ 客户端超时,回退到 TCPQUIC 的应对: HTTP/3 协商机制——客户端尝试 QUIC 连接,如果超时或失败,自动回退到 HTTP/2 over TCP 或 HTTP/1.1:
# 浏览器实际的优雅降级逻辑
async def fetch_with_http3_fallback(url: str):
try:
# 先尝试 HTTP/3 (QUIC over UDP)
response = await quic_request(url, timeout=5.0)
return response
except (QuicConnectionError, TimeoutError, OSError):
# QUIC 失败 → 优雅降级到 HTTP/2
print(f"QUIC 连接失败,回退到 HTTP/2 (TCP)")
response = await http2_request(url)
return response
except Http2ConnectionError:
# HTTP/2 失败 → 再降级到 HTTP/1.1
print(f"HTTP/2 连接失败,回退到 HTTP/1.1")
response = await http11_request(url)
return response最佳实践: QUIC 应该作为"性能优化"而不是"唯一选择"。总是实现 TCP 回退。
陷阱3:连接迁移的安全风险
场景: 你的法术卷轴用传送阵访问浮空法术材料交易行,切到魔导缆线后连接继续。
问题: 攻击者如果在 WiFi 期间获得了你的 QUIC 连接 ID,能否伪造你继续通信?
QUIC 的应对——源地址验证(Source Address Validation):
- 首次连接:服务器在握手时验证客户端的 IP 地址
- 连接迁移时:服务器先验证新路径(发送探测帧,验证新 IP 可达)才启用新路径的数据传输
- 连接 ID 轮换:客户端在迁移时可以使用新的连接 ID——这防止被动的路径观察者将持续追踪你的连接
# 安全迁移示意
class SecureMigration:
def migrate(self):
# 1. 生成新的连接 ID
new_scid = os.urandom(8)
# 2. 通过旧路径通知服务器:"我要切到新路径"
self.send_packet(
dest_id=self.original_dcid,
frame=NEW_CONNECTION_ID(new_cid=new_scid)
)
# 3. 通过新路径发送探测帧
self.send_packet_on_new_path(
dest_id=self.original_dcid, # 服务器用这个识别我
src_id=new_scid, # 但用新 ID 回复我
frame=PING
)
# 4. 等待服务器回复 NEW_CONNECTION_ID 验证新路径
# 验证通过前,新路径的数据被视为"有效但不可信"
# 只有收到验证回复后,才使用新路径发送应用数据通关挑战
挑战1:查看你当前连接的 QUIC/HTTP 版本()(boringly easy)
# 方法1:Chrome → DevTools → Network → Protocol 列
# 方法2:curl(需支持 HTTP/3)
curl --http3 -I https://www.youtube.com/ 2>&1 | grep -i "alt-svc\|h3"
# 方法3:用 Online 工具
# https://http3check.net/记录你访问的三个知名网站(Google、YouTube、Cloudflare)使用的协议版本。尝试解释:为什么有的网站用 HTTP/3,有的还是 HTTP/2?
挑战2:模拟 QUIC 的无 HOL 多路复用()
修改下面代码,模拟一个 QUIC 连接中 3 条流的独立递交。在"丢包事件"发生时,只阻塞受影响的那条流。
import random
class Stream:
def __init__(self, sid: int):
self.id = sid
self.buffer = {}
def deliver(self):
"""尝试递交有序数据"""
# TODO: 实现有序递交逻辑
class QUICConnection:
def __init__(self):
self.streams = {1: Stream(1), 3: Stream(3), 5: Stream(5)}
self.lost_packets = {3} # 模拟流1的包3丢了
def on_packet(self, stream_id: int, seq: int, data: str):
# TODO: 丢包时只阻塞当前流
pass扩展: 对比如果所有流共享一个 TCP 连接,丢包 3 时会发生什么。
挑战3:用 curl --http3 对比延迟()
# 如果 curl 编译了 quiche/ngtcp2
time curl --http1.1 -so /dev/null https://www.google.com/
time curl --http3 -so /dev/null https://www.google.com/比较首次连接的耗时差异。注意:需要安装支持 HTTP/3 的 curl(Linux 上 sudo apt install curl 可能不够,需要编译版或 cloudflare-quic.com 等测试站点)。
挑战4:制作 HTTP 协议演进对比表()
用 Python 画一个 ASCII 或 Markdown 表格,列出 HTTP/1.1 → HTTP/2 → HTTP/3 在以下维度的对比:
- 传输层
- 连接复用
- 握手指数(首次)
- 握手指数(重连/0-RTT)
- 头部压缩
- 队头阻塞
- 连接迁移
验收标准
完成本章后,你应当可以:
- [ ] 向一个 TCP 开发者解释 TCP 的队头阻塞是怎么发生的
- [ ] 说清 QUIC 为什么选择 UDP 而不是直接改 TCP
- [ ] 对比 QUIC 首次连接(1-RTT)和后续连接(0-RTT)的差异
- [ ] 解释 QUIC 连接迁移的工作原理(连接ID 替代 IP:Port)
- [ ] 区分 HTTP/2 的 HOL 阻塞(TCP 层面)和 HTTP/3 的无阻塞(QUIC 流层面)
- [ ] 明白为什么 0-RTT 不能用于非幂等请求(重放攻击风险)
- [ ] 知道如果一个网站不支持 QUIC,你的请求会回退到 HTTP/2 或 HTTP/1.1
常见卡点
| 卡点 | 原因 | 解药 |
|---|---|---|
| QUIC 基于 UDP,那它怎么保证可靠? | 直觉上 UDP = 不可靠 | QUIC 在应用空间重新实现了重传、ACK、流量控制、拥塞控制——不是用 UDP 的不可靠,而是在 UDP 之上建可靠的传输层 |
| HTTP/2 不是已经解决了队头阻塞? | HTTP/2 解决了"应用层"的 HOL(一个请求等另一个) | 但 TCP 层面的 HOL 还在——一个 TCP 连接内字节流有序是最基本的承诺,QUIC 的独立流空间才真正解决了 |
| 为什么 0-RTT 不安全? | "加密了怎么就还能被攻击?" | 0-RTT 数据在握手完成前发送,此时服务器还没确认客户端身份——重放攻击的窗口就在这里 |
| QUIC 能彻底替代 TCP 吗? | 技术上看 QUIC 更强 | 硬件和网络基础设施上,TCP 卸载(TSO、RSS、硬件校验)已经普及,QUIC 在 UDP 上无法利用这些硬件优化。短期不会全替代 |
| Rust Go 对 QUIC 支持怎么样? | 看到 Python 示例觉得其他语言不支持 | Rust 的 s2n-quic(AWS)、quinn、Go 的 quic-go 都很成熟。Java 生态稍慢(Netty 孵化器) |
| 连接迁移真的常用吗? | 只在切换网络时用 | 日常场景频繁:WiFi ↔ 蜂窝、VPN 断开/连接、负载均衡器切换路径。对移动应用的体验提升巨大 |
现在不需要理解
- QUIC 的精确包格式:长头部 vs 短头部,Packet Number 的编码(Variable-Length Integer)、Connection ID 的格式(基于 DCID/SCID 长度的设计)。这些是协议实现者的工作,应用层开发者不需要关心。
- QUIC 的拥塞控制详细实现:QUIC 内部与 TCP 共享 AIMD 框架(由于流水线复用,设计上几乎相同)。另,QUIC 的 Loss Detection 不使用超时而是使用"时基丢包检测"(Time-Based Loss Detection),那是 RFC 9002 的三个附录。
- 多路径 QUIC(MPQUIC):同时使用多条路径(如 WiFi + 蜂窝同时发数据)。实验性标准,还没有广泛部署。主路径迁移已经够强。
- QUIC 的 0-RTT 密钥更新细节:0-RTT 使用前次连接的 Application Traffic Secret 的前向推导。理解"缓存 Session Ticket = 0-RTT 的唯一凭证"就够了。
- HTTP/3 的流类型详细编码:控制流、推送流、编码器指令流、解码器指令流之间的完整互动关系。QPACK 的编码细节(Required Insert Count、Delta Base)。
旅人笔记
QUIC 和 HTTP/3 可能是过去 20 年互联网传输层最重要的变革。
它的核心哲学其实很简单:
- TCP 很好,但它在用户空间不能动,也不能解决队头阻塞和连接迁移
- 操作系统内核不是给应用开发者随便改的——那就绕开它,用 UDP
- 在 UDP 上重新设计一个"TCP++"——加加密、加多流、加0-RTT、加连接迁移
- HTTP/3 只是在这个新传输层上跑的 HTTP 而已
每一层的问题都来自底层的约束。TCP 约束了 HTTP/2——所以 QUIC 来了。QUIC 也约束(UDP 不可靠、0-RTT 可重放)——所以有应用层的应对策略。
这就是网络协议的贸易模式:没有完美方案,只有不断妥协和权衡。 QUIC 交出了比 TCP 更好的权衡表——换 IP 不断连、等待握手可以发数据、丢一包不卡全队——代价是 UDP 被阻时回退、0-RTT 有安全窗口。
一句话消化本章: QUIC = 在 UDP 上重造了一个更现代的 TCP(加密、多流、0-RTT、可迁移),HTTP/3 = HTTP over QUIC,从此 TCP 的队头阻塞不再掣肘 Web 性能。
→ 下一站预告
网络调试——当你读完了 TCP、UDP、QUIC、HTTP 的所有理论,接下来就是真正面对"网页打不开"的时刻。
下一章,我们把前面所有知识变成实战武器:从 ping 到 tcpdump,从 Wireshark 到 scapy,系统性地解决所有网络故障。