4. QUIC 流与多路复用:流的创建与关闭、流优先级、无队头阻塞的实现
聊到 QUIC 的多路复用,我总想起几年前调优 HTTP/2 连接的经历。那时候线上偶尔出现一个慢请求拖垮整个页面的情况,查了半天发现是 TCP 的队头阻塞在作祟。后来换到 QUIC,这个问题才算真正解决。今天我们就来拆解 QUIC 的流机制——它到底是怎么做到“一个流出事,其他流不受影响”的。
4.1 流的创建与关闭
QUIC 里的流,说白了就是一个独立的、有序的字节序列。每个流都有自己的 ID,数据在流内严格按序交付,但流与流之间完全隔离。
4.1.1 流 ID 的分配规则
QUIC 用流 ID 来区分不同的流。这个 ID 可不是随便分配的,它隐含了流的类型和方向:
| 流 ID 范围 | 类型 | 方向 | 谁可以发起 |
|---|---|---|---|
| 0, 4, 8, 12, ... | 客户端双向流 | 双向 | 客户端 |
| 1, 5, 9, 13, ... | 服务端双向流 | 双向 | 服务端 |
| 2, 6, 10, 14, ... | 客户端单向流 | 单向(客户端→服务端) | 客户端 |
| 3, 7, 11, 15, ... | 服务端单向流 | 单向(服务端→客户端) | 服务端 |
你发现规律了吗?最低两位(bit 0 和 bit 1)决定了流的属性:
- bit 0(最低位):0 表示客户端发起,1 表示服务端发起。
- bit 1:0 表示双向流,1 表示单向流。
举个例子,流 ID = 5,二进制是 0101。最低位是 1,说明是服务端发起的;第二位是 0,说明是双向流。嗯,就是这么设计的。
stream_id & 0x01 判断发起方,stream_id & 0x02 判断方向。比查表快多了。
4.1.2 流的创建流程
QUIC 创建流不需要显式的握手。你直接发一个包含新流 ID 的 STREAM 帧,对端收到后就知道这个流开始了。零 RTT 创建,这才是真正的“即用即开”。
不过要注意,流不能无限创建。两端在握手时会协商 initial_max_streams_bidi 和 initial_max_streams_uni 两个参数,限制并发流的数量。我曾经在压测时遇到过一个问题:客户端疯狂创建流,结果服务端直接发了 STREAMS_BLOCKED 帧——流配额用完了。
4.1.3 流的关闭与优雅终止
QUIC 流的关闭有两种方式:
- 正常关闭:发送端发一个 FIN 标志位(在 STREAM 帧里),表示数据发完了。接收端收到后,等所有数据都交付了,流就进入“半关闭”状态。如果双向流,另一端还可以继续发数据。
- RESET_STREAM:如果某个流出错了,或者你不想继续了,直接发 RESET_STREAM 帧。这个帧会携带一个错误码,告诉对端“这个流我放弃了”。
你想想看,RESET_STREAM 和 TCP 的 RST 有什么区别?TCP 的 RST 会直接干掉整个连接,而 QUIC 的 RESET_STREAM 只影响单个流。其他流完全不受影响。这就是无队头阻塞的基础。
4.2 流优先级
多路复用搞定了,但还有个问题:这么多流同时跑,谁先谁后?
QUIC 本身没有定义优先级的具体算法,它只提供了一个 优先级框架,让上层应用(比如 HTTP/3)来决定。HTTP/3 的优先级方案是“Extensible Priorities”,用 urgency 和 incremental 两个参数来控制。
| 参数 | 取值范围 | 含义 |
|---|---|---|
| urgency | 0~7 | 0 最高优先级,7 最低。默认是 3。 |
| incremental | true / false | true 表示可以和其他流交错发送(比如视频流),false 表示必须等这个流发完才能发别的(比如关键 CSS)。 |
核心思路:优先级不是绝对的“先发完 A 再发 B”,而是控制带宽分配的比例。高优先级的流分到的带宽更多,但低优先级的流也不会被饿死。
我在项目中遇到过一个问题:某个页面加载时,图片流和 JS 流都在抢带宽。后来把关键 JS 流的 urgency 设为 0,incremental 设为 false,图片流的 urgency 设为 5,incremental 设为 true。结果首屏加载时间直接降了 30%。
4.3 无队头阻塞的实现
这是 QUIC 最核心的卖点。为什么 QUIC 能做到无队头阻塞,而 HTTP/2 不行?
4.3.1 HTTP/2 的队头阻塞问题
HTTP/2 虽然支持多路复用,但底层还是 TCP。TCP 是一个有序的字节流,如果某个 TCP 段丢了,后续所有段都得等它重传。这就导致:
- 流 1 丢了一个包,TCP 开始重传。
- 流 2 的数据虽然已经到了,但 TCP 层不交给上层,因为顺序乱了。
- 流 3、流 4……全部卡住。
说白了,HTTP/2 的多路复用是“假并行”——底层还是串行的。
4.3.2 QUIC 的独立流隔离
QUIC 在 UDP 之上自己实现了可靠传输。每个流都有自己的序号空间,丢包只影响那个流本身。其他流的数据照常交付。
举个例子:
- 流 1 的包 5 丢了,QUIC 只重传流 1 的包 5。
- 流 2 的包 3 已经到了,直接交给上层应用。
- 流 3 的包 1 也到了,继续处理。
你想想看,这就像多个独立的管道,每个管道自己管自己的顺序。一个管道堵了,其他管道照常流水。
4.3.3 无队头阻塞的代价
当然,没有免费的午餐。无队头阻塞也带来了一些挑战:
- 内存开销:每个流都要维护自己的发送缓冲区、接收缓冲区、重传队列。流多了,内存占用会上升。
- 调度复杂度:多个流同时有数据要发,调度器要决定先发哪个流的数据。如果调度算法不好,可能导致低优先级流长时间得不到服务。
- 流控复杂度:除了每个流的流控,还有连接级的流控。两个级别的流控要协同工作,否则容易出问题。
我曾经在实现中遇到过一个问题:某个流的数据一直发不出去,查了半天发现是连接级流控的窗口用完了。虽然那个流的窗口还有余量,但连接级窗口卡住了,所有流都发不了。后来调整了流控参数的配置,才解决。
4.4 核心逻辑图:QUIC 流与多路复用
下面这张图展示了 QUIC 流的核心逻辑:多个流在同一个连接上独立传输,互不干扰。
4.5 小结
QUIC 的流机制,核心就三件事:
- 流独立:每个流有自己的 ID、序号空间、流控状态。一个流出问题,不影响其他流。
- 优先级灵活:通过 urgency 和 incremental 两个参数,精细控制带宽分配。
- 无队头阻塞:丢包只影响那个流本身,其他流照常跑。