HTTP 缓存控制

缓存(Cache)是计算机领域里的一个重要概念,是优化系统性能的利器。

由于链路漫长,网络时延不可控,浏览器使用 HTTP 获取资源的成本较高。所以,非常有必要把“来之不易”的数据缓存起来,下次再请求的时候尽可能地复用。这样,就可以避免多次请求 - 应答的通信成本,节约网络带宽,也可以加快响应速度。

实际上,HTTP 传输的每一个环节基本上都会有缓存,非常复杂。基于“请求 - 应答”模式的特点,可以大致分为客户端缓存和服务器端缓存,因为服务器端缓存经常与代理服务“混搭”在一起,所以今天我先讲客户端——也就是浏览器的缓存。

服务器的缓存控制

  1. 浏览器发现缓存无数据,于是发送请求,向服务器获取资源;
  2. 服务器响应请求,返回资源,同时标记资源的有效期;
  3. 浏览器缓存资源,等待下次重用;

cache.png

服务器标记资源有效期使用的头字段是“Cache-Control”,里面的值“max-age=30”就是资源的有效时间,相当于告诉浏览器,“这个页面只能缓存 30 秒,之后就算是过期,不能用。”

你可能要问了,让浏览器直接缓存数据就好了,为什么要加个有效期呢?
这是因为网络上的数据随时都在变化,不能保证它稍后的一段时间还是原来的样子。

“Cache-Control”字段里的“max-age”和上一讲里 Cookie 有点像,都是标记资源的有效期。

但我必须提醒你注意,这里的 max-age 是“生存时间”(又叫“新鲜度”“缓存寿命”,类似 TTL,Time-To-Live),时间的计算起点是响应报文的创建时刻(即 Date 字段,也就是离开服务器的时刻),而不是客户端收到报文的时刻,也就是说包含了在链路传输过程中所有节点所停留的时间。

比如,服务器设定“max-age=5”,但因为网络质量很糟糕,等浏览器收到响应报文已经过去了 4 秒,那么这个资源在客户端就最多能够再存 1 秒钟,之后就会失效。

“max-age”是 HTTP 缓存控制最常用的属性,此外在响应报文里还可以用其他的属性来更精确地指示浏览器应该如何使用缓存:

  • no-store:不允许缓存,用于某些变化非常频繁的数据,例如秒杀页面;
  • no-cache:它的字面含义容易与 no-store 搞混,实际的意思并不是不允许缓存,而是可以缓存,但在使用之前必须要去服务器验证是否过期,是否有最新的版本(如果有新版本,就使用新版本);
  • must-revalidate:又是一个和 no-cache 相似的词,它的意思是如果缓存不过期就可以继续使用,但过期了如果还想用就必须去服务器验证;

“no-cache”属性可以理解为 “max-age=0,must-revalidate”。

除了 “Cache-Control”,服务器也可以用“Expires”字段来标记资源的有效期,它的形式和 Cookie 的差不多,同样属于“过时”的属性,优先级低于 “Cache-Control”。还有一个历史遗留字段“Pragma: no-cache”,它相当于“Cache-Control: no-cache”,除非为了兼容 HTTP/1.0 否则不建议使用。

我把服务器的缓存控制策略画了一个流程图,对照着它你就可以在今后的后台开发里明确“Cache-Control”的用法了。

cache02.png

客户端的缓存控制

其实不止服务器可以发“Cache-Control”头,浏览器也可以发“Cache-Control”,也就是说请求 - 应答的双方都可以用这个字段进行缓存控制,互相协商缓存的使用策略。

当你点“刷新”按钮的时候,浏览器会在请求头里加一个“Cache-Control: max-age=0”。因为 max-age 是“生存时间”,max-age=0 的意思就是不需要浏览器缓存,而本地缓存里的数据至少保存了几秒钟,所以浏览器就不会使用缓存,而是向服务器发请求。服务器看到 max-age=0,也就会用一个最新生成的报文回应浏览器。

Ctrl+F5 的“强制刷新”又是什么样的呢?

它其实是发了一个“Cache-Control: no-cache”,含义和“max-age=0”基本一样,就看后台的服务器怎么理解,通常两者的效果是相同的。

强制刷新会清空请求头里的 If-Modified-Since 和 If-None-Match 字段,替换为 Cache-Control:no-cache。
刷新按钮不会,还是会携带 If-Modified-Since 和 If-None-Match 字段,这时服务器可能会响应状态码 304;

你可以访问实验环境的 URI “/20-1”,看看具体的请求 - 应答过程。

cache03.png

那么,浏览器的缓存究竟什么时候才能生效呢?

试着点一下浏览器的“前进”“后退”按钮,再看开发者工具,你就会惊喜地发现“from disk cache”的字样,意思是没有发送网络请求,而是读取的磁盘上的缓存。

另外,如果用之前说的重定向跳转功能,也可以发现浏览器使用了缓存:

http://www.chrono.com/18-1?dst=20-1

cache04.png

这几个操作与刷新有什么区别呢?

其实也很简单,在“前进”“后退”“跳转”这些重定向动作中浏览器不会“夹带私货”,只用最基本的请求头,没有“Cache-Control”,所以就会检查缓存,直接利用之前的资源,不再进行网络通信。

较早版本的 Chrome(66 之前)可以使用 URL “chrome://cache” 检查本地缓存,但因为存在安全隐患,现在已经不能使用。

条件请求

浏览器用“Cache-Control”做缓存控制只能是刷新数据,不能很好地利用缓存数据,又因为缓存会失效,使用前还必须要去服务器验证是否是最新版。

那么该怎么做呢?

浏览器可以用两个连续的请求组成“验证动作”:先是一个 HEAD,获取资源的修改时间等元信息,然后与缓存数据比较,如果没有改动就使用缓存,节省网络流量,否则就再发一个 GET 请求,获取最新的版本。

但这样的两个请求网络成本太高了,所以 HTTP 协议就定义了一系列“If”开头的“条件请求”字段,专门用来检查验证资源是否过期,把两个请求才能完成的工作合并在一个请求里做。而且,验证的责任也交给服务器,浏览器只需“坐享其成”。

条件请求一共有 5 个头字段,我们最常用的是“if-Modified-Since”和“If-None-Match”这两个。
需要第一次的响应报文预先提供“Last-modified”和“ETag”,然后第二次请求时就可以带上缓存里的原值,验证资源是否是最新的。

如果响应报文里提供了“Last-modified”,但没有“Cache-Control”或“Expires”,浏览器会使用“启发”(Heuristic)算法计算一个缓存时间,在 RFC 里的建议是:(Date - Last-modified) * 10%。

每个 Web 服务器对 ETag 的计算方法都不一样,只要保证数据变化后值不一样就好,但复杂的计算会增加服务器的负担。Nginx 的算法是“修改时间 + 长度”,实际上和 Last-modified 基本等价。

如果资源没有变,服务器就回应一个“304 Not Modified”,表示缓存依然有效,浏览器就可以更新一下有效期,然后放心大胆地使用缓存了。

cache05.png

“Last-modified”很好理解,就是文件的最后修改时间。ETag 是什么呢?

ETag 是“实体标签”(Entity Tag)的缩写,是资源的一个唯一标识,主要是用来解决修改时间无法准确区分文件变化的问题。

比如,一个文件在一秒内修改了多次,但因为修改时间是秒级,所以这一秒内的新版本无法区分。
再比如,一个文件定期更新,但有时会是同样的内容,实际上没有变化,用修改时间就会误以为发生了变化,传送给浏览器就会浪费带宽。

使用 ETag 就可以精确地识别资源的变动情况,让浏览器能够更有效地利用缓存。

ETag 还有“强”“弱”之分。

强 ETag 要求资源在字节级别必须完全相符,弱 ETag 在值前有个“W/”标记,只要求资源在语义上没有变化,但内部可能会有部分发生了改变(例如 HTML 里的标签顺序调整,或者多了几个空格)。

条件请求大概有如下几种情况:

  • “if-Modified-Since”和“Last-modified”;
  • “If-None-Match”和“弱 ETag”;
  • “If-None-Match”和“强 ETag”;

条件请求里其他的三个头字段是“If-Unmodified-Since”“If-Match”和“If-Range”,其实只要你掌握了“if-Modified-Since”和“If-None-Match”,可以轻易地“举一反三”。

强缓存/协商缓存

浏览器缓存里还有强缓存,协商缓存的概念,强缓存就是指服务器的缓存控制。

启用强缓存有以下几种情况。

  1. 存在 Cache-Control 属性, 设置 max-age 属性值或者 must-revalidate、public、private 属性值;
  2. 无 Cache-Control 属性时,存在 Expires 字段;

当强缓存失效并且服务端返回 Last-Modified 和 E-Tag,会走协商缓存。过程如下图。

browser_cache.png

设置 no-cache 时,并不是不允许缓存,而是可以缓存,但在使用之前必须要去服务器验证是否过期,是否有最新的版本,这时如果存在 ETag 或者 Last-modified 会走协商缓存,如果状态码为 304,使用本地缓存。

设置 no-store 时,禁止缓存,不存在强缓存协商缓存的概念。

总结

  1. 缓存是优化系统性能的重要手段,HTTP 传输的每一个环节中都可以有缓存;
  2. 服务器使用“Cache-Control”设置缓存策略,常用的是“max-age”,表示资源的有效期;
  3. 浏览器收到数据就会存入缓存,如果没过期就可以直接使用,过期就要去服务器验证是否仍然可用;
  4. 验证资源是否失效需要使用“条件请求”,常用的是“if-Modified-Since”和“If-None-Match”,收到 304 就可以复用缓存里的资源;
  5. 验证资源是否被修改的条件有两个:“Last-modified”和“ETag”,需要服务器预先在响应报文里设置,搭配条件请求使用;
  6. 浏览器也可以发送“Cache-Control”字段,使用“max-age=0”或“no_cache”刷新数据;

HTTP 缓存看上去很复杂,但基本原理说白了就是一句话:“没有消息就是好消息”,“没有请求的请求,才是最快的请求。”

知识拓展

1. Cache 和 Cookie 都是服务器发给客户端并存储的数据,你能比较一下两者的异同吗?

Cookie 会随请求报文发送到服务器,Cache 不会;

发起 HTTP 请求,可能会携带 If-Modified-Since (保存资源的最后修改时间)和 If-None-Match (保存资源唯一标识)字段来验证资源是否过期。

Cookie 在浏览器可以通过脚本获取(未设置 HttpOnly 时),Cache 不能通过脚本获取;

Cookie 通过响应报文的 Set-Cookie 字段获得,Cache 会缓存完整的报文;

Cookie 常用于身份识别、广告追踪,Cache 则是由浏览器管理,用于节省带宽、加快响应速度;

Cookie 的 max-age 是从浏览器拿到响应报文时开始计算的,Cache 的 max-age 是从响应报文的生成时间(Date 头字段)开始计算的;

2. 即使有“Last-modified”和“ETag”,强制刷新(Ctrl+F5)也能够从服务器获取最新数据(返回 200 而不是 304),请你在实验环境里试一下,观察请求头和响应头,解释原因。

强制刷新后,请求头里的 If-Modified-Since 和 If-None-Match 会被清空,变成 Cache-Control:no-cache。
服务器无法处理缓存,就只能返回最新的数据。

3. 缓存带来的好处

  1. 减少网络带宽消耗;
  2. 降低服务器压力;
  3. 减少网络延迟带来的影响,加快响应速度;