4. QUIC流与多路复用机制

这一章,我们来啃 QUIC 最核心的骨头——数据包结构和帧类型。说实话,我刚接触 QUIC 时也被它的分层设计绕晕过。但抓过几次包之后,你会发现它的设计其实非常优雅。

4.1 长头 vs 短头:一眼认出连接状态

QUIC 的数据包头部有两种形态:长头(Long Header)和短头(Short Header)。你抓包时,看第一个字节的最高位就能区分——1 是长头,0 是短头。

核心区别一句话:长头用于连接建立阶段,短头用于数据传输阶段。

长头长什么样?我习惯把它拆成三块:

  • 公共字段:版本号、源连接ID、目标连接ID
  • 类型字段:Initial、Handshake、Retry、0-RTT 四种
  • 长度字段:载荷长度、包号长度等

短头就简洁多了。它去掉了版本号和连接ID对,只保留目标连接ID和包号。为什么?因为连接已经建立,双方都知道对方是谁了,没必要再带那么多冗余信息。

抓包小技巧:我在排查连接建立失败问题时,会先看长头包。如果客户端发了 Initial 但服务器没回 Handshake,十有八九是版本协商或连接ID解析出了问题。

4.2 四种长头包类型

长头包有四种,每种对应连接建立的一个阶段。我画了张图帮你理清关系:

QUIC 长头包类型与连接建立流程 客户端 服务器 Initial Handshake 1-RTT (短头) 0-RTT (长头) Retry (长头) 说明:实线箭头表示正常流程,虚线箭头表示 0-RTT 提前发送

四种长头包各有各的使命:

包类型 用途 加密级别
Initial 客户端发送的第一个包,包含 TLS ClientHello 初始密钥(公开)
Handshake 交换握手消息,协商加密参数 握手密钥
0-RTT 客户端在握手完成前发送数据(需之前连接过) 0-RTT 密钥
Retry 服务器要求客户端重新连接(用于负载均衡或验证) 无加密

注意:我曾经在生产环境遇到过一个坑——客户端发了 0-RTT 包,但服务器因为负载均衡换了节点,导致连接ID不匹配,直接丢弃了数据。所以 0-RTT 虽然快,但并不是每次都能成功。

4.3 QUIC 帧类型:数据包的灵魂

数据包是容器,帧才是真正的内容。一个 QUIC 包可以装多个帧,就像一辆卡车可以拉不同种类的货物。我抓包时最喜欢看的就是帧类型,它能告诉我这个包到底在干什么。

常用的帧类型有这些:

帧类型 类型值 作用 出现频率
STREAM 0x08-0x0F 传输应用数据(HTTP/3 的请求和响应) 最高
ACK 0x02-0x03 确认收到哪些包,附带延迟信息 很高
CRYPTO 0x06 传输 TLS 握手消息 连接建立阶段
PING 0x01 保活或探测 RTT 中等
NEW_CONNECTION_ID 0x18 提供新的连接ID(用于连接迁移) 低
MAX_DATA / MAX_STREAM_DATA 0x10 / 0x11 流量控制,告诉对方还能发多少 中等

4.4 STREAM 帧:数据搬运工

STREAM 帧是 QUIC 里最重要的帧,没有之一。HTTP/3 的请求和响应数据,最终都装在 STREAM 帧里传输。

它的结构长这样:

STREAM 帧格式:
+--------+--------+--------+--------+--------+--------+--------+--------+
| 帧类型  | 流ID   | 偏移量  | 数据长度 | 数据    |
+--------+--------+--------+--------+--------+--------+--------+--------+
  (1字节)  (1-8B)   (0-8B)   (0-8B)    (可变)

几个关键字段:

  • 流ID:标识属于哪个流。客户端发起的流 ID 是偶数,服务器发起的流 ID 是奇数。
  • 偏移量:数据在流中的位置。有了它,乱序到达的数据也能正确重组。
  • 数据长度:可选字段。如果不填,帧结束就是数据结束。

抓包经验:我排查 HTTP/3 请求慢的问题时,会先看 STREAM 帧的偏移量是否连续。如果发现大量重复偏移量,说明发生了重传;如果偏移量跳跃很大,说明乱序严重。这两种情况都可能导致应用层等待数据。

4.5 ACK 帧:QUIC 的确认机制

ACK 帧和 TCP 的 ACK 不太一样。QUIC 的 ACK 可以确认多个包号范围,而且不需要严格按序确认。

举个例子,假设我收到了包号 1、2、3、5、6、8:

ACK 帧携带的信息:
- 最大确认包号:8
- ACK 块数:2
- 第一个块长度:3(确认 6,7,8)
- 间隔:1(跳过 4,5)
- 第二个块长度:3(确认 1,2,3)

这种设计让 QUIC 在丢包时不需要像 TCP 那样等待超时重传,可以更灵活地处理乱序和丢包。

关键区别:TCP 的 ACK 是累积确认,必须按序;QUIC 的 ACK 是选择性确认,可以跳着确认。这意味着 QUIC 对乱序的容忍度远高于 TCP。

4.6 CRYPTO 帧:握手数据的专属通道

CRYPTO 帧专门用来传输 TLS 握手消息。它和 STREAM 帧很像,也有偏移量和数据长度,但它不属于任何流——它是连接级别的数据。

为什么需要单独的 CRYPTO 帧?因为握手数据必须在应用数据之前完成加密协商。如果混在 STREAM 帧里,接收方不知道哪些是握手数据,哪些是应用数据。

我刚开始学 QUIC 时有个疑惑:CRYPTO 帧和 STREAM 帧结构这么像,为什么不直接用 STREAM 帧?后来想明白了——CRYPTO 帧的优先级更高,而且它不受流量控制限制。握手数据必须尽快送达,不能被应用层的流量控制卡住。

4.7 包号与加密:安全性的基石

QUIC 的包号设计很有意思。它不像 TCP 那样用字节序号,而是用递增的整数。每个加密级别(Initial、Handshake、1-RTT)都有自己的包号空间。

为什么这么设计?

  • 避免歧义:不同加密级别的包号互不干扰
  • 简化重传:重传的包使用新的包号,接收方不会混淆
  • 提升安全性:包号被加密,中间人无法通过包号推测通信模式

安全提醒:我曾经在抓包分析时发现,有些 QUIC 实现会把包号明文暴露在短头里。虽然包号本身不包含敏感信息,但结合其他信息可能被用来做流量分析。QUIC v1 要求包号必须加密,如果你看到明文包号,说明这个实现可能不符合规范。

加密方面,QUIC 的包号加密使用的是 AEAD 算法(通常是 AES-GCM 或 ChaCha20-Poly1305)。每个包都有一个独立的 nonce,由包号和密钥派生。这意味着即使两个包的内容相同,加密后的结果也不同——这就是所谓的「语义安全」。

4.8 实战:抓包看帧结构

最后,我带你用 Wireshark 看一眼真实的 QUIC 包。假设你抓到了一个 Initial 包:

QUIC Initial Packet
  [Packet Number: 0]
  [Packet Length: 1232]
  QUIC Short Header: 1... .... = Long Header
  Version: 1 (0x00000001)
  Destination Connection ID: 8e4a5f1b2c3d...
  Source Connection ID: a1b2c3d4e5f6...
  QUIC CRYPTO Frame
    [Offset: 0]
    [Length: 256]
    TLS 1.3 Handshake: ClientHello
  QUIC PING Frame
    [Length: 0]

看到没?一个 Initial 包里装了两个帧:CRYPTO 帧携带 ClientHello,PING 帧用来探测 RTT。这就是 QUIC 的「多帧复用」——一个包能干多件事。

我的习惯:在 Wireshark 里过滤 QUIC 帧,可以用 quic.frame_type == 0x06 只看 CRYPTO 帧,或者 quic.stream_id == 4 只看某个特定流。这些过滤表达式能帮你快速定位问题。