JobbyM's Blog

一日一练-网络 缓存最佳实践

子曰:一日一练-网络 缓存最佳实践

关于本文:
原文:https://jakearchibald.com/2016/caching-best-practices/
作者:@Jake Archibald

正确地获得缓存会带来巨大的性能优势,节省带宽并降低服务器成本,但许多站点都会降低缓存,导致竞争条件导致相互依赖的资源不同步。

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

模式 1:不可变的内容 + 长的max-age(Pattern 1:Immutable content + long max-age)

1
Cache-Control: max-age=31536000
  • 此网址的内容永远不会更改,因此……
  • 浏览器/CDN 可以将此资源缓存一年而不会出现问题
  • 可以在不咨询服务器的情况下使用小于max-age 秒的缓存内容

在此模式中,你永远不会更改特定URL 的内容,你只会更改URL 匹配不同的内容:

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

每个URL 都包含随其内容一起更改的部分。它可以是版本号,修改日期或内容的哈希值 – 这是我在此博客上所使用的。

大多数服务器端框架都有工具来简化(我使用DjangoManifestStaticFilesStorage),还有一些较小的Node.js 库可以实现相同的功能,例如gulp-rev

但是,此模式不适用于文章等内容。他们的网址无法进行版本控制,其内容必须能够更改。 说真的,鉴于我所犯的基本拼写和语法错误,我需要能够快速频繁地更新内容。

模式 2:可变的内容,需要服务端重新验证(Pattern 2: Mutable content, Always server-revalidated)

1
Cache-Control: no-cache
  • 此URL 的内容可能会更改,因此……
  • 没有服务器的认可,任何本地缓存的版本都不受信任

注意:no-cache并不意味着“不缓存(don’t cache)”,这意味着它必须在使用缓存资源之前使用服务器检查(或“重新验证(revalidate)”,当它调用它)。no-store 告诉浏览器根本不要缓存它。must-revalidate 并不意味着“必须重新验证(must revalidate)”,这意味着如果本地资源比提供的max-age 小,则可以使用本地资源,否则必须重新验证。是啊。我知道。

在此模式中,你可以向响应添加ETag(你选择的版本ID)或Last-Modified 日期头(date header)。下次客户端获取资源时,它分别通过If-None-MatchIf-Modified-Since 回显它已经拥有的内容的值,允许服务器说“只使用你已经获得的内容,它取决于日期”,或它拼写它,“HTTP 304”。

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

这种模式总是涉及网络请求,因此它不如可以完全绕过网络的模式 1 好。

被模式 1 所需的基础设施拖延并不罕见,但同样地被网络请求模式 2 所需要推迟,而是在中间寻找一些东西:一个小的max-age 和可变内容。这是一个比较坏的妥协。

可变内容的max-age 通常是错误的选择(max-age on mutable content is often the wrong choice)

……不幸的是,这种情况并不罕见,例如它发生在Github 页面上。

设想:

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

……使用如下服务:

1
Cache-Control: must-revalidate, max-age=600
  • 网址URL 的内容会发生变化
  • 如果浏览器的缓存版本不到10 分钟,在不咨询服务器的情况下使用它
  • 否则,使用If-Modified-SinceIf-None-Match(如果可用)进行网络请求

这种模式似乎可以在测试中可用,但在实际中会出现暴露问题,而且很难追踪。在上面的示例中,服务器实际上已经更新了HTML,CSS 和JS,但页面最终使用了来自缓存的旧HTML 和JS,以及来自服务器的更新的CSS。版本不匹配导致了问题。

通常,当我们对HTML 进行重大更改时,我们可能还会更改CSS 以反映新结构,并更新JS 以迎合样式和内容的更改。这些资源是相互依赖的,但缓存头(caching header)无法表达这些。用户可能最终获得一、两个资源的新版本,但其他的资源是旧版本。

max-age 关联响应时间的,所以如果所有上述资源都作为同一导航的一部分被请求,那么它们将被设置为在大致相同的时间到期,但是那里仍然有很小的竞争。如果你有一些不包含JS的页面,或包含不同的CSS,你的到期日期可能会不同步。更糟糕的是,浏览器总是从缓存中删除内容,并且它不知道HTML,CSS 和JS 是相互依赖的,所以它会地丢弃一部分。将所有这些加在一起,导致资源的不匹配。

对于用户而言,这可能导致布局和或功能损坏。从微妙的故障,到完全无法使用的内容。

值得庆幸的是,用户有一个逃生舱……

刷新有时会修复它(A refresh sometimes fixes it)

如果页面是作为刷新的一部分加载的,浏览器将始终使用服务器重新验证,忽略max-age。因此,如果用户遇到因max-age 而损坏的内容,则点击刷新应该可以解决所有问题。 当然,强迫用户这样做会降低信任度,因为它会让人觉得你的网站是不友好的。

Service worker 可以处理这些错误(A service worker can extend the life of these bugs)

假设你有以下service worker:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 的不兼容版本。

一旦它们被缓存,我们将服务不兼容的CSS 和JS,直到我们下次更新service worker – 并且假设我们在下一次更新中没有遇到另一种竞争条件。

你可以绕过server worker 中的缓存:

1
2
3
4
5
6
7
8
9
self.addEventListener('install', event => {
event.waitUntil(
caches.open(`static-${version}`)
.then(cache => cache.addAll([
new Request('/styles.css', { cache: 'no-cache' }),
new Request('/script.js', { cache: 'no-cache' })
]))
)
})

不幸的是,Chrome/Opera 尚不支持缓存选项,而最新Firefox Nightly 支持,但需要你自己进行排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
self.addEventListener('install', event => {
event.waitUntil(
caches.open(`static-${version}`)
.then(cache => cache.addAll(
[
'/styles.css',
'/script.js'
].map(url => {
// cache-bust using a random query string
retur fetch(`${url}?${Math.random()}`).then(reponse => {
// fail on 404, 500 etc
if (!response.ok) throw Error('Not ok')
return cache.put(url, response)
})
})
))
)
})

在上面我用随机数缓存 – 但你可以更进一步使用构建步骤来添加内容的哈希值(类似于sw-precache 所做的)。这有点在JavaScript 中重新实现模式 1,但仅限于service worker 用户而不是所有浏览器和CDN。

Service worker 和HTTP 缓存配合得很好!(The service worker & the HTTP cache play well together, don’t make them fight)

正如你所看到的,你可以解决service worker 中缓慢的缓存问题,但最好还是解决问题的根源。 正确使用缓存会使service worker 的工作变得更轻松,同时也使不支持service worker 的浏览器(Safari,IE/Edge)受益,并从CDN 中获得最大收益。

正确的缓存头(caching header)意味着你也可以大规模简化service worker 更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
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'
]))
)
})

在这里,我将使用模式 2(服务器重新验证)缓存根页面,使用模式 1(不可变内容)缓存其余资源。每个service worker 更新都将触发对根页面的请求,但只有在其URL 已更改时才会下载其余资源。这非常棒,因为无论你是从上一个版本还是10 个版本之前更新,它都可以节省带宽并提高性能。

与原生相比,这是一个巨大的优势,即使是微小的变化,也可以下载整个二进制文件,或者涉及复杂的二进制差异。在这里,我们可以用相对较少的下载更新大型Web 应用程序。

service worker 最好是作为一种增强而不是解决方法,所以不要使用缓存,而是使用它!

小心使用,max-age 和mutable 内容可能是有益的(Used carefully, max-age & mutable content can be beneficial)

可变内容的max-age 通常是错误的选择,但并非总是如此。例如,此页面的max-age 为三分钟。竞争条件不是问题,因为此页面没有任何依赖项,其遵循相同缓存模式的(我的CSS,JS 和图像URL 遵循模式 1 – 不可变内容),并且没有依赖此页面的内容遵循相同的模式。

这种模式意味着,如果我很幸运能够撰写一篇热门文章,我的CDN(Cloudflare)可以消除我服务器的压力,只要我能忍受它,用户可以看到最多三分钟的文章更新我是谁

不应轻易使用此模式。如果我在一篇文章中添加了一个新的部分并在另一篇文章中链接到它,我创建了一个可以竞争的依赖项。用户可以单击该链接并将其带到没有引用部分的文章副本。如果我想避免这种情况,我会更新第一篇文章,使用他们的UI 刷新Cloudflare 的缓存副本,等待三分钟,然后在另一篇文章中添加链接。是的……你必须非常小心这种模式。

正确使用,缓存是一个巨大的性能增强和带宽节省。支持任何可以轻松更改的URL 的不可变内容,否则在服务器重新验证时可以安全地使用它。如果你勇敢混合max-age 和可变内容,并且你确定你的内容没有依赖,或者是同步的依赖。