4. QUIC 流与多路复用:流的概念、流的优先级、流与 HTTP/2 帧的对比、流并发控制

聊到 QUIC,最绕不开的就是它的「流」机制。说实话,我第一次看 QUIC 的 RFC 时,第一反应是:这不就是 HTTP/2 那套东西换了个壳吗?后来踩了几个坑才明白——完全不是一回事。

QUIC 的流,本质上是一条独立的、有序的字节序列。每个流都有自己的 ID,有自己的状态机,最关键的是——流与流之间互不干扰。你想想看,这在 HTTP/2 里是做不到的。

4.1 流的概念与生命周期

QUIC 流分为三种类型:

  • 客户端双向流(Client-Initiated Bidirectional)
  • 服务端双向流(Server-Initiated Bidirectional)
  • 单向流(Unidirectional)

每个流由 62 位的流 ID 唯一标识。最低两位用来区分类型:

流类型 流 ID 最低两位 发起方 方向
客户端双向流 00 客户端 双向
服务端双向流 01 服务端 双向
客户端单向流 10 客户端 单向
服务端单向流 11 服务端 单向

流的状态机其实不复杂。我习惯把它简化成四个核心阶段:

  1. 创建:发送方发送 STREAM 帧,隐式创建流
  2. 数据传输:双方通过 STREAM 帧收发数据
  3. 关闭:发送方发送 FIN 标记,表示数据发送完毕
  4. 销毁:双方确认数据完整接收后,流被彻底回收

关键点:QUIC 流的关闭是独立的。一个流关闭了,其他流完全不受影响。这在 HTTP/2 里是做不到的——HTTP/2 的流虽然也是独立的,但底层 TCP 的队头阻塞会让所有流一起遭殃。

4.2 流的优先级

优先级这东西,说起来简单,做起来全是坑。HTTP/2 的优先级方案用了「依赖树」+「权重」,理论上很灵活,但实际部署时问题一堆。

我记得有一次做性能优化,发现某个页面的关键资源总是被非关键资源阻塞。查了半天,发现是 HTTP/2 的优先级依赖树在代理服务器上被搞乱了。从那以后,我对复杂的优先级方案一直持保留态度。

QUIC 的优先级设计就务实多了。它没有内置复杂的优先级机制,而是把优先级信息放在应用层。具体来说:

  • QUIC 传输层只保证流之间的公平调度
  • 应用层(比如 HTTP/3)通过 Extensible Priorities 框架来传递优先级
  • 优先级信息通过 PRIORITY_UPDATE 帧 动态调整

Extensible Priorities 框架的核心是两个参数:

参数 含义 取值范围
Urgency(紧急度) 流的相对重要性 0-7(0 最紧急)
Incremental(增量标记) 是否可与其他流并行传输 true / false

我的建议:不要把优先级搞得太复杂。一般场景下,把 HTML 文档设为 Urgency=0,CSS 和关键 JS 设为 Urgency=1,图片和视频设为 Urgency=3-5,基本就够了。我曾经见过有人搞了 8 级优先级,结果性能反而更差——调度器光处理优先级就消耗了大量 CPU。

4.3 流与 HTTP/2 帧的对比

这是很多人容易混淆的地方。我直接画个对比表,一目了然:

对比维度 HTTP/2 流 QUIC 流
底层传输 基于 TCP 字节流 基于 UDP + 自研传输层
队头阻塞 有(TCP 层丢包阻塞所有流) 无(每个流独立丢包重传)
流 ID 空间 31 位(约 21 亿) 62 位(约 4.6e18)
流创建方式 通过 HEADERS 帧隐式创建 通过 STREAM 帧隐式创建
流关闭 RST_STREAM 帧 RESET_STREAM 帧
流量控制 基于 TCP 的滑动窗口 流级别 + 连接级别双重控制
优先级 依赖树 + 权重(复杂) Extensible Priorities(简洁)

说白了,最大的区别就一句话:HTTP/2 的流是「逻辑流」,底层还是同一个 TCP 管道;QUIC 的流是「物理流」,每个流都有自己的传输保障。

举个例子。你在 HTTP/2 上同时请求 10 个资源,如果第 1 个资源的某个 TCP 包丢了,后面 9 个资源都得等着。但在 QUIC 上,第 1 个资源的丢包只影响它自己,其他 9 个资源照常传输。

注意:虽然 QUIC 解决了传输层的队头阻塞,但应用层的队头阻塞仍然存在。比如 HTTP/3 的 QPACK 压缩表如果丢失了某个更新,后续的请求还是得等。这个坑我在项目里踩过,后面讲 QPACK 时会细说。

4.4 流并发控制

并发控制是 QUIC 流的核心机制之一。它不像 TCP 那样只有一个全局的流量窗口,而是有两层控制:

  1. 连接级别(Connection-Level):限制所有流的总数据量
  2. 流级别(Stream-Level):限制单个流的数据量

这两层控制通过 MAX_DATA 和 MAX_STREAM_DATA 帧来动态调整。接收方可以根据自己的处理能力,随时告诉发送方:「嘿,慢点,我快撑不住了。」

另外,还有一个重要的参数叫 MAX_STREAMS,它限制了并发流的数量。为什么要有这个限制?

你想想看,如果客户端一口气打开 10 万个流,服务端光维护流的状态就要消耗大量内存。所以 QUIC 规定:

  • 初始并发流数量由传输参数协商
  • 接收方可以通过 MAX_STREAMS 帧动态增加限额
  • 流关闭后,限额不会自动恢复(需要接收方主动释放)

实战经验:我在调优一个视频直播平台时,发现并发流数量设得太大会导致内存暴涨。后来我们把初始并发流限制在 100,然后根据实际负载动态调整。效果很明显——内存占用下降了 40%,而且用户感知到的延迟几乎没有变化。

最后,我画了一张 QUIC 流与多路复用的整体架构图,帮你把这几块内容串起来:

QUIC 流与多路复用架构 应用层(HTTP/3) Extensible Priorities · QPACK · 请求/响应映射 QUIC 流层 流 ID=0(双向) Urgency=0 流 ID=4(双向) Urgency=1 流 ID=8(双向) Urgency=3 流 ID=2(单向) Urgency=5 QUIC 传输层 连接级别流量控制 · 流级别流量控制 · 并发流限制 MAX_DATA · MAX_STREAM_DATA · MAX_STREAMS UDP + IP 网络层 无连接 · 尽力交付 · 无队头阻塞 应用层优先级 驱动流调度 流独立丢包重传 无队头阻塞

从这张图可以看得很清楚:应用层把请求分配到不同的流上,每个流有自己的优先级和流量控制。传输层负责把这些流的数据打包成 QUIC 包,通过 UDP 发送出去。任何一个流丢包了,只影响它自己,其他流照常跑。