1
头图

正确使用缓存可以带来巨大的性能优势,节省宽带,并降低服务器成本,但许多网站并不重视缓存,造成竞争条件,导致相互依赖的资源不同步。

绝大多数最佳实践缓存属于以下两种模式之一:

  • 模式一:不可变(immutable)内容 + 长 max-age
  • 模式二:可变(mutable)内容,始终由服务器验证

模式一:不可变内容 + 长 max-age

Cache-Control:max-age=31536000

适用以下情况:

  • 此 URL 上的内容永远不会改变。
  • 浏览器/CDN 可以将此资源缓存一年没有问题。
  • 可以使用小于 max-age 几秒的缓存内容,无需咨询服务器。

image.png

在这个模式下,您永远不会更改特定 URL 的内容,而是更改 URL:

<script src="/script-f93bca2c.js"></script>
<link rel="stylesheet" href="/styles-a837cb1e.css" />
<img src="/cats-0e9a2ef4.jpg" alt="…" />

每个 URL 包含的信息都会随之改变,它可以是版本号、修改日期或内容的哈希值。

大多数服务器端框架都自带工具来简化这一过程(我使用 Django 的 ManifestStaticFilesStorage),还有一些较小的 Node.js 库也能实现同样的功能,例如 gulp-rev

不过,这种模式不适用于文章和博文等内容,它们的 URL 无法版本化,内容也必须能够更改。说真的,鉴于我经常会犯一些基本的拼写和语法错误,我需要能够快速、频繁地更新内容。

模式二:可变内容,始终由服务器验证

Cache-Control: no-cache

适用以下情况:

  • 此 URL 上的内容可能会更改
  • 未经服务器许可,任何本地缓存版本都不可信

image.png

注意:no-cache 并不意味着 "不缓存",而是指在使用缓存资源前必须与服务器进行检验(或称为 "重新验证")。此外,must-revalidate 并不意味着 "必须重新验证",而是说如果本地资源的时效小于所提供的 max-age,就可以使用,否则就必须重新验证。

在这种模式下,可以在响应中添加 ETag(你选择的版本 ID)或 Last-Modified 日期标头。下一次客户端获取资源时,就会分别通过 If-None-MatchIf-Modified-Since 回传已有内容的值,从而允许服务器说 "就用你已有的吧,它是最新的",或者正如它的拼写那样 "HTTP 304"。

如果无法发送 ETag/Last-Modified,服务器将始终发送完整内容。

这种模式总是需要通过网络获取,因此不如模式一那样可以完全绕过网络。

模式一所需的基础设施让人望而却步,而模式二所需的网络请求又让人同样望而却步,因此,人们往往会选择介于两者之间的模式:较小的 max-age 和可变内容,这是一个糟糕的折衷方案。

可变内容的 max-age 通常是错误的选择

遗憾的是,这种情况并不少见,例如在 Github 页面上就会发生。

想象一下

  • /article/
  • /styles.css
  • /script.js

所有服务:

Cache-Control: must-revalidate, max-age=600

包含以下场景:

  • URL 内容更改
  • 如果浏览器有不到 10 分钟的缓存版本,则使用该版本,无需询问服务器
  • 否则,进行网络获取,如果可用,使用 If-Modified-SinceIf-None-Match

image.png

这种模式在测试中似乎有效,但在实际场景中却会造成故障,而且很难追查。在上面的例子中,服务器实际上已经更新了 HTML、CSS 和 JS,但页面最终使用的是缓存中的旧 HTML 和 JS,以及服务器上更新的 CSS。版本不匹配导致了问题的出现。

通常情况下,当我们对 HTML 进行重大修改时,很可能也会修改 CSS 以反映新的结构,并更新 JS 以适应样式和内容的变化。这些资源是相互依存的,但缓存标头无法表达这一点。用户最终可能会使用其中一个/两个资源的新版本,而使用另一个/多个资源的旧版本。

max-age 是相对于响应时间而言的,因此如果上述所有资源都是作为同一导航的一部分被请求的,那么它们将被设置为在大致相同的时间过期,但仍然存在竞争的可能性。

如果有些页面不包含 JS,或包含不同的 CSS,过期日期就会不同步。更糟糕的是,浏览器经常会从缓存中删除一些内容,而它并不知道 HTML、CSS 和 JS 是相互依存的,所以它会很乐意删除其中一个,而不删除其他的。将这些因素相乘,最终出现这些资源版本不匹配的情况也就不是不可能了。

对于用户来说,这可能会导致布局和/或功能被破坏,从细微的故障到完全无法使用的内容。

值得庆幸的是,用户有一个逃生通道...

刷新有时可以解决

如果页面是作为刷新的一部分加载的,浏览器总是会与服务器重新验证,而忽略 max-age。因此,如果用户遇到的问题是由于 max-age 导致的,点击刷新就能解决一切问题。当然,强迫用户这样做会降低信任度,因为这会让人觉得你的网站很不稳定。

Service Worker 线程可以延长这些错误的寿命

假设您有以下 Service Worker:

const version = '2';

self.addEventListener('install', (event) => {
  event.waitUntil(
      caches.open(`static-${version}`)
        .then((cache) => cache.addAll(['/styles.css', '/script.js']))
  )
})

self.addEventListener('activate', (event) => {
  // delete old caches...
})

self.addEventListener('fetch', (event) => {
  event.respondWith(
      caches.match(event.request)
        .then((response) => response || fetch(event.request))
  )
})

这个 Service Worker 线程...

  • 预先缓存脚本和样式
  • 如果匹配,则从缓存中提供服务,否则通过网络提供服务

如果我们更改了 CSS/JS,我们就会提升 version,使 Service Worker 的字节不同,从而触发更新。不过,由于 addAll 是通过 HTTP 缓存获取的(几乎所有的获取都是这样),我们可能会遇到 max-age 竞争条件,并缓存到不兼容的 CSS 和 JS 版本。

一旦它们被缓存,在下次更新 Service Worker 之前,我们将一直提供不兼容的 CSS 和 JS。

您可以绕过 Service Worker 中的缓存:

self.addEventListener('install', (event) => {
  event.waitUntil(
      caches.open(`static-${version}`)
    .then((catch) => {
      cache.addAll([
        new Request('/styles.css', { cache: 'no-cache' }),
        new Request('/script.js', { cache: 'no-cache' })
      ])
    })
  )
})

遗憾的是,Chrome/Opera 尚不支持缓存选项,而 Firefox Nightly 最近才支持缓存选项,不过你也可以自己尝试一下:

self.addEventListener('install', (event) => {
  event.waitUntil(
      caches.open(`static-${version}`).then((cache) => {
      return Promise.all(
          ['/styles.css', '/script.js'].map(() => {
          // cache-bust using a random query string
          return fetch(`${url}?${Math.random()}`).then((response) => {
            // fail on 404, 500 etc
            if(!response.ok) throw Error('Not ok');
            return cache.put(url, response);
          })
        })
      )
    })
  )
})

在上文中,我使用随机数来破坏缓存,但您可以更进一步,使用构建步骤来添加内容的哈希值(类似于 sw-precache 的做法)。这有点像在 JavaScript 中重新实现模式一(不可变内容),但只是为了 Service Worker 用户的利益,而不是所有浏览器和 CDN 的利益。

Service Worker 和 HTTP 缓存可以很好地合作,不要让它们打架!

正如您所看到的,您可以解决 Service Worker 中的糟糕的缓存问题,但最好还是解决问题的根源。正确设置缓存可以换 Service Worker 领域的工作变得更轻松,而且也有利于不支持 Service Worker 的浏览器(Safari、IE/Edge)受益,并让您最大限度地利用 CDN。

正确的缓存标头意味着您还可以大幅简化 Service Worker 的更新:

const version = '23';

self.addEventListener('install', (event) => {
  event.waitUntil(
      caches.open(`static-${version}`)
        .then((cache) => {
        cache.addAll([
          '/',
          '/script-f93bca2c.js',
          '/styles-a837cb1e.css',
          '/cats-0e9a2ef4.jpg'
        ])
      })
  )
})

在这里,我会使用模式二(服务器重新验证)缓存根页面,使用模式一(不可变内容)缓存其他资源。每次 Service Worker 更新都会触发对根页面的请求,但其他资源只有在 URL 发生变化时才会被下载。这样做非常好,因为无论从上一版本还是 10 个版本更新,都能节省带宽并提高性能。

与本地程序相比,这是一个巨大的优势,在本地程序中,即使是很小的改动也要下载整个二进制文件,或者涉及复杂的二进制差异,在这里,我们只需相对较少的下载就能更新一个大型网络应用程序。

Service Worker 的最佳工作方式是增强而不是变通,因此与其与缓存对抗,不如与它合作!

谨慎使用 max-age 和可变内容可带来益处

在可变内容上使用 max-age 通常是错误的选择,但并非总是如此。

例如,本页面的 max-age 为三分钟,这里并不存在竞争条件的问题,因为该页面没有任何依赖项遵循相同的缓存模式(我的 CSS、JS 和图片 URL 都遵循模式一 ——不可变内容),而且该页面的任何依赖项都不遵循相同的模式。

这种模式意味着,如果我有幸写了一篇受欢迎的文章,我的 CDN(Cloudflare)可以为我的服务器分担热量,只要我可以忍受文章更新需要三分钟才能被用户看到,而我现在就是这样。

这种模式不能随便使用,如果我在一篇文章中添加了一个新的部分,并在另一篇文章中进行了链接,那么我就创建了一个可能会发生竞争的依赖关系。用户点击链接后,可能会进入一篇没有引用部分的文章。如果我想避免这种情况,我会更新第一篇文章,使用 Cloudflare 的用户界面刷新 Cloudflare 的缓存副本,等待三分钟,然后在另一篇文章中添加链接。是的......使用这种模式必须非常小心。

正确使用缓存可以大大提高性能和节省带宽。对于任何容易改变的 URL,最好使用不可变内容,否则就使用服务器重新验证。只有当你觉得自己很勇敢,并且确信你的内容没有依赖关系或可能不同步的依赖关系时,才会混合使用 max-age 和可变内容。

推荐阅读:彻底理解浏览器缓存机制

在 GitHub 上查看此页面


破晓L
2.1k 声望3.6k 粉丝

智慧之子 总以智慧为是