2019年,一个电商平台发现他们的页面在Chrome下加载需要8秒,而换用Firefox只需要4秒——同样网络环境、同样服务器、同样代码。性能团队排查了DNS、TLS握手、服务器响应时间,所有指标都正常。最终发现问题出在一个被大多数人忽略的地方:HTTP/2流优先级。浏览器向服务器发送了优先级信号,但服务器没有正确处理。这不是个案。Andy Davies的测试显示,全球主流CDN中只有不到30%正确实现了HTTP/2优先级。Google Cloud CDN、Amazon CloudFront、Azure CDN——这些巨头的服务都曾在这个问题上栽过跟头。

优先级问题的本质:一个连接,多个竞争者

HTTP/1.1时代,浏览器通过开6个TCP连接来并行下载资源。每个连接一次只传输一个文件,优先级由浏览器完全控制——先请求的先下载。这个模型简单但效率低下:连接建立开销大,TCP慢启动重复发生,队头阻塞无处不在。

HTTP/2用单一连接和多路复用解决了这些问题。但多路复用带来了新挑战:当多个资源在同一个连接上竞争带宽时,谁应该先传输?浏览器知道答案——HTML比图片重要,CSS比脚本重要,首屏可见的图片比折叠区域的图片重要。但服务器不知道。于是HTTP/2引入了优先级机制,让浏览器告诉服务器每个资源的相对重要性。

这个看似简单的设计,却在浏览器实现、服务器处理、网络传输三个层面制造了复杂的博弈。

依赖树:HTTP/2优先级的数学模型

RFC 7540定义的优先级机制是一个树形数据结构。每个HTTP/2流(代表一个资源请求)在树中有一个位置,由三个参数决定:

依赖关系(Dependency):流A依赖流B意味着A必须等B完成(或无法继续)才能开始传输。这形成了父子关系。根节点是虚拟流0,代表整个TCP连接。

权重(Weight):取值1-256,用于兄弟节点之间的带宽分配。如果有三个兄弟节点,权重分别是10、20、30,它们应该分别获得$1/6$、$1/3$和$1/2$的带宽。计算公式为:

$$B_i = \frac{W_i}{\sum_{j \in S} W_j}$$

其中$B_i$是节点$i$获得的带宽比例,$W_i$是其权重,$S$是所有兄弟节点的集合。

独占标志(Exclusive):当一个流以独占方式依赖父节点时,它会"篡位"成为父节点的唯一子节点,原来的子节点都变成它的子节点。这个机制常用于表达"这个资源绝对最重要"的语义。

用一个具体例子说明。假设浏览器请求了HTML(流1)、CSS(流3)、两个图片(流5和7)。Chrome可能构建这样的依赖树:

流0(根)
 └── 流1(HTML)weight=256, exclusive=1
      └── 流3(CSS)weight=256, exclusive=1
           ├── 流5(图片A)weight=147
           └── 流7(图片B)weight=147

这意味着HTML必须先下载完成,然后CSS,最后两个图片并行下载并平分带宽。Chrome使用独占依赖创建了一个串行链条,确保高优先级资源不会被低优先级资源打断。

nghttp2的博客文章详细记录了这种优先级树的实际工作方式。当流9(CSS)因为流控暂停时,它的子节点(图片)会被解锁并开始传输。这展示了依赖树的一个关键特性:父节点阻塞时,子节点可以前进。

浏览器的四种策略:从理想主义到实用主义

HTTP/2规范给了浏览器极大的自由度。规范只说"客户端可以发送优先级信号",没说具体怎么构造优先级树。结果是各家浏览器采取了截然不同的策略。

Chrome的动态FCFS(First Come First Served)

Chrome将资源分为五个优先级桶:Highest、High、Medium、Low、Lowest。每个桶内的资源按请求顺序串行排列,桶之间也形成串行链。它的依赖树是一条长链:

Highest资源 → High资源 → Medium资源 → Low资源 → Lowest资源

每个节点都独占依赖前一个节点,权重值几乎无关紧要(因为很少有兄弟节点)。Chrome的策略假设服务器会按链的顺序逐一发送资源。对于渲染阻塞资源(CSS、同步JS),这种方式很高效。但对于图片,每个图片都必须完全下载才能开始下一个,这可能不是最优的——渐进式JPEG本来可以边下载边显示。

Firefox的树状分组

Firefox采用了更复杂的策略。它在连接建立时创建五个"幽灵流"(phantom streams),这些流不传输任何数据,只作为分组节点:

流0(根)
 ├── 流3(Leaders组)weight=201
 │    └── 流11(Followers组)weight=1
 ├── 流5(Unblocked组)weight=100
 └── 流7(Other组)weight=100

HTML归入Followers组,CSS和head中的JS归入Leaders组,异步脚本和XHR归入Unblocked组,图片归入Other组。组之间按权重分配带宽,组内资源并行下载。

这种设计的精妙之处在于:Leaders组的权重是Followers组的201倍,意味着CSS和head中的JS会优先下载。当Leaders组完成后,Followers组的HTML会被解锁。同时,Unblocked组和Other组可以"偷用"Leaders组不用的带宽。

Safari的权重加权

Safari的实现简单得多:所有资源都直接依赖根节点,不使用独占标志,只通过权重区分重要性。CSS和JS的权重是24-32,图片的权重是8-22,字体和XHR的权重是16。

结果所有资源并行下载,按权重分配带宽。这种方式实现简单,但失去了"阻塞资源必须完成才能渲染"的语义——CSS可能和图片一起下载,导致渲染延迟。

Edge的无为而治

2018年的测试显示,Microsoft Edge(以及IE)在HTTP/2请求中不发送任何优先级信息。服务器回退到默认行为:所有资源并行下载,平分带宽。这是最糟糕的情况——关键CSS可能被大量图片阻塞,页面渲染延迟可能高达25%以上。

Cloudflare的测试数据量化了这些差异。在一个典型的电商页面上,启用正确优先级处理的版本在5秒时开始显示内容,而没有优先级处理的版本在19秒后才显示——相差近4倍。

服务器的困境:缓冲区是优先级的敌人

浏览器发送了正确的优先级信号,不等于服务器会正确处理。问题出在缓冲区。

当服务器向TCP连接写入数据时,数据首先进入操作系统的TCP发送缓冲区。一旦进入缓冲区,数据就按写入顺序发送,服务器无法重新排序。如果服务器先写入了一个10MB的低优先级图片,然后发现高优先级的CSS请求到达,它无能为力——CSS必须等图片发完。

这就是为什么Andy Davies的测试显示,很多CDN虽然"支持"HTTP/2优先级,但在实际部署中完全失效。Cloudflare的Patrick Meenan解释了问题的规模:

大多数服务器理论上支持HTTP/2优先级,但因为网络路径中的缓冲而实际上失效。服务器缓冲、TCP栈缓冲、网络设备缓冲——任何一层的缓冲都会破坏优先级。

缓冲区问题在服务器内部就开始了。Nginx等Web服务器通常会缓冲响应内容,批量写入TCP。对于HTTP/1.1这是优化,对于HTTP/2这是灾难。

Cloudflare的解决方案包括两个关键配置。第一是设置tcp_notsent_lowat为16384字节,这限制了TCP发送缓冲区的大小,确保最多只有16KB低优先级数据"排队等待",高优先级请求可以快速插入。第二是使用BBR拥塞控制算法,它通过测量往返延迟而非丢包来估算带宽,避免了传统算法在存在Bufferbloat的网络中产生的巨大缓冲区。

实测效果惊人。一个WordPress博客页面,正确配置后在4.5秒完成渲染;使用默认配置时,10.2秒才开始渲染——相差2.3倍。

CDN支持现状:少数人的游戏

2018年12月,Andy Davies发布了HTTP/2优先级支持测试结果,覆盖了35家CDN和主机商。结果令人震惊:

只有9家通过测试:Akamai、CDNsun、Cloudflare、DreamHost、Fastly、Google Firebase、section.io、WordPress.com。26家失败,包括Google Cloud CDN、Amazon CloudFront、Microsoft Azure、Netlify等知名服务。

测试方法很直接:先加载大量低优先级图片,然后请求高优先级的可见图片和阻塞脚本。如果优先级正常工作,高优先级请求应该"插队"完成;如果失败,高优先级请求会被低优先级数据阻塞。

这个测试揭示了一个关键洞察:真正重要的是HTTP/2连接终止的位置。如果CDN在前端终止HTTP/2连接,后端服务器使用HTTP/1.1,那么CDN的优先级实现决定了用户体验。这也是为什么Cloudflare能够通过优化优先级处理,让任何后端服务器都获得正确的行为。

HTTP/3的简化革命:从树形到线性的范式转变

HTTP/2优先级机制的复杂性问题在HTTP/3设计时被重新审视。结果RFC 9218定义了一个全新的可扩展优先级方案,完全抛弃了依赖树的概念。

新方案只有两个参数:

Urgency(紧急度):0-7的整数,0最重要,7最不重要,默认是3。服务器应该先发送所有urgency=0的资源,然后是urgency=1,以此类推。

Incremental(增量):布尔值,表示资源是否可以增量处理。渐进式图片incremental=true,阻塞脚本incremental=false。对于incremental=true的资源,服务器可以交错发送多个资源的数据。

信号传输方式也简化了。HTTP/2需要特殊的PRIORITY帧,HTTP/3可以直接使用Priority头部字段,如:

Priority: u=0, i

这表示urgency=0,incremental=true。

Robin Marx在Web Performance Calendar上详细分析了三大浏览器对HTTP/3优先级的实现差异:

Chrome和Firefox从未设置incremental参数(默认false),意味着所有资源串行下载。Safari则对所有资源设置incremental=true,包括阻塞脚本——这可能导致CSS被图片交错延迟。

在urgency值的映射上,Chrome使用0-4(5个级别),Safari使用0,1,3,5,7(更分散),Firefox使用1-4。这些差异虽然不会影响优先级的正确性(只要相对顺序正确),但反映了各浏览器对优先级粒度的不同理解。

一个有趣的细节是Chrome计划改变其incremental策略:未来只有高优先级的JS和CSS不设置incremental,其他资源都设置。这意味着Chrome在HTTP/2和HTTP/3之间会有不同的行为——另一个需要开发者注意的兼容性问题。

工程实践:诊断与修复

如何判断你的网站是否存在优先级问题?Chrome DevTools的网络面板是一个起点。查看 waterfall图,如果高优先级资源(CSS、阻塞JS)的下载被大量低优先级资源(图片)阻塞,那就是优先级失效的信号。

更精确的诊断需要WebPageTest。Patrick Meenan创建的测试页面可以专门验证优先级是否正常工作。方法是加载3MB的低优先级图片,然后加载4个高优先级资源。如果优先级正常,高优先级资源会快速完成;如果失败,它们会被排队等待。

对于自建服务器,Linux 4.9及以上内核需要配置:

# /etc/sysctl.conf
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_notsent_lowat = 16384

应用配置后重启服务器,使用WebPageTest重新测试。

对于使用CDN的网站,选择正确的CDN可能是最简单的解决方案。Cloudflare、Fastly、Akamai的优先级支持经过验证,可以直接使用。如果必须使用不支持优先级的CDN,可以考虑Cloudflare Workers手动设置优先级头部:

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const url = new URL(request.url)
  
  // 为CSS设置最高优先级
  if (url.pathname.endsWith('.css')) {
    const response = await fetch(request)
    const newResponse = new Response(response.body, response)
    newResponse.headers.set('cf-priority', '0/0')  // urgency=0, 并发=0
    return newResponse
  }
  
  // 为首屏图片设置中等优先级
  if (url.pathname.includes('hero')) {
    const response = await fetch(request)
    const newResponse = new Response(response.body, response)
    newResponse.headers.set('cf-priority', '30/n')  // urgency=2, 并发=n
    return newResponse
  }
  
  return fetch(request)
}

优先级的未来:自动化与智能化

当前的优先级机制有一个根本限制:浏览器基于资源类型做粗粒度判断,不知道资源对特定页面的实际重要性。一张首屏的hero图片和一张折叠区域的缩略图获得相同优先级,尽管前者对用户体验的影响大得多。

Priority Hints API(fetchpriority属性)提供了一种手动干预的方式:

<img src="hero.jpg" fetchpriority="high">
<img src="thumbnail.jpg" fetchpriority="low">

Chrome已经支持这个API,Firefox和Safari正在开发中。但手动标注不是长久之计。更智能的方案是服务器端分析:根据资源的引用位置、CSS中的display属性、viewport大小来推断真实优先级。

学术界已经在这方面进行了探索。WProf通过提取资源加载依赖关系构建"关键路径",Polalis使用JavaScript引擎的反馈来优化网络和CPU的协同。但这些方案都需要深度集成浏览器,难以在标准框架下实现。

结语

HTTP/2优先级机制是一个被设计为优化性能的特性,却因为实现复杂度成为了性能问题的来源。浏览器的策略分歧、服务器的缓冲陷阱、CDN的支持缺口——每一层都可能成为优先级失效的环节。

从工程角度看,理解优先级机制的价值不在于能够手动构建最优的依赖树,而在于能够诊断问题所在。当页面加载慢但所有指标都正常时,检查优先级是否工作可能是被忽视的最后一步。

随着HTTP/3的普及和RFC 9218的采用,优先级机制正在简化。但简化不等于问题消失——浏览器之间的实现差异依然存在,服务器缓冲区问题需要主动配置解决。优先级的隐形战争还将持续。

参考资料

  1. RFC 7540 - Hypertext Transfer Protocol Version 2 (HTTP/2)
  2. RFC 9218 - Extensible Prioritization Scheme for HTTP
  3. Wijnants, M., Marx, R., Quax, P., & Lamotte, W. (2018). HTTP/2 Prioritization and its Impact on Web Performance. WWW 2018.
  4. Meenan, P. (2018). HTTP/2 Prioritization. Web Performance Calendar.
  5. Marx, R. (2022). HTTP/3 Prioritization Demystified. Web Performance Calendar.
  6. Cloudflare Blog. Better HTTP/2 Prioritization for a Faster Web.
  7. Cloudflare Blog. Optimizing HTTP/2 prioritization with BBR and tcp_notsent_lowat.
  8. nghttp2 Blog. How Dependency Based Prioritization Works.
  9. Davies, A. HTTP/2 Prioritization Issues. GitHub Repository.