HTTP/1.1、2、3 差的不是速度,是队头阻塞卡在哪一层
问 HTTP/2 比 1.1 快在哪,标准答案是「多路复用」。对,但没答到点子上。
真正的分界线是:队头阻塞卡在哪一层。
先看全貌
| 维度 | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| 规范 | RFC 9112 | RFC 9113 | RFC 9114 |
| 传输层 | TCP | TCP | QUIC(UDP) |
| 报文格式 | 文本 | 二进制分帧 | 二进制分帧 |
| 并发 | 浏览器约 6 连接/域名 | 单连接多路复用 | 单连接多路复用 |
| 应用层队头阻塞 | 有 | 解决 | 解决 |
| 传输层队头阻塞 | 有 | 仍有 | 解决 |
| 头部压缩 | 无,重复头重发 | HPACK | QPACK |
| 握手 RTT | 3(TLS 1.2) | 2(TLS 1.3) | 1,恢复 0 |
| 加密 | 可选 | 浏览器实际强制 | 内建,关不掉 |
| 连接迁移 | 无 | 无 | 有 |
| Server Push | — | 已废弃 | 同样没用 |
| 缓存语义 | 相同 | 相同 | 相同 |
队头阻塞有两层
这是整篇的核心,也是最容易混的地方:
HTTP/1.1 应用层队头阻塞 ✗ + 传输层队头阻塞 ✗
HTTP/2 应用层队头阻塞 ✓ + 传输层队头阻塞 ✗ ← 只解决了一半
HTTP/3 应用层队头阻塞 ✓ + 传输层队头阻塞 ✓
「应用层队头阻塞」是协议自己造成的:1.1 一条连接同时只能有一个请求在飞,响应必须按顺序回来。前面的慢,后面的全等 —— 浏览器拿 6 条连接硬顶,这是实现,不是规范。
「传输层队头阻塞」是 TCP 造成的,跟 HTTP 无关。
HTTP/2 只干掉了一层
h2 把「按顺序」这件事从连接挪到了流上:一条 TCP 连接跑几百条流,流之间互不干扰。应用层队头阻塞没了。
但流是 HTTP 层的逻辑概念,TCP 根本不知道有这回事。TCP 眼里的数据就是一串字节,按序交付。
所以丢一个包:
TCP 缓冲区: [包1] [包2] [✗丢] [包4] [包5]
↑ 内核把后面的全扣住等重传
→ 所有流一起卡住
A 流的数据和 B 流的数据在同一个 TCP 缓冲区里排着队。丢包那一刻,所有流一起停。
这带来一个反直觉的结论:弱网下 h2 可能比 h1.1 更慢。 h1.1 的 6 条连接互相独立,丢包只废掉一条;h2 把所有鸡蛋放进一条 TCP,丢包废掉全部。
HTTP/3 把「流」下移到传输层
QUIC 不是给 TCP 打补丁,是换了个传输层 —— 跑在 UDP 上,自己实现可靠性、拥塞控制、加密。
关键差别:流是 QUIC 原生的,每条流有自己的序号,而不是共用一套字节序号。
HTTP/2 over TCP: [流A][流B][流C] 拼成一条字节流 → 一起堵
HTTP/3 over QUIC: [流A] [流B] [流C] 各自独立序号 → 各堵各的
B 流丢包,A 流和 C 流照常交付。
握手省了几个 RTT
HTTP/1.1 + TLS 1.2 3 RTT TCP 三次握手 1 + TLS 1.2 握手 2
HTTP/2 + TLS 1.3 2 RTT TCP 三次握手 1 + TLS 1.3 握手 1
HTTP/3 1 RTT QUIC 把传输握手和 TLS 1.3 合并成一次
HTTP/3 会话恢复 0 RTT 直接带着数据发
QUIC 把「传输握手」和「加密握手」合成一个动作,这是它省 RTT 的原因 —— 不是把 TLS 做快了,是不用等传输层先握完手。
0-RTT 有个硬限制:只能发安全方法。GET、HEAD、OPTIONS、TRACE 可以,POST 不行 —— 0-RTT 数据天然可被重放(重放 GET 无所谓,重放 POST 就是重复下单)。
报文长什么样
# HTTP/1.1 —— 文本,有请求行
GET /api/user HTTP/1.1
Host: example.com
# HTTP/2 / HTTP/3 —— 伪头,无请求行,头名强制小写
:method: GET
:path: /api/user
:scheme: https
:authority: example.com
:authority 取代了 Host。几个伪头永远在最前面,且不能出现在 trailers 里。
几个容易答错的细节
QPACK 为什么不直接用 HPACK。 HPACK 有张全连接共享的动态表,靠「表状态在两端同步推进」来省字节。h2 里所有流在一条 TCP 上按序走,表不会错位;QUIC 的流是乱序的,动态表更新一乱序就重新制造队头阻塞 —— 把 h2 刚解决的问题又引回来。QPACK 的解法是把动态表更新丢到单向流上单独同步,解码不等它。
h2 的优先级树已经废了。 RFC 7540 那棵依赖树太复杂,各浏览器实现互相不一致,实际效果接近于没有。现在换成了 RFC 9218 的 Extensible Priorities,就是简单的 u=(urgency,0-7)加 i=(incremental 布尔)。
HTTP/3 首次发现不能只靠 ALPN。 h2 靠 TLS 的 ALPN 直接协商出 h2;h3 跑在 UDP 上,你得先知道对方支持 UDP。所以要么响应头带 Alt-Svc: h3=":443",要么查 DNS 的 HTTPS/SVCB 记录。
连接迁移。 QUIC 用 Connection ID 标识连接,不绑 IP 和端口。手机从 WiFi 切 4G,连接不断 —— 这是 h3 对移动端最实际的好处,跟「快」没关系。
h3 跑在用户态。 不用改内核就能换拥塞控制算法(CUBIC、BBR 随便换)。代价是有些网络封 UDP,得能回落 h2。
h2/h3 淘汰了一批 h1 时代的优化手法
| 手法 | h1.1 时代 | h2/h3 时代 |
|---|---|---|
| 域名分片 | 突破 6 连接限制 | 反模式,杀死连接复用 |
| 文件合并(雪碧图/大 bundle) | 减少请求数 | 反模式,缓存颗粒度变粗 |
| 内联资源 | 省一次请求 | 反模式,进不了独立缓存 |
| Server Push | — | 已死(Chrome 106 移除) |
| 资源预热 | Server Push | 103 Early Hints |
一句话:h2 让「不合并、不分片、细粒度文件」变成最优解。因为请求不再昂贵,缓存的颗粒度才是收益来源 —— hash 文件名 + Cache-Control: immutable 这套组合这时才真正成立。
缓存语义一点没变
这是最容易被带偏的一点:h2/h3 不引入任何新的缓存机制。
它们改的是传输层,缓存是语义层。RFC 9111 只是把老的 RFC 7234 重新组织了一遍,规则一字未改。
Cache-Control / ETag / Last-Modified / Expires / Vary / Age
缓存键 = method + URL ( + Vary 声明的头 )
stream id 不进缓存键,QUIC 的 connection id 也不进。h2 的 connection coalescing(同 IP + 同证书的域名复用一条连接)同样不影响缓存隔离 —— 缓存条目还是按 origin 分开存。
真正算「新」的是这几个策略头,跟协议版本无关:
Cache-Control: immutable—— 告诉浏览器这个 URL 永远不会变,别再发条件请求stale-while-revalidate/stale-if-error—— RFC 5861,在 h2/h3 时代才被 CDN 普遍默认开
小结
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| 一句话 | 文本、按序、开 6 条连接 | 二进制分帧,流多路复用 | 换 QUIC,流真正独立 |
| 解决 | — | 应用层队头阻塞 | 传输层队头阻塞 |
| 没解决 | 两层都有 | 传输层队头阻塞 | — |
| 适用 | 兼容兜底 | 常规默认 | 弱网、移动端、切换网络频繁 |
| 缓存 | RFC 9111 | RFC 9111 | RFC 9111 |
一句话:h2 解决的是应用层队头阻塞,h3 解决的是传输层队头阻塞 —— h3 的卖点是弱网和移动端,不是快。而缓存语义三层完全一样,因为它压根不属于传输层。
Ask why HTTP/2 is faster than 1.1 and the standard answer is "multiplexing". True, but that misses the point.
The real dividing line is which layer head-of-line blocking sits on.
The whole picture first
| Dimension | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| Spec | RFC 9112 | RFC 9113 | RFC 9114 |
| Transport | TCP | TCP | QUIC (UDP) |
| Wire format | text | binary framing | binary framing |
| Concurrency | ~6 connections per origin | one connection, multiplexed | one connection, multiplexed |
| App-layer HOL | yes | solved | solved |
| Transport-layer HOL | yes | still there | solved |
| Header compression | none, headers resent | HPACK | QPACK |
| Handshake RTT | 3 (TLS 1.2) | 2 (TLS 1.3) | 1, or 0 on resume |
| Encryption | optional | effectively required by browsers | built in, cannot be turned off |
| Connection migration | no | no | yes |
| Server Push | — | deprecated | equally useless |
| Cache semantics | identical | identical | identical |
There are two layers of head-of-line blocking
This is the core of the whole thing, and the easiest part to get backwards:
HTTP/1.1 app-layer HOL ✗ + transport-layer HOL ✗
HTTP/2 app-layer HOL ✓ + transport-layer HOL ✗ ← only half solved
HTTP/3 app-layer HOL ✓ + transport-layer HOL ✓
App-layer HOL is the protocol's own doing: a 1.1 connection handles one request at a time, and responses must come back in order. One slow response stalls everything behind it. Browsers work around this with ~6 connections per origin — that is an implementation detail, not the spec.
Transport-layer HOL is TCP's doing, and has nothing to do with HTTP.
HTTP/2 only removes one layer
h2 moves "in order" from the connection down to the stream: one TCP connection carries hundreds of streams, independent of each other. App-layer HOL is gone.
But a stream is a logical concept in the HTTP layer. TCP has no idea it exists. From TCP's point of view the data is just one sequence of bytes delivered in order.
So when a packet is lost:
TCP buffer: [pkt1] [pkt2] [✗lost] [pkt4] [pkt5]
↑ kernel holds everything after it for retransmit
→ every stream stalls together
Stream A's data and stream B's data are queued in the same TCP buffer. The moment a packet drops, all streams stop at once.
That leads to a counterintuitive conclusion: on a lossy network, h2 can be slower than h1.1. The six h1.1 connections are independent — a lost packet only ruins one. h2 puts all its eggs in a single TCP connection, so one lost packet ruins all of them.
HTTP/3 moves streams down into the transport
QUIC is not a patch on TCP — it is a different transport, running over UDP and implementing reliability, congestion control, and encryption itself.
The key difference: streams are native to QUIC. Each has its own sequence numbers instead of sharing one byte sequence.
HTTP/2 over TCP: [stream A][stream B][stream C] → concatenated into one byte stream → all block together
HTTP/3 over QUIC: [stream A] [stream B] [stream C] → independent sequence numbers → each blocks alone
Stream B loses a packet; A and C keep delivering.
The handshake saves a few RTTs
HTTP/1.1 + TLS 1.2 3 RTT TCP 3-way 1 + TLS 1.2 2
HTTP/2 + TLS 1.3 2 RTT TCP 3-way 1 + TLS 1.3 1
HTTP/3 1 RTT QUIC merges the transport and TLS 1.3 handshakes into one
HTTP/3 resumed 0 RTT send data immediately
QUIC folds the transport handshake and the encryption handshake into one. That is where the RTT goes — not because TLS got faster, but because you no longer wait for the transport handshake first.
0-RTT has a hard limit: safe methods only. GET, HEAD, OPTIONS, TRACE are fine, POST is not. 0-RTT data is inherently replayable, and replaying a GET is harmless while replaying a POST puts in a duplicate order.
What the messages look like
# HTTP/1.1 — text, with a request line
GET /api/user HTTP/1.1
Host: example.com
# HTTP/2 and HTTP/3 — pseudo-headers, no request line, lowercase header names
:method: GET
:path: /api/user
:scheme: https
:authority: example.com
:authority replaces Host. Pseudo-headers always come first and are never allowed in trailers.
Details that are easy to get wrong
Why QPACK instead of HPACK. HPACK keeps a dynamic table shared by the whole connection and saves bytes by advancing its state in lockstep on both ends. In h2 every stream travels in order on one TCP connection, so the table cannot desync. QUIC streams are out of order, so a dynamic-table update landing out of order would reintroduce the very head-of-line blocking h2 just eliminated. QPACK fixes this by moving dynamic-table updates onto a dedicated unidirectional stream, so decoding never waits on them.
h2's priority tree is dead. The dependency tree from RFC 7540 was too complicated, implementations disagreed with each other, and in practice it did close to nothing. It has been replaced by RFC 9218 Extensible Priorities — just u= (urgency, 0-7) plus i= (an incremental boolean).
HTTP/3 discovery does not work through ALPN alone. h2 negotiates itself directly through TLS ALPN, but h3 runs over UDP, so you have to know the peer speaks UDP first. Either the response carries Alt-Svc: h3=":443", or you look up the DNS HTTPS/SVCB record.
Connection migration. QUIC identifies a connection by its Connection ID rather than by IP and port. A phone moving from WiFi to 4G keeps the connection alive. This is h3's most practical benefit for mobile, and it has nothing to do with speed.
h3 runs in user space. You can swap congestion control algorithms (CUBIC, BBR, whatever) without touching the kernel. The cost is that some networks block UDP, so falling back to h2 has to work.
h2/h3 retired a whole generation of h1 optimizations
| Technique | HTTP/1.1 era | h2/h3 era |
|---|---|---|
| Domain sharding | get around the 6-connection limit | anti-pattern, kills connection reuse |
| File concatenation (sprites, one big bundle) | fewer requests | anti-pattern, coarser cache granularity |
| Inlining assets | save a request | anti-pattern, cannot be cached separately |
| Server Push | — | dead (removed in Chrome 106) |
| Asset prewarming | Server Push | 103 Early Hints |
One sentence: h2 makes "do not concatenate, do not shard, ship fine-grained files" the optimal strategy. Requests stopped being expensive, so cache granularity became the source of the win — which is when hashed filenames plus Cache-Control: immutable actually started to hold up.
Cache semantics changed not at all
This is the easiest thing to be misled about: h2 and h3 introduce no new caching mechanism whatsoever.
They change the transport layer; caching is a semantics-layer concern. RFC 9111 merely reorganized the old RFC 7234. Not one rule changed.
Cache-Control / ETag / Last-Modified / Expires / Vary / Age
cache key = method + URL ( + headers named by Vary )
Stream IDs do not enter the cache key. Neither does QUIC's connection ID. h2's connection coalescing — reusing one connection across domains that share an IP and a certificate — does not affect cache isolation either. Entries are still stored per origin.
What is genuinely new is a handful of policy headers, unrelated to protocol version:
Cache-Control: immutable— tells the browser this URL will never change, so it can skip the conditional requeststale-while-revalidate/stale-if-error— RFC 5861, only widely enabled by CDNs in the h2/h3 era
Summary
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| In one line | text, in order, six connections | binary framing, multiplexed streams | QUIC, genuinely independent streams |
| Solves | — | app-layer HOL | transport-layer HOL |
| Does not solve | both layers | transport-layer HOL | — |
| Where it fits | compatibility fallback | the normal default | lossy networks, mobile, frequent network switches |
| Caching | RFC 9111 | RFC 9111 | RFC 9111 |
One line: h2 solves app-layer head-of-line blocking; h3 solves transport-layer head-of-line blocking. h3's selling point is lossy networks and mobile, not speed. And cache semantics are identical across all three, because caching was never part of the transport layer.