掌握聚合最新动态了解行业最新趋势
API接口,开发服务,免费咨询服务

缓存最佳实践及max-age注意事项

本文由qgy18在众成翻译平台上翻译。

使用缓存会带来巨大的性能提升,还能节省带宽、减少服务端开销,但很多网站对缓存一知半解,让相互依赖的资源出现竞态条件,从而无法同步更新。

使用缓存的最佳实践大体上可以归纳为这两种模式:

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

Cache-Control: max-age=31536000

  • URL 对应的内容绝对不会改变,因此:

  • 浏览器 / CDN 直接将这个资源缓存一年也没问题;

  • 在 max-age 指定时间内,缓存副本可以直接使用,不需要与服务端协商;

页面:嘿,我需要 "/script-v1.js"、"/styles-v1.css" 和 "/cats-v1.jpg"。(10:24)

缓存:我这儿都没有。服务端,你有么?(10:24)

服务端:当然,给你。对了 缓存,把这些资源存起来,一年之内直接用吧。(10:25)

缓存:多谢!(10:25)

页面:赞哦!(10:25)

在这种模式中,绝对不会修改某个 URL 的内容,只会改变 URL 本身:

<script src="/script-f93bca2c.js">

<link rel="stylesheet" href="/styles-a837cb1e.css">

<img src="/cats-0e9a2ef4.jpg" alt="">

上面每个 URL 都包含了与文件内容同步修改的部分。这部分可以是版本号、修改时间,或者是文件内容的 MD5 —— 我的博客就是这么干的。

大部分服务端框架都有对应工具来轻松完成这项工作(我在用 Django 的 ManifestStaticFilesStorage),还有一些很轻量的 Node.js 库可以实现同样功能,例如 gulp-rev。

但是,文章或博客详情这类 HTML 页面不适用于这种模式。这些页面 URL 不能包含版本号,页面内容还必须能修改。尤其是发现有拼写错误或语法错误后,需要快速频繁地更新。

模式二:可变内容,每次都走服务端验证

Cache-Control: no-cache

  • URL 对应的内容可能会改变,因此:

  • 没有服务端的指示,不能使用本地缓存的任何版本;

页面:嘿,我需要 "/about/" 和 "/sw.js" 两份资源。(11:32)

缓存:我搞不定。服务端,看看?(11:32)

服务端:哈,我有,给你。缓存,你可以自己存一份,但用之前要先问我哈。(11:33)

缓存:明白!(11:33)

页面:多谢!(11:33)

注:no-cache 并不是说「不缓存」,它意味着使用缓存前必须检查(或者说验证)这个资源在服务端是否有更新。no-store 用来告知浏览器完全不要缓存这个资源。类似的,must-revalidate 并不是说「每次都要验证」,它意味着某个资源在本地已缓存时长短于 max-age 指定时长时,可以直接使用,否则就要发起验证。好,这下明白了。

这种模式下,也可以给资源响应加上 ETag(资源的版本 ID)、 Last-Modified 时间这两个头部。下次客户端请求这些资源时,会通过 If-None-Match 或 If-Modified-Since 这两个请求头带上之前的值,这样服务端就可以返回「直接用你之前缓存的版本吧,它们是最新的」,换成行话就是「HTTP 304」。

如果服务端没办法发送 ETag / Last-Modified 头部,那每次都需要发送完整的响应内容。

这种模式下,每次都会产生网络请求,所以它没有能节省网络请求的模式一好。

模式一被基础设施影响,模式二被网络请求影响,都是常见的事儿。所以又有了中间方案:给可变内容加上短一点的 max-age。这个折中方案太太太糟糕了。

给可变内容加上 max-age,通常是错的

不幸的是这种做法并不罕见,Github pages 当前就是这样。

假设这样三个资源:

  • /article/

  • /styles.css

  • /script.js

都有这样的响应头:

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

  • URL 对应的内容发生了改变;

  • 如果浏览器有一个十分钟之内的缓存副本,就不会与服务端协商,直接使用;

  • 否则发起网络请求,可能的话还会带上 If-Modified-Since 和 If-None-Match 请求头;

页面:嘿,我需要 "/article/"、"/script.js" 和 "/styles.css"。(10:21)

缓存:我这里没有,服务端?(10:21)

服务端:没问题,给你。对了 缓存,这些资源你可以存 10 分钟。(10:22)

缓存:明白!(10:22)

页面:多谢!(10:22)

这种场景在测试环境可以构造出来,但在真实环境中难复现,也难追查。在上述例子中,实际上服务端同时更新了 HTML、CSS 和 JS,但是页面最终从缓存中拿到旧的 HTML 和 JS,并从服务端拿到最新的 CSS。版本不匹配导致功能异常。

通常,当我们对 HTML 改动很大时,很可能 CSS 需要为新结构作出调整,JS 也需要配合 CSS 和 HTML 改动而进行相应修改。这些资源相互依赖,但无法通过缓存头反映出来。最终页面可能会拿到一部分新资源,一部分旧资源。

max-age 是响应时间的相对值,某个页面上的所有资源请求,会被设置在大致相同的时间后失效,但仍有小概率出现竞争。如果你有一些不包含 JS、或包含不同 CSS 的页面,过期时间可以不同步。更为糟糕的是,浏览器一直都在淘汰缓存的资源,它不可能知道某些 HTML、CSS 和 JS 相互有依赖,所以会出现部分淘汰的情况。综上所述,最终页面拿到版本不匹配的资源并非不可能发生。

对于用来来说,这会破坏页面布局和/或功能,从小问题到大事故都有可能发生。

谢天谢地,我们有个解决方案。。。

刷新之后通常就好了

刷新页面,会让浏览器向服务端发起验证,忽略 max-age。所以如果用户对 max-age 的这个问题很有经验的话,点击刷新按钮就能解决一切问题。当然,要求用户这样做会降低用户对你的信任,会让用户觉得你的网站很不稳定。

service worker 会延长这个 BUG 的生命周期

假设有下面这样的 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 里绕过 HTTP 缓存:

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 都不支持 cache 选项,只有最新的 Firefox Nightly 才支持,当然你也可以自己解决:

self.addEventListener('install', event => {

  event.waitUntil(

    caches.open(`static-${version}`)

      .then(cache => Promise.all(

        [

          '/styles.css',

          '/script.js'

        ].map(url => {

          // 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);

          })

        })

      ))

  );

});

上面代码通过加随机数的方式绕过了缓存,也可以更进一步,利用构建工具自动添加文件内容 MD5(类似于 sw-precache 所做的工作)。这有点像在 JavaScript 中实现了模式一,但是只能让 service worker 受益,不包括浏览器和 CDN。

让 service worker 和 HTTP 缓存相互协作,而不是打架

如你所见,我们可以用一些技巧来改善 service worker 中的缓存,但更好的做法是从源头解决问题。正确使用 HTTP 缓存不但可以简化 service worker 逻辑,还可以让那些不支持 service worker 的浏览器获益(Safari、IE/Edge),也能用好 CDN。

正确配置缓存响应头意味着可以大幅简化 service worker 的更新逻辑:

const version = '23';


self.addEventListener('install', event => {

  event.waitUntil(

    caches.open(`static-${version}声明:所有来源为“聚合数据”的内容信息,未经本网许可,不得转载!如对内容有异议或投诉,请与我们联系。邮箱:marketing@think-land.com

  • 营运车判定查询

    输入车牌号码或车架号,判定是否属于营运车辆。

    输入车牌号码或车架号,判定是否属于营运车辆。

  • 名下车辆数量查询

    根据身份证号码/统一社会信用代码查询名下车辆数量。

    根据身份证号码/统一社会信用代码查询名下车辆数量。

  • 车辆理赔情况查询

    根据身份证号码/社会统一信用代码/车架号/车牌号,查询车辆是否有理赔情况。

    根据身份证号码/社会统一信用代码/车架号/车牌号,查询车辆是否有理赔情况。

  • 车辆过户次数查询

    根据身份证号码/社会统一信用代码/车牌号/车架号,查询车辆的过户次数信息。

    根据身份证号码/社会统一信用代码/车牌号/车架号,查询车辆的过户次数信息。

  • 风险人员分值

    根据姓名和身份证查询风险人员分值。

    根据姓名和身份证查询风险人员分值。

0512-88869195
数 据 驱 动 未 来
Data Drives The Future