HTTP/3 的多路复用和 QUIC 到底能让页面快多少?聊聊连接迁移和 0-RTT
上个月灰度上了 HTTP/3,盯着 Grafana 看了一周的数据。LCP 掉了 200ms 左右,移动端弱网环境下收益更明显,某些场景甚至能砍掉 400ms。
但说实话,这个结果来之前我心里也没底。HTTP/3 的宣传材料看了不少,"队头阻塞解决了"、"握手更快了"------这些都是正确的废话。真正上线的时候,你关心的是:我的业务场景能吃到多少红利?哪些地方可能翻车?
这篇就聊聊实际落地的体感。
先说清楚 HTTP/3 改了什么
HTTP/2 的多路复用有个硬伤------它跑在 TCP 上。
TCP 是个"有序"协议,丢一个包,后面所有包都得等着。你在一条 TCP 连接上跑 6 个请求,其中一个请求丢了个包,其余 5 个请求也被卡住了。这就是 TCP 层面的队头阻塞。
less
TCP 连接(HTTP/2):
请求A: [包1] [包2] [包3✗] ← 丢了
请求B: [包1] [包2] ...等着 ← 被连坐
请求C: [包1] ...等着 ← 也被连坐
QUIC 连接(HTTP/3):
流A: [包1] [包2] [包3✗] ← 丢了,只有流A等重传
流B: [包1] [包2] [包3] ← 该干嘛干嘛
流C: [包1] [包2] ← 完全不受影响
QUIC 把多路复用下沉到了传输层。每个流(stream)独立管理丢包和重传,互不干扰。
这在理想网络下差别不大------丢包率 0.1% 的时候你根本感知不到。但一旦丢包率上去(移动网络切基站、地铁里、电梯口),差距就出来了。
0-RTT 握手到底省了什么
TCP + TLS 1.3 握手要 2-RTT(TCP 一次,TLS 一次)。QUIC 把传输层握手和加密握手合并了,首次连接 1-RTT,重连 0-RTT。
scss
// TCP + TLS 1.3 (首次)
客户端 → SYN → 服务端 // RTT 1: TCP
客户端 ← SYN-ACK ← 服务端
客户端 → ClientHello → 服务端 // RTT 2: TLS
客户端 ← ServerHello + 证书 + Finished ← 服务端
客户端 → Finished + 请求数据 → 服务端 // 终于可以发请求了
// QUIC (首次)
客户端 → Initial(ClientHello) → 服务端 // RTT 1: QUIC + TLS 合并
客户端 ← Initial(ServerHello...) ← 服务端
客户端 → 请求数据 → 服务端 // 直接发
// QUIC (重连, 0-RTT)
客户端 → Initial + 0-RTT数据 → 服务端 // 第一个包就带请求数据
0-RTT 是说:如果之前连过这个服务器,客户端缓存了一些加密参数,下次直接把请求数据塞进第一个包里发出去。服务端收到就能直接处理,不用等握手完成。
省下的这一个 RTT,在跨地域访问的时候特别值钱。北京到广州的 RTT 大概 30-40ms,到美西 150-200ms。对于一个首屏需要 3-4 个串行请求的页面,0-RTT 能直接砍掉一次握手延迟。
但 0-RTT 有个安全问题------重放攻击。
ts
// ⚠️ 0-RTT 的数据可能被中间人截获并重放
// 所以只能用于幂等请求
// ✅ 适合 0-RTT 的:
fetch('/api/product/123', { method: 'GET' }) // 幂等,重放无副作用
// ❌ 不适合 0-RTT 的:
fetch('/api/order', { method: 'POST', body: orderData }) // 非幂等,重放会重复下单
服务端要自己判断哪些请求接受 0-RTT early data,哪些必须等握手完成。Nginx 的 ssl_early_data on 打开后,还得配合 Early-Data header 让后端知道这是 0-RTT 请求,由业务层决定是否处理。
连接迁移:移动端的大杀器
这个特性说出来简单,实际体感却最明显。
TCP 连接靠四元组标识(源 IP、源端口、目标 IP、目标端口)。手机从 WiFi 切到 4G,IP 变了,所有 TCP 连接全部断开,需要重新建连、重新握手、重新请求。
QUIC 用 Connection ID 标识连接,跟 IP 无关。网络切换时,换了 IP 没关系,Connection ID 还在,连接直接迁移过去。
ts
// 模拟一个典型场景:用户在地铁里刷信息流
// HTTP/2 (TCP) 的表现:
// 1. 进隧道 → 信号丢失 → TCP 超时断开
// 2. 出隧道 → 重新 TCP 握手 (1 RTT)
// 3. 重新 TLS 握手 (1 RTT)
// 4. 重新发请求
// 用户感知:卡了 2-3 秒,页面白一下
// HTTP/3 (QUIC) 的表现:
// 1. 进隧道 → 信号丢失 → QUIC 探测包持续发送
// 2. 出隧道 → 探测包通了 → 连接恢复,继续传输
// 用户感知:卡了一下就好了
之前我们 App 里的 WebView 页面,在弱网环境下的白屏率有 8% 左右。上了 HTTP/3 之后降到 5% 出头。不全是连接迁移的功劳,但占了很大一块。
前端资源加载的实际收益量化
光说原理没用,得看数据。我们做了个 A/B 测试,对照组走 HTTP/2,实验组走 HTTP/3,跑了两周。
matlab
测试环境:
- CDN 已支持 HTTP/3 (Cloudflare)
- 页面资源:1 个 HTML + 3 个 JS bundle + 2 个 CSS + 12 张图片
- 样本量:各组约 50 万 PV
结果(中位数):
HTTP/2 HTTP/3 提升
DNS + 连接建立 120ms 68ms -43% ← 0-RTT 贡献最大
首字节 (TTFB) 210ms 155ms -26%
LCP 1420ms 1230ms -13%
FCP 890ms 780ms -12%
按网络类型拆分 LCP:
4G 稳定网络 1350ms 1250ms -7% ← 好网络下差距不大
4G 弱信号 2100ms 1650ms -21% ← 弱网收益明显
WiFi 1180ms 1100ms -7%
网络切换期间 3200ms 1800ms -44% ← 连接迁移的功劳
几个观察:
好网络下提升有限,大概 7% 左右。丢包率低的时候,队头阻塞本来就不是瓶颈。
弱网才是 HTTP/3 的主场。丢包率 2% 以上的时候,QUIC 的独立流控优势就很明显了。
连接迁移的收益最夸张,但触发频率不高。不过对于那些被影响到的用户来说,体验是质变。
怎么在项目里落地
CDN 侧
大部分情况你不需要自己部署 QUIC,CDN 厂商基本都支持了。Cloudflare 默认开启,Akamai 和 AWS CloudFront 也都有。
关键是确认你的 CDN 在响应头里带了 Alt-Svc:
ini
// 服务端响应头,告诉浏览器"我支持 HTTP/3,你可以来"
Alt-Svc: h3=":443"; ma=86400
浏览器首次还是走 HTTP/2,看到 Alt-Svc 后下次才会尝试 HTTP/3。所以第一次访问是吃不到 HTTP/3 红利的。
Nginx 自建的情况
nginx
server {
# HTTP/3 需要 UDP 443
listen 443 quic reuseport;
# 同时保留 HTTP/2 做降级
listen 443 ssl;
http2 on;
http3 on;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# 0-RTT 开启(注意重放风险)
ssl_early_data on;
# 告知浏览器支持 HTTP/3
add_header Alt-Svc 'h3=":443"; ma=86400' always;
# 防火墙别忘了放行 UDP 443,这个坑我踩过
# 当时排查了半天,curl 死活握不上,最后发现安全组只开了 TCP 443
}
前端代码层面
前端代码基本不需要改。HTTP/3 是传输层的升级,fetch/XHR 的 API 没有变化。
但有几个地方值得注意:
ts
// 检测当前连接是否走了 HTTP/3
// Performance API 可以拿到协议信息
const entries = performance.getEntriesByType('resource')
entries.forEach(entry => {
// nextHopProtocol 会告诉你实际用的协议
console.log(entry.name, entry.nextHopProtocol)
// "h3" → HTTP/3
// "h2" → HTTP/2
})
// 统计 HTTP/3 的覆盖率,塞到你的监控里
const h3Ratio = entries.filter(e => e.nextHopProtocol === 'h3').length / entries.length
reportMetric('h3_coverage', h3Ratio)
ts
// 资源加载提示,帮浏览器更快建立 QUIC 连接
// preconnect 对 HTTP/3 同样有效
const link = document.createElement('link')
link.rel = 'preconnect'
link.href = 'https://cdn.example.com'
document.head.appendChild(link)
// 更激进的做法:dns-prefetch + preconnect 一起上
// <link rel="dns-prefetch" href="https://cdn.example.com">
// <link rel="preconnect" href="https://cdn.example.com">
资源打包策略可能要调整
HTTP/2 时代的"拆小包"策略在 HTTP/3 下更合理了。
ts
// webpack / vite 配置思路
// HTTP/1.1 时代:合并成大文件减少请求数
// HTTP/2 时代:拆成中等大小,利用多路复用
// HTTP/3 时代:可以拆得更碎,因为不会有 TCP 队头阻塞
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
// 拆包粒度可以更细
manualChunks(id) {
if (id.includes('node_modules')) {
// 按包名拆,别一股脑塞进 vendor
const name = id.split('node_modules/')[1].split('/')[0]
return `vendor/${name}`
}
},
// HTTP/3 下小文件的传输惩罚更低
// 但也别拆太碎,每个文件还是有解析开销
experimentalMinChunkSize: 5 * 1024, // 5KB 兜底
}
}
}
}
不过这块我个人觉得不用太激进。除非你的页面资源特别多(50+ 个请求),否则 HTTP/2 和 HTTP/3 在打包策略上的差异不大。
几个容易踩的坑
UDP 被墙了。 不少企业网络、学校网络会封 UDP 443。浏览器会自动降级到 HTTP/2,但这个降级过程本身有延迟------浏览器得先尝试 QUIC 握手,超时后才回退。Chrome 默认等 300ms。
ts
// 如果你发现某些用户的连接建立时间反而变长了
// 大概率是 QUIC 被封,降级到 HTTP/2 多了 300ms
// 可以通过 Performance API 监控降级情况
const nav = performance.getEntriesByType('navigation')[0]
if (nav.nextHopProtocol === 'h2' && someHeuristic()) {
// 记录下来,看看降级比例
reportMetric('quic_fallback', 1)
}
0-RTT 没生效。 0-RTT 需要浏览器缓存 TLS session ticket。如果用户清了缓存、换了浏览器、或者 session ticket 过期了,就退化成 1-RTT。实测下来 0-RTT 的命中率大概在 60-70%,没有想象中那么高。
服务端没准备好。 开了 HTTP/3 之后,服务端的 CPU 开销会涨一些。QUIC 的加密是逐包的,不像 TCP+TLS 可以批量处理。我们上线初期 CPU 涨了约 15%,后来升级了 Nginx 版本(用上了 kernel 的 UDP GSO)才压下去。
回过头看
HTTP/3 不是银弹。好网络下它的收益有限,可能就快那么几十毫秒。但在弱网、网络切换这些"极端"场景下,体验提升是实打实的。
而且说实话,这事儿的投入产出比很高------大部分工作在运维侧(CDN 开个开关、Nginx 加几行配置),前端代码几乎不用改。加个监控统计一下 HTTP/3 覆盖率和性能数据,基本就完事了。
值不值得搞?如果你的用户主要在桌面端、好网络,优先级可以放低。但如果移动端占比高、有海外用户、或者对弱网体验有要求,那值得尽早推。