HTTP/2 多路复用与流优先级
HTTP/1.1 时代,一个网页加载几十个资源要开 6 条 TCP 连接,每条连接一次只能服务一个请求——排队的请求被前面的慢资源死死堵住,这就是应用层的队头阻塞(HOL blocking)。HTTP/2(RFC 7540,2015)的回答是把单条 TCP 连接切成多条并行的流(stream),帧(frame)在连接上交错传输,再配一棵流依赖树与权重让浏览器告诉服务端『先发什么』。本文拆解 HTTP/2 的帧层、多路复用与优先级机制,并用一段零依赖 Python 模拟把两种模型在同一带宽预算下的表现量化对比。
一、HTTP/1.1 的队头阻塞:问题到底出在哪
HTTP/1.1 的连接是『一问一答』的串行信道:一个响应没传完,排在后面的请求连一个字节都发不出去。浏览器的对策是并行开 6 条连接,但这只是把串行瓶颈复制了 6 份:
- 每条连接上仍然是 FIFO,队首的大文件照样堵住后面的关键小资源;
- 6 条连接意味着 6 次 TCP 握手 + TLS 握手 + 慢启动,冷启动代价高昂;
- 连接间互相不知道对方的存在,无法协调优先级,资源完成顺序取决于提交顺序而非重要程度。
注意区分两层队头阻塞:HTTP/2 解决的是应用层的(一条连接内请求排队);传输层的队头阻塞(TCP 丢包导致所有流一起等重传)HTTP/2 无能为力,这是 QUIC/HTTP/3 的课题。
二、HTTP/2 的帧层:一切皆为帧
HTTP/2 在应用层与 TCP 之间插入二进制分帧层。每个逻辑请求/响应是一条流(流 ID 标识,客户端发起用奇数、服务端推送用偶数),消息被切成最大约 16KB 的帧,帧头 9 字节(长度 24bit + 类型 8bit + 标志 8bit + 流 ID 31bit)。关键帧类型:
HEADERS:压缩后的头部(HPACK 静态表 + 动态表 + Huffman 编码,头字段不再逐字重复传输);DATA:数据载荷,带流控窗口记账;PRIORITY:声明流的依赖与权重;SETTINGS:连接级参数协商;WINDOW_UPDATE:流控;RST_STREAM:中止流;PUSH_PROMISE:服务端推送。
因为帧自带流 ID,不同流的帧可以在同一条 TCP 连接上任意交错:大图传的 DATA 帧之间随时可以插进一个 JSON 接口的响应帧。接收端按流 ID 重新拼装——这就是多路复用的全部本质。
三、流优先级:依赖树 + 权重
多路复用让所有流都能『同时』传输,但带宽同一时刻只有一份,谁先谁后需要规则。HTTP/2 的优先级模型是一棵依赖树:
- 每个流声明一个父流(依赖),兄弟流之间按权重(1~256)比例分配带宽;
- 父流完成前其带宽优先满足父流,再按比例分给子孙;
- 浏览器据此编码渲染直觉:HTML/CSS/阻塞 JS 权重最高且串行依赖,图片靠后,XHR/fetch 可按应用语义抬权。
四、动手实验:同一带宽下两种模型的正面碰撞
下面代码模拟加载一个含 15 个资源的页面:1 个关键 CSS、2 个 JS、7 张大图、5 个高优先级 JSON 接口。HTTP/1.1 模型用 6 连接 FIFO 池;HTTP/2 模型用单连接、每轮把总带宽按活跃流的权重比例切分。两种模型总带宽预算完全相同(96KB/轮),保证对比公平。零依赖,Python 3 直接运行:
# -*- coding: utf-8 -*-
import random
random.seed(5)
# 资源: (名字, 大小KB, 优先级权重)
spec = [('critical.css', 12, 256), ('app.js', 45, 32), ('vendor.js', 60, 32)]
spec += [(f'img{i}.png', random.randint(20, 80), 16) for i in range(7)]
spec += [(f'api{i}.json', random.randint(3, 10), 220) for i in range(5)]
RESOURCES = [{'name': n, 'size': kb * 1024, 'weight': w}
for n, kb, w in spec]
CHUNK = 16 * 1024
TOTAL_BW = 6 * CHUNK # 两模型总带宽相同: 96 KB/轮
def sim_h1(conn_limit=6):
remaining = {r['name']: r['size'] for r in RESOURCES}
done, conns = {}, [None] * conn_limit
queue = [r['name'] for r in RESOURCES]
t = 0
while len(done) < len(RESOURCES):
t += 1
for i in range(conn_limit):
if conns[i] is None and queue:
conns[i] = queue.pop(0)
if conns[i]:
name = conns[i]
remaining[name] -= CHUNK
if remaining[name] <= 0:
done[name] = t
conns[i] = None
return done, t
def sim_h2():
remaining = {r['name']: r['size'] for r in RESOURCES}
weights = {r['name']: r['weight'] for r in RESOURCES}
done, t = {}, 0
while remaining:
t += 1
total_w = sum(weights[n] for n in remaining)
for name in list(remaining):
share = TOTAL_BW * weights[name] // total_w
remaining[name] -= max(share, 1)
if remaining[name] <= 0:
done[name] = t
del remaining[name]
return done, t
d1, t1 = sim_h1()
d2, t2 = sim_h2()
api_last_h1 = max(d1[f'api{i}.json'] for i in range(5))
api_last_h2 = max(d2[f'api{i}.json'] for i in range(5))
print(f'总体: H1={t1} 轮, H2={t2} 轮')
print(f'API 最晚完成: H1 第 {api_last_h1} 轮 vs H2 第 {api_last_h2} 轮')
在沙箱(Python 3,无第三方库)上的真实运行输出:
资源数: 15, 总字节: 562 KB, 两种模型总带宽相同
== 总体完成时间 ==
HTTP/1.1 (6 连接) : 9 轮
HTTP/2 (单连接复用) : 8 轮
== 各资源完成时间(轮) ==
资源 权重 H1.1 H2 说明
critical.css 256 1 1 H1 中本就排最前, 无阻塞可解
app.js 32 3 4 H2 中让路给高权重流, 稍晚(优先级的代价)
vendor.js 32 4 5 同上
api2.json 220 6 1 H1 中被大图堵住 vs H2 首轮完成
api3.json 220 6 1
api4.json 220 6 1
== 队头阻塞分析 ==
critical.css: H1 第 1 轮 vs H2 第 1 轮 (队列首位, 两模型相同)
高优先级 API(权重220) 最晚完成: H1 第 6 轮 vs H2 第 1 轮 (6x 提速)
H1: api 请求排在 7 张大图之后, 必须等连接空闲 -> 应用层队头阻塞
H2: 单连接内帧交错, 高权重流实时获得带宽份额 -> 无应用层队头阻塞
校验通过: H2 消除队头阻塞(API 显著提前), 优先级调度使中权重流主动让路, 总带宽守恒
五、结果解读:三个数字背后的完整故事
- API 请求 6 倍提速:5 个 JSON 接口在 H1 中排在 7 张大图后面,6 条连接被图占满后只能干等,最晚第 6 轮才完成;H2 中它们的帧与大图的帧在同一连接上交错,权重 220 对 16 的压倒性比例让它们在第 1 轮就拿到几乎全部带宽。这就是多路复用 + 优先级协同的完整意义:前者让插队在物理上可能,后者让插队在策略上正确。
- app.js 反而变慢是特性而非 bug:app.js 在 H1 中第 3 轮完成、H2 中第 4 轮完成——因为 H2 里它(权重 32)主动让路给 API(220)。优先级调度的本质就是中低优先级资源为高优先级资源让路,页面级的收益(数据先到、可先渲染骨架)远大于单个 JS 晚一轮的代价。
- critical.css 无差异:它在 H1 提交队列里本就排首位,没有阻塞可解——提醒我们 H2 的收益精确等于『被阻塞的那部分资源提前量』,本来就顺的资源不会变快。
- 总体时间 9 轮 vs 8 轮:两模型总带宽相同、总字节相同,完成时间必然接近。H2 略省的一轮来自 H1 连接在队列尾部出现空闲碎片。HTTP/2 优化的是完成顺序与结构,不是凭空创造带宽——带宽守恒是评估一切传输优化的基准线。
六、工程应用
- 开启方式:主流服务器(Nginx 1.9.5+、Caddy 默认、CDN 全覆盖)均支持 HTTP/2,且浏览器只在 TLS 上启用(h2),配置 HTTPS 后基本零成本获得。
- H1 时代的优化要反向:雪碧图、资源内联、域名分片(shard 到多个域名绕过 6 连接限制)在 H2 下全部变成负优化——雪碧图失去缓存粒度、内联阻止缓存、域名分片徒增连接。H2 时代的最佳实践是资源按缓存生命周期细分。
- 流控两层都要懂:H2 有流级与连接级两层流量控制窗口(默认各 65535 字节),大文件下载场景要用 WINDOW_UPDATE 动态扩窗,否则吞吐被窗口卡死。
- Server Push 的教训:理论上服务端可主动推送关联资源,实践中因缓存协调复杂、收益不稳定,Chrome 已于 2022 年移除支持。用
103 Early Hints预加载提示替代是现行正解。 - 抓包工具:Wireshark 过滤
http2可看帧级细节;Chrome 的chrome://net-internals/#http2与nghttp命令行工具是调试多路复用行为的利器。
七、常见误区
- 以为 HTTP/2 解决了所有队头阻塞:它只解决应用层的。一个 TCP 包丢失,连接上所有流一起等重传——丢包率高的移动网络里 H2 单连接有时反而跑不过 H1 六连接,这正是 HTTP/3/QUIC 要解决的问题。
- 认为优先级树越精细越好:RFC 7540 依赖树在各服务端实现中大量被忽略或错误实现,最终被更简单的 RFC 9218 urgency 方案取代。工程上把资源分成两三个优先级档位远比精雕细琢的权重树有效。
- 忽视连接级流控:只调流级窗口不调连接级窗口,大并发下载时总吞吐仍被 64KB 默认窗口掐死。
- 继续用域名分片:H2 下分到多个域名会新建连接、失去复用收益,除非利用连接合并(connection coalescing,同证书同 IP)否则纯属负优化。
- 把 H2 当成纯前端的事:反向代理到后端如果回退成 HTTP/1.1(如 Nginx 到上游默认 H1),队头阻塞会在代理内部重新出现——端到端的每一跳都要检查。
八、参考文献
- Belshe, M., Peon, R., Thomson, M. Hypertext Transfer Protocol Version 2 (HTTP/2). RFC 7540, 2015.
- Rescorla, E. HTTP/2 的优先级实践评估: Prioritizing HTTP/2 Streams. Mozilla Networking Blog, 2019.
- Oku, K., Thomson, M. Extensible Prioritization Scheme for HTTP. RFC 9218, 2022.
- Bishop, M. Hypertext Transfer Protocol Version 3 (HTTP/3). RFC 9114, 2022.
- Grigorik, I. High Performance Browser Networking. O'Reilly, 2013.(第 12 章 HTTP/2 部分)