编者按 / 相对于HTTP2,HTTP/3的优先级更加简单,浏览器厂商更可能实现统一的优先级策略。本文来自老朋友Robin Marx,已获授权转载,感谢刘连响对本文的技术审校。
翻译 /  核子可乐
技术审校 / 刘连响

原文链接 / https://calendar.perfplanet.com/2022/http-3-prioritization-demystified/
如果大家做过Web性能调优,可能会听说过HTTP资源优先级。这波趋势从去年开始风头愈劲,因为Chromium通过新的fetchpriority属性引入了所谓“优先级提示”,允许开发者进行优先级调整。另外,HTTP/2和HTTP/3之间的优先级系统也发生了变化。
但是,这个优先级究竟是什么?它是怎么起效的?为什么有必要加以控制?而且最关键的,是不是所有浏览器都对资源的重要性拥有共识(当然不是)?顺着这个思路,我们就一起说道说道:
  • 优先级究竟是什么?
      • 资源加载延迟
  • 优先级信号
  • 浏览器差异
      • 原始协议细节
      • 主要结论(可能是您最感兴趣的部分)
  • 服务器差异
  • 总结
注意:HTTP优先级是个非常宽泛的话题,甚至专门写篇博士论文都没问题。所以在本文中,我刻意忽略掉了很多细微差别,只专注讨论几个关键点。
01 优先级究竟是什么?
HTTP资源优先级,属于主要面向HTTP/2(H2)和HTTP/3(H3)的概念。在HTTP/1.1(H1)中,浏览器往往会打开多个TCP连接(每个域最多6个),且每个连接每次仅加载1个资源/文件。这里的优先级隐式存在,代表在可用连接上首先请求资源。
但在H2和H3这边,我们目标是仅使用单一TCP/QUIC连接来提高效率。但如果单一连接也只能像H1那样每次只有一个资源处于“活动”,那肯定不利于性能表现。所以H2和H3可以同时发送多个请求。但请千万注意,这并不是说这多个请求就一定能同时得到充分响应。因为在任意给定时间,连接所能发送的数据量都受到拥塞和流量控制等因素的限制。
图一:典型的拥塞控制算法对慢启动管理得很严,对后续延迟增长则表现得较为宽容。
特别是在连接启动时,我们只能在每次网络往返中发送有限数量的数据,因为服务器需要等待浏览器确认其已成功接收到每波突发数据。也就是说,服务器需要选择到底先响应多个请求中的哪一个。
举例来说,浏览器已经加载了.html页面,并在一个H2/H3连接上同时请求了3种资源:一个被延期的JS文件(100 KB)和两个.jpg图像(分别为300和400 KB)。假设服务器在此次往返中只能发送50 KB数据(约35个数据包,具体数字取决于各种因素),那现在就得决定怎么填充这35个数据包了。所以,到底该首先发送什么?
我们可以说JS总是最重要的(高优先级),哪怕存在延期也是。或者,我们也可以说其中一张图像可能是Largest Contentful Paint(审校者注:LCP,即最大内容绘制,用于监控网页可视区内“绘制面积”最大的元素开始呈现在屏幕上的时间点。)的候选对象,所以应该优先发送其中一张(但到底是哪张?)。我们甚至可以说jpg图像可以逐步显现,所以分别给每张图像25 KB的配额(这就是所谓资源数据的「交错」或多路复用)。但如果延期的JS就是实际加载LCP图像的主体……那是不是也得给它分配点带宽?
图二:可以用多种方式为三种资源分配带宽。
很明显,这里服务器做出的选择会对各项Webperf指标产生重大影响,毕竟某些资源数据将始终被置于“更高优先级”的“重要”数据之后。如果先发送JS,那么图像会延迟1次或经历多次网络往返,反之亦然。这一点对(渲染阻塞)JS和CCS的影响尤其明显,因为其需要完整下载后方可应用/执行。
图三:如果资源以交错/多路复用方式加载(上方),则会拖慢其完全加载的速度。
注意:这就是我反对人们总说什么H2和H3允许并行发送多个资源的理由,因为这根本就不叫真正的并行!H1反而比H2/H3并行得多,因为前者有6个独立连接。充其量,H2/3数据只能算在线路上交错或多路复用(例如将配额分别给予两张图像),但常规响应仍是按顺序发送(先是完整加载第一张图像,之后是第二张)。所以作为一个“学究”,我还是更倾向于并发资源(或者叫并行请求和多路响应)。
这里的问题在于,服务器并没有足够的信息来做出最佳选择。事实上,它甚至不知道JS文件在HTML被标记为延期(defer),因为浏览器的HTTP请求中并不包含这段上下文(而且服务器往往不会亲自解析HTML来发现这些修饰符)。同样的,服务器也不知道图像是否立即可见(例如,在viewport中)或者尚未可见(用户需要向下滚动才能看到轮播中的第二张图)。至于新鲜上架的fetchpriority属性,服务器更是闻所未闻。
所以拥有足够上下文来决定该先加载哪种资源的,其实是浏览器。它需要一种方式将首选项传递给服务器,而这就是HTTP优先级机制的由来:一种浏览器向服务器发送资源请求具体顺序的标准化方式。
资源加载延迟
这里要提醒大家,优先级并不是影响实际资源交付顺序的唯一因素。毕竟优先级决定的仅仅是如何处理同时处于活动状态的多个请求。有些朋友可能以为,对于HTTP/2和HTTP/3,浏览器可以在HTML中发现资源后立即提出请求,再单靠优先级排序来获得正确的响应。不好意思,做不到的。
实际上,所有浏览器都或多或少具备一些(高级或基础)逻辑,用于主动延迟某些请求,即使在发现资源之后也是如此。举个简单的例子,预取资源通常会在<head>中的<link>元素中指示,但仅在当前页面加载完成时由浏览器请求。
另一个例子就是Chromium的“紧凑模式”,它会主动延迟掉不太重要的资源(例如HTML中的图像、CSS和JS),直到(大部分)更重要的资源加载完成。再有,同时激活的预加载资源也有限制。
我们可以通过以下瀑布图看到,部分资源即使被发现得更早,也只会在一段时间后才被请求。
图四:在Safari中,这些资源在被发现后也不会被同时请求。
在实践当中,浏览器同时发送哪些请求、各请求之间的优先级关系间存在着相当复杂的相互作用。受篇幅所限,本文就不过多讨论了。当然,我接下来要介绍的测试方法确实可以实现这种影响分析,这也是我个人的下一步研究方向。
02 优先级信号
查看浏览器开发工具中“Network”选项卡下的优先级列(参见上图四),就会看到由高到低(或者类似排序)的文本数值。您可能觉得这就是发送至服务器的内容,但很遗憾,情况并非如此。
特别是在HTTP/2中,有一套更高级的系统在起作用,就是所谓“优先级树”。在这里,资源以树状数据结构排列。资源在树中的位置(父项和兄弟项分别是什么)与所关联的“权重”会影响其何时获得带宽、获得多少带宽。在请求资源时,浏览器会使用特殊的附加HTTP/2消息(PRIORITY帧)向服务器表达该资源在树中的位置。
图五:Firefox使用复杂的HTTP/2优先级树。
这套系统如此灵活和强大,但事实也证明其非常复杂,甚至是过度复杂。以至于即使在今天,不少HTTP/2在具体实现上都有严重的错误,另一些堆栈则根本无法实现(直接忽略浏览器的信号)。不同浏览器使用该系统的方式也存在巨大差异。
因此在HTTP/3当中,我们才决定建立一套更简单的系统,这最终成了RFC 9218:HTTP的可扩展优先级方案。这种方法没有完整的树状结构,只有8个数字的优先级(即「紧急度」,取值在0到7之间)外加一个“增量”布尔标记,用于指示资源能否与其他资源交错(渐进式jpg:可以交错;渲染阻塞JS:最好不要交错)。默认情况下,资源的紧急度为3且非增量。
图六:新系统使用两个参数——紧急度与增量
其中的概念非常简单:服务器应首先发送具有最高非空优先级组内的所有资源(u0应在u1前处理等),之后再继续下一个组。因此只有还有一种u=0的资源处于活动状态(而且我们有数据要发送给它),就不应为其他紧急组发送数据。在同组之内,资源则按请求顺序发送(由先到后),因此<head>中靠前的JS会在<head>中靠后的JS前交付。
注意:如果一个紧急组内只有增量或非增量资源,那么情况当然很简单(审校者注:“incremental”没有非常恰当的中文说法,暂且译成“增量”。)。而一旦增量与非增量资源混合起来(比如前文示例,一个非增量JS和两张增量图像均在u=3组内),处理难度就上来了。我们要不要先把完整的JS发送完(因为它是非增量的)?还是说,我们该为图像提供部分先期带宽(因为它们的优先级相同,而且可以增量发送)?很遗憾,RFC本身并没有给出具体指导,因为每种选项都有利弊。
新系统在发送紧急和增量信号的方式上也更简单:这里使用的并非特殊的HTTP/3消息,而是名为priority的新文本HTTP标头。这种总体更简单的方法降低了实现和调试难度,而且有望带来比H2系统更好的支持并减少bug(剧透一下,其实也还做不到)。
图七:新系统使用新的“Priority”HTTP标头。
但有时候好想法就只是个想法。实际HTTP标头只能用于表达资源的初始优先级,一旦稍后需要更新优先级(比如延迟加载的图像最初获得低优先级,但在滚动至视图内时需要切换至高优先级),那单靠HTTP标头就实现不了了。为此,我们还是需要特殊的H3(和H2!)二进制消息:PRIORITY_UPDATE帧。这些细节对大多数人来说并不重要,但我还是想做做强调,毕竟HTTP标头有Firefox和Safari在用,但Chromium却根本没用。出于“种种原因”,谷歌只使用PRIORITY_UPDATE框帧来表示初始优先级(会立即覆盖掉默认优先级)。
需要注意的是,这种新方案也并非HTTP/3所独有。其目标就是随时间推移,将功能移植到现有H2实现当中(但据我所知,目前还没有H2堆栈实际采用)。另外,它之所以被称为“可扩展”的优先级系统,是希望能在未来引入“紧急度”和“增量”以外的更多其他参数。
03 浏览器差异
正如前文提到,不同浏览器使用复杂HTTP/2系统的具体方式也有很大区别。各种主流浏览器引擎(Chromium、Firefox、Safari等)会生成截然不同的优先级树和信号。
但在接下来的部分,我将只关注HTTP/3上的新系统,毕竟所有三种主流浏览器都能支持。我想搞清它们在新系统的实现方法上是否还有差异。但经过检索,我发现只有Chrome发布了关于具体方法和逻辑的开放文档,而Safari和Firefox那边压根没有任何研究资料。所以,我只好亲自动手了!
开始之后,我马上遇到了两个问题:
  1. 新信号的观察非常困难,因为目前还没有相应的支持工具。这些信号不会以原始形式出现在浏览器的开发工具中,也不会出现在WebPageTest内。所以我决定修改aioquic HTTP/3服务器,为优先级信号添加一些额外的日志记录。
  2. 我四处查看,但没有哪个测试页面能包含所有可能影响优先级的全部资源加载项(异步/延期、懒加载/急加载、fetchpriority、预加载/预取等)。所以我创建了自己的测试页面,其中包含多达36种不同的情况。
之后,我在自定义HTTP/3服务器上托管了自定义测试页面,并分别用三款浏览器进行加载。我保存了来自浏览器的HAR(审校者注:HAR即HTTP Archive format, 一种HTTP请求存档格式。)文件和来自服务器的日志,想搞清楚浏览器通过网络到底发送了什么内容。感兴趣的朋友可以在GitHub上(https://github.com/http3-prioritization/prioritization-experiments)查看完整信息,接下来我只谈其中比较重要的部分。
原始协议细节
下面,我会从原始底层结果开始讨论。如果大家不关心细节,可以直接跳往下一节阅读宏观层面的解析。
图八:部分原始结果。
首先,如前所述,Chromium只使用PRIORITY_UPDATE框架,而未使用HTTP标头。Firefox和Safari则相反,仅使用标头。受测试页面的性质决定(仅包含初始加载),所以我无法观察浏览器是否真的发送了更新。但在原理上,Chromium肯定会为图像执行此操作(先将其视为低优先级,之后在图像需要可见时再更新为高优先级)。在这种情况下,Chromium只会发送2个PRIORITY_UPDATE帧,其一用于初始优先级,其二用于后续实际更新。再聊点纯技术细节:初始PRIORITY_UPDATE的发送次序在HTTP标头之前。
第二个重要区别,就是增量参数的使用。Chromium和Firefox会默认将其设置为“关闭”,就是说服务器不会在多个资源间分配带宽;最重要的资源必须先被完全加载,之后再转往下一个。Safari则相反:它会为所有资源类型设置增量参数,包括渲染阻塞的JS和CCS资源。但前面也提过,这两种作法其实各有问题。
另外需要强调一点,Chromium的执行逻辑已经有了明确的调整计划。据我所知,后续更新会为Chromium内的所有内容设置增量参数,除了(高优先级的)JS和CSS文件。这有助于解决一些长期存在的错误,比如大规模HTML下载可能无端导致渲染阻塞CSS发生延迟。开发团队显然意识到Chromium采取的纯按序加载机制的缺陷。更重要的是,这意味着与HTTP/3相比,Chromium会对HTTP/2使用不同的优先级逻辑(HTTP/2也未使用类增量参数机制)。视具体设置而定,这项调整计划可能会对页面产生很大影响,所以建议大家尽快切换至HTTP/3。最后需要注意的是,Firefox也有类似的情况,已经在HTTP/2中使用增量信号,但在HTTP/3中却没有。
第三,不同览器间的信号使用方式也有细微差别。例如,Chromium和Firefox不会明确发送“u=3”的信号,因为这是服务器所遵循的默认值。但Safari却会明确发送“u=3,i”。此外,浏览器使用的紧急度值也有不同。在内部,Chromium和Safari使用的是5档优先级(分为:最高、高、中、低、最低),而Firefox似乎只使用4档。Chromium的紧急度数值为(0,1,2,3,4),Safari分得更开(0,1,3,5,7),Firefox则直接跳过了0(1,2,3,4)。这应该不会影响服务器的资源交付方式(紧急度值的比较关系更重要,具体使用哪个数字其实无所谓),但有趣的是,即使是这样一套简单系统,市面上也出现了三种不同的实现方法。
对于以下主要结论,我选择最高、高、中、低、最低的5档紧急度划分方式(Firefox只到4,所以不存在「最低」这档)。
主要结论
跟大家预想的一样,除了协议细节之外,浏览器为不同资源类型和加载方法分配的重要度也有很大差异。下面我交讨论几组资源类型(HTML与字体、CSS、JS、图像、提取)以及三款浏览器间的一些主要区别。请注意,我会尽量避免去评判哪种方法更好,毕竟这取决于特定页面设置和使用到的功能。希望大家尽量把我给出的结论,当作在不同浏览器之上开展性能调优的参考意见。
幸运的是,三款浏览器都认为主HTML文件是最重要的(虽然实际上也未必)。而在字体方面,三者的权重就不同了。Chromium认为字体跟HTML本体一样重要,而Safari和Firefox则将其置于中甚至是低优先级。特别是跟CSS和JS街霸减仓资源的优先级进行比较后,我们发现Chromium对字体的重视程度确实远超另外两款浏览器。字体预加载也很有趣:在这方面,Chromium实际上是降低了优先级(可能是因为预加载对应的是将要需要、而非立即需要的资源)。Firefox则采用相反的逻辑,为预加载字体分配了更高的优先级。
对<head>中CCS进行加载的关键区别在于,Chromium会将其视为最高优先级,等同于HTML文件;而其他两种浏览器则将全部CSS都视为“高”类别。对于HTML中排序靠后的CSS(在我的测试用例中甚至是垫底位置),Chromium有趣地将其列入“中”类别(这是对的,毕竟它并不是真正的「渲染阻塞」)。所有浏览器都准确将带有media="print"的CSS放置在了最低优先级(除了Firefox,因为它没有「最低」这项)。
至于JS,几乎可以说是随处优先。所有浏览器都要求积极处理<head>中的沉浸阻塞JS。而除此之外,对于预加载JS,只有Firefox调低了其优先级(可能是采用了Chromium的字体处理逻辑),而且所有浏览器都正确地显著下调了预取JS的优先级。但异步/延期部分有点奇怪,Chromium和Firefox都为其分配了相同的优先级。就个人而言,我觉得延期的定义就该决定它优先级更低。Safari就更奇怪的,它反倒把延期的优先级设得比异步更高(我真的完全理解不了这一点)。另外,Safari给稍后加载的JS分配了与<head>内相同的优先级,Chromium和Firefox则认为前者应该比<head>内的JS优先级低。最后,Safari几乎把所有CSS和JS都划入了“高”优先级序列,意味着不那么重要的文件也可能拖慢关键资源,特别是在Safari对所有请求都使用“增量”参数的背景下。
对于图像,各浏览器的思路比较一致。但其中有个有趣的例外,即Safari调低了懒加载隐藏图像的优先级(我这个测试页面中display: none <div>内的普通<img>),但Firefox和Chromium则认为其优先级应该与可见图像相同。另外还有两个明确的例子,能够说明单纯强调优先级还不够:预加载和懒加载都根本不会影响到图像优先级!这些功能只在实际请求资源时才会起效(详见前文),所以我们才需要新的FetchPriority属性,这实际上对优先级机制起到了增强作用(Chromium已经迈出了这一步,有报道称Firefox也在努力兼容)。最后,测试页面中还包含隐藏的懒加载图像,各浏览器均未对其发出请求。
作为测试的收尾,我打算试试用JavaScript fetch() API发出的请求的优先级。在这部分,不同浏览器的表现又出现了重大分歧。Chromium认为这些请求非常重要,而Firefox则默认将其划入“低”优先级(等同于图像,甚至是预取)。我还测试了从延期JS文件内(第二行)执行fetch() ,并猜测Chromium会给它设置较低的优先级(跟延期JS本身保持一致),但结果并非如此。这再次凸显出fetchpriority属性的重要意义,它能有效帮助Chromium实现优先级下调。
之后,我又想到覆盖掉优先级信息。毕竟在新系统中,这是靠HTTP标头完成的,我们可以在fetch()调用中设置自定义标头!不出所料,在手动发出priority: u=0,1 这条标头后,三款浏览器又做出了彼此不同的反应。
图九:不同浏览器在处理自定义优先级HTTP标头时的差异。
Chromium会同时发送Priority_update帧加自定义标头。Firefox只发送两条priority标头字段:本身,再加上来自fetch()的字段。我不敢100%确定,但我猜HTTP RFC应该不允许这种作法吧。毕竟两个值是矛盾的,所以不清楚服务器应该选择使用哪个值(在上表中,我选择了Firefox的u=4默认值)。最后,Safari用我们传递给fetch()的一个标头覆盖了自己的标头,这可以算是“正确”(至少符合预期)的反应。
总体而言,我对浏览器允许手动设置标头感觉有点意外。之前这个问题就在IETF上激起过讨论,而且Chromium还明确表示反对。但理论上讲,我们能利用这项功能通过自定义优先级实现细粒度的资源加载——比如单页应用程序,应该会很有趣!
很明显,不同浏览器在某些资源类型的重要度方面存在重大意见分歧。但这本来可以统一起来的,毕竟其实现根本就不依赖于浏览器,而是(HTML)加载语义。从表象上看,各家浏览器厂商好像就是一拍脑袋就定下了这些规则,根本就没有实证数据作为支持(也没有正确记录)。好了,吐槽就到这里,马上进入下一环节。
04 服务器差异
如大家所见,即使是在新的和比较简单的系统当中,不同浏览器向服务器发送优先级信号的方式和思路也有很大差别。但对服务器来说,这些都不重要:只要按浏览器发来的要求执行,尽可能照章办事就行了。但真是如此吗?
注意:操作并不总是严格按照信号进行的。特别是在较为复杂的设置中(比如使用CDN),某些优先级更高的资源可能暂时没有可用数据(例如尚未被缓存在边缘位置),这时候先发送优先级较低的资源反而更有意义。
同样,我发现还没人对已经非常成熟的HTTP/3服务器进行过优先级支持性调查(前不久,才刚刚有人对HTTP/2服务器做过这方面调查)。靠人不如靠己,我还是决定亲自上场。
很遗憾,得到的结果不怎么好。我测试的服务器几乎没有一个能完全遵循哪怕是比较简单的优先级信号,而且大多数在处理更复杂的信号组合时还出了严重问题。
但这里我不打算把这些服务器的名号报出来,毕竟这种羞辱性的展示没啥建设性。相反,我直接联系到服务器开发团队,探讨该如何解决问题。再过半年或者一年,也许我们可以重新回顾这个话题并分享后续进展。
以下列出的是我观察到的不良行为,各截图均来自Chromium加载的原始测试页面:
图十:在相同浏览器内加载同一页面时,HTTP/3服务器的不同表现。
如您所见,服务器并不总是根据浏览器的信号接收数据,这当然会对某些Webperf指标产生影响。如果大家已经在尝试使用HTTP/3,但得到的结果达不到预期,也许原因就在这里。
就个人而言,我很难理解为什么会存在这些问题。HTTP/2服务器之所以表现不佳,一大原因就是HTTP/2的优先级树难以正确实现。可以说,新的HTTP/3系统应该更容易做对,可我们在所谓的成熟实现中还是碰上了影响很大的bug……算了,懒得抱怨了😉
05 总结
本文的中心,就是展示不同浏览器在通过HTTP加载资源的具体方式上存在巨大差异(优先级排序只是其中一例)。根据具体页面设置,您可能在不同浏览器间发现中等或较大的Webperf差距,大家应该了解这些差距的由来以应对可能出现的极端状况。
另外,本文还希望向大家展示新的“优先级提示”及fetchpriority属性的意义。它们不仅能更改浏览器的默认行为,还能跨不同浏览器实现更统一的反应方式(如果Firefox和Safari愿意接纳这些新元素)。
最后,从更广泛的“软件工程”层面来讲,我发现了一个有趣的现象:再简单的系统,也不一定就能保证跨平台间的行为一致,也不能保证堆栈自设计之初就不存在bug。尽管问题多多,我还是为自己参与新的HTTP/3系统的设计工作而自豪。我认为这是朝着正确方向迈出的一步,也希望新成果能被向下移植到HTTP/2实现当中。
鸣谢
特别感谢Barry Pollard、Lucas Pardue和Patrick Meenan为本文撰写提供的帮助。
对“HTTP/3优先级揭秘”的四条回应
1.Web Performance Calendar » The Web Performance Engineer’s Swiss Army Knife December 26th, 2022
[…] 随着HTTP/3的推出,HTTP/2安享的静好岁月被打破了。Robin Marx之前曾发文讨论过优先级排序中的一些细微差别 […]
2.Web Performance Calendar » LCP attribution: a fetchpriority breakdown December 30th, 2022
[…] 除了通过web vitals attribution了解关于fetchpriority的信息,我还再次体会到HTTP优先级排序确实是件复杂的事情。[…]
3.HTTP/3 Prioritization Demystified - My Blog January 3rd, 2023
[…] 了解更多 […]
4.Andrew January 3rd, 2023
“注意:这就是我反对人们总说什么H2和H3允许并行发送多个资源的理由,因为这根本就不叫真正的并行!H1反而比H2/H3并行得多,因为前者有6个独立连接。充其量,H2/3数据只能算在线路上交错或多路复用。”
其实这跟H1并没有区别。假定客户端有一条互联网连接,来自这6个“独立”H1连接的数据包必须在这条线路上交错,以便沿单一通道传输。二者的不同之处在于:H1上的序列化是由我们无法控制的下一跳路由器在无形中完成的,而H2/H3则是通过客户端和服务器间的协作端到端完成的。
了解LiveVideoStackCon 2022北京站详情请扫描图中二维码或点击阅读原文了解大会更多信息。
继续阅读
阅读原文