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 | 服务端 | 单向 |
流的状态机其实不复杂。我习惯把它简化成四个核心阶段:
- 创建:发送方发送 STREAM 帧,隐式创建流
- 数据传输:双方通过 STREAM 帧收发数据
- 关闭:发送方发送 FIN 标记,表示数据发送完毕
- 销毁:双方确认数据完整接收后,流被彻底回收
关键点: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 那样只有一个全局的流量窗口,而是有两层控制:
- 连接级别(Connection-Level):限制所有流的总数据量
- 流级别(Stream-Level):限制单个流的数据量
这两层控制通过 MAX_DATA 和 MAX_STREAM_DATA 帧来动态调整。接收方可以根据自己的处理能力,随时告诉发送方:「嘿,慢点,我快撑不住了。」
另外,还有一个重要的参数叫 MAX_STREAMS,它限制了并发流的数量。为什么要有这个限制?
你想想看,如果客户端一口气打开 10 万个流,服务端光维护流的状态就要消耗大量内存。所以 QUIC 规定:
- 初始并发流数量由传输参数协商
- 接收方可以通过 MAX_STREAMS 帧动态增加限额
- 流关闭后,限额不会自动恢复(需要接收方主动释放)
实战经验:我在调优一个视频直播平台时,发现并发流数量设得太大会导致内存暴涨。后来我们把初始并发流限制在 100,然后根据实际负载动态调整。效果很明显——内存占用下降了 40%,而且用户感知到的延迟几乎没有变化。
最后,我画了一张 QUIC 流与多路复用的整体架构图,帮你把这几块内容串起来:
从这张图可以看得很清楚:应用层把请求分配到不同的流上,每个流有自己的优先级和流量控制。传输层负责把这些流的数据打包成 QUIC 包,通过 UDP 发送出去。任何一个流丢包了,只影响它自己,其他流照常跑。