Web 缓存及策略制定

我们常常会注意到静态资源后面都跟着一串指纹码:
image_1b1c9hn2rcdv1jn7pav1gdtjs6m.png-20.6kB
image_1b1c9gujm10snq8f947iv5t49.png-28.4kB
image_1b1c9jtvr1aogmd0seg1o0714lu13.png-15.8kB
image_1b1c9of7qf6b1abfvmp1er12801g.png-17.2kB

同时我们在进行网络请求分析时会遇到 Cache-Control、Last-Modified 等这些 Header 字段。

在冥冥中,我们知道这些肯定与缓存有着重大的关系。缓存机制中有哪些关键的东西在起作用,如何制定最优的 缓存-更新 机制就是本文需要研究的问题。

前言

首先让我们来看一次经典的 请求-响应 过程,以 zhihu.com 登录页的某个 JavaScript 资源为例:
image_1b1cacu1n1b4019s61jo816fj5ud1t.png-586.8kB

我们从这次连接中看到以下与缓存相关的 HTTP(s) 请求头/响应头:

  • Request Header
    1. Cache-Control
    2. If-Modified-Since
    3. If-None-Match
  • Response Header
    1. Cache-Control(图上没有体现)
    2. Last-Modified
    3. ETag

一般,我们请求的缓存就是由上述 HTTP Header 进行校验控制。是否需要缓存?缓存层级?缓存多久?缓存到期后如何处理?这些问题就是接下来的文章需要解决的。

ETag & If-None-Match

ETag 我们可以很形象地称之为验证令牌,这个令牌在缓存不符合我们的 Cache-Control 指定的规则 时起作用。

ETag 由服务器生成,由 Response Header 携带传送给客户端进行保存,客户端请求 Request Header 通过 If-None-Match 携带缓存的 ETag 值给服务器进行校验。

ETag 是 Response Header 的字段,If-None-Match 是 Request Header 的字段,因为二者功能协作且字段值一致,下面统一用 ETag 代替。

假如我们有如下静态资源,请求细节如下:
image_1b1cbv67c17de1kdb16rce9mtk2a.png-18.4kB

现在过了 120 秒,浏览器又对该资源发起了请求。

首先,浏览器会检查本地缓存并找到之前的响应,不幸的是,这个响应缓存的文件现在已经“过期”,不能直接使用。此时,浏览器可以直接发出新请求,获取新的完整响应,但是这样做效率较低,因为如果本地缓存已过期,但服务器上资源在此期间未被更改过,我们就没有理由再去下载一遍

这就是 ETag 头中指定的验证令牌所要解决的问题1:服务器会生成并返回一个随机令牌,通常是文件内容的哈希值或者某个其他指纹码(具体实现细节由服务器决定)。客户端不必了解指纹码是如何生成的,只需要在下一个请求中将其(通过 If-None-Match 携带)发送给服务器:如果指纹码仍然一致,说明资源未被修改,我们就可以跳过下载,继续延长 Cache-Control(120s)。

1:如果资源未被更改过,我们就没有理由再去下载与客户端缓存中已有的完全相同的资源。

总结以上内容就是:

  1. ETag 在缓存过期(过了 Cache-Control 指定期限)之后起作用的
  2. ETag 在缓存过期后判定是对缓存续命还是更新

Last-Modified & If-Modified-Since

其实在 ETag 出现之前,就有了 Last-Modified/If-Modified-Since 验证机制。使用资源的最近一次更新时间(Last-Modified)进行校验是最符合我们日常认知的,也是最容易想到的一种方式。

Last-Modified 是 Response Header 的字段
If-Modified-Since 是 Request Header 的字段

ETag 的出现是对 Last-Modified 机制的补充与严谨化。

  • Last-Modified 标注的最后修改只能精确到秒级,如果某些文件在1秒钟以内,被修改多次的话,它将不能准确标注文件的修改时间
  • 如果某些文件会被定期生成,当有时内容并没有任何变化,但 Last-Modified 却改变了,导致文件没法使用缓存(而 ETag 是依据文件内容特征生成的指纹,能更精确地表示文件有无变化)
  • 有可能存在服务器没有准确获取文件修改时间,或者与代理服务器时间不一致等情形

Last-Modified 与 ETag 一起使用时,服务器会优先验证 ETag,一致的情况下,才会继续比对 Last-Modified,最后才决定是否返回 304。

Cache-Control

Cache-Control 有一个比较特殊的点:Request Header 有此字段,Response Header 也有这个字段。

max-age=[num]

服务器返回头 Cache-Control 的 max-age 告诉客户端:此资源在客户端缓存时间为多久

而比较特殊的 max-age=0 则相当于告知客户端:此资源刻意缓存在客户端,但每次重新请求都应该向服务器请求校验

  1. 服务器首次返回一个 max-age=0 的静态资源,客户端缓存到本地
  2. 客户端需要重新请求此静态资源时,发现本地有缓存,但是缓存已过期(因为 max-age=0)
  3. 客户端重新发送请求,携带 If-None-Match 头以及 max-age=0
  4. 服务端将收到的 If-None-Match 与文件的 ETag 比对
  5. 如果比对不一致,则下发静态资源,同时返回 Cache-Control 头和新的 ETag,客户端比对本地的 ETag 与返回的 ETag 后使用新的资源,并回到步骤 1
  6. 如果比对一致,则不下发返回静态资源,同时返回 Cache-Control 头和没变的 ETag,客户端比对本地的 ETag 与返回的 ETag 后直接使用缓存

no-cache

Response Header 的 no-cache 和 max-age=0 作用你可以将其等同起来。

链接1
链接2

  1. no-cache 不代表禁止客户端缓存
  2. no-cache 不是不缓存,而是不直接使用缓存(需要验证)

I believe max-age=0 simply tells caches (and user agents) the response is stale from the get-go and so they SHOULD revalidate the response (eg. with the If-Not-Modified header) before using a cached copy, whereas, no-cache tells them they MUST revalidate before using a cached copy.

Cache-Control: max-age=0Cache-Control: no-cache 可能存在的不同点是:可能在点击浏览器的前进后退按钮时会存在差异。Cache-Control: max-age=0 可以直接使用,而 Cache-Control: no-cache 则会验证。

还有一个问题时,在某些版本的浏览器下,客户端对 no-cache 与 no-store 的处理是一样的:都是直接不缓存。

no-cache 在 Request Header 的作用则比较简单:告诉服务器,本地没有缓存或不使用缓存,你需要给我最新的文件。Chrome 下的强制刷新以及 Disable Cache 采取的就是在 Request Header 使用 no-cache。

no-store

Cache-Control: no-store 直接禁止浏览器和所有中继缓存存储返回的任何版本的响应 - 例如:一个包含个人隐私数据或银行数据的响应。每次用户请求该资源时,都会向服务器发送一个请求,每次都会下载完整的响应。

public & private

服务器返回的 Cache-Control Header 如果有 public 字段,则表示可以多级缓存(用户代理、CDN、服务提供商)。

private 则表示单用户缓存,不允许任何中继缓存对其进行缓存 - 例如,用户浏览器可以缓存包含用户私人信息的 HTML 网页,但是中间服务商不能缓存。

实例:Express req.fresh 的判定机制

Express 中 req.fresh 用于判定客户端的请求是否是“新鲜”的请求(而不是对已缓存资源的请求),只有当请求头的 cache-control 不等于 no-cache 并且满足以下任意一条才表示客户端的请求是“新鲜”的(即服务器需要提供新资源):

  • 指定了 if-modified-since 请求头并且 last-modified 请求头等于或时间上早于 modified 响应头
  • if-none-match 请求头是 *
  • if-none-match 请求头无法匹配响应头的 etag

HTTP Caching 最佳实践

image_1b1ciceslctp1ho2ib1rvf1evj2n.png-47.8kB

缓存与频繁更新的矛盾

有些资源是需要频繁更新的,但是我们又确实希望客户端对其进行缓存。此时我们就需要针对我们资源的特性制定不同的缓存策略与缓存级别,并使用资源指纹 URL搭配实现资源的随意更新。

于是我们可以看回本文开始部分介绍的几个典型网站静态资源的指纹URL案例:

  1. index.xxxxxxx.js
  2. index$xxxxxx.js
  3. index-xxxxxxx.js
  4. /index.js?x.x.x

上面的 x 即为指纹区,选择何种指纹视项目与团队规范而定。

指纹 URL + Cache-Control 实现缓存与更新的精细控制案例:
image_1b1cj198v4itmue1ishdsecbj34.png-29.7kB

分析一下上面的例子:

  • HTML 被标记成 no-cache(或 max-age=0),这意味着浏览器在每次请求时都会重新验证文档,如果内容更改,会获取最新版本。同时,在 HTML 标记中,我们在 CSS 和 JavaScript 资源的网址中嵌入指纹码:如果这些文件的内容更改,网页的 HTML 也会随之更改,并将下载 HTML 响应的新副本。
  • 允许浏览器和中继缓存(例如 CDN)缓存 CSS,过期时间设置为 1 年。注意,我们可以放心地使用 1 年的“远期过期”,因为我们在文件名中嵌入了文件指纹码:如果 CSS 更新,网址也会随之更改。
  • JavaScript 过期时间也设置为 1 年,但是被标记为 private,也许是因为包含了 CDN 不应缓存的一些用户私人数据
  • 缓存图片时不包含版本或唯一指纹码(一般项目中也会包含指纹码),过期时间设置为 1 天。

组合使用 ETagCache-Control 和唯一网址(指纹 URL),我们可以提供最佳的方案:较长的过期时间,控制可以缓存响应的位置,以及按需更新。

注意点

  1. 网址区分大小写

  2. 除了文中提到的这些 Header 字段,服务器还可以自己制定与实现一些辅助缓存机制的字段。如:X-Cache 这样。

  3. 如果在应用中使用 Webview 来获取和显示网页内容,可能需要提供额外的配置标志,以确保启用了 HTTP 缓存,并根据用途设置了合理的缓存大小,同时,确保缓存持久化。查看平台文档并确认您的设置!

    例如在微信内 Webview 的缓存就有一些比较特殊的地方,具体可阅读开发文档。

  4. 在很多情况下我们还会在响应 Header 内遇到 Expires 字段,这是 HTTP 1.0 时的标准,主要是为了兼容较为老旧的浏览器。在 HTTP 1.1 中定义了 Cache-Control 代替 Expires。


参考资料:
Google: Web Fundamentals - HTTP Caching
What’s the difference between Cache-Control: max-age=0 and no-cache?

FuChee wechat
扫一扫,关注我