3. QUIC 数据包结构:长包头与短包头、帧类型与格式、连接ID的作用
聊 QUIC 的丢包分析,第一步得先看懂它的数据包长什么样。说实话,我第一次看 QUIC 的包结构时,第一反应是——这玩意儿比 TCP 复杂多了。但用久了你会发现,这种设计其实很巧妙。
3.1 长包头 vs 短包头:什么时候用哪个?
QUIC 的包头分两种:长包头(Long Header)和短包头(Short Header)。我习惯这么记——长包头用于“握手阶段”,短包头用于“数据传输阶段”。
为什么要有两套?你想想看,连接刚建立时,双方需要交换很多信息:连接ID、版本号、令牌等等。这些信息在握手完成后就不需要每次都带了。所以 QUIC 设计得很聪明——握手时用长包头,建立连接后切到短包头,减少开销。
| 特性 | 长包头 | 短包头 |
|---|---|---|
| 使用阶段 | 连接建立、版本协商、重试 | 数据传输阶段 |
| 包头大小 | 较大(通常 30+ 字节) | 较小(通常 16 字节左右) |
| 包含字段 | 版本号、连接ID、令牌等 | 仅目标连接ID、包号等 |
| 第一个字节最高位 | 1 | 0 |
长包头的第一个字节最高位固定为 1,短包头则为 0。这是接收端区分两者的最快方式——看一眼最高位就够了。
核心要点:长包头是“建立连接用的”,短包头是“传输数据用的”。抓包时看到长包头,说明还在握手阶段;看到短包头,说明连接已经建立,正在传数据。
3.2 长包头结构详解
长包头的结构,我拆开给你看:
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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|1| 类型(7) | 版本(32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 目标连接ID长度(8) | 目标连接ID (0..160) ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 源连接ID长度(8) | 源连接ID (0..160) ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 特定类型字段 ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
这里有几个关键字段:
- 类型(7位):标识包的类型,比如 Initial、Handshake、Retry 等。
- 版本(32位):QUIC 版本号,比如 0x00000001 表示 v1。
- 目标连接ID:接收方用来查找连接状态的标识。
- 源连接ID:发送方自己的连接ID,接收方后续回复时用。
我记得有一次排查一个连接建立失败的问题,抓包发现客户端发的 Initial 包里的版本号是 0x00000000——这是版本协商的标志,不是真正的版本号。当时排查了半天,最后发现是客户端库的版本协商逻辑写错了。
小技巧:抓包时如果看到大量长包头包,但始终没有切到短包头,说明握手可能卡住了。优先检查版本号是否匹配、连接ID是否被正确路由。
3.3 短包头结构:轻量级的数据传输
握手完成后,双方切换到短包头。短包头结构简单得多:
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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|0|1| 自旋位 | 保留 | 密钥阶段 | 包号长度(2) | 目标连接ID ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 目标连接ID (0..160) ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 包号 (8/16/24/32) ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
短包头里没有版本号,没有源连接ID,只有目标连接ID和包号。包号长度可以是 1、2、3、4 字节,由包号长度字段控制。
这里有个细节——短包头的包号是经过加密的。为什么?为了防止中间人通过观察包号来推断流量模式。嗯,这个设计在安全性上确实比 TCP 强不少。
3.4 帧类型与格式:数据包里的“集装箱”
QUIC 的数据包(Packet)里装的是帧(Frame)。我习惯把帧理解为“集装箱”,每个集装箱装一种特定类型的数据。
常见的帧类型有这些:
| 帧类型 | 类型值 | 用途 |
|---|---|---|
| PING | 0x01 | 保活、RTT测量 |
| ACK | 0x02-0x03 | 确认收到哪些包 |
| RESET_STREAM | 0x04 | 重置某个流 |
| STREAM | 0x08-0x0F | 传输应用数据 |
| MAX_DATA | 0x10 | 通知对方可以发送的总数据量上限 |
| MAX_STREAM_DATA | 0x11 | 通知对方某个流的数据量上限 |
| MAX_STREAMS | 0x12-0x13 | 通知对方可以打开的流数量上限 |
| DATA_BLOCKED | 0x14 | 发送方被流控阻塞 |
| STREAM_DATA_BLOCKED | 0x15 | 某个流被流控阻塞 |
| STREAMS_BLOCKED | 0x16-0x17 | 无法打开新流 |
| NEW_CONNECTION_ID | 0x18 | 提供新的连接ID |
| RETIRE_CONNECTION_ID | 0x19 | 废弃旧的连接ID |
| PATH_CHALLENGE | 0x1A | 路径验证 |
| PATH_RESPONSE | 0x1B | 路径验证响应 |
| CONNECTION_CLOSE | 0x1C-0x1D | 关闭连接 |
| HANDSHAKE_DONE | 0x1E | 服务端通知握手完成 |
每个帧的格式都不同。拿最常用的 STREAM 帧举例:
STREAM 帧格式:
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 类型(8) | 流ID (可变长度) | 偏移量 (可变长度, 可选) | 长度 (可变长度, 可选) |
| 数据 ... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
类型字节的低 4 位控制着帧的行为:
- 第 1 位(0x01):是否包含偏移量
- 第 2 位(0x02):是否包含长度
- 第 3 位(0x04):是否为流的结束
- 第 4 位(0x08):保留位
举个例子,类型值 0x09(二进制 00001001)表示:包含偏移量、不包含长度、不是流的结束。这种设计让 STREAM 帧可以根据需要灵活调整大小。
注意:一个 QUIC 数据包里可以装多个帧。帧之间没有分隔符,接收端通过帧类型字节来解析。所以如果帧类型字节损坏,整个包的数据都可能解析失败。我在项目中遇到过因为网络中间设备篡改字节导致帧解析错乱的情况,排查起来非常痛苦。
3.5 连接ID的作用:不止是标识
连接ID(Connection ID)是 QUIC 里一个非常核心的设计。它不像 TCP 那样用五元组(源IP、源端口、目标IP、目标端口、协议)来标识连接,而是用连接ID。
这样做的好处是什么?说白了,就是连接迁移。你想想看,手机从 WiFi 切到 4G,IP 地址变了。TCP 连接会断,但 QUIC 不会——因为连接ID没变,服务端依然能识别出这是同一个连接。
连接ID有几个关键作用:
- 连接路由:负载均衡器根据目标连接ID把包转发到正确的后端服务器。
- 连接迁移:网络切换时,IP变了但连接ID不变,连接不中断。
- 隐私保护:连接ID可以定期更换,防止中间人通过固定ID追踪用户。
- 多路径支持:同一个连接可以使用多个路径,每个路径用不同的连接ID。
我曾经帮一个客户排查过连接迁移的问题。他们的移动端应用在 WiFi 和 5G 之间切换时,QUIC 连接总是断。后来发现是服务端没有正确处理 NEW_CONNECTION_ID 帧——客户端发了新ID,服务端没存下来。切换网络后,客户端用新ID发包,服务端认不出来,直接丢了。
关键点:连接ID是 QUIC 连接的核心标识。抓包分析时,先看连接ID是否一致。如果同一个连接里出现了不同的连接ID,说明发生了连接迁移或ID轮换。
3.6 知识体系总览
下面这张图把本章的核心知识点串起来了,方便你理解整体结构:
这张图把长包头、短包头、帧和连接ID的关系都串起来了。你可以看到,长包头和短包头是数据包的“外壳”,帧是“内容”,连接ID则是贯穿整个连接的“身份证”。