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 四种长头包类型
长头包有四种,每种对应连接建立的一个阶段。我画了张图帮你理清关系:
四种长头包各有各的使命:
| 包类型 | 用途 | 加密级别 |
|---|---|---|
| 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 只看某个特定流。这些过滤表达式能帮你快速定位问题。