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包的层次关系讲清楚了:

QUIC 数据包结构层次图 Public Header(明文头部) Protected Header(受保护头部) Packet Number 空间 Payload(加密载荷) 明文可读 加密保护 单调递增 完全加密

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. 读第一个字节:判断是长头还是短头。如果最高位是1,就是长头包(Initial/Handshake/0-RTT);如果是0,就是短头包(1-RTT)。
  2. 提取DCID:根据Flags中的CID长度字段,读取DCID。这是路由的关键。
  3. 解密Protected Header:用当前阶段的密钥解密包号。注意,这里需要先恢复完整的包号。
  4. 解密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是真正的数据。下次抓包时,按这个思路去拆解,你会发现问题定位快很多。