This website requires JavaScript.

图说 HTTP 缓存

2018.03.15 11:02字数 8204阅读 301喜欢 0评论 0

无论是软件应用还是硬件应用,缓存都扮演着重要的角色,其对提升性能的重要性无可置疑。

本文主要介绍 HTTP 缓存,涉及其原理和应用。HTTP 缓存主要通过 HTTP 首部来控制。

缓存示例

先看一个简单的缓存示例:

  1. 浏览器首次请求 app.js 时,服务器会返回资源内容和相关头部,其中 Cache-Control: max-age=120 告诉浏览器说,这个资源的缓存有效期为 120 秒,从当前时间 Date: Mon, 05 Mar 2018 08:00:00 GMT 开始算起。浏览器收到资源后便将 app.js 及其相应头部存储在本地。

  2. 如果在 05 Mar, 2018 08:02:00 GMT 之前再次请求 app.js ,则浏览器会直接使用存储在本地的资源,而不用再次向服务器发起请求。

这个过程中,我们就说 app.js 被缓存且命中了。

基本概念

在进一步理解缓存之前,先看下跟缓存相关的几个概念:

  • 命中:请求数据不需再次下载,可以直接使用缓存数据

  • 过期:缓存数据超过设置的有效时间,将被标记为“陈旧”

  • 验证:判断过期缓存是否仍然有效,需要与服务器交互

  • 失效:缓存数据不再有效,需要从服务端重新下载新数据

基本理解

HTTP 缓存涉及到请求-响应链上的多个角色,包括客户端(本文指浏览器)、代理和服务器。其中,浏览器自身也实现了缓存功能。浏览器在请求资源时,总是先从本地缓存中查找,如果找到未过期资源,则直接使用,否则向服务器发起请求。代理也是服务器的一种,但一般情况下不会把它单独抽出来分析,只有在跟它有关的地方会把它区分于源服务器。所以,下文的示例图中将不会把它列进去。

HTTP 缓存的理解基本上可以总结为三个问题:

  1. 缓存数据可以存储在哪些设备上?(WHERE)

  2. 缓存数据如何判断过期?(HOW)

  3. 过期缓存内容是否真的需要重新下载?(WHETHER)

问题 1 说明存储缓存数据的设备是多样的,可以存储于各级代理服务器,也可以存储于浏览器本地。

问题 2 说明使用什么办法来判断缓存数据是否已经过期,当然是比较时间啦,那么如何比较呢?

问题 3 说明缓存虽然过期了,但是其内容仍然可能与服务端一致,这时就没必要重新下载相同数据,只需要向服务端询问下是否可以继续使用缓存即可。

带着上面三个问题去理解 HTTP 缓存头部设置会更有助于理解和记忆。

有人根据是否需要进行问题 3 中的重新验证把缓存策略的设置分为强缓存和协商缓存,强缓存无须再次验证的缓存策略,协商缓存是需要再次验证的缓存策略。两者的区别在于,协商缓存多发起了一次 HTTP 请求。

缓存首部

HTTP 缓存主要通过 HTTP 首部来实现缓存控制。这些与缓存相关的 HTTP 首部这里统称为缓存首部,具体首部如下表所示。

首部字段首次定义首部类型
PragmaHTTP/1.0通用首部
AgeHTTP/1.1响应首部
ExpiresHTTP/1.0实体首部
Cache-ControlHTTP/1.1通用首部
EtagHTTP/1.1响应首部
If-MatchHTTP/1.1请求首部
If-None-MatchHTTP/1.1请求首部
If-Modified-SinceHTTP/1.0请求首部
Last-ModifiedHTTP/1.0实体首部

其中,“首次定义”是指首次出现在哪个 HTTP 版本。之所以列出这项内容,是因为实际应用需要考虑兼容旧版 HTTP 。

现代的 HTTP 缓存策略主要使用 Cache-Control 实现,它是目前最新的缓存首部,用于取代较老的缓存首部如 Pragma 、Expires 等。所以应用中应该倾向于使用 Cache-Control 。但是为了支持只实现了 HTTP/1.0 的客户端设备,服务端通常还是都会同时设置 Expires、Pragma 和 Cache-Control 等,此时 Cache-Control 会有更高的优先级。提醒一下,现代浏览器都已支持 Cache-Control 。

Cache-Control

Cache-Control 是通用首部,这意味着它既可以出现在请求中,也可以出现在响应中。

Cache-Control 的值可由多个字段组合而成,以逗号分隔,如 Cache-Control: private,max-age=3600 。下面对常用的可取字段进行说明。

public: 表示当前响应数据所有用户共享的,可以被任何设备缓存,包括客户端、代理服务器等。

private: 表示当前响应数据是单个用户所独占的,只能被客户端缓存,不能被代理服务器缓存。

max-age=<seconds>: 指定缓存的有效时间,单位为秒。其值是任意整数,0 和负数表示缓存过期,正数值加上当前响应头中的 Date 首部值即为过期时间。

max-stale[=<seconds>]: 只用于请求,表示客户端仍然愿意接受过期缓存,只要过期时间没超过指定时间,如果未指定时间,则表示任何过期的时间。

min-fresh=<seconds>: 只用于请求,表示客户端愿意接受还剩余多少秒过期的缓存。

s-maxage=<seconds>: 功能与 max-age 一致,但它仅作用于共享缓存,对私有缓存无效。

no-cache: 并非字面意思,它并非禁止缓存,而是强制在使用已缓存数据之前,需要去服务端验证一下是否可以使用缓存数据。

no-store: 真正的禁止缓存,任何设备都不允许缓存,每次请求都需要向服务端重新获取数据。

no-transform: 表示响应的实体数据不应被转换。Content-Encoding 、Content-Range 和 Content-Type 首部也不能被修改。实际应用中,有些代理服务器会对图片资源进行格式转换以节省空间或者带宽。

作为通用首部,其部分指令值可以出现在请求首部,也可以出现在响应首部,两者可能略有区别:

指令值请求响应
public-可共享数据,可被任何设备缓存
private-用户私有数据,只能被客户端缓存
no-cache使用前需验证使用前需验证
no-store禁止使用缓存数据禁止缓存
max-age要求资源的 age 小于这个时间最大过期时间
min-fresh要求资源至少还剩余多少过期时间-
max-stale超过过期时间多少秒内仍愿意接受-
no-transform不要转换格式不要转换格式

这些指令用在请求首部的情况比较少见,最可能接触的地方是 Chrome DevTools 中的 Network 标签页。其中,有个 Disable cache 选项,选中后 DevTools 会自动给所有请求头部加上 Cache-Control: no-cache 首部,以告诉浏览器和代理使用本地缓存之前必须先验证。

Last-Modified/If-Modified-Since

If-Modified-Since 首部比较的是资源的修改时间,精度为秒,是一种缓存过期后的常用验证方式。一般来说,验证资源是否修改过,对比资源的修改时间是一种最简单的办法。

使用过程如下:

  1. 客户端首次请求 app.js 时,服务器响应带上 Last-Modified 首部,告诉客户端当前资源的最后修改时间。客户端根据 Cache-Control: max-age=120 ,把 app.js 和响应首部缓存起来。

  2. 客户端再次发起请求 app.js 时,把之前保存的 Last-Modified 时间放入 If-Modified-Since 首部发给服务器。服务器发现资源的 Last-Modified 时间没有发生改变,于是直接响应 304 。客户端收到 304 后,直接使用缓存的 app.js ,同时更新缓存有效期。

  3. 客户端再次发起请求 app.js 时,把之前保存的 Last-Modified 时间放入 If-Modified-Since 首部发给服务器。服务器发现资源的 Last-Modified 时间已经发生改变,于是响应 200 ,将修改后的 app.js 和新的 Last-Modified 发送给客户端。客户端收到 200 后,重新下载新的 app.js ,并把新的 app.js 和响应首部缓存起来,替换原先的旧缓存。

ETag/If-Matched/If-None-Match

ETag 叫实体标签(Entity Tag),用于表示实体资源是否发生变化,其生成原理类似 MD5 ,也是一种用于验证的首部。当响应的首部信息或者消息实体发生变化时,实体标签也会改变。

使用过程如下:

  1. 客户端首次请求 app.js 时,服务器响应带上 ETag 首部,告诉客户端当前资源的实体标签。客户端根据 Cache-Control: max-age=120 ,把 app.js 和响应首部缓存起来。

  2. 客户端再次发起请求 app.js 时,把之前保存的 ETag 值放入 If-None-Match 首部发给服务器。服务器发现自己的资源 ETag 值并没有发生改变,于是直接响应 304 。客户端收到 304 后,直接使用缓存的 app.js ,同时更新缓存有效期。

  3. 客户端再次发起请求 app.js 时,把之前保存的 ETag 值放入 If-None-Match 首部发给服务器。服务器发现自己的资源 ETag 值已经发生改变,于是响应 200 ,将修改后的 app.js 和新的 ETag 发送给客户端。客户端收到 200 后,重新下载新的 app.js ,并把新的 app.js 和响应首部缓存起来,替换原先的旧缓存。

当客户端本地存储有多个版本的资源时,会把所有的实体标签都上传,形如 ETag: "abc","def" ,服务端会使用 ETag 首部返回匹配中的实体标签值。

实体标签分为强标签(Strong ETag)和弱标签(Weak ETag),弱标签以 W/ 开头,如 ETag: W/"1234" 。强标签使用强比较,弱标签使用弱比较。强比较意味着两个比较对象的每一个字节都相同,弱比较意味着两者语义相同(Semantic Equivalence)。举个栗子,假如响应首部包含一个渲染时间 Rendered-Time,A 响应的渲染时间为 365,B 响应的渲染时间为 345,两个响应的实体内容一致。这种情况下,我们可以说 A 和 B 弱比较相等,强比较不相等。

一般来说,静态内容使用强标签,动态生成的内容使用弱标签。

由此可以看出,实体首部可以解决一些 Last-Modified 无法解决的问题:

  1. 某些服务器不能得到文件的精确的最后修改时间

  2. 修改时间变了并不意味着内容的改变,比如改完保存后又改回去

  3. 修改时间只能精确到秒,一秒内的修改无法判断

If-Match 和 ETag 的另一种用法:避免“空中碰撞”,以防编辑冲突。当客户端使用 PUT 或者 POST 更新服务端资源时,需要使用 If-Match 来携带实体标签给服务端,以确保客户端要修改的资源没有被别人修改过,避免覆盖别人的修改。不过这种用法比较少,可以不用深究。

Expires

Expires 指明资源的过期时间,如 Expires: Wed, 04 Jul 2012 08:26:05 GMT 。非法的日期格式(如 0)将会被当做过去的时间,表示该资源已经过期。

如果 Expires 和 Cache-Control 的 max-age 或者 s-maxage 同时出现,Expires 将被忽略。

Age

Age 表示资源在代理服务器上已经缓存了多久时间,单位为秒。如果是 Age: 0 ,表明该资源刚刚从服务器获取。它的计算方式一般使用代理服务器当前的时间减去缓存资源的 Date 时间。

Pragma

Pragma 是 HTTP/1.0 中引入的首部,现在使用时一般用于向后兼容 HTTP/1.0,不鼓励使用。

Pragma: no-cache 的作用与 Cache-Control: no-cache 一致,表示需要跟服务器进行验证后才能使用缓存资源。

启发式缓存策略

并不是每个服务器都会返回明确的缓存策略,这种情况下客户端会采取启发式缓存策略。注意,只有在服务端没有返回明确的缓存策略时才会激活启发式缓存策略。

启发式缓存策略会根据其他的首部信息来计算一个过期时间,其他的首部通常是 Date 和 Last-Modified 。此时,缓存有效期一般取两者差值的 10% 。

使用启发式缓存策略时,如果超过当前时间 24 小时且从未警告过,浏览器或者代理服务器应该在响应中产生一个警告首部字段 Warning: 113 。