【译】优化高延迟环境


上周,我在LinkedIn上发布了一条关于CrUX新RTT数据的简短更新。可以去看看——了解背景会有帮助。

最近,Chrome开始在Chrome用户体验报告(CrUX)中添加往返时间(RTT)数据。这为我们提供了关于访客网络拓扑的有趣见解,以及我们可能会受到高延迟区域影响的程度。

什么是RTT?

往返时间(RTT)基本上是延迟的衡量标准——从一个端点到另一个端点再返回花了多长时间?如果你曾经通过机上WiFi对www.google.com进行过ping操作,你就测量过RTT。

延迟是网络上的一个关键限制因素:考虑到大多数网页请求的资源相对较小(与下载软件更新或流式传输电影相比),我们发现大多数体验受到延迟的限制,而不是带宽的限制。

往返时间还测量了在该过程中出现的中间步骤,例如传播延迟、传输延迟、处理延迟等。这些中间步骤超出了本文的讨论范围,但如果你曾经运行过traceroute,你就走在正确的方向上了。

CrUX的RTT数据来自哪里?

RTT旨在用更高分辨率的时间信息取代有效连接类型(ECT)。因此,重要的是要意识到RTT数据并不是衡量访客对你网站的延迟,而是他们总体的延迟。RTT不是你网站的特性,而是访客的特性。这与说某人来自尼日利亚、某人使用手机或某人处于高延迟连接并无不同。

你不能改变某人来自尼日利亚,不能改变某人使用手机,也不能改变他们的网络状况。RTT不是“你”的问题,而是“他们”的问题。

RTT数据应被视为一种见解而非衡量指标。如果你发现有大量用户处于高延迟连接,你就需要相应地构建你的应用程序。这正是本文要讨论的内容。

如何查看RTT信息?

由于RTT数据的引入还处于初期阶段,查看它并不像其他CrUX数据那样简单。不过,我们有几种方法可以查看——其中一些确实更容易且免费。

CrUX API

要查看给定来源的第75百分位RTT数据,你可以使用CrUX API:

1
2
3
curl "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=<KEY>" \
--header 'Content-Type: application/json' \
--data '{"origin": "https://website.com", "formFactor": "DESKTOP", "metrics": ["round_trip_time"]}'

https://website.com 和DESKTOP替换为相关输入。对于我的网站,我可以看到我的移动RTT为144ms,桌面RTT为89ms——这个差异我想我们不会感到惊讶。

Treo

如果你还没有Treo账号,那你真的错过了。去注册一个吧。它是一个神奇的工具,让性能工程师的生活变得更加轻松(也更有趣)。Treo已经开始在URL级别添加RTT数据,这非常令人兴奋:

RTT按从慢到快排序。注意这些URL中的一些国家:这个客户确实有一个国际化的受众群体,延迟指标对我来说非常重要。
同样,由于RTT是一种特性而非衡量指标,Treo做了一个明智的决定,将其包含在设备仪表板中,而不是加载仪表板中。

Tame the Bots

Dave Smart在他的网站Tame the Bots上构建了一个很棒的CrUX历史数据可视化工具——你可以在那里试试看,查看来源级和URL级的CrUX数据,包括新的RTT。

一个特别好的一点是他将RTT与TTFB(首次字节时间)进行对比——记住,首次字节时间包含一次往返。

有趣的是,延迟仅占我整体TTFB指标的一小部分。

##为高延迟环境优化体验
在我们深入探讨之前,我想重申一下,本文讨论的是优化高延迟体验的常规方法——不是改善CrUX数据集中的指标。接下来讨论的是基于延迟设计的最佳实践建议。

这一部分介绍了一些机会改进措施,希望能改善延迟限制用户的体验。

减少传输大小

简单来说……

网络服务器并不会一次发送整个文件——它们会将文件分块,然后逐个包发送。这些包会在客户端重新组装。每个包都有其自己的RTT生命周期(尽管不一定同步)。这意味着需要更多包的大文件将会产生更多的往返时间——每个往返时间就是延迟。文件下载速度将是带宽和往返时间的函数。

如果你希望资源在高延迟连接上加载得更快,减小它们的大小仍然是个合理的想法,尽管随着文件大小的增加,文件大小通常更多与可用带宽相关。

使用CDN

减少往返时间的最有效方法之一是缩短距离。我有一个客户位于布拉格,他们的网站也在同一个城市内的本地服务器上托管。他们目前没有CDN,但他们确实从全球各地吸引了大量流量:

与受众群体在地理上更接近是迈向正确方向的最大一步。
查看他们的受欢迎度排名,他们在某些撒哈拉以南国家的受欢迎程度甚至高于他们自己的国家捷克!为这个客户设置CDN(可能是Cloudflare)是我在这个项目中的首要任务之一。

除了提供大量(咳咳)其他性能和安全功能外,使用CDN的主要好处就是地理位置的接近。数据传输的距离越短,速度就越快。

如果你还没有使用CDN,那么你应该使用。如果你已经在使用,可能在接下来的部分中会免费获得某些功能……

使用快速的DNS提供商

新访客访问你的网站时要做的第一件事是通过域名系统(DNS)解析IP地址。作为网站所有者,你可以在一定程度上选择谁作为你的权威提供者。Cloudflare负责管理我的DNS,他们是最快的提供者之一。如果可能的话,请确保你使用的提供商在排名中名列前茅。

升级到HTTP/2

网络上超过75%的响应是通过HTTP/2传输的,这非常棒!如果你属于剩下的25%,你应该优先处理这个问题。通过迁移到CDN,你可能会获得HTTP/2作为标准功能,一石二鸟。

HTTP/1和HTTP/2都通过传输控制协议(TCP)运行。当两个HTTP端点想要通信时,它们需要通过三次握手建立连接。这几乎全是纯延迟,应该尽量避免。

如果我们以我网站当前的144ms移动往返时间为例,建立一个TCP连接将会像这样:

TCP 更准确地来说是 SYN 和 ACK 的组合,但这超出了本文想要说明的范围。

一个完整的往返时间(144ms)才能发送页面的 GET 请求。

HTTP/1.0 的一个低效之处在于每个连接只能满足一个请求-响应周期,这意味着请求多个文件(如大多数网页所需)是非常慢的。

为了解决这个问题,HTTP/1.1 允许同时打开多个连接到服务器。这个数量有所不同,但通常认为是六个。这意味着客户端(如浏览器)可以通过打开六个连接同时下载六个文件。虽然这样总体上更快,但它通过打开六个单独的 TCP 连接引入了六倍的延迟。不过,一个好消息是,一旦连接建立,它会保持打开并被重用(更多内容将在下一节讨论)。

你可以在下图中看到通过 HTTP/1.1 连接加载我的主页。每个 DNS、TCP、TLS 都可以被视为纯延迟,但这里我只讨论 TCP。

请注意第1项:在0.6到0.8秒之间有一些蓝色(HTML),从大约0.8到1.0秒有一些紫色(图像),然后从3.4到5.0秒有更多的蓝色条目——这证明了连接的重用。
注意,我们为csswizardry.com打开了五个连接,为res.cloudinary.com打开了六个连接,总共打开了23个TCP连接:这导致了大量的累计延迟!然而,值得注意的是,这些连接被重用了(稍后会详细说明)。

HTTP/2 的解决方案是只打开一个 TCP 连接,大大减少了连接开销,并通过在该连接中多路复用流来允许并发下载:

现在我们只为csswizardry.com打开了两个连接(其中一个需要CORS启用),为res.cloudinary.com打开了一个连接,总共13个,所有连接都被重用了。真是大大改善了!

HTTP/2 减少了整体延迟,因为它不需要处理大量新的或额外的三次握手。

关于 HTTP/1.0 的一点说明

HTTP/1.0 是一个过时的协议,我这里只提一下作为趣闻。希望阅读本文的没有人还在使用 HTTP/1.0。

在 HTTP/1.0 中,问题更严重,因为连接在使用后会立即关闭。这意味着每个文件都需要单独的连接协商。每个文件都带来了大量的一次性延迟:

有趣的是,这个网站实际上并不是在运行 HTTP/1.0——它运行的是 HTTP/1.1,但通过在响应中添加 Connection: close 强制执行了 1.0 的行为。
每个响应都有其自己的连接,并立即终止。真的没有比这更慢的了。

升级到 HTTP/2,确保任何你必须打开的连接都是被重用和持久的。

升级到 TLS 1.3

希望你注意到了上一节中的某个细节:连接是不安全的。我之前简要提到了 DNS,并且我们深入讨论了 TCP,现在是时候讨论 TLS 了。

如果你还在运行 HTTP 而不是 HTTPS,请尽快修复这个问题。

如果我们升级到 HTTP/2,我们也必须运行 HTTPS——这是要求之一。因此,可以安全地假设,如果你运行 HTTP/2,那么你也是在安全地运行。不过,这确实意味着更多的延迟……

现在是三个往返(432ms)才能发送 GET 请求!安全层添加在 TCP 连接之后,这意味着更多的往返。我宁愿拥有一个安全的网站,而不是一个快的网站,但如果可以选择,我会希望两者兼得。

只需升级到 TLS 1.3,我们就可以获得内置的优化。TLS 1.3 通过去除一些旧的协议部分,减少了一个完整的往返:

现在是两个往返(288ms)才能发送 GET 请求。更快了,但还不算快。我们继续努力。

TLS 1.3+0-RTT

TLS 1.3 的一个额外可选功能是 0-RTT,用于恢复之前的连接。通过在第一次握手中共享一个预共享密钥(PSK),我们可以在同时发送 GET 请求:

现在我们的 GET 请求在一个往返之后(144ms)就已发送!

由于安全权衡,0-RTT 是 TLS 1.3 中的可选机制。

主要结论

安全至关重要,但它不一定要慢。切换到 TLS 1.3 可以减少新连接的往返次数,并在恢复的连接上实现潜在的零往返!

升级到 HTTP/3(QUIC)

通过升级到 HTTP/3,实际上我们接触到了 QUIC。正如前面所讨论的,HTTP/1 和 HTTP/2 是建立在 TCP 之上的。而 HTTP/3 是建立在 QUIC 之上的,QUIC 在速度更快的 UDP 协议之上实现了类似 TCP 的层。它具有 TCP 的安全性和规范性,但避免了许多与其相关的延迟问题。这些变化和改进对于日常开发者来说是透明的,你无需改变任何工作流程,因此本文不会详细讨论 HTTP/2 与 HTTP/3 或 TCP、UDP 和 QUIC 之间的差异。

不过,我想说的是,协议设计中所蕴含的纯粹优雅、时间和努力,很大程度上并没有被终端开发者所理解。我们只是简单地切换一下开关,所有这些事情就自动完成了™。我们真的不配享受这种便利,但我扯远了……

也就是说,HTTP/3 的一个关键改进是,由于它建立在 QUIC 之上,QUIC 能够直接访问传输层,因此可以将 TLS 作为协议的一部分来提供。与在初始连接之后再进行 TLS 连接不同,它是与连接同时进行的!

现在我们的 GET 请求只需一次往返(144ms)就能发出!

这是一个在 DevTools 中观察并行化的好例子:注意到“初始连接”和(被错误标记为)SSL 是并行且相同的:

这意味着 HTTP/3 的最坏情况与 TLS 1.3+0⁠-⁠RTT 的最佳情况类似。如果你能使用 HTTP/3,我强烈建议启用它。

QUIC 0⁠-⁠RTT

由于 TLS 1.3+0⁠-⁠RTT,QUIC 也有其自己的 0⁠-⁠RTT 模型。这是由于 QUIC 将 TLS 集成到协议中的结果。这种新协议级别功能的累积效应意味着恢复的 HTTP/3 会话可以利用 0⁠-⁠RTT 模型来向相关源发送后续请求:

现在,我们的请求在零次往返(0ms)后已发送。再也不会比这更快了。

连接迁移

为了使这一切更加令人印象深刻,QUIC 为我们提供了连接迁移的功能!坏消息是,目前没有人实现它,但一旦实现……

互联网用户,尤其是移动设备用户,在其浏览过程中会经历网络条件的变化:在城市中行走时连接到新的基站,回家后加入 WiFi 网络,离开酒店时断开 WiFi 连接。

这些变化都会迫使 TCP 重新协商新的连接。TCP 使用四元组方法来保持连接同步,即客户端的 IP 地址和端口加上服务器的 IP 地址和端口用于标识连接。任何这四个参数的变化都会要求建立新的 TCP 连接。

QUIC 特别设计了方法来避免这种情况,它使用了连接 ID 来标识打开的连接,使其不受四元组变化的影响。再一次,这要归功于 QUIC 作为“从零开始”设计的协议。

这意味着,在网络变化的情况下,我们不必完全拆除和重建当前连接。在理想情况下,HTTP/3 可以在现有连接上无缝恢复连接。看起来像这样:

图像有意留空——什么都不会发生。
在 H/3 的世界里,最坏的情况是一次往返连接。这是一个相当不错的最坏情况:

如果我们仍然运行基于 TCP 的协议(如 HTTP/1 或 2),我们的最佳情况可能类似于 TCP 1.3+0⁠-⁠RTT 设置:

我们的最坏情况很可能是 HTTP/1 或 2 运行在 TLS 1.2 上:

拆除一切,重新来过。

主要结论

HTTP/3 的底层协议 QUIC 默认将 TLS 集成到其设计中,消除了连接和 TLS 需要背靠背执行的情况。它还可以实现设备在互联网中移动时的真正无缝连接迁移。

避免延迟

好了!它们都是非常好的升级机会,但如果 a) 你无法升级协议,或者 b) 你已经升级了所有可以升级的内容呢?最佳选择永远是避免问题。正如所说,预防总是比治疗便宜。如何完全绕过延迟?

避免不必要的新连接

在 HTTP/1.1 时代,避免过多的 HTTP 请求是一个明智的建议,因为请求和连接本质上是有限的。在 HTTP/2 的世界中,我们被告知可以稍微放松一些。然而,尽可能避免不必要的连接仍然是明智之举。

尽可能避免访问第三方来源,尤其是在关键路径上的资源。我之前说过,而且还会一遍又一遍地重复,直到所有人都听到了:自行托管你的静态资源。

我有一个客户在 TTFB(首字节时间)和 FCP(首次内容绘制)之间有巨大的差距,而其中很大一部分是由于延迟造成的——重新协商新连接,许多是不必要的,并且在关键路径上(由一个白色交叉与橙色圆圈相交表示):

在这个瀑布图中,1,874ms 被浪费在渲染阻塞的、可避免的延迟上。
根据 CrUX 数据,该客户的访问者的往返时间与全球最慢的 25% 相符——这是一个需要优化延迟的客户。通过自托管大多数资源,我们可以立即收回大量时间。

主要结论

尽管连接不像以前那么可怕,但建立新连接仍然是纯延迟——尽量避免,尤其是在关键路径上。

避免重定向

尽可能避免重定向。重定向也是纯延迟。我见过一些场景,开发者将所有 href 编写为指向无尾斜杠的路径,例如:

1
<a href=/products>查看所有产品…</a>

但他们网站的 URL 策略中包含尾部斜杠,例如:

1
https://wwww.website.com/products/

这意味着用户的每次点击都会产生完整的往返延迟,以获取一个 3xx 类重定向,然后再产生更多的往返时间来访问 Location 头中列出的资源:

除去初始连接时间,我们浪费了 184ms 的纯延迟——这是 7.36% 的 LCP 预算!
我建议查看你提供的 3xx 类响应的数量——今年我就有不少客户在不知不觉中因重定向浪费了大量时间!

有趣的是,304 响应仍然是一种重定向:服务器将访问者重定向回他们的 HTTP 缓存。确保你没有浪费地重新验证仍然有效的资源:

这些文件在重复页面查看时被重新验证,因为它们都携带了 Cache-Control: public, max-age=0, must-revalidate。数百毫秒的纯延迟。讽刺的是,因为它们都是指纹化的,这个客户本可以走完全相反的方向:Cache-Control: max-age=2147483648, immutable。这是我在这个项目中做的第一个修复。

从 http 重定向到 https 是非常必要的,应该不顾时间损失始终进行,不过可以通过使用 HSTS 来加速这一过程,我们稍后会讨论。

主要结论

虽然有时无法避免,但重定向也是纯延迟。确保你没有造成不必要的工作,并告诉你的市场部门停止使用 URL 缩短工具。

避免预检请求

非简单的 HTTP 请求会自动附带纯延迟的预检请求。当实际请求满足某些 CORS 条件时,例如发送非标准请求头或尝试进行 DELETE 请求,便会触发预检请求。

这是单页应用程序 (SPA) 访问 API 端点时常见的延迟来源。比如这个客户的例子:对他们 API 端点的请求包含一个非标准的 Accept-Version 头。这自动触发了预检请求,以便服务器了解即将到来的请求,并有机会拒绝它。

每个非简单的 HTTP 请求前都会附带一个同步的预检请求。
上述预检的 OPTIONS 请求附带了以下请求头(格式已整理):

  • Origin: https://website.com
  • Access-Control-Request-Method: GET
  • Access-Control-Request-Headers: Accept-Version

服务器对预检请求的响应为 204 状态码,包含相应的响应头(格式已整理):

  • Access-Control-Allow-Origin: https://website.com
  • Access-Control-Allow-Methods: HEAD, GET, POST
  • Access-Control-Allow-Headers: Accept-Charset, Accept-Encoding, Accept-Language, Accept-Version, Authorization, Cache-Control, Content-Type, Server-Id

这告诉浏览器,https://website.com 允许使用列出的请求方法和头进行请求。

完成此过程后(全是纯延迟),浏览器才能真正发出包含 Accept-Version: 1.0 的实际请求,该请求就是之前预检请求所询问的内容。

如果可能,避免发出非简单请求,因为这样做会触发纯延迟的预检请求。可以在 MDN 上查看会触发预检请求的条件。

如果无法避免预检请求,请继续阅读。

关键提示

如果你在构建 SPA(很可能是),检查你的客户端 API 调用发生了什么。

提前支付延迟成本并异步处理

即使我们尽最大努力,仍然无法避免一些延迟。像 0-RTT 这样的技术只能用于会话恢复,完全不访问其他来源几乎是不可能的。那么我们能否提前支付延迟成本?

preconnect

我们可以通过 preconnect(谨慎使用)来预先打开到未来可能需要访问的重要来源的连接。我之前写过关于配置 preconnect 的文章,建议你看看。

preconnect 是一个提示,告诉浏览器它将需要打开与提供的来源的新连接,从而将设置成本与发起请求分离:

1
<link rel=preconnect href=https://fonts.gstatic.com crossorigin>

这使得水流图中产生了这种漂亮的左移:

preconnect 加速 Google 字体的影响
一般来说,你只会希望 preconnect 任何对页面重要的来源(如 Google 字体,而非 Google Analytics),以及 <head> 中未提前引用的资源。额外加分的是通过 HTTP 头或 Early Hint 部署 preconnect

推测规则 API

preconnect 更进一步的是,实际预先获取资源本身。可以使用 prefetchprerender 在新的推测规则 API 中做到这一点。此机制允许我们提前、在后台支付延迟代价,因此当用户点击进入下一页时,它可能已经获取并等待。

我最近写了一篇关于此的文章,所以再度建议你看看,但记住要谨慎使用。对于诸如 preconnectprefetchpreloadprerender 这样的东西,少即是多。

缓存一切

如果你要做某件事,尽量只做一次。

如果我们无法进行相关升级,也无法避免延迟,那么我们就要尽力缓存任何受延迟影响的交互结果。

HTTP/浏览器缓存

最快的请求是从未发出的请求。确保你有一个健全的缓存(和重新验证)策略。我已经详细讲解过 HTTP 缓存,你可以从中获取所需的全部内容(甚至更多)。

CDN 缓存

CDN 只在请求在此终止时有助于解决延迟问题:任何返回源站的请求仍然会走慢路径。

为充分发挥优势,确保你的 CDN 已配置为充分利用边缘级缓存。如果你需要将 CDN(或共享)缓存值与浏览器缓存分别设置,使用 s-maxage 缓存控制指令。

严格传输安全 (HSTS)

第一次有人通过 HTTP 访问你的网站时,他们可能(希望如此)会被重定向到 HTTPS。如果你选择使用 HSTS,那么可以让浏览器在本地缓存这一重定向,从而避免未来的 3xx 类延迟,自动将访问者引导至安全 URL。

HSTS 通过 Strict-Transport-Security 响应头部署,例如:

1
Strict-Transport-Security: max-age=31536000

这不仅更快,还更安全。

要更快、更安全,可以将你的网站添加到 HSTS 预加载列表中。这样浏览器会硬编码你的网站,确保不会有首次 HTTP 到 HTTPS 的 3xx 重定向——你甚至不会一次性遭受那种延迟(或暴露)。

缓存你的预检请求

如前所述,如果无法删除预检请求,至少可以缓存它们。这与通常的 Cache-Control 头不同,而是通过专门的 Access-Control-Max-Age 响应头实现。认真考虑其值——这是一个重要的安全功能。为了防止开发人员设置过于宽松的值,Firefox 将其限制为最多 24 小时,Chrome 则限制为两小时——即使你传递了 31,536,000 秒(一年),也只能缓存 86,400 秒(一天):

1
Access-Control-Max-Age: 86400

这些头与其他响应头一样是按 URL 设置的,因此无法设置一个覆盖整个来源的策略(这是一个功能,而不是错误)。

关键提示
任何无法避免的延迟,请一次性处理掉,后续的请求应该通过缓存来避免。

那么我的选择是什么?

你有很多选择,但请记住,我刚刚花了近 5000 字解释了如何解决可能是你最不严重的问题。只有当你知道并且很明显延迟是最大问题时,才应实施本文中的大部分建议。

我的首要建议是通过积极缓存任何昂贵的操作来遏制当前问题。

其次,尽量避免任何你可以轻松重构的内容——如果可以控制它,最好不要做。

对于无法避免的情况,尝试异步解决:预连接来源或预渲染后续导航是快速的胜利。

此外,利用协议级的机会升级来让自己超前。协议级的改进可以吞噬我们许多现有的问题。

不过,我讨论的许多内容要么是:

  • 使用一个不错的 CDN 很容易实现;
  • 是最佳实践。

    附录

    如果你有兴趣对比不同协议级别的差异,可以参考以下内容:

本文讨论的协议规范可参见以下文档: