深入理解 HTTP/2 多路复用
HTTP/1.1 的队头阻塞(Head-of-Line Blocking)是 Web 性能的一个老问题。HTTP/2 通过多路复用解决了它,但引入了新的复杂度。这篇文章梳理一下 HTTP/2 多路复用的工作原理和实际应用中的注意事项。
HTTP/1.1 的问题
在 HTTP/1.1 中,每个 TCP 连接同一时间只能处理一个请求-响应。浏览器通过打开多个连接(通常 6 个)来并行加载资源,但这带来了连接管理的开销。更致命的是队头阻塞:即使后面的请求已经就绪,也必须等前面的慢请求完成才能使用这个连接。
常见的变通方案——CSS sprites、资源内联、域名分片——本质都是在绕过协议的限制而非解决问题。
多路复用的核心:帧和流
HTTP/2 引入了一个二进制分帧层。每个 HTTP 消息被拆成多个帧(HEADERS 帧、DATA 帧),在同一个 TCP 连接上以流(Stream)的形式交错传输。接收端根据帧头中的 Stream ID 重新组装。
TCP 连接
├── Stream 1: [HEADERS] [DATA] [DATA] ← index.html
├── Stream 3: [HEADERS] [DATA] ← style.css
├── Stream 5: [HEADERS] [DATA] [DATA] ... ← bundle.js
└── Stream 7: [HEADERS] [DATA] ← logo.png
关键设计:
- 流优先级:客户端可以指定流的依赖关系和权重(1-256),服务器据此分配带宽
- 流量控制:每个流和整个连接都有独立的流量控制窗口,防止快速发送方压垮慢速接收方
- 服务器推送:服务器可以主动推送客户端尚未请求的资源(如 HTML 引用的 CSS),省去一轮 RTT
HPACK 头部压缩
多路复用让头部压缩变得更有价值。HTTP/1.1 中每个请求都要发送完整的 Cookie 和 User-Agent(通常几百字节)。在 HTTP/2 中,同一个连接上的请求共享一个 HPACK 动态表,后续请求只需要发送变化的部分。
实际测试中,对于典型的 API 调用(Authorization header + 少量自定义 header),HPACK 能把第二个请求起的头部从 400 字节压缩到几十字节。
新的队头阻塞
HTTP/2 解决了应用层的队头阻塞,但在传输层仍然受影响——TCP 的可靠传输要求数据按序到达。如果 TCP 层丢了一个包,后续所有流的数据都会被阻塞,即使它们在应用层没有依赖关系。
这就是 HTTP/3 选择 QUIC(基于 UDP)而非 TCP 的原因。QUIC 在传输层也实现了流的多路复用,丢包只影响单个流。
Nginx 配置要点
# 启用 HTTP/2(需要 TLS)
listen 443 ssl http2;
# 调整流并发数(默认 128)
http2_max_concurrent_streams 256;
# 控制 HPACK 表大小
http2_max_header_size 16k;
http2_recv_buffer_size 256k;
几个实践建议:
- 域名分片反而有害:多路复用让单个连接就够了,多域名反而增加 DNS 查询和 TLS 握手开销
- 服务器推送要谨慎:推送的资源如果客户端已有缓存就是纯浪费。用
Link rel=preload配合nopush属性更灵活 - 小文件合并不再必要:CSS sprites 和文件拼接带来的收益在 HTTP/2 下大打折扣,反而增加了缓存失效的粒度
监控指标
如果使用 Nginx,以下指标值得关注:
$http2_stream_id— 在 access log 中记录流 ID,排查时能定位到具体请求http2_push指令 — 统计服务器推送的命中率- 连接级别的 TIME_WAIT 数量 — HTTP/2 长连接减少了连接数,TIME_WAIT 应该显著低于 HTTP/1.1
HTTP/2 的多路复用看似简单——不过是把一个连接上的请求交错发送——但背后的帧协议、流量控制、优先级调度和头部压缩构成了一套精心设计的系统。理解这些细节,才能在遇到奇怪的性能问题时知道该往哪里看。