为什么我们需要 QUIC
Finally, Finally, Finally。
quicX 是一个完整的 QUIC 与 HTTP/3 库,从底层的 socket 数据接收到顶层的 HTTP 接口语义实现,是一个完备且架构优化的网络库实现。包含特性多,跨越层级广,实现细节杂,本文将从一个较为宏观的角度上描述:实现一个 QUIC/HTTP3 协议库都需要处理哪些问题。
之前写过一篇文章《什么是 HTTP3》对 QUIC 和 HTTP/3 做了一些概括性的介绍,但那篇文章的定位更像是一本百科全书,将各项特性罗列在那里,缺乏对 “为什么” 的深入探讨。本文换一个角度,不再面面俱到的列举特性,而是试图将这些特性背后的来龙去脉串联起来。正所谓知其然也要知其所以然,我们首先简单回顾一下为什么需要 QUIC 协议。
一、 为什么需要 QUIC
HTTP/1.x 时代
最早的 HTTP/1.0 时代,HTTP 通过 TCP 协议传输,单次请求使用单个 TCP 连接。而 TCP 建立连接是一个成本很高的过程,三次握手加上可能的 TLS 四次握手,每次请求在开始传输数据之前就要消耗掉好几个 RTT。随着互联网的发展,一个页面加载完成可能需要几十上百个请求响应过程,每次都重新建连,代价之高不言而喻。
HTTP/1.1 引入了 keep-alive 机制来复用 TCP 连接,这可能是 HTTP/1.1 最重要的升级内容。但复用连接之后又引入了新的问题:HTTP/1.1 规定服务端的响应必须按照客户端请求的顺序依次返回。如果第一个请求是个耗时的慢查询,后续即使是几毫秒就能处理完的请求,也得在后面排队等着。为了绕过这个限制,浏览器不得不对同一个域名建立多条 TCP 连接来换取一些并发能力,但这又重新引入了大量的握手开销。
这就是 HTTP 级别的队头阻塞问题。
HTTP/2 时代
为了解决 HTTP/1.1 应用层的队头阻塞,HTTP/2 引入了多路复用和二进制分帧机制。在 HTTP/2 中,一条 TCP 连接被虚拟化成了多个带有 Stream ID 的流,数据被切分为二进制帧在流上传输,谁先处理完谁先返回,从应用层面上看并发问题被完美解决了。
然而 HTTP/2 的底层依然是 TCP。TCP 不认识什么 Stream ID,它只认自己的 Sequence Number。一旦网络波动丢了一个 TCP 报文段, TCP 协议栈会铁面无私地把后续所有已经到达的报文扣押在内核接收缓冲区里,直到那个丢失的包被重传补齐。哪怕后续报文属于完全不同的 HTTP Stream,TCP 也不管,一律扣押。
结果就是在弱网高丢包的移动网络环境下,HTTP/2 的多路复用反而导致所有并发请求因为一个包的丢失而全部卡死。此队头阻塞非彼队头阻塞,HTTP/1.1 的队头阻塞发生在应用层,而 HTTP/2 遇到的则是 TCP 级别的队头阻塞。在某些极端场景下,表现甚至比开了 6 条 TCP 连接的 HTTP/1.1 还要糟糕。
此外,TCP 与 TLS 必须分两层串行握手,建连的 RTT 开销依然很大。
QUIC 的破局
正是为了打破操作系统内核对 TCP 协议的固化限制,Google 提出了 QUIC 协议。核心思路是:放弃 TCP,拥抱 UDP。在 UDP 之上,把可靠传输的机制搬到用户态来实现。
QUIC 带来了几个根本性的改变:
- 彻底消灭队头阻塞:QUIC 在传输层原生感知 Stream 的概念。Stream 之间的丢包恢复完全独立,Stream A 丢包不会阻塞 Stream B 和 C 的数据交付。
- 极速握手:将 TLS 1.3 与传输层建连融为一体,首次连接只需 1 个 RTT 即可发送数据,恢复会话时甚至可以做到 0-RTT 直接携带业务数据。
- 连接迁移:TCP 靠五元组 {源 IP, 源端口, 目的 IP, 目的端口, 协议类型} 来标识连接,网络切换意味着连接断开重连。而 QUIC 通过一个用户层协商的 Connection ID 来维系会话,IP 变了连接也不会中断。
从这几个方面可以看出来 QUIC 的野心很大,其对四层协议的改动甚至深于七层协议的改动。据相关统计,现在整个网络中 TCP 流量占比超过 80%,可以说是触动了计算机网络中核心的核心。
二、 替换 TCP 需要实现哪些机制
纸上谈兵总是容易的。放弃成熟的内核态 TCP,相当于抛弃了过去 40 年互联网最核心的可靠性基石。要用不可靠的 UDP 来承载 HTTP/3 这个千亿级流量的协议,就必须在用户态把 TCP 的核心功能重新实现一遍,甚至在某些方面要做得更好。
以下几个机制在 network 传输中至关重要,缺少任何一个都无法构建可用的传输协议。
流量控制
为什么需要流量控制?接收方的内存是有限的,应用层消费数据的速度往往跟不上网络接收的速度。如果没有流量控制,发送方就会像决堤的洪水一样盲目发送数据,不仅会撑爆接收方的内存,还会白白浪费带宽,因为接收方处理不过来的数据终究要被丢弃重传。
TCP 使用基于整体连接的滑动窗口来做流量控制。但 QUIC 由于支持了多路复用,单一维度的窗口不够用了,必须实现双层的流量控制机制:
- Stream 级流量控制:防止某一个流占用掉接收方全部的内存资源。每个 Stream 都有自己的
MAX_STREAM_DATA窗口大小,告诉发送方 “这个流你最多还能发多少字节”。 - Connection 级流量控制:防止所有并发 Stream 累加起来的数据量超限。通过
MAX_DATA帧,接收方限制整个连接在同一时刻能接收的字节总量。
两层窗口之间需要精打细算。当窗口告急,发送方会发出 DATA_BLOCKED 或 STREAM_DATA_BLOCKED 帧通告对端;当业务层把数据消费掉、内存腾出后,接收方又会发出窗口更新帧,继续唤醒发送端。这个过程涉及到的帧类型和状态机转换都比 TCP 要复杂不少。
拥塞控制
流量控制保护的是接收端的内存,拥塞控制保护的是中间网络链路。当成千上万的设备同时往网络管道里塞数据,如果总和超过了路由器的处理极限,就会发生大面积丢包甚至拥塞崩溃。发送方必须有一种算法,通过探测延迟的增加或丢包的发生来推断当前网络管道的容量,进而动态调整自己的发包速率。
TCP 的拥塞控制算法(如 Reno、CUBIC、BBR 等)被固化在操作系统内核中。要升级一次拥塞控制算法,意味着要给几十万台服务器升级内核,这对绝大多数企业来说几乎是不可能的事情。这也是新的算法极难在互联网上快速普及的根本原因。
而 QUIC 将拥塞控制完全搬到了用户态。这意味着在 quicX 这样的库里,可以非常敏捷地切换不同的算法。quicX 内部实现了一套抽象的拥塞控制接口,底层通过精准测量瓶颈带宽 (Bottleneck Bandwidth) 和最小往返延迟 (Min RTT),配合 Pacer(发包起搏器)来平滑发送,即使在剧烈波动的弱网环境下也能保持比较稳定的传输效率。这种灵活性是内核态 TCP 望尘莫及的。
丢包检测与恢复
UDP 是一个 “尽力而为” 的协议,数据包发出去之后就不管死活了。但 HTTP 必须可靠,一个字节都不能少。所以必须设计严密的自动重传请求机制(ARQ),发送方要能准确知道哪个包丢了并及时补发。
这里不得不说 TCP 的一个经典难题:重传歧义(Retransmission Ambiguity)。设想这样一个场景:TCP 发送了一个序列号为 100200 的包,超时未收到 ACK,于是重传了一个序列号还是 100200 的包。一段时间后,TCP 收到了对 200 的 ACK 确认。问题来了:这个 ACK 到底是确认第一次发出的包,还是确认重传的包?如果是确认第一次的,说明网络延迟很大但包没丢;如果是确认重传的,说明第一个包确实丢了。TCP 无法区分这两种情况,这导致 RTT 的测量变得模糊,进而影响到超时重传定时器的设置和拥塞控制的判断。虽然 TCP 后来引入了 Timestamp 选项来补救,但终究是个不彻底的补丁。
QUIC 选择推翻这一设计,将 “数据流逻辑偏移(Stream Offset)” 与 “底层物理包序号(Packet Number)” 彻底分离。在 QUIC 中,底层包的序号是严格单调递增的。即使某份数据丢失了需要重发,这份数据也会被封装在一个拥有全新且更大 Packet Number 的新包中。打个比方:包含业务数据 “Hello” 的 Packet 1 丢失了,超时后发送方重新打包 “Hello”,但它的编号是 Packet 5。当发送方收到对 Packet 5 的 ACK时,它清清楚楚地知道这是对第二次传输的确认。困扰 TCP 数十年的重传歧义问题,至此被连根拔起。
配合 RFC 9002 中定义的 PTO(Probe Timeout)探测超时机制,以及使用 ACK Delay 帧去精确扣除接收方的处理耗时,QUIC 实现了有史以来最精确的 RTT 测量与丢包恢复状态机。这部分的实现在 quicX 中也是最复杂的模块之一。
加密与安全
互联网早期的 TCP 是完全明文的协议,任何中间节点都可以窥探甚至篡改报文内容。更严重的是由此引发的网络僵化(Ossification)问题 —— 中间的防火墙和路由器长期依赖 TCP 包头中固定位置的字段做各种检测和过滤,一旦 TCP 试图修改或扩展其头部格式,这些中间设备就可能直接将包丢弃。这导致 TCP 协议几十年来几乎无法演进。
QUIC 从设计之初就决定不信任网络上的任何中间设备。它不仅强制绑定 TLS 1.3 对应用层数据加密,甚至采用了 Header Protection 机制 —— 连包头中的 Packet Number 本身也被加密。对于中间设备而言,QUIC 的报文和普通的 UDP 包别无二致,就是一串随机数据,中间设备既看不懂也改不了,彻底断绝了被篡改和僵化的可能性。
要在一个用户态的库里复刻这一安全性,需要从零集成 BoringSSL,严格依循 RFC 9001 的指引完成复杂的密钥轮换:何时使用 Initial Secret 解密握手包,何时通过 HKDF 导出 Handshake 级别的密钥,最终如何平滑升级到 1-RTT 的 Application Secret,每一步都必须精确无误,错一个字节整个握手就会失败。
高性能支撑
内核态的 TCP 之所以稳健,背后有着操作系统级别的线程调度和定时器系统在支撑。当决定把传输协议搬到用户态进程中时,这些基础设施就得自己来造。
一个 QUIC 服务端的 UDP socket 可能同时承载着成千上万的用户连接,每个连接上又有多条流。每个连接的探测包超时等待(PTO),每个连接的心跳保持(Idle Timeout),甚至每个对端的延迟 ACK 确认,背后都会衍生出大量定时器任务。这些定时器的创建、触发和销毁频率极高,如果用优先队列或者暴力遍历去管理,CPU 资源一瞬间就会被消耗殆尽。
在 quicX 的底层基础设施中,我实现了分层时间轮(Timing Wheel)组件来管理这些定时器,将添加和检测的时间复杂度控制在 O(1) 级别。之前的文章《深入剖析网络 IO 复用》中提到过 one loop per thread 的线程模型,quicX 也采用了类似的设计,每个线程维护自己的事件循环,结合自定义的内存池分配器实现了跨线程无锁的数据传递,在网卡收数据到协议栈解包再到抵达业务回调的全链路中,尽可能减少内存拷贝和锁竞争。
三、 结语
上述的种种机制,其实每一条展开讲都是极其复杂的,甚至需要一整篇 RFC 来描述和定义。但也正因为其变革的根本性,解决了传统 TCP 的诸多限制,提供了更好的性能、安全性和用户体验,尤其适用于现在移动网络以及弱网的传输场景。
本文仅仅从宏观的视角上做了一些介绍,后续会结合 quicX 的实现,对上述提到的各个机制做更详细的分析和讨论。
笔者断断续续,历时四年有余,终于将这个 HTTP/3 库写得初有眉目:
quicX:quicX
其实现了大部分 RFC 文档中定义的功能,我也还在一直完善。
不揣浅陋,在这里分享出来,与诸君共勉。