2. QUIC协议栈架构:分层模型与核心数据结构

聊QUIC,很多人一上来就扎进细节,结果越看越晕。我个人习惯是,先搞清楚它的分层结构。说白了,QUIC不是一块铁板,它内部是分层的——传输层、加密层、应用层,各司其职。

你想想看,TCP+TLS+HTTP/2那套老架构,三层是分开的,各自有各自的握手、状态机、丢包重传。QUIC聪明在哪?它把加密层直接嵌进了传输层,两者共享状态。嗯,这里要注意,这不是简单的“合并”,而是从设计上就揉在一起了。

核心观点:QUIC的分层不是物理隔离,而是逻辑分层。加密层和传输层共用同一个Packet空间,这是QUIC性能优于TCP+TLS的关键原因之一。

2.1 三层模型:传输层、加密层、应用层

我们先看一张整体架构图,把三层的关系理清楚。

QUIC协议栈分层架构图 QUIC 协议栈分层架构 应用层 (Application Layer) HTTP/3, DNS-over-QUIC, WebTransport, gRPC 加密层 (Crypto Layer) TLS 1.3 握手 · 密钥协商 · 0-RTT 连接迁移 · 数据包保护 传输层 (Transport Layer) 连接管理 · 流控 · 丢包检测 · 拥塞控制 Packet 组帧 · Frame 解析 · 多路复用 Stream 0-3 专用 · 无队头阻塞 加密层与传输层共享 Packet 空间,握手完成后加密层“下沉”到传输层内部

这张图我画得比较直白。从上到下:应用层、加密层、传输层。但实际运行时,加密层和传输层是交织的。我记得第一次看QUIC源码时,发现加密层居然在传输层的数据结构里直接读写密钥,当时还挺惊讶——这在TCP栈里根本不可能。

2.2 各层职责详解

2.2.1 传输层

传输层是QUIC的“地基”。它负责:

  • 连接管理:创建、销毁、迁移连接
  • 流控:连接级流控 + Stream级流控
  • 丢包检测:基于Packet Number的单调递增检测
  • 拥塞控制:NewReno、CUBIC、BBR等算法
  • 多路复用:多个Stream共享一个连接

这里有个坑。我曾经在项目中遇到一个问题:QUIC的丢包检测和TCP不一样。TCP靠序列号,丢了就重传,但序列号是连续的。QUIC的Packet Number是严格单调递增的,重传包会分配新的PN。这意味着什么?接收端能精确区分“原始包”和“重传包”,这对RTT采样精度提升非常大。

避坑指南:我曾经调试过一个丢包率异常的问题,发现是拥塞控制算法没适配QUIC的PN特性。如果你把TCP的丢包检测逻辑直接搬过来,RTT样本会严重偏大。记住:QUIC的PN是唯一的,重传包不是“重发”,而是“新包”。

2.2.2 加密层

加密层本质上是TLS 1.3的QUIC定制版。它负责:

  • 握手:1-RTT握手、0-RTT快速握手
  • 密钥协商:生成初始密钥、握手密钥、应用密钥
  • 数据包保护:对Packet Payload进行AEAD加密
  • 连接迁移:更换网络时,加密上下文保持不变

嗯,这里要特别说一下0-RTT。很多人觉得0-RTT就是“零延迟握手”,其实没那么简单。0-RTT只能发送幂等请求,因为存在重放攻击风险。我在生产环境里,0-RTT只用来发GET请求,POST请求一律走1-RTT。安全第一。

2.2.3 应用层

应用层是QUIC对外暴露的接口。最典型的例子就是HTTP/3。但QUIC不止能跑HTTP,它还能跑:

  • DNS-over-QUIC (DoQ)
  • WebTransport
  • gRPC (基于HTTP/3)
  • 自定义应用协议

应用层通过Stream API读写数据。每个Stream是独立的字节流,Stream之间没有队头阻塞。你想想看,HTTP/2里一个TCP连接上所有流共享一个窗口,丢一个包全堵住。QUIC呢?每个Stream独立流控,丢包只影响那个Stream,其他Stream照跑不误。

2.3 协议头结构:Long Header vs Short Header

QUIC的Packet头分两种:Long Header和Short Header。握手阶段用Long Header,数据传输阶段用Short Header。为什么这么设计?说白了,握手时需要携带更多元信息(连接ID、版本号、令牌等),握手完成后这些信息可以精简。

字段 Long Header Short Header
适用阶段 握手、版本协商、重试 数据传输
固定位 第1字节最高2位为11 第1字节最高2位为10
连接ID 源连接ID + 目标连接ID 仅目标连接ID
版本号 4字节,必须携带 无版本号
Packet Number 可变长度(1-4字节) 可变长度(1-4字节)
负载 加密的Frame序列 加密的Frame序列

我刚开始写QUIC协议栈时,最头疼的就是解析Long Header。因为它的字段长度是可变的,而且不同Packet Type的格式还不一样。后来我总结了一个经验:先读第1字节的高2位,判断是Long还是Short;如果是Long,再读第2字节的低4位,判断具体类型(Initial、Handshake、0-RTT、Retry)。

注意:Short Header虽然叫“短头”,但它的Packet Number长度是可变的(1-4字节)。解析时不能假设固定长度,必须根据第1字节的低2位判断PN长度。我曾经因为这个bug,导致生产环境里偶发性解析失败,排查了两天才找到原因。

2.4 Packet与Frame的关系

这是QUIC里最容易混淆的概念。我直接说结论:Packet是传输单元,Frame是逻辑单元。

  • Packet:在UDP上发送的最小数据单元。一个Packet包含一个Header + 一个或多个Frame。
  • Frame:承载具体数据的逻辑结构。比如Stream Frame传应用数据,ACK Frame传确认信息,Ping Frame做心跳。

一个Packet可以包含多个Frame,这些Frame按顺序排列,共享同一个Packet Number。接收端收到Packet后,先解密Payload,再逐个解析Frame。

举个例子:

Packet {
  Header: Short Header (1 byte)
  Destination Connection ID: 8 bytes
  Packet Number: 2 bytes (0x01 0x2A)
  Encrypted Payload: [
    Frame 1: STREAM (Stream ID=5, Offset=0, Length=100, Data=...)
    Frame 2: ACK (Largest Acknowledged=0x0129, ACK Delay=...)
    Frame 3: PADDING (填充到MTU)
  ]
}

你看,一个Packet里塞了三个Frame。STREAM Frame传应用数据,ACK Frame告诉对端“我收到0x0129了”,PADDING Frame用来凑够MTU。这种设计的好处是:减少小包数量,提升带宽利用率。

我记得有一次优化QUIC性能,发现小包太多导致UDP丢包率上升。后来我调整了Frame聚合策略,把多个小STREAM Frame合并到一个Packet里,丢包率直接降了一半。嗯,这就是Packet和Frame关系在实际工程中的应用。

2.5 常见Frame类型速查

Frame类型 Type值 用途 是否可合并
PADDING 0x00 填充到MTU,防放大攻击 是
PING 0x01 心跳、RTT测量 是
ACK 0x02-0x03 确认收到哪些Packet 否(每个Packet最多一个)
RESET_STREAM 0x04 终止某个Stream 是
STOP_SENDING 0x05 请求对端停止发送 是
CRYPTO 0x06 传输TLS握手消息 是
NEW_TOKEN 0x07 服务端下发地址验证令牌 是
STREAM 0x08-0x0F 传输应用数据(核心Frame) 是
MAX_DATA 0x10 连接级流控更新 是
MAX_STREAM_DATA 0x11 Stream级流控更新 是
MAX_STREAMS 0x12-0x13 允许对端创建更多Stream 是
DATA_BLOCKED 0x14 告知对端连接级流控阻塞 是
STREAM_DATA_BLOCKED 0x15 告知对端Stream级流控阻塞 是
STREAMS_BLOCKED 0x16-0x17 告知对端Stream数量受限 是
NEW_CONNECTION_ID 0x18 提供新的连接ID用于迁移 是
RETIRE_CONNECTION_ID 0x19 废弃不再使用的连接ID 是
PATH_CHALLENGE 0x1A 路径验证(连接迁移用) 是
PATH_RESPONSE 0x1B 路径验证响应 是
CONNECTION_CLOSE 0x1C-0x1D 关闭连接 否(最后一个Frame)
HANDSHAKE_DONE 0x1E 服务端通知握手完成 是

这张表我建议你收藏。实际开发中,80%的时间都在跟STREAM、ACK、CRYPTO这三个Frame打交道。其他Frame虽然不常用,但一旦遇到就是疑难杂症。比如PATH_CHALLENGE/PATH_RESPONSE,连接迁移时如果处理不对,客户端切WiFi后直接断连。

2.6 小结

QUIC的分层模型,说白了就是:传输层管“怎么发”,加密层管“安全发”,应用层管“发什么”。Packet和Frame的关系,就是“信封”和“信纸”的关系——一个信封里可以装多张信纸。

我个人觉得,理解QUIC架构的关键不在于记住每个字段的偏移量,而在于理解为什么这么设计。比如为什么要有Long/Short两种Header?因为握手阶段需要更多信息,传输阶段需要更少开销。为什么Packet和Frame要分离?因为Packet是传输的原子单位,Frame是逻辑的原子单位,分离后可以灵活组合。