跳到内容

第 4 卷 / 第 11 章 / 第 1 课

11.1 QUIC:连接、流、丢包恢复与迁移 ​

HTTP/2 把多个请求放进一条 TCP 连接后,一个丢失的 TCP 段仍可能让无关请求一起等待。QUIC 在 UDP 之上提供加密和多条可靠流,把有序交付的等待限制在各条流内。本课从包号、流偏移和确认信息出发,解释它减少了哪些等待,以及丢包、拥塞和切网时还需要处理什么。

QUIC v1 的核心由 2021 年发布的 RFC 9000、9001 和 9002 定义,分别覆盖传输、TLS 集成、丢包检测与拥塞控制。它提供可靠流、流量控制和连接迁移,但没有原样复制 TCP 的全部语义。

1. 多条流如何减少队头阻塞 ​

HTTP/2 在一条 TCP 字节流上多路复用多个 HTTP 流。某个 TCP 段丢失时,TCP 接收端必须补齐字节缺口,才能继续向上层按序交付;缺口后面恰好属于另一个请求的数据,也会被挡住。这是传输层的队头阻塞(head-of-line blocking,HOL)。

QUIC 按流重组字节。假设流 A 缺少偏移 [0, 100) 的数据,而流 B 已收到从偏移 0 开始的完整响应,B 就可以继续交付。A 的缺口不再强制 B 等待,但还有几类依赖:

  • 同一条流内,按序交付仍须补齐缺口;
  • 连接共享拥塞控制,丢包和排队可能降低所有流可用的发送速率;
  • 连接级流量额度耗尽,可能同时阻塞多条流;
  • HTTP/3 的 QPACK 动态表依赖,可能阻塞字段解码;
  • 应用若必须先拿到 A 的结果才能处理 B,传输协议无法消除这项依赖。

TCP 仍有成熟的硬件卸载、网络兼容性和广泛的 API。QUIC 的价值在于把传输、加密和多路复用一起设计,并让常见的用户态实现能随应用或库升级。换了协议,也不能替应用取消一个确实存在的依赖。

2. UDP 提供数据报,QUIC 补上连接语义 ​

下面是分层示意,DATAGRAM 是可选扩展:

text
HTTP/3 or other application protocol
              |
QUIC streams / optional DATAGRAM extension / connection control
              |
UDP datagrams
              |
IP

UDP 不保证送达、顺序或去重;QUIC 自己维护确认(ACK)、丢包恢复、流偏移、流量控制和拥塞控制。QUIC DATAGRAM 扩展还允许应用发送不可靠数据报:它们受拥塞控制,但丢失后不会由 QUIC 重传,也不使用流的字节额度。RFC 9221 §5

用户态实现不必等待内核 TCP 栈更新,但仍可利用 UDP GSO/GRO、校验和卸载、RSS 和批量收发。具体性能取决于操作系统、网卡和库,不能从“用户态”直接推导出“必然更快”或“完全没有硬件加速”。

3. 包号标识一次发送,流偏移标识数据位置 ​

一个 QUIC 包可携带多个帧,例如 STREAM、ACK、CRYPTO、MAX_DATA 和 PATH_CHALLENGE。每个发送方的包号在所属包号空间中递增且不重用;线上编码只携带 1–4 字节的截断值,接收端结合预期值恢复完整包号。Retry 和版本协商包不使用这种包号。

QUIC v1 有三个包号空间:Initial、Handshake、Application Data。0-RTT 与 1-RTT 共用 Application Data 空间,但 0-RTT 包不能携带 ACK。

丢包后,需要可靠交付的字节会放进带有新包号的包,帧也可以重新拆分或合并。下面是人为构造的发送记录,区间采用左闭右开表示:

包号携带的信息观察到的状态
5流 0 的字节 [0, 100)暂未确认,随后被判丢失
6流 4 的字节 [0, 80)已确认,可独立交付
17再次发送流 0 的字节 [0, 100)后续 ACK 覆盖包号 17

ACK 确认的是包号范围。发送端根据自己的记录,知道包 17 携带哪些字节,并更新这些字节的交付状态;ACK 并不包含“17 是 5 的重传”这一含义。若包 5 只是迟到,接收端也不会把重复的流字节交付两遍。传输层 ACK 不证明应用已经处理请求,业务结果仍需应用层确认。

4. 丢包阈值与 PTO 不能混为一谈 ​

ACK 帧可报告多个包号范围。范围间的缺口是尚未确认的数据,可能源于丢包,也可能只是乱序,不能看到缺口就立即判丢。

RFC 9002 的两种基于确认的检测都先要求:同一包号空间里,一个较新的包已经被确认。

  • 包号阈值:未确认包与最大已确认包号之差达到阈值时判丢,推荐初值为 3。最大已确认包号为 10 时,仍未确认的包 7 已达到阈值。
  • 时间阈值:较早包的发送时间距现在超过阈值时判丢。推荐阈值为 max(9/8 × max(smoothed_rtt, latest_rtt), timer_granularity)。

例如,平滑 RTT 为 40 ms、最新 RTT 为 48 ms、计时粒度为 1 ms,时间阈值为 54 ms。即使一个包已经等了 60 ms,没有较新包的 ACK,也不能仅凭这一条规则宣告它丢失。RFC 9002 §6.1

**探测超时(Probe Timeout,PTO)**负责缺少反馈的情况:计时器到期后发送能引出确认的探测包,连续超时采用指数退避。PTO 到期本身既不宣告旧包丢失,也不直接缩小拥塞窗口;后续判定的丢包或持续拥塞才触发相应拥塞响应。RFC 9002 §6.2

确认延迟也有边界。对需要确认的 Initial 和 Handshake 包,接收端应立即确认;不能套用应用数据阶段的延迟确认策略。发送端计算这两个空间的 RTT 时忽略对端报告的 ACK Delay。

5. QUIC 如何使用 TLS 1.3 ​

QUIC v1 使用 TLS 1.3 握手,但不搬运 TLS 记录层。握手消息放在 QUIC CRYPTO 帧中,TLS 提供密钥派生和对端认证,QUIC 负责包保护。

加密级别用途边界
Initial携带 ClientHello 等初始握手消息密钥由客户端 Initial 的目标 CID 和版本固定盐值派生,旁观者也能计算
Handshake保护握手后续消息使用 TLS 握手秘密派生的密钥
0-RTT恢复会话时发送早期应用数据可能重放,不具备前向保密性,服务端可拒绝
1-RTT保护正常应用数据和控制信息使用应用流量密钥,并支持密钥更新

Initial 包虽有加密形式,却不提供对旁观者的保密性或可靠的对端认证。它有助于避免中间设备依赖明文握手格式,并不能隐藏一个未另行保护的 ClientHello。RFC 9001 §5.2

头部保护主要遮蔽包号和部分标志位。CID 等路由信息仍可见,长头部中的版本号也可见。认证加密保护密钥已保密的包不被无声篡改,但不能把 Initial 的公开派生密钥说成同样的认证保障。

6. 首次连接、会话恢复与 0-RTT ​

没有 Retry、丢包和其他额外往返时,新建 QUIC 连接通常允许客户端在 1 RTT 后发送由 1-RTT 密钥保护的应用数据。TCP 加 TLS 1.3 若串行建立,通常先花 1 RTT 建 TCP,再花 1 RTT 做 TLS;这只是连接建立的比较,不是完整页面加载时间。

浏览器还会复用连接、预连接、恢复 TLS 会话、处理 DNS,并可能并行尝试其他协议。因此不能承诺每次访问都固定节省若干 RTT。

0-RTT 依赖可恢复的会话状态或票据。客户端在握手完成前发送早期数据,服务端可以拒绝;拒绝后,客户端与应用需按各自接口约定在 1-RTT 阶段重新发送,避免遗漏或重复处理。

早期数据已经加密,攻击者仍可能原样重放密文。HTTP 服务可通过 425 Too Early 要求客户端在握手后重试,但重试不得再次使用早期数据。幂等也不等于完全无害:重复 GET 或 PUT 仍可能增加计费、审计、缓存预热或通知开销。是否开放 0-RTT,需要逐接口审查副作用、凭据范围和分布式防重放机制。RFC 8470 §5.2

7. 流编号与流量控制 ​

流有“客户端或服务端发起”和“双向或单向”两个维度。流 ID 的最低位标识发起方,次低位标识方向,所以同一类型的流 ID 每次增加 4。

下面的 Python 3 程序只演示编号规则,不建立网络连接。保存为 stream_ids.py,运行 python3 stream_ids.py:

python
for first_id in range(4):
    initiator = "server" if first_id & 1 else "client"
    direction = "uni" if first_id & 2 else "bidi"
    ids = [first_id + 4 * n for n in range(3)]
    print(f"{initiator} {direction}: {ids}")

预期输出:

text
client bidi: [0, 4, 8]
server bidi: [1, 5, 9]
client uni: [2, 6, 10]
server uni: [3, 7, 11]

流内顺序由字节偏移决定。一个包可以携带多条流的帧,同一段流数据重传时也可以重新分帧;一个已经编码好的帧不会横跨两个 QUIC 包。

流量控制分两层:MAX_STREAM_DATA 给出单条流允许发送的字节上界,MAX_DATA 给出连接内所有流的累计额度。重传已有字节不会再次消耗额度;稀疏偏移和流终止时的最终大小也参与额度计算,不能只统计应用实际收到的字节。

MAX_STREAMS 控制对端可打开的累计流数,双向和单向分别计数。接收端消费数据、回收流资源后,需要适时增加相应额度。传输额度之外,应用仍要限制并发请求与缓冲内存。

8. CID 找到连接,路径验证确认地址 ​

目标连接 ID(Destination Connection ID,DCID)帮助接收端和负载均衡器在源地址或端口变化后找到原来的连接状态。QUIC v1 的 CID 最长 20 字节;允许零长度,但客户端尚未收到服务端 Initial 或 Retry 时,选择的目标 CID 至少为 8 字节。零长度 CID 需要依赖地址等其他信息分流,通常难以支持地址变化后的连接延续。

对端通过 NEW_CONNECTION_ID 提供备用 CID,并用 RETIRE_CONNECTION_ID 通知退役。更换 CID 可减少跨路径关联,但包大小、时间和 IP 地址仍会泄露关联线索,CID 轮换不等于匿名通信。

QUIC v1 的主动迁移由客户端发起,且要等握手确认后。新路径通过 PATH_CHALLENGE / PATH_RESPONSE 验证对端能否在该地址收包;验证前还受路径发送和反放大规则限制。disable_active_migration 可禁止主动迁移,但不能阻止 NAT 自行改写地址或端口。

新路径的容量和 RTT 可能不同,不能直接套用旧估计。确认新地址后通常需要重置拥塞与 RTT 状态;若只改变了对端端口,RFC 允许保留这些状态,因为这常是 NAT 重绑定。RFC 9000 §9.4

连接延续还要求请求仍到达持有密钥和流状态的端点。CID 不会自动把这些状态复制到另一台服务器,负载均衡部署见下一课。

9. 地址验证、三倍限制与数据报大小 ​

攻击者可能伪造 UDP 源地址。在客户端地址尚未验证时,服务端向该地址发送的字节数不得超过从该地址收到字节数的三倍。若收到的合格数据报共计 1200 字节,当前发送预算最多是 3600 字节;这限制的是字节,不是包数。

服务端可用 Retry 和令牌要求客户端证明能收到回包,再分配更多状态或发送更多数据。Retry 会增加一次往返,因此需要权衡拒绝服务防护、状态开销和握手延迟。

客户端发送的每个包含 Initial 包的 UDP 数据报,其 UDP 载荷至少为 1200 字节,必要时用填充或合并包补足。这个数不包含 UDP/IP 头部,也不表示握手后的每个包都必须这么大。

端点应使用数据报分组层路径 MTU 发现(DPLPMTUD)或 PMTUD 探测可用大小;没有这些机制时,应保守地使用 1200 字节上限。若一条路径连 1200 字节 UDP 载荷都承载不了,就不能继续在该路径上正常发送 QUIC 数据。RFC 9000 §14

10. 验收问题 ​

  1. 流 A 有缺口、流 B 数据完整时,哪些等待消失了,哪些依赖仍存在?
  2. 表中的包 5 与包 17 携带相同字节,为什么包号仍必须不同?
  3. 只有等待时间变长,没有较新包的 ACK,为什么不能直接套用时间阈值判丢?
  4. Initial 密钥为什么不提供对旁观者的保密性?
  5. 将示例中的编号步长改成 1,会混入哪些其他类型的流?
  6. CID、路径验证和拥塞状态更新在迁移中各负责什么?
  7. 服务端收到 1200 字节后向未验证地址发送 4000 字节,违反哪项限制?

下一课进入11.2 HTTP/3、QPACK、发现与部署。

参考 ​

Built with VitePress | Software Systems Atlas