HTTP/3 不是 HTTP/2 加个 3:QUIC 为什么要在 UDP 上把 TCP 重造一遍

上周排查一个"页面首屏偶发卡三秒"的问题,我照例打开 Network 面板,顺手把 Protocol 那一列勾出来,看到满屏的 h3。

一束光纤电缆捆在一起,截面反射出彩色的光

我当时心里飘过一句"哦,HTTP/3,HTTP/2 的升级版",然后就去查 JS 了。直到后来跟同事聊起为什么我们 CDN 上开了 HTTP/3 之后,弱网下的 P95 反而掉得比预期多,我才被迫去读了一遍 RFC 9000。

读完的第一个感受是:HTTP/3 根本不是 HTTP 的事。HTTP/3 本身几乎没改什么语义,它就是把 HTTP/2 的帧塞进一个新的传输协议 QUIC 里。真正被推翻的是下面那层跑了四十年的 TCP。

而 QUIC 最反直觉的一点是:它跑在 UDP 上。一个"可靠、有序、带拥塞控制"的协议,跑在一个"不保证送达、不保证顺序"的协议上面,然后把可靠性自己实现一遍。

# 一、HTTP/2 解决了一半,然后停在了 TCP 门口

先回到 HTTP/1.1。它最要命的问题有两个:

  • 一个连接同一时刻只能处理一个请求,浏览器只好开 6~8 个 TCP 连接来凑并发
  • 每个请求都要重复带上几百字节的 header(Cookie、UA、一堆 Accept-*)

HTTP/2 把这两件事都解决了:单个 TCP 连接上跑多路复用(multiplexing),用 HPACK 压缩头部。于是大家兴高采烈地把域名分片、雪碧图、内联 CSS 这些 hack 都扔掉了。

高速公路上的车辆堵成一条长龙,前方是事故现场

然后人们发现,在丢包率高的网络下,HTTP/2 有时比 HTTP/1.1 还慢。

原因是队头阻塞(Head-of-Line Blocking)并没有被消灭,只是被往上挪了一层:

HTTP/2:多个 stream 复用一条 TCP 连接

  stream 1  ▓▓▓▓
  stream 2     ▓▓▓▓
  stream 3        ▓▓▓▓
  ─────────────────────────────────► 一条 TCP 字节流
                    ✗ 这一个 TCP 段丢了

  TCP 必须按序交付 → 后面的段全部卡住等重传
  → stream 1 / 2 / 3 全部停摆,尽管丢的那一段只属于 stream 2

HTTP/1.1 反而没这个问题:它开 6 个连接,一个连接丢包只影响这一个请求,另外 5 条照跑。HTTP/2 把所有鸡蛋放进一个篮子,好处是省了握手和拥塞窗口的竞争,代价是一次丢包惩罚所有请求。

在 1% 丢包的移动网络下,这个惩罚非常明显。Google 早年的实测数据里,丢包率到 2% 时 HTTP/2 的收益就被吃光了。

所以问题很清楚:只要下面还是单条有序字节流,多路复用就有天花板。

# 二、那为什么不改 TCP?

这是个很自然的问题。TCP 有四十年的历史,我们为什么不干脆给它加一个"多流独立交付"的选项?

答案是:加不了。不是技术上做不到,而是网络中间的设备不会让你加。

机柜里一排排网络交换机和路由器,指示灯密密麻麻

互联网不是两端加一根线,中间经过运营商的 NAT、防火墙、负载均衡、DPI 深度包检测、各种加速代理。这些设备会读 TCP 头,并且按自己对 TCP 的理解去改写它:重写窗口字段、剥离不认识的选项、对"看起来不对"的包直接丢掉。

结果是 TCP 事实上被冻结了:

  • MPTCP(多路径 TCP,RFC 8684)设计得挺好,但中间盒会改写或丢掉它的选项,实际可用性大打折扣
  • TCP Fast Open 出来快十五年了,因为会被中间盒干扰,始终没能默认打开
  • 连 TCP 选项空间的"实验性"字段,也会被当成异常流量丢包

这个现象有个专门的名字:协议僵化(protocol ossification)。传输层的创新被卡在了中间设备上——它们不是恶意的,只是写死了对旧格式的假设,而且部署量太大,没人换得起。

QUIC 的作者们从这件事里得出的结论是:别在 TCP 上改,换个没人管的地方重起炉灶。

# 三、QUIC 的答案:加密掉传输头,把可靠传输搬到 UDP 上重写

QUIC 的做法可以概括成一句话:在 UDP 之上,用应用层自己实现一套可靠传输,并且把传输头加密起来。

三个关键设计:

1. 跑在 UDP 上,是为了绕开中间盒

UDP 头只有 8 个字节(源端口、目的端口、长度、校验和),中间盒没什么可改的,也就不会改。这就绕开了僵化问题:以后想加新的拥塞控制、新的恢复算法,改 QUIC 自己就行,不用求任何人。

2. 传输头几乎全加密,是为了防止新的僵化

QUIC 的包头里只有极少数字段是明文的(版本号、连接 ID、包号的一部分),其余包括 ACK 帧、流控帧在内全是加密的。这不是为了防监听,而是为了让中间盒看不懂、从而没法依赖它。

换句话说,QUIC 有意识地把中间盒变成纯转发设备。这是从 TCP 那四十年里学到的教训:能被中间设备解析的字段,迟早会被写死。

3. 流(stream)在 QUIC 层,不在 TCP 层

这是解决队头阻塞的那一刀。QUIC 连接里有多个独立的 stream,每个 stream 自己保证有序,stream 之间互不影响:

丢了一个属于 stream 2 的包,stream 1 和 3 照样往前走。因为"顺序"这个概念被下沉到了 stream 内部,而不是整条连接。

包裹在分拣传送带上被自动分拨到不同的滑道

顺带说明一下 QUIC 的层次关系,很多人会搞混:

UDP datagram          ← 一个 UDP 报文
  └─ QUIC packet      ← 一个或多个
       └─ frame       ← STREAM / ACK / CRYPTO / MAX_DATA ...
            └─ HTTP/3 frame  ← HEADERS / DATA(装在 STREAM 帧里)

一个 UDP 报文里可以塞多个 QUIC 包(coalescing),一个 QUIC 包里可以装多个帧,来自不同 stream 的 STREAM 帧还能装在同一个包里。"包"是加密和传输的单位,"帧"是语义的单位,"流"是顺序的单位,三者解耦了,这才是它能避免队头阻塞的根本原因。

# 四、握手:1-RTT、0-RTT,以及 0-RTT 的账单

HTTP/2 over TCP + TLS 1.3 的首次连接,需要:TCP 三次握手(1 RTT)+ TLS 握手(1 RTT)= 2 RTT 才能发出第一个请求。

QUIC 把传输握手和加密握手合并了:QUIC 的包里直接携带 TLS 1.3 的握手消息,所以首次连接是 1 RTT。

更狠的是 0-RTT:客户端之前连过这个服务器,手里有一张会话票据(session ticket),于是可以在第一个包里就直接带上 HTTP 请求,不用等任何往返。

听起来很美,但 0-RTT 有一个绕不开的问题:重放攻击(replay)。

因为服务器在收到 0-RTT 数据的时候还没完成握手,它无法像正常流程那样证明"这个客户端确实是现在在线的"。攻击者可以把你发的那个 0-RTT 包录下来,重发一遍:

客户端 ─── 0-RTT: POST /api/transfer?to=attacker&amount=100 ──► 服务器
                         ↓
              攻击者录制并重放同一个包
                         ↓
客户端 ───(同一份加密包,服务器无法区分是重放)──► 服务器

所以 0-RTT 的正确用法有一条硬规则:0-RTT 里只能放幂等的、无副作用的请求。反过来说:

  • GET 类的资源请求可以用
  • 下单、转账、写库、发消息这类绝对不能走 0-RTT
  • 服务端要能做 anti-replay(接受 0-RTT 时通常需要限制窗口、记录已见票据)

这也是为什么很多站点即便开了 HTTP/3,也默认只在"看起来安全"的请求上启用 0-RTT。性能这种东西,一旦和安全冲突,永远是安全赢。

# 五、连接迁移:换网不断线,靠的是一串 Connection ID

这是我最喜欢的一个设计,也是最能体现"重造传输层"价值的地方。

TCP 连接是用四元组标识的:(源 IP, 源端口, 目的 IP, 目的端口)。你的手机从 Wi-Fi 切到 4G,源 IP 变了,四元组就变了,TCP 连接立刻失效——所有进行中的请求全部超时重来。这就是为什么走进电梯、出地铁口的那几秒,App 经常卡一下。

铁塔上的移动通信天线阵列,衬着天空

QUIC 不用四元组标识连接,它用 Connection ID(CID)——一串由端点自己选的、长度不定的字节。包里带上 CID,双方就认这个连接。

于是切换网络变成:

  1. 手机 IP 变了,继续用同一个 CID 发包
  2. 服务器发现"这个 CID 从新地址来了",触发路径验证(PATH_CHALLENGE / PATH_RESPONSE),确认新地址不是伪造的(防止用它做放大攻击)
  3. 验证通过,连接迁移完成,应用层无感知

还有个隐私上的小细节:CID 是端点自己生成的,客户端可以在换网时主动换一个新的 CID,避免同一个标识符被跨网络关联起来追踪。服务器也能一次发多个 CID 给你备用(所以 CID 长度可变、数量可变),这跟 TCP 那种"连接身份 = 你的 IP"的模型比,是完全不同的思路。

# 六、把拥塞控制搬进用户态:红利和账单

QUIC 跑在用户空间(应用进程里),不再由操作系统内核实现。这一点带来的影响比很多人想的大。

红利:可以迭代了。

TCP 的拥塞控制算法在内核里,改一次要等内核升级、等操作系统铺货,周期以年计。QUIC 的拥塞控制在应用里,你发一个新版本的 App 或者新版本的服务端,就能换掉它。BBR 能在 Google 的服务器和 Chrome 里快速铺开,靠的就是这一点。丢包恢复、pacing、ECN 标记的处理,全都可以独立演进。

账单:

  • CPU 更贵。每个包都要在用户态做加密、解密、ACK 处理、流控计算,而 TCP 这些活儿在内核里做了几十年优化。早期数据里,同样流量下 QUIC 的服务端 CPU 开销明显高于 TCP,直到硬件 AES 和 GSO/GRO 这类批量收发优化铺开才追上来。小流量站点感受不到,大流量 CDN 会算这笔账。
  • 得自己实现 pacing。内核 TCP 有现成的发包节奏控制,QUIC 得在应用层用定时器做。定时器精度、唤醒成本都会影响吞吐。
  • 内核帮不上忙了。拥塞窗口、RTT 估计、重传定时器这些原本内核替你管的状态,现在都要应用自己维护,一旦实现有 bug,表现就是"莫名其妙的吞吐抖动"。

一句话:性能上能更快迭代,运维上要多操一份心。

# 七、现实里怎么开,怎么验,什么时候它反而更慢

# 怎么知道自己在不在 HTTP/3 上

最简单的是浏览器 DevTools —— Network 面板勾出 Protocol 列,显示 h3 就是在跑 HTTP/3(注意 Chrome 的显示有时会写 h3 但实际经由 h3-Q050 之类的 draft 版本,看清楚)。

命令行上:

# curl 需要编译时带 HTTP/3 支持(新版官方二进制默认有)
curl -I --http3 https://example.com

# 只看协商结果
curl -sI --http3 https://example.com -w '%{http_version}\n'

浏览器侧还有个隐藏入口:chrome://net-internals/#http3 能看到每个域名的 QUIC 协商状态。

# 服务发现:Alt-Svc 和 HTTPS 记录

HTTP/3 有个鸡生蛋问题:浏览器怎么知道这台服务器支不支持 QUIC?总不能先试 UDP 再回退,那就多花一个 RTT。

解法是先用 TCP 连一次,顺便告诉对方:

Alt-Svc: h3=":443"; ma=86400

服务器在 HTTP/1.1 或 HTTP/2 的响应里带上这个头,浏览器记住"这个域名 24 小时内可以走 443 端口的 h3",下次访问就直接上 UDP。

更进一步的办法是 HTTPS RR(DNS 里的 SVCB/HTTPS 记录),在 DNS 解析阶段就把 alpn=h3 告诉你,这样连第一次访问都能省掉那个来回。目前主流 CDN 都在推这个。

# 什么时候它反而更慢

这部分是我觉得最该说的,因为网上大部分 HTTP/3 文章只讲好处:

  • UDP 被限流或被打压。一些企业网络、校园网、酒店 Wi-Fi 只放行 TCP 443,或者对 UDP 做 QoS 限速。浏览器有 fallback,但探测期通常有几个 RTT 的额外等待,体验可能比直接 HTTP/2 还差。
  • 有状态防火墙的 UDP 超时短。TCP 连接空闲几十分钟还活着,UDP 的"流"在防火墙眼里可能 30 秒就过期。服务器和客户端的 idle timeout 不一致时,会出现"连接悄悄死了、下次发包才被发现"。QUIC 规范要求端点自己发 keep-alive,实现不好的话这种问题很隐蔽。
  • MTU 与分片。QUIC 要求 UDP payload 至少 1200 字节不被分片,路径 MTU 探测失败时会被迫降包大小,吞吐会明显下降。某些隧道/VPN 环境下这一层特别容易出问题。
  • 小站点 CPU 成本。流量不大时,QUIC 那点用户态开销换来的收益可能覆盖不了。
  • 观测难度。因为传输头是加密的,传统基于 TCP 头做被动测量的运维工具全瞎了。现在要靠 qlog / QUIC Connection ID 的可路由性这类新机制补上。你在排查"为什么这个用户慢"的时候,会发现以前的手段不好使了。

# 现状

主流 CDN 基本都默认开启了,浏览器支持率在九成以上,公开统计里站点层面的启用比例大概在三成上下并且还在涨。规范这边,QUIC 是 RFC 9000、QUIC over TLS 是 RFC 9001、HTTP/3 是 RFC 9114、头部压缩 QPACK 是 RFC 9204,早就不是草案了。

# 八、一点想法

我最开始以为 HTTP/3 只是个版本号,是因为我下意识地把"协议分层"理解成了"每层各管各的,互不干涉"。但 QUIC 这件事恰好证明了相反的道理:当某一层被卡住的时候,唯一的出路是绕过它另起一层。

TCP 卡在哪儿?卡在它太成功了。它成功到世界上每一台中间设备都对它的格式有 opinion,成功到任何改动都要和几十亿台部署在野的设备谈判。一个协议的成熟度和它的可演化性,在这个尺度上是负相关的。

QUIC 那帮人最聪明的地方不是技术——把可靠传输在 UDP 上实现一遍,工程量不小但思路不新鲜。他们真正做对的是那个判断:与其和一个僵化的层谈判,不如换一层没人管的、并且从第一天起就加密到让中间盒无从下嘴。

这条经验我觉得在别的地方也成立。我经历过几次"大家都觉得这块该改,但谁也改不动"的事:一个被十个系统调用的公共函数、一份谁都不敢删的历史数据、一个所有流程都绕不过去的中间环节。它们之所以改不动,往往不是因为难,而是因为依赖它的东西太多了,而且这些依赖是隐式的。你以为你只依赖它的语义,实际上你还依赖了它的 bug、它的时序、它偶尔返回 null 的那个边界。

解决办法通常也不是"小心地改"。要么像 QUIC 一样,在旁边起一套新的、把旧的完整包住(它确实这么做了:对外仍然是一个端口、一套 HTTP 语义,只是下面换了);要么接受它就在那儿,绕着走。

至于我自己,下一步想做的其实很简单:把我们线上那个 CDN 的 HTTP/3 灰度数据按丢包率分桶看一眼。传闻里的收益都在弱网,可我从没在自己的数据里确认过这件事。


(本文涉及的标准:QUIC 传输为 RFC 9000,基于 TLS 的 QUIC 为 RFC 9001,HTTP/3 为 RFC 9114,QPACK 为 RFC 9204,多路径 TCP 为 RFC 8684。连接迁移相关机制见 RFC 9000 §9,路径验证见 §8.2。)

# 图片版权

文中配图来自 Wikimedia Commons: