HTTP/2 已经把一个 TCP 连接里的 HTTP 消息拆成多条流。它解决了 HTTP/1.1 响应必须排队返回的问题,却没有解决 TCP 字节流自己的等待:一个 TCP 段丢失后,内核不能把后面的字节先交给上层,即使那些字节只属于另一条 HTTP/2 流。应用层分帧已经知道“这几个字节属于流 3”,TCP 仍然只看到“前面的字节没齐”。

QUIC 的入口问题就在这里:如果应用已经需要多流、加密、恢复、拥塞控制和连接迁移,为什么还要把这些能力放在内核 TCP 之上,再在上层补一套绕不过去的状态?QUIC 的选择是把传输机制搬到 UDP 之上,由端点实现包号、流、恢复、拥塞控制、TLS 集成和路径变化处理。许多实现选择用户态,这是部署方式,不是 QUIC 规范要求。UDP 在这里不是可靠传输,只是报文承载;它也可能被网络策略阻断。

前置是第 17 篇超时与重传、第 18 篇流量控制、第 19 篇拥塞控制、第 24 篇 TLS 身份验证和第 25 篇会话恢复。本篇只讨论 QUIC 传输机制。HTTP/3 怎样把请求、响应、控制流和 QPACK 映射到 QUIC,放到下一篇。文中的验证是 RFC 9000、RFC 9001、RFC 9002 的规范核查和有限手算;没有运行成熟 QUIC 实现,也没有抓包验证丢包恢复或路径迁移。手算可以检查概念关系,不能替代真实端到端行为。

包号不是字节序号

TCP 的序号标识字节流位置。QUIC 的包号标识“这个包在某个包号空间里的发送次序”。这两个编号不承担同一件事。

QUIC 包里可以装一个或多个帧。STREAM 帧再带 stream id、offset、length 和数据。包号用于确认、丢包检测和恢复决策;流偏移用于把同一条流里的字节排回应用可读的顺序。一个包被判定丢失后,恢复层关注的是其中哪些信息需要在新包中再次发送;STREAM 数据还可能重新分帧。应用交付层关注的是每条流各自缺了哪段 offset。

RFC 9000 §12.3 和 RFC 9002 §2 把 QUIC 包号分成三个空间:Initial、Handshake、Application Data。每个发送方向、每个包号空间分别分配严格递增且在连接内不复用的包号;ACK 只确认同一空间里的包。0-RTT 与所有 1-RTT 密钥代际共享 Application Data 包号空间,而不是各用一套包号。这样的拆分让握手阶段和应用数据阶段分别确认与判断丢包。

一个最小手算如下:

1
2
3
4
5
Initial:    packet 0, packet 1
Handshake: packet 0
Application packet 0: STREAM 0 offset 0 length 6
Application packet 1: STREAM 4 offset 0 length 5
Application packet 3: STREAM 0 offset 6 length 4

这里同时出现了三个“packet 0”,并不冲突,因为它们位于不同包号空间。Application 空间跳过 packet 2 也不表示 stream 0 缺了字节;stream 0 是否连续,要看 STREAM 帧里的 offset。最终 stream 0 有 [0, 10),stream 4 有 [0, 5)。包号只回答“哪些包被确认或疑似丢失”,不回答“某条流可交付到哪个字节”。

这也是 QUIC 不能被简化成“TCP 换成 UDP”的原因。TCP 的可靠交付围绕一条字节流;QUIC 用包号跟踪发送尝试,用流偏移组织多条字节流。两层状态分开后,发送端能在新包中恢复已丢失信息,接收端能让没有缺口的流继续前进。

流独立不等于拥塞独立

QUIC stream 是有序字节流。每条流有自己的 offset、FIN 和流量控制窗口;一条流缺少 offset 6 到 10 的字节,不会阻止另一条已经连续到 offset 5 的流被交给应用。这个“独立”指的是流内重组和应用交付边界。

它不表示每条流都有独立的网络拥塞控制。RFC 9002 §7 定义的拥塞控制限制路径上的发送量,而且拥塞控制与 RTT 估计跨包号空间统一维护。多个 QUIC 流复用同一条连接、同一路径时,共享拥塞窗口和在途字节预算。某条流可以在语义上不被另一条流的缺口卡住,但发送端仍要服从同一路径的容量判断。

一个小例子能把两个层次拆开:

1
2
3
4
5
6
7
已收到 stream 0: [0,6), [10,14)   => 缺 [6,10),只能交付到 6
已收到 stream 4: [0,5) => 可交付到 5

cwnd = 12000 bytes
bytes_in_flight = 9000 bytes
当前可再发送 = 3000 bytes
待补数据 = 3500 bytes

stream 4 不需要等待 stream 0 的缺口,这是多流交付层面的独立。示例把拥塞窗口与在途量都记为受控字节,包括 QUIC 包头和包保护开销,不等同于纯 STREAM 载荷。常规发送只剩 3000 字节预算,待恢复信息对应 3500 字节受控数据,不能一次全部发送;RFC 9002 §7.2 对 PTO 探测包另有有限例外。把前者写成“每流独立拥塞控制”就是错位。

流量控制还要再分一层。QUIC 有每条流的流量控制,也有连接级流量控制。流量控制保护接收端缓冲,拥塞控制保护网络路径。一个流被自己的 MAX_STREAM_DATA 卡住,是接收端不允许这条流继续占用更多缓冲;整个连接被 MAX_DATA 卡住,是连接级接收缓冲预算用完;拥塞窗口卡住,则是发送端认为路径上在途数据已经够多。这三种等待现象都可能表现为“发不出去”,原因不同。

恢复看 ACK 区间,不重发原包

QUIC ACK 帧确认的是包号区间。接收端可以表示 Application 空间里 packet 0、1、3 已到达,packet 2 未被确认。发送端结合 ACK 进展、包阈值和时间阈值判定丢包,再把需要恢复的信息放进新包;新包使用新包号。PTO 是缺少 ACK 进展时触发探测的另一条路径,PTO 到期本身不得把此前的包直接标记为丢失。

这个设计和“包号不等于字节序号”配套。packet 2 如果装的是 stream 0 offset 6 length 4,恢复时可以把同一段流信息重新分帧并放进 packet 4。接收端最后看到的是 stream 0 的缺口被补齐;它不要求 packet 2 本身后来出现。包号确认发送尝试,stream offset 负责流数据的去重与重组。

PTO 也容易被写错。RFC 9002 给出的基础公式是:

1
PTO = smoothed_rtt + max(4*rttvar, kGranularity) + max_ack_delay

若握手已经确认,正在计算 Application Data 空间的第一次 PTO,且尚未发生指数退避,smoothed_rtt = 40msrttvar = 8mskGranularity = 1ms、对端通告的 max_ack_delay = 25ms,结果为 40 + max(32, 1) + 25 = 97ms。Initial 和 Handshake 空间不加入 max_ack_delay。这个数值只表示探测计时器,不表示 97ms 后就能断言包永久丢失或必须降低拥塞窗口。

TLS 是传输状态的一部分

QUIC 没有再把 TLS 当成 TCP 连接里的字节流协议。RFC 9001 定义 QUIC 如何使用 TLS 1.3;握手消息由 QUIC CRYPTO 帧承载,密钥阶段和 QUIC 包保护直接绑定。这样做的结果是,传输层从一开始就知道哪些包属于 Initial、Handshake 或 Application Data 阶段,丢包恢复也按对应包号空间处理。

0-RTT 的边界也必须放在这里说清楚。0-RTT 不是“第一次连接就少一次往返”。它依赖已有会话恢复材料、服务器接受早期数据的策略,以及应用能承受重放风险的请求语义。客户端第一次连接一个服务器时没有可用恢复票据,也就没有符合条件的 0-RTT。即使有恢复材料,服务器也可以拒绝早期数据;应用也不能把所有请求都当成可重放安全。

QUIC 把 TLS 握手和传输恢复绑在一起,并不表示应用认证问题消失。RFC 9001 还明确限制了 TLS post-handshake client authentication,因为 QUIC 的多路复用会让客户端难以把证书请求和触发它的应用层事件对应起来。传输层合并了加密状态,不等于所有应用层身份问题都在传输层得到解决。

迁移依赖连接 ID 和路径验证

TCP 连接通常用源 IP、源端口、目的 IP、目的端口识别。移动网络切换、NAT 重新绑定或客户端端口变化,都可能让这组地址变化。QUIC 用 connection ID 把连接身份从地址上拆出来,使端点有机会在地址变化后继续识别同一条连接。连接 ID 只能帮助识别连接,不能证明新路径可达。

这不是无条件迁移。RFC 9000 §8.2 定义 PATH_CHALLENGE/PATH_RESPONSE 做路径验证:PATH_CHALLENGE 携带 8 字节数据,匹配的 PATH_RESPONSE 才能证明对端从该路径收到挑战。端点在握手确认前不得主动迁移。disable_active_migration 限制主动迁移,但不把 NAT rebinding 当成同一件事。

迁移还有拥塞与隐私边界。RFC 9000 §9.4 要求端点在新路径上重置拥塞控制器和 RTT 估计,只有对端端口变化而地址不变时可以保留;旧路径发送的包不能用于建立新路径的拥塞状态。连接 ID 的复用可能让观察者关联多段活动,更新 ID 又要求两端管理可用 ID 与退休状态。实现是否启用主动迁移、何时切换路径以及如何配合负载均衡,属于部署策略。

条件和反例

第一个反例是“QUIC 消除了所有队头阻塞”。QUIC 消除的是 TCP 字节流在传输层造成的跨流交付等待;它没有消除流内有序交付等待。某条流自己的 offset 缺口没补齐,这条流仍然不能把后续字节按序交给应用。到了 HTTP/3,还会出现 QPACK 动态表依赖导致的等待;那属于应用映射和头压缩层,不能提前算成 QUIC 传输层已解决。

第二个反例是“QUIC 每条流独立拥塞控制”。QUIC 流有独立 offset 和流量控制窗口,但拥塞控制通常作用于连接使用的网络路径。多条流共享路径容量;一个大下载流占满在途预算时,小请求流即使语义上可独立交付,也可能要等待发送调度和拥塞窗口释放。

第三个反例是“UDP 上实现就能绕过网络限制”。QUIC 使用 UDP 承载,是为了在现有网络中部署新的传输语义;它仍然要面对丢包、MTU、NAT、限速、UDP 阻断和负载均衡。网络把 UDP 限得很狠时,QUIC 不会凭空得到更好的路径。

第四个反例是“0-RTT 等于免费加速”。0-RTT 需要恢复前提,携带重放风险,并受服务器和应用策略限制。写性能结论时至少要区分首次连接、恢复连接、服务器接受早期数据、服务器拒绝早期数据、应用请求是否幂等这几种情况。

可复现手算

同名素材目录里的 handcalc.py 只用 Python 标准库,验证三件事:

1
2
python3 source/_posts/2026-09-22-计算机网络27-QUIC为什么重新实现传输机制/handcalc.py --help
python3 source/_posts/2026-09-22-计算机网络27-QUIC为什么重新实现传输机制/handcalc.py

素材包括手算脚本固定输出资料核查记录

脚本断言三个有限例子。第一,Initial、Handshake、Application Data 可以各自有 packet 0,Application 空间还可以有意跳过 packet 2;stream byte range 另按 stream offset 计算。第二,stream 0 有缺口时只能交付到 offset 6,stream 4 可以交付到 offset 5,但两个流共享 3000 字节拥塞预算。第三,PTO 公式在给定数值下得到 97ms。

这类手算能检查文章里的概念边界:包号空间不等于流字节序号,流独立交付不等于独立拥塞控制,PTO 是计时器计算而不是端到端实测。它不能证明某个实现的调度公平性、某条公网路径上的性能,也不能证明路径迁移在具体负载均衡环境里可用。

练习

练习一:某 QUIC 连接的 Application Data 空间收到 ACK 区间 [0, 1][3, 3],packet 2 里有 stream 8 offset 100 length 20。后来发送端在 packet 4 里重新发送 stream 8 offset 100 length 20。接收端最终应按哪个编号去重,按哪个编号更新 ACK 状态?说明 packet 2 和 packet 4 是否能同时对应同一段 stream 数据。

练习二:一条连接上有 stream 0 和 stream 4。stream 0 已收到 [0, 8)[12, 20),stream 4 已收到 [0, 6);连接级可用发送预算为 1000 字节,stream 0 的缺口需要 4 字节,stream 4 还有 1500 字节新数据待发。分别写出应用可交付位置、每条流的缺口、发送调度会受哪个共享条件限制。

参考资料

验证边界

已验证:当次核查 RFC 9000、RFC 9001、RFC 9002 的相关章节;handcalc.py 用断言复算包号空间、流缺口、共享拥塞预算和 PTO 算术。Hexo 构建与页面检查只验证内容和素材可生成、可访问,不构成 QUIC 协议行为验证。

未验证:没有运行 ngtcp2、quiche、msquic、OpenSSL QUIC API 或浏览器实现;没有抓取 QUIC 包、qlog 或 TLS key log;没有在隔离网络中注入真实丢包、重排、限速、NAT rebinding 或地址迁移;没有比较 HTTP/2 与 HTTP/3 的同内容传输。成熟实现多流传输、丢包恢复和路径验证仍是计划验收项,本篇只把未运行部分标为 NOT_RUN,不把手算、静态检查、HTTP 200 或构建成功写成真实端到端验证。