无论是软件应用还是硬件应用,缓存都扮演着重要的角色,其对提升性能的重要性无可置疑。
本文主要介绍 HTTP 缓存,涉及其原理和应用。HTTP 缓存主要通过 HTTP 首部来控制。
缓存示例
先看一个简单的缓存示例:
- 浏览器首次请求
app.js
时,服务器会返回资源内容和相关头部,其中Cache-Control: max-age=120
告诉浏览器说,这个资源的缓存有效期为 120 秒,从当前时间Date: Mon, 05 Mar 2018 08:00:00 GMT
开始算起。浏览器收到资源后便将app.js
及其相应头部存储在本地。 - 如果在
05 Mar, 2018 08:02:00 GMT
之前再次请求app.js
,则浏览器会直接使用存储在本地的资源,而不用再次向服务器发起请求。
这个过程中,我们就说 app.js
被缓存且命中了。
基本概念
在进一步理解缓存之前,先看下跟缓存相关的几个概念:
- 命中:请求数据不需再次下载,可以直接使用缓存数据
- 过期:缓存数据超过设置的有效时间,将被标记为“陈旧”
- 验证:判断过期缓存是否仍然有效,需要与服务器交互
- 失效:缓存数据不再有效,需要从服务端重新下载新数据
基本理解
HTTP 缓存涉及到请求-响应链上的多个角色,包括客户端(本文指浏览器)、代理和服务器。
其中,浏览器自身也实现了缓存功能。浏览器在请求资源时,总是先从本地缓存中查找,如果找到未过期资源,则直接使用,否则向服务器发起请求。代理也是服务器的一种,但一般情况下不会把它单独抽出来分析,只有在跟它有关的地方会把它区分于源服务器。所以,下文的示例图中将不会把它列进去。HTTP 缓存的理解基本上可以总结为三个问题:
- 缓存数据可以存储在哪些设备上?(WHERE)
- 缓存数据如何判断过期?(HOW)
- 过期缓存内容是否真的需要重新下载?(WHETHER)
问题 1 说明存储缓存数据的设备是多样的,可以存储于各级代理服务器,也可以存储于浏览器本地。
问题 2 说明使用什么办法来判断缓存数据是否已经过期,当然是比较时间啦,那么如何比较呢?
问题 3 说明缓存虽然过期了,但是其内容仍然可能与服务端一致,这时就没必要重新下载相同数据,只需要向服务端询问下是否可以继续使用缓存即可。
带着上面三个问题去理解 HTTP 缓存头部设置会更有助于理解和记忆。
有人根据是否需要进行问题 3 中的重新验证把缓存策略的设置分为强缓存和协商缓存,强缓存无须再次验证的缓存策略,协商缓存是需要再次验证的缓存策略。
两者的区别在于,协商缓存多发起了一次 HTTP 请求。缓存首部
HTTP 缓存主要通过 HTTP 首部来实现缓存控制。这些与缓存相关的 HTTP 首部这里统称为缓存首部,具体首部如下表所示。
首部字段 | 首次定义 | 首部类型 |
---|---|---|
Pragma | HTTP/1.0 | 通用首部 |
Age | HTTP/1.1 | 响应首部 |
Expires | HTTP/1.0 | 实体首部 |
Cache-Control | HTTP/1.1 | 通用首部 |
Etag | HTTP/1.1 | 响应首部 |
If-Match | HTTP/1.1 | 请求首部 |
If-None-Match | HTTP/1.1 | 请求首部 |
If-Modified-Since | HTTP/1.0 | 请求首部 |
Last-Modified | HTTP/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
首部比较的是资源的修改时间,精度为秒,是一种缓存过期后的常用验证方式。一般来说,验证资源是否修改过,对比资源的修改时间是一种最简单的办法。
使用过程如下:
- 客户端首次请求
app.js
时,服务器响应带上Last-Modified
首部,告诉客户端当前资源的最后修改时间。客户端根据Cache-Control: max-age=120
,把app.js
和响应首部缓存起来。 - 客户端再次发起请求
app.js
时,把之前保存的Last-Modified
时间放入If-Modified-Since
首部发给服务器。服务器发现资源的Last-Modified
时间没有发生改变,于是直接响应 304 。客户端收到 304 后,直接使用缓存的app.js
,同时更新缓存有效期。 - 客户端再次发起请求
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 ,也是一种用于验证的首部。当响应的首部信息或者消息实体发生变化时,实体标签也会改变。
使用过程如下:
- 客户端首次请求
app.js
时,服务器响应带上ETag
首部,告诉客户端当前资源的实体标签。客户端根据Cache-Control: max-age=120
,把app.js
和响应首部缓存起来。 - 客户端再次发起请求
app.js
时,把之前保存的ETag
值放入If-None-Match
首部发给服务器。服务器发现自己的资源ETag
值并没有发生改变,于是直接响应 304 。客户端收到 304 后,直接使用缓存的app.js
,同时更新缓存有效期。 - 客户端再次发起请求
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
无法解决的问题:
- 某些服务器不能得到文件的精确的最后修改时间
- 修改时间变了并不意味着内容的改变,比如改完保存后又改回去
- 修改时间只能精确到秒,一秒内的修改无法判断
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
。