HTTP/2 推送之难,远超想象

阅读数:2806 2017 年 6 月 11 日

话题:GoogleChrome语言 & 开发架构

在讨论页面加载性能问题时,我经常听到有人说“HTTP/2 推送可以解决这问题”,但我对这个技术的了解不多,于是打算深入研究一下。

HTTP/2 推送远比我最初想象中更复杂,也更底层,但最让我措手不及的地方在于,这种技术在不同浏览器上的表现竟然有这么大的差别,本来我还觉得这技术已经足够成熟,可以在生产环境中使用了。

本文并不是那种认为“HTTP/2 推送一无是处”的吐槽文。我觉得 HTTP/2 推送真的很强大,以后还会更加完善,但并不算能解决所有问题的万灵药。

完整的 Fetch 路径

在页面和目标服务器间,横亘着一系列可能拦截请求的缓存和其他机制:

当人们希望尝试或向别人解释 Git 或其他看得见的东西时,通常会使用类似上面这样的流程示意图,在本来就懂的人看来,这样的示意图一目了然,但不懂的人往往会一头雾水。如果你也有这种感觉,那要先说声抱歉了!希望下文能帮你更好地理解。

HTTP/2 推送的工作原理

  • 页面:嘿 example.com,能把你的首页让我看一看吗?
  • 服务器:没问题!哦,在我给你发送首页的同时,还需要发送一些样式表、图片、JavaScript,以及一些 JSON。
  • 页面:额,好的。
  • 页面:我已经看到 HTML 了,但貌似还需要一个样式……哦,好像你已经发过来了,酷!

服务器响应请求时,可以顺便包含额外的资源。例如包含一系列请求报头,这样稍后浏览器就知道如何匹配不同报头。这些额外的资源位于缓存中,当浏览器请求的资源与缓存中的匹配时即可直接从缓存中获取。

这种方法可以改善性能,原因在于可以提前发送可能需要的资源,而不需要等待浏览器索取,因此可以提高页面加载速度。

多年来我对 HTTP/2 推送的了解仅限于此,听起来挺简单,但魔鬼往往隐藏在细节中……

任何东西都可以使用推送缓存

HTTP/2 推送是一种底层网络功能,用到网络栈的任何东西都可以使用该功能。但只有确保一致性以及可预测性,才能发挥最大作用。

我试着推送一些资源,并通过下列方式收集:

  • fetch()
  • XMLHttpRequest
  • <link rel="stylesheet" href="…">
  • <script src="…">
  • <iframe src="…">

为了解浏览器能否对正在推送中的内容进行匹配,我还降低了所推送资源主体的交付速度。这一过程中用到零零散散的测试套件已发布至GitHub

  • Chrome - 良好支持
  • Safari - 糟糕支持
  • Firefox - 良好支持
  • Edge - 部分支持

Edge使用 fetch()、XMLHttpRequest 或 <iframe> 无法从推送缓存中获取所需项(问题描述,含视频)。

Safari最奇怪。到底用或不用推送缓存,似乎是通过抛硬币决定的。Safari 遵从于 OSX 的闭源网络栈,但我觉得一些 Bug 是 Safari 自身造成的。似乎是因为打开了太多连接,导致被推送的项分散在不同连接中。这意味着只有足够好运,请求与推送使用了同一个连接,此时才能命中缓存,不过这就超过我的了解范围了(问题描述,含视频)。

所有浏览器(Safari 行事怪异时除外)都可以使用匹配的推送项,哪怕推送工作还在进行中。这一点倒是不错。

然而不幸的是,只有Chrome为此提供了开发工具方面的支持。开发工具的网络窗格可以告诉你哪些项是从推送缓存中获取的。

建议

如果浏览器无法从推送缓存中获取项,那么最终的速度甚至会比完全不进行推送更慢。

Edge 的支持很贫瘠,但至少也是“一致的”贫瘠。因此你需要通过 User-Agent 嗅探的方式来确保只向 Edge 推送能被它使用的资源。如果因为任何原因做不到这一点,那么更稳妥的方法是避免向 Edge 用户推送任何内容。

Safari 的表现有些缺乏必然性,但这个问题不是你能解决的。请使用 User-Agent 嗅探机制避免向 Safari 用户推送资源。

可以推送非缓存和非存储资源

为了使用 HTTP 缓存,项必须具备诸如 max-age 等属性,才能让浏览器无需与服务器重新验证而直接使用(这里有篇介绍缓存报头的文章)。HTTP/2 推送则不同,并不需要检查匹配项的“新鲜度”。

  • Chrome - 良好支持
  • Safari - 良好支持
  • Firefox - 良好支持
  • Edge - 良好支持

所有浏览器都表现出了相同的行为。

建议

一些单页应用由于不仅需要使用 JS,还需要在 JS 开始执行时获取其他数据(例如 JSON 之类的)才能顺利渲染,因此经常会遇到性能问题。此时最佳解决方案是服务器端渲染,但如果不可行,可以将 JS 与 JSON 和页面一起推送。

然而考虑到上文提到有关 Edge/Safari 的问题,更可靠的做法是使用内联式 JSON。

HTTP/2 推送缓存是浏览器需要考虑的最后一道缓存

推送项与 HTTP/2 共存,这意味着浏览器只会在之前的所有缓存(例如图片缓存、预载缓存、服务工作进程,以及 HTTP 缓存)都没有提供时才会使用 HTTP/2 的推送项。

  • Chrome - 良好支持
  • Safari - 良好支持
  • Firefox - 良好支持
  • Edge - 良好支持

所有浏览器都表现出了相同的行为。

建议

有个问题需要注意。举例来说,如果在 HTTP 缓存中有一个匹配项,并且按照其 max-age 来看这个项还很新鲜,但随后又推送了一个更新鲜的项,推送的项将被忽略,浏览器会继续使用 HTTP 缓存中那个较老的项(除非 API 因为任何原因跳过了 HTTP 缓存)。

身处“缓存链”的最末尾似乎不是什么大问题,但了解到这种缓存项与连接的共存,这帮助我理解了以前观察到的很多其他行为。例如……

如果连接关闭,那就跟推送缓存道别吧

推送缓存会与 HTTP/2 连接共存,因此连接关闭后缓存也会丢失。就算所推送的资源有很大可能会被缓存,依然会发生这种情况。

推送缓存位于 HTTP 缓存之后,因此只有在浏览器请求后,项才会进入 HTTP 缓存。此时项才能从推送缓存,途径 HTTP 缓存、服务工作进程等到达页面。

如果用户的连接不稳定,此时虽然可能成功推送某些资源,但会在页面恰好得到这些资源之前丢失连接。这意味着需要新建连接并重新下载资源。

  • Chrome - 良好支持
  • Safari - 良好支持
  • Firefox - 良好支持
  • Edge - 良好支持

所有浏览器都表现出了相同的行为。

建议

不要过于依赖在推送缓存里长时间保存的项。推送这种方式最适合紧迫性高的资源,推送资源,以及该资源最终被使用,这两个阶段间隔的时间越短越好。

多个页面可使用同一个 HTTP/2 连接

每个连接都有自己的推送缓存,但多个页面可使用同一个连接,这意味着多个页面可能共享同一个推送缓存。

在实践中这意味着,如果在响应导航操作的过程中推送了资源(例如一个 HTML 页面),这个资源将不会是该页面专用的(下文将使用“页面”这个词,但实际上也可用于代表其他可以获取资源的东西,例如工作进程)。

  • Chrome - 良好支持
  • Safari - 糟糕支持
  • Firefox - 良好支持
  • Edge - 部分支持

Edge似乎会为每个标签页使用一个新连接(问题描述,含视频)。

Safari会不必要地为同一个源创建多个连接,我很确信这就是造成各种怪异问题的根源(问题描述,含视频)。

建议

在伴随页面推送诸如 JSON 数据等东西时需要注意这个问题,因为最终使用这些东西的可能并不是同一个页面。

这种行为有一定好处,例如伴随一个页面推送的资源可以被安装中服务工作进程发起的请求所使用。

Edge 的行为虽然不是最优的,但目前还不需要担心。一旦 Edge 开始支持服务工作进程,这可能会成为一个问题。

再次提醒,我会避免向 Safari 用户推送资源。

不包含凭据的请求将使用单独的连接

本文会多次提到“凭据”。凭据是指浏览器发送的,代表特定用户身份的信息。通常这意味着 Cookie,但也可能意味着 HTTP 基本身份验证,以及连接层面的身份,例如客户端证书。

可以将 HTTP/2 连接想象成电话通话,一旦你说出自己身份,此次通话就不再是匿名的了,而你在自我介绍前说过的一切也都不再是匿名的。出于隐私方面的考虑,浏览器会为“匿名”请求设置一个单独的“通话”。

然而由于推送缓存是与连接共存的,因此在发起不含凭据的缓存后,可能会失去某些缓存的项。举例来说,如果伴随页面(使用包含凭据的请求)推送了资源,随后(使用不含凭据的请求)fetch() 该资源,这会新建一个连接,导致无法获取已推送的项。

如果跨源样式表(含凭据)推送了一个字体,浏览器的字体请求(不含凭据)将无法获得推送缓存中的字体。

建议

确保请求使用相同的凭据模式。大部分情况下,这意味着需要确保请求包含凭据,因为页面请求通常总是包含凭据的。

若要在包含凭据的情况下 Fetch,请使用:

fetch(url, {credentials: 'include'});

虽然无法为跨源字体请求添加凭据,但可以将其从样式表中删除:

<link rel="stylesheet" href="…" crossorigin>

……这意味着样式表和字体请求将使用同一个连接。然而,如果该样式表还应用了背景图片,这些请求将始终包含凭据,因此最终依然需要再建立一个连接。此时唯一的解决方案是使用服务工作进程,这样便可更改每个请求执行 Fetch 的方式。

我曾听开发者说不含凭据的请求性能更高,因为不需要发送 Cookie,但需要将这个问题与建立新连接的成本进行权衡。此外 HTTP/2 可对不同请求之间重复的报头进行压缩,因此 Cookie 其实算不得一个什么大问题。

也许我们可以更改一下规则

Edge 是唯一不遵守这些规则的浏览器。Edge 允许含凭据和不含凭据的请求共享同一个连接。然而这里就不像上面那样对每个浏览器进行对比了,因为我更希望看到相关规范以后能有所变化。

如果页面向源发起了不含凭据的请求,此时再建立一个连接的意义并不大。含凭据的资源发起了请求,因此也可以通过 URL 将自己的凭据添加给“匿名”请求(这也叫“环境权限:Ambient Authority”)。

其他情况我不是很确定,但如果你要对同一个服务器同时发起含凭据和不含凭据的请求,由于浏览器指纹的存在,基本上无法实现匿名。如果希望深入了解这个问题,可以参阅GitHub 上的讨论Mozilla 邮件列表,以及Firefox 的 Bug 跟踪器

嗯,这段内容的“行话”有点多,抱歉哈。

推送缓存中的项只能使用一次

一旦浏览器使用了推送缓存中的内容,该内容会从缓存中删除。虽然该内容最后可能会保留在 HTTP 缓存中(取决于缓存报头),但不会继续保存在推送缓存中。

  • Chrome - 良好支持
  • Safari - 糟糕支持
  • Firefox - 良好支持
  • Edge - 良好支持

Safari在这方面存在竟态条件的问题。如果一个资源在推送过程中被多次 Fetch,那么将多次获得推送的项(问题描述,含视频)。如果项推送完成之后被 Fetch 两次,此时的表现是正确的,第一次从推送缓存中获取,而第二次是从其他地方获取。

建议

如果决定向 Safari 用户推送资源,那么在推送非缓存资源(例如 JSON 数据)时需要注意这个 Bug。也许可以在响应中传递一个随机 ID,如果同一个 ID 收到两次,那么就可以确定自己遇到这个 Bug 了,此时可以等待一秒后重试。

一般来说,可以使用缓存报头或服务工作进程对 Fetch 完毕的已推送资源进行缓存,除非并不需要进行缓存(例如只需要进行一次的 JSON Fetch)。

如果已经具备,浏览器可能忽略推送的项

在推送内容时,其实并不需要与客户端进行太多协商。这意味着推送的某些内容可能已经位于浏览器的某个缓存中。这种情况下,HTTP/2 规范允许浏览器使用 CANCEL 或 REFUSED_STREAM 代码忽略传入的数据流,这样可以避免浪费带宽。

  • Chrome - 部分支持
  • Safari - 部分支持
  • Firefox - 糟糕支持
  • Edge - 良好支持

这个规范的要求并不严格,因此我觉得可以根据开发者的实际情况自行决定。

Chrome会拒绝被推送已经位于推送缓存中的项。Chrome 会使用 PROTOCOL_ERROR 而不是 CANCEL 或 REFUSED_STREAM 进行拒绝,但这并不是个什么大问题(问题描述)。然而 Chrome 不会拒绝已经位于 HTTP 缓存中的项。听说这个问题已经修复了,但我还没进行测试(问题描述)。

Safari会拒绝被推送已经位于推送缓存中的项,但前提是按照缓存报头(例如 max-age)的设置来看,推送缓存中的项依然是“新鲜的”,除非用户手工点击了“刷新”。这一点与 Chrome 有所不同,但我觉得也不算“错”。然而与 Chrome 类似,Safari 不会拒绝已经位于 HTTP 缓存中的项(问题描述)。

Firefox会拒绝被推送已经位于推送缓存中的项,但随后它会丢弃已经位于推送缓存中的项,什么都不留下!这使得缓存机制变得极为不可靠,难以辩解(问题描述,含视频)。此外 Firefox 也不会拒绝已经位于 HTTP 缓存中的项(问题描述)。

Edge不会拒绝被推送已经位于推送缓存中的项,但会拒绝已经位于 HTTP 缓存中的项。

建议

遗憾的是,就算浏览器提供了完美的支持,依然会在收到取消信息之前浪费带宽和服务器 I/O。此时可以通过缓存摘要 (Cache digest)提前告诉服务器哪些内容已经缓存。

同时也可以使用 Cookie 来追踪是否已经将可缓存的资源推送给用户。然而由于浏览器的“心血来潮”,项可能从 HTTP 缓存中消失,但 Cookie 是可以持久保存的,因此 Bookie 的存在并不一定意味着项依然位于用户的缓存中。

除了新鲜度,推送缓存中的项还可以使用 HTTP 语义进行匹配

上文介绍过,在对推送缓存进行匹配时会忽略内容的新鲜度(no-store 和 no-cache 项就是这样匹配的),但其实也可以使用其他匹配机制。我测试过 POST 请求和 Vary: Cookie。

更新:规范中提到,推送的请求“必须是可缓存的,必须是安全的,并且必须不能包含请求主体”,之前我漏掉了这些规定。POST 请求不能算是“安全的”,因此浏览器应当拒绝 POST。

  • Chrome - 糟糕支持
  • Safari - 部分支持
  • Firefox - 糟糕支持
  • Edge - 糟糕支持

Chrome可接受 POST 推送流,但似乎无法使用这种流(问题描述)。在匹配已推送项时,Chrome 还会忽略 Vary 报头(问题描述),但问题描述中提到可以配合 QUIC 使用。

Firefox会拒绝推送的 POST 流,然而在匹配已推送项时,Firefox 会忽略 Vary 报头(问题描述)。

Edge也会拒绝推送的 POST 流,但也会忽略 Vary 报头(问题描述)。

Safari与 Chrome 类似,可接受 POST 推送流,但似乎无法使用(问题描述)。但 Safari 会遵守 Vary 报头,是唯一一个有此行为的浏览器。

建议

其实我挺难过,除了 Safari,其他浏览器在处理推送项时都没有遵守 Vary 报头。这意味着我们可以推送某些专门针对某一个用户的 JSON,随后该用户注销,另一个用户登录,但如果之前为上一个用户推送的 JSON 尚未使用,后来的用户将能看到这些内容。

如果要推送只适用于一个用户的数据,请在响应中包含预期用户的 ID。如果随后发现 ID 与预期不符,可以再次发起请求(这样以前推送的内容将消失)。

在 Chrome 中,也可以在用户注销时使用Clear site data 报头。此外也可以通过终止 HTTP/2 连接的方式清理推送缓存中的项。

还可以为其他源推送项

作为 developers.google.com/web 的负责人,我们可以让自己的服务器推送包含 android.com 所需任何内容的响应,并设置缓存一年的时间。一个简单的 Fetch 就足以将其放入 HTTP 缓存。随后如果我们的访客访问了 android.com,他们就可以看到用粉红色 Comic Sans 字体显示的“NATIVE SUX – PWA RULEZ(原生弱爆了 – PWA 才是王道)”字样,或我们希望显示的任何内容。

当然我们不会那么做的,毕竟我们那么热爱 Android,我就是这么一说…… Android:如果你搞砸了 Web,我们会给你好看的。

好吧,开玩笑而已,但上述情况在技术上是可以实现的。实际上不能为任何其他源推送资源,但可以针对连接的“权威”源进行推送。

查看 developers.google.com 的证书会发现,它是所有 Google 源,包括 android.com 的权威。

https://youtu.be/i9uXp64KUcw

在 Chrome 中查看证书信息

其实上面的说法有点不准确,因为在 Fetch android.com 时,需要执行 DNS 查询,最终会将 developers.google.com 指向一个不同的 IP,因此需要新建连接并失去推送缓存中的项。

为了解决这个问题,可以使用ORIGIN frame,它可以让连接说:“嘿,如果你需要向 android.com 请求任何资源,问我要就行了,不需要搞 DNS 那些事”,只要 developers.google.com 是 android.com 的权威就行。这种特性非常适合一般的连接聚合 (Connection coalescing),但还是一种新技术,目前仅 Firefox Nightly 支持。

如果使用了 CDN 或其他某种类型的共享主机,还需要检查一下证书,看看应该从哪个源开始为你的网站推送内容。这个过程比较麻烦,不过好在(据我所知)没有任何主机能针对 HTTP/2 推送提供全面的控制能力,而规范中的下列注解使得这一点几乎无法实现:

如果有多个租户共用同一台服务器,服务器必须确保租户无法推送并非以自己为权威的资源表征。

HTTP/2 规范

这样做很有必要。

  • Chrome - 良好支持
  • Safari - 部分支持
  • Firefox - 支持情况未知
  • Edge - 支持情况未知

Chrome 可以让网站推送以自己为权威的其他源的资源。如果其他源解析为相同 IP 地址,则可以继续使用原有的连接,因此已推送的项可以直接使用。Chrome 目前尚不支持 ORIGIN frame。

Safari可以让网站推送以自己为权威的其他源的资源,但需要为其他源新建一个连接,因此推送的项其实永远不会被使用。Safar 不支持 ORIGIN frame。

Firefox会拒绝其他源的推送。与 Safari 类似,会为其他源新建连接,然而我在 Firefox 中跳过了有关证书的警告,因此结果可能不可靠。Firefox Nightly 可支持 ORIGIN frame。

Edge也会拒绝其他源的推送。同样因为跳过了证书警告,因此在证书没问题的时候结果可能有所差异。Edge 不支持 ORIGIN frame。

建议

如果在同一个页面上使用了多个源,但最终会指向同一台服务器,则可以考虑使用ORIGIN frame。一旦该技术得到广泛支持,将不再需要进行 DNS 查询,可改善性能。

如果你觉得跨源推送的好处更大,建议写几个比本文更好的测试,确保浏览器可以真正使用你所推送的内容。否则可以通过 User-Agent 嗅探机制推送给不同浏览器。

推送还是预载

除了推送资源,我们还可以使用 HTML 让浏览器预载资源:

<link
  rel="preload"
  href="https://fonts.example.com/font.woff2"
  as="font"
  crossorigin
  type="font/woff2"
>

或者使用页面头:

Link: <https://fonts.example.com/font.woff2>; rel=preload; as=font; crossorigin; type='font/woff2'
  • href – 要预载的 URL。
  • as – 响应的目标,这意味着浏览器可设置恰当的报头并应用相应的 CSP 策略。
  • crossorigin – 可选,代表这是一个 CORS 请求。CORS 请求可在不含凭据的情况下发送,除非使用了 crossorigin="use-credentials"。
  • type – 可选,如果所提供的 MIME 类型不被支持,可以让浏览器忽略预载。

浏览器看到预载链接后,将会进行 Fetch。该功能类似于 HTTP/2 推送,但:

  • 一切均可预载。
  • no-cache 和 no-store 项也可以预载。
  • 只有凭据模式相同的情况下,请求才能与预载项相匹配。
  • 缓存的项只能使用一次,不过可以放在 HTTP 缓存中以备随后 Fetch。
  • 除了新鲜度,还可以通过 HTTP 语义进行项的匹配。
  • 可以预载来自其他源的项。

此外还有下列不同之处:

浏览器会 Fetch 资源,这意味着浏览器会按顺序从服务工作进程、HTTP 缓存、HTTP/2 缓存,以及目标服务器中获取响应。

预载的资源将独立于页面(或工作进程)存储。这意味着预载资源将会是浏览器首先检查的缓存(先于服务工作进程和 HTTP 缓存),连接丢失不会导致预载项丢失。与页面之间的直接链接也意味着如果预载项未使用,将能通过开发工具看到包含有用提示的警告信息。

每个页面都有自己的预载缓存,因此对打算用于其他页面的资源进行预载是没意义的。例如,我们无法对加载其他页面时需要的资源进行预载。此外对服务工作进程安装中使用的页面进行预载也是没意义的,服务工作进程并不会检查页面的预载缓存。

  • Chrome - 部分支持
  • Safari - 部分支持
  • Firefox - 不支持
  • Edge - 不支持

Chrome不支持使用任何 API 进行的预载。例如 fetch() 不会使用预载缓存,虽然 XHR 可以使用,但前提是需要带凭据发送(问题描述)。

Safari只有最新的技术预览版支持预载,fetch() 不使用预载缓存,XHR 也不使用(问题描述)。

Firefox不支持预载,但相关实现正在进行中(问题描述)。

Edge不支持预载。如果希望支持,请来这里投票

建议

再完美的预载,其速度也要略微慢于完美的 HTTP/2 推送,因为推送不需要等待浏览器发出请求。然而预载方式的实现更简单,也更易于调试。建议目前继续使用预载,因为浏览器对预载的支持只会越来越完善,但为了确保预载项能够被正常使用,也需要留意开发工具提供的信息。

一些服务会将预载的报头转换为 HTTP/2 推送。我觉得这种做法是错误的,毕竟这两种做法之间还有一些微妙的差异,但未来一段时间里也许只能如此。然而你需要确保这些服务能将报头从最终响应中剔除,否则可能面临竟态条件,预载先于推送进行,导致带宽占用翻倍。

推送技术的未来

目前 HTTP/2 推送技术还存在一些相当麻烦的 Bug,可一旦这些问题顺利解决,我认为对于目前需要内联的资源,尤其是对渲染结果至关重要的 CSS 来说,该技术将成为最理想的选择。缓存摘要功能顺利实现后,我们将有可能同时从内联以及缓存机制中获益。

哪种做法更恰当,这取决于服务器是否足够智能,可以让我们为内容的流式传输确定最合适的优先级。例如我希望最重要的 CSS 能够与页面头一起并行传输,但 CSS 优先级最高,毕竟 CSS 没有发送完成的情况下,推送暂时无法渲染的主体内容,这也是对带宽的浪费。

如果你的服务器响应速度略慢(由于开销不菲的数据库查询或其他因素),也可以利用这段时间推送页面可能需要的资源,随后在页面可用后更改优先级。

之前已经说过,本文并不是为了吐槽哪种技术,希望你也不要有这样的误解。HTTP/2 推送有助于改善性能,但建议只有在妥善测试后再考虑使用,否则可能会让速度变得更慢。

感谢Gray NortonAddy OsmaniSurma的审校和反馈。

作者Jake Archibald阅读英文原文HTTP/2 push is tougher than I thought