HTTP/3 与 QUIC:为什么这次要绕开 TCP
HTTP/3 是第一个不跑在 TCP 上的 HTTP。这篇讲 TCP 的队头阻塞到底卡在哪、QUIC 把哪些东西搬进了用户态、连接迁移是怎么做到的,以及换协议要付什么代价。
从 1996 年的 HTTP/1.0 到 2015 年的 HTTP/2,二十年间换了两次协议,但底下那层一直是 TCP。HTTP/3 是第一个打破这个惯例的版本:它跑在 QUIC 上,而 QUIC 跑在 UDP 上。
队头阻塞:TCP 的锅不在丢包,在顺序
TCP 提供的是「一条可靠的、有序的字节流」。注意有序这两个字 —— 它意味着如果第 3 个字节丢了,即使第 4 到第 100 个字节已经收到,内核也不能把它们交给应用,必须等重传补上第 3 个字节。

图片来源:Daniele Giacomini,CC BY-SA 2.5,来自 Wikimedia Commons。
HTTP/2 引入了多路复用:一个 TCP 连接上同时跑很多个「流」,解决了 HTTP/1.1 时代一个连接只能有一个请求的问题。但它没有解决 TCP 层的顺序约束 —— 所有流共享同一个字节流,一个包丢了,所有流一起卡住。这就是所谓的 TCP 层队头阻塞。
浏览器只能靠开更多连接来缓解,而连接一多,拥塞控制就各算各的,反而更容易把链路打爆。
QUIC 的做法:把可靠传输搬进用户态
QUIC 的出发点很直接:既然 TCP 的语义改不动,那就在 UDP 上重新实现一套。UDP 只提供「尽力而为的数据报」,其余全在用户态自己写:
| 能力 | TCP 上的做法 | QUIC 的做法 |
|---|---|---|
| 可靠性 | 内核 TCP 重传 | 用户态重传,每个流独立编号 |
| 顺序 | 全局有序,丢包阻塞所有流 | 流内有序,流之间互不影响 |
| 加密 | TLS 叠在上面,两次握手 | TLS 1.3 内建,合并成一次 |
| 连接标识 | 四元组(源 / 目的 IP + 端口) | Connection ID,与地址解耦 |
| 握手往返 | TCP 1-RTT + TLS 1-2 RTT | 1-RTT,重连可 0-RTT |

图片来源:Jigs35,CC BY-SA 2.5,来自 Wikimedia Commons。
三个改动最值得说:
流之间真正独立。 丢一个包只影响它所属的那条流,其他流照常交付。这是 HTTP/3 相对 HTTP/2 最实在的收益,在丢包率高的移动网络上尤其明显。
握手合并。 QUIC 把传输握手和 TLS 1.3 握手揉在一起,一次往返就能开始传数据。重连时用 0-RTT,第一个包就带业务数据 —— 代价是这第一个包可以被重放,所以只适合幂等请求。

图片来源:Tortue rapide,CC BY-SA 4.0,来自 Wikimedia Commons。
连接跟着设备走,不跟着 IP 走。 这是我觉得最漂亮的一点。TCP 连接由四元组唯一确定,所以手机从 Wi-Fi 切到 5G,IP 一变连接就断。QUIC 用 Connection ID 标识连接,换网时只要发一个迁移帧,连接就平移到新地址上 —— 正在下载的文件不会断。
为什么是绕开而不是改 TCP
很多人会问:这些优化为什么不能直接做进 TCP?
因为 TCP 的实现分散在内核和中间设备里。改内核要等操作系统升级周期,而真正难缠的是中间设备:NAT、防火墙、负载均衡器、运营商的策略盒子,几十年来都基于「TCP 长什么样、端口怎么用」做假设。你想在 TCP 头上加个新选项,很可能被某个中间盒直接丢掉。
UDP 的待遇完全不同:它被当作「不透明的数据报」转发,中间设备基本不碰它的载荷。于是把新协议塞进 UDP 载荷,就成了唯一能快速迭代、又能穿过现有网络的路径。这也是为什么 QUIC 被加密到几乎无法被中间设备检视 —— 这不是副作用,是设计目标。
代价
QUIC 不是白拿的:
- UDP 常被限速或屏蔽。 企业网络里 UDP 443 被拦是常态,所以浏览器都实现了「HTTP/3 失败就回退 HTTP/2」,并且会记住失败;
- CPU 开销更大。 用户态协议栈 + 全量加密,同样的吞吐量比内核 TCP 更吃 CPU。这也催生了把 QUIC 卸载到网卡的做法;
- 0-RTT 有重放风险。 只能在幂等请求上用;
- 运维工具要重学。 抓包抓到的是一堆加密的 UDP,排障方式完全不同。
我的判断
HTTP/3 的收益高度依赖网络质量:在干净的机房内网,它和 HTTP/2 的差距小到测不出来;在丢包率 1% 以上的移动网络上,差距非常明显。
所以我给自己的站点的策略是:开 HTTP/3,但保留 HTTP/2 回退,不为了它做特殊优化。CDN 通常一行配置就能开,收益是白捡的;但如果你为了 HTTP/3 去折腾 UDP 优化、调 QUIC 参数,投入产出比就不一定划算了。
真正值得记住的是它背后的思路:当一层协议改不动时,把它整体挪到上一层能自由迭代的地方重新实现。这个思路不只用在传输层。

评论
…