Skip to content

第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 编码表的状态同步细节

你的任务

学完本章,你应当:

  1. 说清 TCP 队头阻塞和连接建立延迟为什么是 Web 性能的根本瓶颈
  2. 解释 QUIC 为什么选择 UDP 作为底层——"在应用层重新实现 TCP"
  3. 理解 0-RTT 握手如何工作以及它带来的安全风险
  4. 用 Python + aioquic 写一个 QUIC 客户端请求 HTTPS 资源
  5. 对比 HTTP/1.1、HTTP/2、HTTP/3 在传输层上的差异
  6. 知道哪些网站/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" 意味着:

  1. 更新速度快:Chrome 可以随浏览器更新 QUIC 实现,不需要等 Windows/macOS/Linux 内核更新
  2. 灵活性强:可以试验新的拥塞控制算法、新的丢包检测机制
  3. 加密内建:不像 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),每条流有自己独立的可靠序列号空间:

python
# 概念模型: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)。

python
# 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)。

python
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 + TLSQUIC
握手开销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/2HTTP/3
传输层TCPQUIC
多路复用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.value

QPACK 的核心思想: 压缩表的同步不依赖 HTTP 请求/响应流的到达顺序——指令流是独立的 QUIC 单向流。编码器通过这个流通知解码器"我在动态表里插入了什么"。解码器收到 HEADERS 帧时,如果引用了还没到达的指令,就阻塞当前流(不影响其他流)。

对比 HPACK 的"所有流共享一个有序表" → 失序会直接破坏共享表的状态。QPACK 解耦了"头部数据"和"表同步"两个通道。


第8战:用 Python + aioquic 写 QUIC 客户端

下面我们用 Python 的 aioquic 库写一个真实的 QUIC 客户端。这不是模拟——这是真正的 QUIC 协议实现。

python
# 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 对不同阶段的包使用不同的加密级别:

包类型加密级别用途
InitialInitial 密钥(来自公共参数)首次握手的 ClientHello/ServerHello
HandshakeHandshake 密钥握手阶段的证书、密钥确认
0-RTT0-RTT 密钥(来自前次连接)重连时的早期数据
1-RTT应用数据密钥正常的 HTTP 数据

为什么这样设计? 因为服务器在握手完成前不能直接识别客户端——但 Initial 包可以被任何第一次连接的客户端发送。用公开的 Initial 密钥保护它,防止中间人随意注入但又不依赖服务器的预知。

QUIC 的 Loss Detection

与 TCP 不同,QUIC 有两套确认机制:

  1. ACK 帧:类似 TCP,确认收到某个包号范围
  2. ECN(Explicit Congestion Notification):利用 IP 层的 ECN 标记

关键差异:QUIC 的 RTT 计算没有重传歧义问题

python
# 伪代码——无歧义的 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_number

TCP 的序列号重传是"同一个序列号发两次";QUIC 的包号是"每个包唯一 ID"——简单、精确、没有歧义。


HTTP/1.1 vs HTTP/2 vs HTTP/3 对比

特性HTTP/1.1HTTP/2HTTP/3
传输层TCPTCPQUIC (UDP)
连接复用顺序复用(一次一个请求)多路复用无队头阻塞的多路复用
队头阻塞严重(一个连接一个请求)TCP 级 HOL(致命)无(QUIC 独立流)
握手延迟TCP 1.5 RTTTCP 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 的部署情况:

类型支持情况
Google全栈部署:Chrome、YouTube、Gmail、Search。Google 内部甚至有自己的 QUIC 变种(gQUIC)经验才催生了标准化
Cloudflare所有客户站点默认启用 HTTP/3。free 套餐即有
Facebook/MetaFacebook、Instagram App 使用 QUIC
Akamai支持 HTTP/3 边缘加速
Fastly支持 HTTP/3
AppleiOS/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
curlcurl --http3(需编译时开启 quiche)
Node.js实验性 QUIC 支持(Node 21+)

如何测试一个网站是否支持 HTTP/3?

bash
# 方法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:

bash
# tcpdump 抓 UDP 包(QUIC 在 UDP 443 端口)
sudo tcpdump -i any "udp port 443" -c 10 -X

# wireshark 过滤:quic

QUIC 数据是加密的所以你看到的是乱码——但 Wireshark 能解析出 Packet Header 的字段(连接ID、包号等)。


常见陷阱

陷阱1:0-RTT 重放攻击

场景: 你发送了 0-RTT 请求"转账 1000 元到账户A"。这个请求被中间人截获了。

问题: 0-RTT 请求的加密数据在握手完成前就被发送了——服务器还没验证客户端身份。这意味着攻击者可以截获 0-RTT 包并多次重放给服务器

python
# 0-RTT 重放攻击示意
# 攻击者截获了客户端的 0-RTT 包,连续重放 10 次
for i in range(10):
    server.handle_0rtt_packet(sniffed_0rtt_request)
# 服务器执行了 10 次转账操作!

QUIC 的应对:

  1. 0-RTT 幂等性要求:客户端不应该在 0-RTT 中发送非幂等操作(如 POST/PUT 中的状态变更)。RFC 要求服务器只对安全幂等的方法(如 GET、HEAD、OPTIONS)处理 0-RTT 数据
  2. Anti-Replay 机制:服务器可以限制 0-RTT 窗口(如只接受 10 秒内的 0-RTT 请求),或使用重复检测缓存
  3. 实际使用建议:支付/登录等敏感操作不要配 0-RTT

陷阱2:UDP 被某些网络阻塞

场景: 你的 App 使用 QUIC,但在某个企业 WiFi 上连接失败——退回到了 HTTP/1.1。

问题: 有些企业网络、公共 WiFi、或者某些 ISP 会限制或完全阻断 UDP 流量。QUIC 运行在 UDP 上,UDP 被阻 = QUIC 不能工作。

客户端 (QUIC) ── UDP 443 →── 企业防火墙  ──→ 服务器

                                  ├── UDP 规则: DROP
                                  │   理由:"UDP 不安全 / 用于 P2P"

                                  └──→ 客户端超时,回退到 TCP

QUIC 的应对: HTTP/3 协商机制——客户端尝试 QUIC 连接,如果超时或失败,自动回退到 HTTP/2 over TCP 或 HTTP/1.1:

python
# 浏览器实际的优雅降级逻辑
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):

  1. 首次连接:服务器在握手时验证客户端的 IP 地址
  2. 连接迁移时:服务器先验证新路径(发送探测帧,验证新 IP 可达)才启用新路径的数据传输
  3. 连接 ID 轮换:客户端在迁移时可以使用新的连接 ID——这防止被动的路径观察者将持续追踪你的连接
python
# 安全迁移示意
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)

bash
# 方法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 条流的独立递交。在"丢包事件"发生时,只阻塞受影响的那条流。

python
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 对比延迟()

bash
# 如果 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 年互联网传输层最重要的变革。

它的核心哲学其实很简单:

  1. TCP 很好,但它在用户空间不能动,也不能解决队头阻塞和连接迁移
  2. 操作系统内核不是给应用开发者随便改的——那就绕开它,用 UDP
  3. 在 UDP 上重新设计一个"TCP++"——加加密、加多流、加0-RTT、加连接迁移
  4. 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,系统性地解决所有网络故障。

Built with VitePress | Software Systems Atlas