一个页面首次打开走几次连接、一条连接上并行几个请求、丢一个包之后还有多少请求跟着等——这三件事分别由 HTTP 版本、连接模型与传输层决定。HTTP/1.1、HTTP/2、HTTP/3 的差别不是"快慢档位",而是每一版都在拆掉上一版的一处结构性瓶颈。这条因果链理清了,才知道自己的链路该停在哪一版。
1. 每个版本解决前一版的什么问题
| 版本 | 上一版的痛点 | 本版的解法 | 带来的新代价 |
|---|---|---|---|
| HTTP/1.0 | 每个请求一条 TCP,握手与慢启动反复付 | 持久连接成为默认(Connection: keep-alive) |
空闲连接长期占用,服务端要管超时 |
| HTTP/1.1 | 请求只能一个接一个发出 | 管线化(pipelining)+ Host 头 + 分块传输 | 响应必须按请求顺序返回,应用层仍串行 |
| HTTP/2 | 并发靠开多条 TCP,握手成本高、头部重复传输 | 单连接多路复用 + HPACK 头部压缩 | 所有流共享一个 TCP 字节流,丢包互相牵连 |
| HTTP/3 | TCP 的字节流有序语义造成的跨流阻塞 | 把可靠传输搬到 QUIC(基于 UDP),按流独立重传 | 需要 UDP 可达的链路,中间设备兼容性问题 |
关键判断:换版本解决的是"并发调度"问题,不是"带宽"问题。带宽受限的链路换到 HTTP/3 也不会有质变。
2. HTTP/1.1:队头阻塞发生在应用层
管线化是 HTTP/1.1 定义的能力:客户端不必等前一个响应就能连续发出下一个请求。但 RFC 7230 规定服务器必须按请求到达的顺序返回响应,于是第一个响应慢,后面的响应全在客户端排队。这层阻塞属于 HTTP 消息语义层的串行,与 TCP 无关——调内核参数对它无效。
$ curl -sv --http1.1 https://TARGET/ -o /dev/null 2>&1 \
| grep -E "Connected|Re-using|ALPN"
浏览器选择绕开而不是修复:对同一域名开若干条并发 TCP 连接换并行度(条数按各浏览器实现核对,典型量级是个位数)。副作用有两个——域名分片与首条连接被慢资源占死。管线化在现代浏览器里默认关闭。
| 阻塞层 | HTTP/1.1 的表现 | 调参能解决吗 |
|---|---|---|
| 应用层(消息顺序) | 前一个响应慢,后续请求等待 | 不能,只能加连接或换版本 |
| 连接层(TCP 重传) | 丢包时该连接上一切等待 | 不能,TCP 语义决定 |
| 服务器并发(worker/backlog) | 新连接排队或被拒 | 能,调 worker 数与 backlog |
3. HTTP/2:流、帧、HPACK,以及推送的退场
HTTP/2 把连接切成帧,帧头带 stream id,属于不同流的帧可以在同一连接上交替发送。接收端按 stream id 重组,于是请求之间不再互相排队——应用层的队头阻塞被消除。
| 概念 | 作用 | 关键约束 |
|---|---|---|
| 流 Stream | 一次请求/响应对应的逻辑通道 | id 单向递增,客户端发起奇数号 |
| 帧 Frame | 传输最小单位(HEADERS / DATA / SETTINGS / WINDOW_UPDATE / RST_STREAM / PING) | 受 SETTINGS_MAX_FRAME_SIZE 限制 |
| HPACK | 头部压缩:静态表 + 动态表 + 霍夫曼编码 | 动态表是有状态的,同一连接内必须按序解码 |
| 流控 | 连接级与流级双层窗口 | 只约束 DATA 帧,不约束 HEADERS |
HPACK 的动态表让头部压缩跨请求生效,代价是同一连接内 HEADERS 帧必须顺序解码——h2 里一个不太被提及的串行点,量级远小于 1.1 的消息级串行。
服务端推送在 HTTP/2 里定义过,但主流浏览器已陆续移除实现。原因不是难做,而是服务端无法准确预测客户端缓存状态:推过去的东西可能已有,白占带宽。替代组合是 103 Early Hints 加 Link: rel=preload。
# nginx 1.25.1 起,http2 从 listen 参数提升为独立指令
server {
listen 443 ssl;
http2 on;
server_name DOMAIN;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_certificate /etc/nginx/cert/APP.crt;
ssl_certificate_key /etc/nginx/cert/APP.key;
}
4. TCP 层队头阻塞:HTTP/3 的直接动因
多路复用把多个请求塞进一条 TCP 连接,这在丢包场景下变成缺点。TCP 保证字节流严格有序交付:任一段丢失,内核会把其后已到达的段留在接收缓冲区不交给应用。对 HTTP/2 而言这些段属于所有流,于是全部流一起等重传。
# 构造丢包环境,观察 h2 多路复用的退化
$ tc qdisc add dev eth0 root netem loss 1%
$ curl -sv --http2 https://TARGET/big.js -o /dev/null 2>&1 | grep -c -i retrans
$ tc qdisc del dev eth0 root
所以 HTTP/2 的收益有条件:丢包率低时明显,丢包率上升时可能还不如多连接的 HTTP/1.1。这正是 HTTP/3 的理由——它不是"更快的 HTTP",而是把可靠传输从内核 TCP 搬出来,改成按流独立重传。
| 现象 | HTTP/2 over TCP | HTTP/3 over QUIC |
|---|---|---|
| 丢一个包 | 所有流一起等待重传 | 只有该流的数据等待 |
| 连接建立 | TCP 握手 + TLS 握手(合计 2~3 RTT) | 传输与 TLS 握手合并(新连接 1 RTT,复用可 0 RTT) |
| 换网络(WiFi→蜂窝) | 四元组变化,连接必须重建 | 连接 ID 不变,连接可迁移 |
| 中间设备可见性 | 明文头部与握手 | 除少数字段外全加密 |
5. QUIC 的四个关键变化
| 变化 | 具体做法 | 换来的能力 |
|---|---|---|
| 基于 UDP | 在用户态实现可靠传输、流控、拥塞控制 | 升级不受内核与中间设备僵化影响 |
| 连接 ID | 不再用四元组标识连接 | 连接迁移,切网不断流 |
| 1-RTT / 0-RTT | TLS 1.3 握手并入传输握手 | 复用连接时首个往返即可携带数据 |
| TLS 1.3 内建 | 加密是协议本体的一部分 | 头部也被加密,可观测性下降 |
UDP 的现实约束要单独记住:部分运营商与企业网络会对 UDP 443 限速或直接丢弃。此时 HTTP/3 不报错,而是静默回退到 HTTP/2,表现就是"配了没生效"。
6. 0-RTT 的重放风险与适用边界
0-RTT 允许客户端在第一个往返就带上应用数据,代价是这些数据不具备前向安全性,也没有抗重放保证——录下来就能重发。
| 请求类型 | 是否适合 0-RTT | 理由 |
|---|---|---|
| 幂等 GET(取静态资源、查列表) | 适合 | 重放一次结果相同 |
| 带副作用的 POST(下单、扣款、发消息) | 不适合 | 重放等于重复执行 |
| 强一致查询 | 不适合 | 可能读到旧连接状态下的结果 |
| 内网低价值探活 | 适合 | 失败重试代价低 |
服务端必须自己做幂等(幂等键 + 去重表),不能把这件事交给传输层。
7. 各版本的启用与降级链路
# 同端口上 HTTP/1.1 与 HTTP/2 由 ALPN 协商,HTTP/3 另开 UDP
server {
listen 443 quic reuseport;
listen 443 ssl;
http2 on;
ssl_early_data on; # 允许 0-RTT
add_header Alt-Svc 'h3=":443"; ma=86400' always;
}
# 后端(Tomcat 蓝绿其一)走明文 HTTP/1.1,回源段不折腾 h2c
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
浏览器的降级顺序固定:读 Alt-Svc 缓存 → 尝试 HTTP/3 → UDP 不通或握手超时回落 HTTP/2 → 再回落 HTTP/1.1。所以"第一次访问不走 h3"是设计如此,不是配错了。
Spring Boot 侧:
# 由底层容器(Tomcat 的 Http2Protocol)承担,需要 TLS 才能协商 h2
server.http2.enabled=true
server.ssl.enabled=true
server.port=8443
| 组件 | 开启方式 | 备注 |
|---|---|---|
| nginx 1.25.1+ | http2 on; 独立指令 |
旧的 listen ... http2 写法已弃用并告警 |
| nginx 1.25 之前 | listen ... ssl http2; |
必须同时有 ssl |
| Spring Boot 3.x(Tomcat) | server.http2.enabled=true |
明文 h2c 需额外 up-grade 配置,实践里少用 |
| curl 验证 | curl -sv --http2 |
看 ALPN, server accepted to use h2 |
| 浏览器验证 | DevTools 的 Protocol 列 | 显示 h2 / h3,这是唯一可信的结论来源 |
8. 什么时候反而不该上 HTTP/2
| 场景 | 建议 | 原因 |
|---|---|---|
| 内网批量拉取大量小文件 | 保持 HTTP/1.1 | 帧头与 HPACK 状态机对极小响应是净开销 |
| 需要逐条抓包看请求 | 保持 HTTP/1.1 | h2 头部被压缩、多流交织,可读性差 |
| 链路中间有老旧 HTTP 解析设备 | 保持 HTTP/1.1 | 部分代理无法正确转发 h2 帧 |
| 公网多静态资源的页面 | 上 h2/h3 | 并发与头部压缩收益最明显的地方 |
9. 常见坑
| 坑 | 现象 | 正解 |
|---|---|---|
| 以为上了 h2 就一定快 | 小站点测不出差异 | 收益来自并发与头部压缩,串行场景无感 |
| 上了 h2 仍做域名分片 | 连接变多、HPACK 动态表被拆散 | 资源合并到同一域名,让多路复用真正起作用 |
| UDP 被限导致 h3 静默回退 | "配了没效果" | 看 DevTools 的 Protocol 列,不看配置 |
http2 on; 写进老版本 nginx |
unknown directive "http2" |
1.25.1 之前用 listen 443 ssl http2; |
只有 h2 没有 Alt-Svc |
浏览器永不尝试 h3 | 补响应头,并确认 UDP 443 可达 |
| 0-RTT 用在写接口 | 偶发重复提交 | 0-RTT 只留给幂等请求,写接口上幂等键 |
| 回源也强行开 h2c | 容器兼容性与调试成本上升 | 回源保持 HTTP/1.1,收益在前端接入层 |
10. 边界:哪些问题压根不该从版本入手
| 问题 | 该看的东西 | 不该做 |
|---|---|---|
| 页面整体慢 | 资源数量、体积、瀑布图的串行段 | 先换版本 |
| 偶发超时 | 抓包看重传率与 RTT 抖动 | 归因到 HTTP 版本 |
| 首屏慢但复访快 | 缓存策略与 Cache-Control |
归因到多路复用 |
| 回源延迟高 | 后端接口耗时、连接池 | 给回源段强上 h3 |
| 是否上 h3 | 用户网络中 UDP 443 的实际可用率 | 因为"新"就上 |
一句话:并发瓶颈找 h2,丢包链路找 h3,头部体积看 HPACK 与会话复用,剩下的多半不在版本上。
11. 自测
- HTTP/1.1 的队头阻塞发生在哪一层?为什么调内核参数解决不了?
- HPACK 的动态表为什么要求同一连接内的 HEADERS 帧按序解码?
- HTTP/2 的多路复用为什么会在丢包时退化成"所有流一起等"?
- QUIC 的连接 ID 解决了什么问题,它和四元组的关系是什么?
- 0-RTT 为什么只能用于幂等请求?服务端还需要做什么补充?
- nginx 1.25.1 之后
http2指令的位置变了,旧写法会有什么表现?