![[HTTP 系列] 第 3 篇 —— HTTP 缓存那些事](https://edge.yancey.app/beg/cgm30he0-1649091196932.jpg)
[HTTP 系列] 第 3 篇 —— HTTP 缓存那些事
这里是《写给前端工程师的 HTTP 系列》,记得有位大佬曾经说过:“大厂前端面试对 HTTP 的要求比 CSS 还要高”,由此可见 HTTP 的重要程度不可小视。文章写作计划如下,视情况可能有一定的删减,本篇是该系列的第 3 篇 —— 《深入理解 HTTP 的缓存机制》。
- [HTTP 系列] 第 1 篇 —— 从 TCP/UDP 到 DNS 解析
- [HTTP 系列] 第 2 篇 —— HTTP 协议那些事
- [HTTP 系列] 第 3 篇 —— HTTP 缓存那些事
- [HTTP 系列] 第 4 篇 —— HTTPS
- [HTTP 系列] 第 5 篇 —— 网络安全
- [HTTP 系列] 第 6 篇 —— 从输入 URL 回车到页面呈现
从一张图片的响应头说起

下面是一张图片的响应头, 我们复习一下各个字段:
-
Accept-Ranges: 该字段告知客户端, 服务器是否能处理范围请求, 当可以处理时其值为
`bytes`
, 否则为`none`
. -
Connection: 该字段决定当前的事务完成后, 是否会关闭网络连接. 如果该值是
`keep-alive`
, 网络连接就是持久的, 不会关闭, 使得对同一个服务器的请求可以继续在该连接上完成. 此外它还可以控制不再转发给代理的首部字段. -
Content-Length: 该字段表明实体主体的大小, 单位是字节.
-
Content-MD5: 该字段用于检查报文主体在传输过程中是否保持完整性, 以及确认传输到达. 服务端对报文主体执行 MD5 算法, 获取一个 128 位的二进制数, 再通过 base64 编码后将结果写入 Content-MD5 字段值. 因为 HTTP 首部无法记录二进制值, 因此需要通过 Base64 进行处理. 客户端在接收到响应后再对报文主体执行一次相同的 MD5 算法. 将计算值于该字段值比较, 即可判断出报文主体的准确性.
-
Content-Type: 报文主体的格式.
-
Date: 表示创建报文的日期和时间.
-
ETag: 该值是将资源以字符串的形式作唯一标识, 服务器给每份资源分配对应的 ETag 值. 当资源更新时, ETag 值也会更新. ETag 有
`强 ETag`
和`弱 ETag`
之分, 前者一般用于静态文件, 后者的字段值起始会有`W`
标志. -
Last-Modified: 该字段为服务器认定的资源做出修改的日期及时间, 它的精度比 ETag 要低, 也就是如果响应头中同时包含 ETag 和 Last-Modified 时, 会以 ETag 为准.
什么是 HTTP 缓存
当客户端向服务端请求资源时, 会先访问浏览器缓存, 如果浏览器有"要请求资源"的副本, 就可以直接从浏览器缓存中提取, 而不是从原始服务器中提取这个资源.
HTTP 缓存都是在第二次请求开始的. 第一次请求资源时, 服务器返回资源, 并在响应头中回传资源的缓存参数; 后续请求中, 浏览器判断这些请求参数, 命中强缓存就直接 200 from cache, 否则就把请求参数加到请求头中回传给服务器, 看是否命中协商缓存, 命中则返回 304, 并使用浏览器缓存, 否则服务器会返回新的资源.
根据是否需要向服务器重新发起请求来分类, 可分为强制缓存和协商缓存; 根据是否可以被单个或者多个用户使用来分类, 可分为私有缓存和共享缓存. 这篇文章我们主要来聊强制缓存和协商缓存.

强缓存
通过上面这张图, 我们知道强缓存由响应头中的 `Pragma`
, `Cache-Control`
和 `Expires`
控制, 因为 `Pragma`
已经在 HTTP1.1 被废弃了, 这里不做讨论.
对于 `Cache-Control`
和 `Expires`
, 如果两者都存在, 且 `Cache-Control`
设置了 max-age 或者 s-max-age, 那么 `Expires`
头会被忽略. 也就是说 `Cache-Control`
的优先级要比 `Expires`
高, 这是因为后者用的是服务器时间, 这就导致客户端跟服务端的时间不一致而发生错误; 此外, 在缓存未失效前, `Expires`
无法获取到修改后的资源.
两个字段本质上都是告知客户端对比本地时间和服务器返回的生存时间来检测缓存是否可用, 如果缓存没有超过它的生存时间, 响应的副本会一直被保存. 当超过指定的时间后, 缓存服务器在请求发送过来时, 转向源服务器请求资源.
下面简单复习一下 Cache-Control 的各个指令, 要注意不止服务器可以发 Cache-Control 头, 浏览器也可以发 Cache-Control 也就是说请求 - 应答的双方都可以用这个字段进行缓存控制, 互相协商缓存的使用策略. 下面是请求头中的 Cache-Control:
指令 | 指令值 | 说明 |
---|---|---|
max-age=[秒] | 必填 | 设置缓存存储的最大周期, 超过这个时间缓存被认为过期, 该指令优先级高于 `Expires` , 并且它传递的是一个相对时间, 而 `Expires` 传递的是一个未来的时间. |
max-stale(=[秒]) | 选填 | 在这个已过期的时间段之内, 客户端愿意接收一个已经过期的资源 |
min-fresh=[秒] | 必填 | 表示客户端希望在指定的时间内获取最新的响应. |
no-cache | 选填 | 表示客户端不会接收缓存过的响应, 在使用之前必须要去服务器验证是否过期, 是否有最新的版本. |
no-store | 无 | 不缓存请求或响应中的任何内容(注意这个才是告知浏览器不缓存资源). |
no-transform | 无 | 代理服务器不得对资源进行转换或转变, 比如 Content-Encoding, Content-Range, Content-Type 等字段信息. |
only-if-cached | 无 | 客户端只接受已缓存的响应, 并且不要向原始服务器检查是否有更新的拷贝, 若没有命中缓存, 则返回 504 状态码(Gateway Timeout). |
下面是响应头中的 Cache-Control:
指令 | 指令值 | 说明 |
---|---|---|
max-age=[秒] | 必填 | 设置缓存存储的最大周期, 超过这个时间缓存被认为过期, 该指令优先级高于 `Expires` , 并且它传递的是一个相对时间, 而 `Expires` 传递的是一个未来的时间. |
s-maxage=[秒] | 必填 | 覆盖 max-age 或者 Expires 头, 但是仅适用于共享缓存(比如各个代理), 私有缓存会忽略它. |
no-cache | 选填 | 表示客户端不会接收缓存过的响应, 在使用之前必须要去服务器验证是否过期, 是否有最新的版本. |
no-store | 无 | 不缓存请求或响应中的任何内容(注意这个才是告知浏览器不缓存资源). |
no-transform | 无 | 代理服务器不得对资源进行转换或转变, 比如 Content-Encoding, Content-Range, Content-Type 等字段信息. |
must-revalidate | 无 | 如果缓存不过期就可以继续使用,但过期了如果还想用就必须去服务器验证 |
public | 无 | 表明响应可以被任何对象(包括: 发送请求的客户端, 代理服务器, 等等)缓存, 即使是通常不可缓存的内容.(例如: 1.该响应没有 max-age 指令或 Expires 消息头;2. 该响应对应的请求方法是 POST.) |
private | 无 | 表明响应只能被单个用户缓存, 不能作为共享缓存(即代理服务器不能缓存它). 私有缓存可以缓存响应内容, 比如: 对应用户的本地浏览器. |
proxy-revalidate | 无 | 与 must-revalidate 作用相同, 但它仅适用于共享缓存(例如代理), 并被私有缓存忽略. |
下面是非标准的:
下面是响应头中的 Cache-Control:
指令 | 指令值 | 说明 |
---|---|---|
immutable | 无 | 一个实验性的属性, 表示响应正文不会随时间而改变. 资源(如果未过期)在服务器上不发生改变, 因此客户端不应发送重新验证请求头. |
stale-while-revalidate=[秒] | 无 | 表明客户端愿意接受陈旧的响应, 同时在后台异步检查新的响应. 秒值指示客户愿意接受陈旧响应的时间长度. |
stale-if-error=[秒] | 无 | 表示如果新的检查失败, 则客户愿意接受陈旧的响应. 秒数值表示客户在初始到期后愿意接受陈旧响应的时间. |

协商缓存
浏览器用 Cache-Control 做缓存控制只能是刷新数据, 不能很好地利用缓存数据, 又因为缓存会失效, 使用前还必须要去服务器验证是否是最新版. 当然浏览器可以用两个连续的请求组成验证动作: 先是一个 HEAD, 获取资源的修改时间等元信息, 然后与缓存数据比较, 如果没有改动就使用缓存, 节省网络流量, 否则就再发一个 GET 请求, 获取最新的版本. 但这样的两个请求网络成本太高了, 所以 HTTP 协议就定义了一系列 If 开头的条件请求字段, 专门用来检查验证资源是否过期, 把两个请求才能完成的工作合并在一个请求里做. 而且, 验证的责任也交给服务器, 浏览器只需坐享其成.
当第一次请求时服务器返回的响应头中符合如下三个条件之一, 浏览器第二次请求时就会与服务器进行协商, 即与服务端对比判断资源是否进行了修改更新.
-
没有 Cache-Control 和 Expires
-
Cache-Control 和 Expires 已经过期
-
Cache-Control 的属性值为 no-cache 时(即不走强缓存)
如果服务器端的资源没有修改, 就返回 304, 那么浏览器可以使用缓存中的数据, 否则直接返回 200 和新的资源. 跟协商缓存相关的头部属性有 `ETag/If-Not-Match`
和 `Last-Modified/If-Modified-Since`
, 他们是成对出现的. 其中 `ETag`
和 `Last-Modified`
是请求头中的字段, 而 `If-Not-Match`
和 `If-Modified-Since`
是响应头中的字段, 下图是对两者的比较.

协商缓存的流程是这样的: 当浏览器第一次向服务器发送请求时, 会在响应头中返回协商缓存的头属性: ETag 和 Last-Modified, 其中 ETag 返回的是一个 hash 值, Last-Modified 返回的是 GMT 格式的最后修改时间; 在后续的请求中, 会在请求头上带上 If-Not-Match 和 If-Modified-Since, 服务器在接收到这两个参数后会做比较, 如果返回的是 304 状态码, 则说明请求的资源没有修改, 浏览器可以直接在缓存中取数据, 否则, 服务器会直接返回数据.


为什么有了 Last-Modified 还需要 ETag 呢? ETag/If-Not-Match 是在 HTTP/1.1 出现的, 主要是修正 Last-Modified 一些不准确的问题:
-
Last-Modified 标注的最后修改只能精确到秒级, 如果某些文件在 1 秒钟以内, 被修改多次的话, 它将不能准确标注文件的修改时间
-
如果某些文件被修改了, 但是内容并没有任何变化, 而 Last-Modified 却改变了, 导致文件没法使用缓存
-
有可能存在服务器没有准确获取文件修改时间, 或者与代理服务器时间不一致等情形
多说一点 Etag
Etag 是实体标签(Entity Tag) 的缩写, 是资源的一个唯一标识, 主要是用来解决修改时间无法准确区分文件变化的问题. ETag 还有强弱之分. 强 ETag 要求资源在字节级别必须完全相符, 弱 ETag 在值前有个 `W/`
标记, 只要求资源在语义上没有变化, 但内部可能会有部分发生了改变(例如 HTML 里的标签顺序调整, 或者多了几个空格).
HTML 文件如何使用缓存
HTML 禁用缓存:
<meta http-equiv="cache-control" content="no-cache" />
HTML 设置缓存:
<meta http-equiv="Cache-Control" content="max-age=7200" />
用户行为对浏览器缓存的影响
所谓用户行为对浏览器缓存的影响, 指的就是用户在浏览器如何操作时, 会触发怎样的缓存策略. 主要有 3 种:
-
打开网页, 地址栏输入地址: 查找 disk cache 中是否有匹配. 如有则使用;如没有则发送网络请求.
-
普通刷新(Command + R): 因为 TAB 并没有关闭, 因此 memory cache 是可用的, 会被优先使用(如果匹配的话). 其次才是 disk cache. 其实是发了一个 Cache-Control: no-cache, 含义和 max-age=0 基本一样, 就看后台的服务器怎么理解, 通常两者的效果是相同的.
-
强制刷新(Command + Shift + R): 浏览器不使用缓存, 因此发送的请求头部均带有 Cache-control: no-cache(为了兼容, 还带了 Pragma: no-cache), 服务器直接返回 200 和最新内容.
私有缓存和共享缓存
私有缓存(浏览器级缓存): 私有缓存只能用于单独用户. 你可能已经见过浏览器设置中的缓存选项. 浏览器缓存拥有用户通过 HTTP 下载的所有文档. 这些缓存为浏览过的文档提供向后/向前导航, 保存网页, 查看源码等功能, 可以避免再次向服务器发起多余的请求. 它同样可以提供缓存内容的离线浏览, 并且只能用于单独的用户.
Cache-Control: Private
共享缓存(代理级缓存): 共享缓存可以被多个用户使用. 例如, ISP 或你所在的公司可能会架设一个 web 代理来作为本地网络基础的一部分提供给用户. 这样热门的资源就会被重复使用, 减少网络拥堵与延迟. 共享缓存可以被多个用户使用.
Cache-Control: Public
HTTP 的缓存代理
上面谈到的强缓存也好, 协商缓存也好, 都是基于客户端(浏览器)的缓存, 它能够减少响应时间, 节约带宽, 提升客户端的用户体验.
但 HTTP 传输链路上, 不只是客户端有缓存, 服务器上的缓存也是非常有价值的, 可以让请求不必走完整个后续处理流程, 就近获得响应结果.
特别是对于那些读多写少的数据, 例如突发热点新闻, 爆款商品的详情页, 一秒钟内可能有成千上万次的请求. 即使仅仅缓存数秒钟, 也能够把巨大的访问流量挡在外面, 让 RPS(request per second)降低好几个数量级, 减轻应用服务器的并发压力, 对性能的改善是非常显著的.
HTTP 的服务器缓存功能主要由代理服务器来实现(即缓存代理), 注意它不同于源服务器的缓存(比如使用 Memcache, Redis, Varnish).

代理服务收到源服务器发来的响应数据后需要做两件事. 第一个当然是把报文转发给客户端, 而第二个就是把报文存入自己的 Cache 里. 下一次再有相同的请求, 代理服务器就可以直接发送 304 或者缓存数据, 不必再从源服务器那里获取. 这样就降低了客户端的等待时间, 同时节约了源服务器的网络带宽.
源服务器的缓存控制
客户端和代理是不一样的, 客户端的缓存只是用户自己使用, 而代理的缓存可能会为非常多的客户端提供服务. 所以, 需要对它的缓存再多一些限制条件.
为了区分客户端上的缓存和代理上的缓存, 需要使用 Cache-Control 上的两个字段 private 和 public. private 表示缓存只能在客户端保存, 是用户私有的, 不能放在代理上与别人共享. 而public 的意思就是缓存完全开放, 谁都可以存, 谁都可以用. 比如你自己的 userId 肯定是不能被代理共享的, 否则别人可能通过你的 userId 就能拿到你私有的代理缓存.
缓存失效后的重新验证也要区分开, must-revalidate 是只要过期就必须回源服务器验证, 而新的 proxy-revalidate 只要求代理的缓存过期后必须验证, 客户端不必回源, 只验证到代理这个环节就行了.
缓存的生存时间可以使用新的 s-maxage(s 是 share 的意思), 只限定在代理上能够存多久, 而客户端仍然使用 max-age.
此外, 代理的 Cache-Control 还有一个特殊的缓存控制, 就是no-transform, 表示代理服务器不得对资源进行转换或转变, 比如 Content-Encoding, Content-Range, Content-Type 等字段信息. 因为我们知道代理服务器可以对报文做一些修改, 该字段勒令代理服务器不要对资源进行转换或转变. 举个例子, 你缓存了个 png 的文件, 如果代理服务器不小心把它转成了 application/json 那就惨了.
当然, 源服务器在设置完 Cache-Control 后必须要为报文加上 Last-modified 或 ETag 字段. 否则, 客户端和代理后面就无法使用条件请求来验证缓存是否有效, 也就不会有 304 缓存重定向.
客户端的缓存控制
客户端在 HTTP 缓存体系里要面对的是代理和源服务器, 也必须区别对待. 除了 max-age、no-store、no-cache 这三个属性, 还多了两个新属性 max-stale 和 min-fresh.
max-stale 的意思是如果代理上的缓存过期了也可以接受, 但不能过期超过 x 秒. min-fresh 的意思是缓存必须有效, 而且必须在 x 秒后依然有效. 换句话说, max-stale 相当于给 max-age 增加时间, min-fresh 相当于减少时间.
举个例子, max-age 是 10 天, max-stale 是 2 天, 那么 12 天內代理缓存都是有效的. 如果 min-fresh 是 1 天, 这意味着只有 9 天内缓存是有效的.
有的时候客户端还会发出一个特别的 only-if-cached 属性, 表示只接受代理缓存的数据, 不接受源服务器的响应. 如果代理上没有缓存或者缓存过期, 就应该给客户端返回一个 504(Gateway Timeout).

缓存清理
- 过期的数据应该及时淘汰, 避免占用空间;
- 源站的资源有更新, 需要删除旧版本, 主动换成最新版(即刷新);
- 有时候会缓存了一些本不该存储的信息, 例如网络谣言或者危险链接, 必须尽快把它们删除.
清理缓存的方法有很多, 比较常用的一种做法是使用自定义请求方法 PURGE, 发给代理服务器, 要求删除 URI 对应的缓存数据.
扩展: 多说一嘴重定向
重定向的一个原因就是资源不可用, 比如域名变了, 服务器变了等等, 为了避免出现 404, 需要跳转至另一个 URI. 另一个原因就是避免重复, 有的网站都会申请多个名称类似的域名, 然后把它们再重定向到主站上. 比如访问 z.cn 会被重定向到 www.amazon.cn/ref=z_cn?tag=zcn0e-23.
如果域名, 服务器, 网站架构发生了大幅度的改变, 比如启用了新域名, 服务器切换到了新机房, 网站目录层次重构, 这些都算是永久性的改变. 原来的 URI 已经不能用了, 必须用 301 永久重定向到新的 URI 上. 如果原来的 URI 在将来的某个时间点还会恢复正常, 常见的应用场景就是系统维护, 把网站重定向到一个通知页面, 告诉用户过一会儿再来访问. 另一种用法就是 服务降级 , 比如在双十一促销的时候, 把订单查询, 领积分等不重要的功能入口暂时关闭, 保证核心服务能够正常运行.
当然, 重定向也有缺点, 第一个就是性能损耗. 很明显 重定向的机制决定了一个跳转会有两次请求 - 应答. 比正常的访问多了一次. 站内重定向还好说, 可以长连接复用, 站外重定向就要开两个连接, 如果网络连接质量差, 那成本可就高多了, 会严重影响用户的体验. 此外, 一个风险就是循环跳转(又叫重定向死锁), 假设某个重定向在 A=>B=>C=>A 中无限转圈圈, 后果可想而知. 因此 HTTP 协议特别规定, 浏览器必须具有检测循环跳转的能力, 在发现这种情况时应当停止发送请求并给出错误提示.

HTML 的 meta 有个刷新, 但也可以用到重定向, 下面的例子是 5 秒后跳转到 `http://example.com/`
这个页面.
<head> <meta http-equiv="Refresh" content="0; URL=http://example.com/" /> </head>
JavaScript 也有重定向, 即 `window.location = "http://example.com/";`
;
它们的优先级如下:
- HTTP 协议的重定向机制永远最先触发, 即便是在没有传送任何页面, 也就没有页面被客户端读取的情况下.
- HTML 的重定向机制会在 HTTP 协议重定向机制未设置的情况下触发.
- JavaScript 的重定向机制总是作为最后诉诸的手段, 并且只有在客户端开启了 JavaScript 的情况下才起作用.
外部重定向和内部重定向
- 外部重定向: 服务器会把重定向的地址给浏览器, 然后浏览器再次的发起请求, 地址栏的地址变化了.
- 内部重定向: 服务器会直接把重定向的资源返给浏览器, 不需要再次在浏览器发起请求, 地址栏的地址不变.
其他
较早版本的 Chrome(66 之前)可以使用 chrome://cache 检查本地缓存, 但因为存在安全隐患, 现在已经不能用了.
no-cache 属性可以理解为 max-age=0,must-revalidate.
除了 Cache- Control 服务器也可以用, Expires 字段来标记资源的有效期, 它的形式和 Cookie 的差不多, 同样属于 过时的属性, 优先级低于 Cache_Control. 还有一个历史遗留字段 Pragma: no-cache, 它相当于 Cache- Control: no-cache, 除非为了兼容 HTTP/1.0, 否则不建议使用.
如果响应报文里提供了 Last-modified 但没有 Cache-Control 或 Expires 浏览器会使用启发(Heuristic)算法计算一个缓存时间, 在 RFC 里的建议是: (Date - Last-Modified) * 10%.
每个 Web 服务器对 ETag 的计算方法都不一样只要保证数据变化后值不一样就好, 但复杂的计算会增加服务器的负担. Nginx 的算法是修改时间 + 长度, 实际上和 Last-modified 基本等价.
有的缓存代理在命中缓存时, 会在响应头中加入一个 Age 字段, 表示报文的时候生存时间, 即已经在缓存里存了多久, 通常这个值会小于 Cache-Control 里的 max-age, 如果大于就认为数据是陈旧的.
判断缓存是否命中类似于查询 hash 表, 使用的 可以通常就是 URI, 在 Nginx 里可以用 proxy_cache_key 来自定义.
Nginx 对 Vary 的处理实际是做了 MD5, 把 Vary 头摘要后写入缓存, 请求时不仅比较 URI, 还要比较摘要.

总结
最后用一张图总结缓存机制:

参考
《图解 HTTP》 -- 上野 宣

PREVIOUS POST
关于我

NEXT POST
简析 AMD / CMD / UMD / CommonJS / ES Module