4、QUIC数据包结构:Public Header、Protected Header、Packet Number空间、Payload结构
聊QUIC的数据包结构,我习惯把它拆成四个层次来看。你想想看,一个QUIC包从外面到里面,就像剥洋葱——先看到的是明文部分,然后是受保护的部分,接着是包号空间,最后才是真正的数据载荷。每个层次都有它的设计意图,也有它容易踩坑的地方。
4.1 整体包结构概览
QUIC的包结构跟TCP完全不一样。TCP的头部是固定的,QUIC则分成了两个阶段:Public Header(明文头部)和 Protected Header(受保护头部)。为什么要这么设计?说白了,就是为了让中间设备能快速识别连接,但又不能看到加密内容。
核心要点:QUIC包 = Public Header(明文) + Protected Header(加密) + Payload(加密)
下面这张图是我自己画的结构图,把QUIC包的层次关系讲清楚了:
4.2 Public Header:明文的门面
Public Header是QUIC包最外层的部分,所有中间设备都能看到它。它的作用就是让路由器、负载均衡器知道这个包属于哪个连接,该往哪转发。
我记得有一次排查线上问题,发现某个CDN节点总是丢包。后来抓包一看,是Public Header里的Connection ID被截断了——那个节点的QUIC实现只支持8字节的CID,但我们发的是18字节的。嗯,这就是Public Header设计上的一个坑:Connection ID长度是可变的。
Public Header包含以下关键字段:
| 字段 | 长度 | 说明 |
|---|---|---|
| Flags(标志位) | 1字节 | 包含版本协商、固定位、长头/短头标识等 |
| Version(版本号) | 4字节 | 仅在长头包中存在,短头包省略 |
| DCID(目标连接ID) | 0~20字节 | 路由决策的关键字段 |
| SCID(源连接ID) | 0~20字节 | 仅在长头包中存在 |
| Packet Number Length | 1~4字节 | 包号编码长度,由Flags的低2位决定 |
实战技巧:抓包时如果看到Public Header异常,先检查DCID长度是否匹配。我曾经遇到过负载均衡器配置了固定CID长度,结果客户端升级后发了不同长度的CID,导致连接全部失败。解决方案是在服务端做CID长度协商时,强制统一到8字节。
4.3 Protected Header:加密的铠甲
Protected Header紧跟在Public Header后面。它包含了包号(Packet Number)和一些控制信息,但这些内容都是加密的。为什么要加密?因为包号如果明文传输,攻击者就能推断出通信频率和数据量。
Protected Header的结构相对简单:
- Packet Number:1~4字节,实际是包号的低若干位
- Protected Payload Length:仅长头包有,表示加密载荷的长度
- Key Phase:1位,指示当前使用的加密密钥阶段
这里有个容易忽略的点:Packet Number在Protected Header里是部分加密的。它的前几个字节是明文,后几个字节是密文。为什么这么设计?我刚开始也觉得很奇怪,后来看了RFC才明白——这是为了支持Header Protection的渐进式解密。
避坑指南:我曾经在实现QUIC协议栈时,把Protected Header里的Packet Number当成了完整的包号来用,结果解密Payload时总是失败。后来才发现,Protected Header里的包号只是完整包号的低N位,高位需要从上下文恢复。这个坑让我debug了整整两天。
4.4 Packet Number空间:单调递增的序列
Packet Number空间是QUIC区别于TCP的一个重要设计。TCP的序列号是字节级别的,QUIC的包号是包级别的,而且每个连接有多个包号空间。
QUIC定义了三种包号空间:
| 空间编号 | 包含的包类型 | 用途 |
|---|---|---|
| 空间0 | Initial包 | 连接建立阶段,用于握手 |
| 空间1 | Handshake包 | 密钥协商阶段 |
| 空间2 | 1-RTT包、0-RTT包 | 数据传输阶段 |
每个空间内的包号都是单调递增的,从0开始。这意味着你永远不会看到同一个空间内出现重复的包号。如果收到了重复包号,那一定是重传或者攻击。
我个人习惯在日志里同时记录三个空间的包号,这样排查问题时能快速定位是哪个阶段出了问题。比如,如果空间0的包号一直不增长,说明Initial握手卡住了。
4.5 Payload结构:真正的数据
Payload是QUIC包的最后一层,也是真正承载数据的地方。它包含一个或多个QUIC Frame(帧),每个帧都有自己的类型和内容。
常见的帧类型包括:
- STREAM帧:传输应用数据,类似TCP的数据段
- ACK帧:确认收到的包号,类似TCP的ACK
- CRYPTO帧:传输TLS握手数据
- PING帧:保活探测
- CONNECTION_CLOSE帧:连接关闭
Payload的结构是TLV格式(Type-Length-Value):
+----------------+----------------+------------------+
| Frame Type (1B) | Length (varint) | Frame Data (var) |
+----------------+----------------+------------------+
每个帧之间紧密排列,没有分隔符。解析时根据Frame Type就能知道后续数据的长度。这种设计的好处是节省带宽,坏处是——如果某个帧解析出错,后面的帧就全乱了。
关键认知:QUIC的Payload是加密的,但帧结构本身是明确定义的。抓包工具(如Wireshark)在拿到密钥后,可以解密Payload并解析出每个帧的内容。这也是QUIC日志分析的基础——没有密钥,你只能看到Public Header。
4.6 实战:如何从日志中解析包结构
假设你拿到了一段QUIC日志,里面包含一个包的十六进制数据。怎么把它拆解成上面说的四个层次?我一般按以下步骤来:
- 读第一个字节:判断是长头还是短头。如果最高位是1,就是长头包(Initial/Handshake/0-RTT);如果是0,就是短头包(1-RTT)。
- 提取DCID:根据Flags中的CID长度字段,读取DCID。这是路由的关键。
- 解密Protected Header:用当前阶段的密钥解密包号。注意,这里需要先恢复完整的包号。
- 解密Payload:用包号和密钥解密整个Payload,然后按TLV格式解析帧。
举个例子,假设你看到这样的日志片段:
Packet: c0 00 00 00 01 08 12 34 56 78 9a bc de f0 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f
我来拆解一下:
c0:Flags,二进制11000000,表示长头、版本协商、固定位00 00 00 01:Version,版本号1(QUIC v1)08:DCID长度,8字节12 34 56 78 9a bc de f0:DCID,8字节- 后面的就是Protected Header + Payload(加密状态)
小技巧:如果你在日志里看到大量包号跳跃(比如从5直接跳到100),别慌。这通常不是丢包,而是QUIC的包号空间切换了——可能是从Initial切到了Handshake。检查一下包类型字段就能确认。
好了,QUIC数据包的结构就讲到这里。记住四个层次:Public Header是明文的门面,Protected Header是加密的铠甲,Packet Number空间是单调递增的序列,Payload是真正的数据。下次抓包时,按这个思路去拆解,你会发现问题定位快很多。