[译] 缓存最佳实践

1,261 阅读7分钟

原文地址:Caching best practices & max-age gotchas

译文开始:

正确的使用缓存可以带来巨大的性能提升,节省带宽,减少服务器消耗,但是很多网站对他们的缓存并没有好好管理,导致相互依赖的资源不同步(后面会介绍)。

缓存的最佳实践大多数情况下是下面两种模式的其中一种:

模式一:内容不会变化 + max-age 时间设置大

Cache-Control: max-age=31536000
  • 这个url请求的资源内容不会改变,因此...
  • 浏览器/CDN可以将这个资源缓存一年都不会有任何问题
  • 缓存资源的时间比max-age的时间小的话就可以不咨询服务器直接使用

在这个模式中,一些特殊的url内容永远不会发生改变,如果内容改变你可以修改url:

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

写url的地址随着内容的变化而变化.他可以使版本号,修改时间,或者内容的hash。

很多框架有一些工具来生成一定规则的url。

但是,这个模式不适用一些类似文章或者博客的内容.这些url不能被版本化,他的内容会频繁的改变。

模式2:可能会修改的内容,每次都需要服务器验证

Cache-Control: no-cache
  • 这些url的资源内容可能会发生改变,因此...
  • 所有本地缓存在没有服务器确认的情况下都是不能被使用的

注意:no-cache不代表"别缓存",他表示在使用缓存资源之前必须要经过服务器的验证.no-store是告诉浏览器别缓存资源。另外must-revalidate不是"必须经过验证"的意思,他的意思是:本地缓存的时间如果比max-age小就可以直接用,不然的话要经过服务器验证。

在这个模式中你可以在响应头里添加ETag(一个版本id)或者Last-Modified.下次浏览器获取资源的,他会通过If-None-Match把版本号带给服务器或者通过If-Modified-Since把最后修改时间带给服务器,允许服务器通过HTTP 304告诉浏览器:就用你现在有的资源,这个是最新的.或者重新返回最新的资源文件。

这个模式每次都会发送请求,所以他无法像第一种模式一样完绕过网络请求这一步。

很多网站无法满足第一种模式要求资源的内容不能变,又不想要第二种模式每次都需要发送请求。而是选择中间的一种方式:一个很小的max-age来配合会变化的内容.这是一个非常不明智的妥协。

在内容可能会变化的资源上加上max-age通常是一个错误的选择

很不幸,这种情况并不少见,比如Github的页面就有这样的情况.

想象一下:

  • /article/
  • /styles.css
  • /script.js 这些资源服务器都包含了这个响应头
Cache-Control: must-revalidate, max-age=600

接下来

  • 这些url资源文件的内容发生了改变
  • 如果浏览器缓存的版本小于10分钟,那么缓存就不需要服务器验证就可以直接使用这些资源文件
  • 不然的话就发送一个网络请求,同时会带着If-Modified-Since或者If-None-Match的请求头

这个模式在测试的时候不会有问题,但是在真实环境里就会有问题,同时这种问题很难被定位.就好像上面这个例子,事实上服务器需要同时更新HTML,CSS和JS,但是浏览器从缓存中获取了老的HTML和JS,从服务器获取了新的CSS.版本的不统一导致了问题的发生。

通常来说.我们更改了HTML的同时,也会同时修改CSS来装饰你的HTML结构.也许也会同时更改JS。这些资源是相互关联的,但是我们的缓存头并有反应出这些.用户可能会同时获得一两个新版本的资源和一个旧版本的资源。

max-age是和响应时间有关系的,所以如果上面这些资源在一个页面中被请求那么他们的过期时间粗略的算式一样的,但是仍然有可能他们的响应时间是有出入的.如果你有些页面只包含了三个相关联资源中的两个,那么他们的过期时间就会出现不同步的情况。更糟的是,浏览器缓存经常会丢失缓存,并且缓存并不知道这三个资源是相互关联的,那么缓存根本不会在意其中一个资源丢失。这些可能性加到一起,缓存版本不同步的情况会很有可能发生了。

对用户来说,结果就是破坏layout或者同时破坏交互。这些隐藏小故障,可能会导致这个页面不可用。

幸好,我们还有办法可以拯救一下我们的用户...

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 => {
  // 删除老的缓存
});

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

这个service worker做这几件事

  • 缓存script和styles
  • 如果匹配的话返回缓存,不然的话就发送请求

如果我们修改了我们的JS或者CSS,就修改version,使service workerc触发更新。然而,因为addAll请求还是会通过HTTP缓存(就像大多数的请求一样),我们任然需要面对max-age不一致导致的JS和CSS缓存不兼容的情况.

一旦这些资源被缓存,service worker在下次更新前都将提供不兼容的JS,CSS。我们还要祈祷下次更新的时候不会出现不兼容的情况.

我们可以让service worker绕过缓存:

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支持,但是也可以用下面的兼容方案

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(`static-${version}`)
      .then(cache => Promise.all(
        [
          '/styles.css',
          '/script.js'
        ].map(url => {
          // 使用随机数作为参数使他不使用缓存
          return fetch(`${url}?${Math.random()}`).then(response => {
            if (!response.ok) throw Error('Not ok');
            return cache.put(url, response);
          })
        })
      ))
  );
});

上面的代码我们通过随机数"绕过"了缓存,但是还可以做的更好一些,使用内容的hash来替代随机数。就有点像通过js重新实现了模式一,但是只能适用于能使用service worker的用户,而不是全部的浏览器或者CDN。

service worker和HTTP缓存合作

你可以在service worker里处理缓存,但是我们最好还是能够找到问题的根源并且解决。正确的使用缓存会让事情变的更简单,不仅仅是对于service worker来说,同时对那些不支持service worker的浏览器也有好处,并且能充分的利用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改变的时候才会有网络请求。不论你的缓存版本怎么改变,都这样可以节省带宽,提高性能。

这相比原本的缓存来说是一个巨大的提升,原本一个微小的改变都需要把资源整个下载。现在我们可以下载一个相对比较小的资源文件,来更新一个大的web。

Service workers的使用可以作为一个缓存的加强,而不是一个替代缓存的解决方案。所以相比于缓存对着干,让他和缓存一起工作会带来更好的效果。

小心使用的话,max-age配合会变的资源还是有好处的

在有可能会改变的资源上使用max-age通常不是一个好主意,但是这个也不是绝对的.比如这个网站我们有些会改变的资源有三分钟的max-age,因为这个页面没有任何相互依赖的资源文件使用相同的缓存模式(CSS,JS还有图片的URL使用的是模式一 - 不改变的内容),相互没有依赖关系的资源使用同一个模式。

这意味着,如果我够幸运能写出一篇非常流行的文章,我的CDN可以减轻我服务器的压力,但是我文章的更新可能会最长需要延迟三分钟才能被用户看到,这对于我来说也是可以接受的。

如果我在文章中新增了一个在别的文章中需要跳转过来引用的小节,我就创建了可能会导致不一样的相互关联的资源。用户可能点击了链接跳转过来发现并没有发现新加的一小节内容.如果我想避免这样的事情发生,我需要更新第一个文章,更新CDN,等待三分钟,然后在另外一个文章中加上链接.用这个模式要十分的小心。

正确的使用缓存可以代码巨大的性能提升,还可以节省带宽.对于不会改变的内容使用第一种模式,否则使用服务器验证是比较安全的.只有在你非常有把握的时候把max-age和会改变的资源文件放在一起使用,但是你要确保你的资源文件没有相互关联性,或者相互关联的资源文件不会不同步。