简述

在 web 开发中,经常会用到缓存。这是因为很多客户端访问同样的一个服务器页面时,如果由服务器一遍的返回同样的一个页面内容。

这些数据会消耗掉昂贵的网络带宽,同时也会加重 web 服务器的负载。有了缓存,就可以保存第一条服务器相应的副本,后续的请求就可以由缓存的副本来直接返回到客户端了,这样可以减少重复的流量。

缓存的拓扑结构图:

(图片来源网络)

先搞清楚一些基本概念:

私有缓存

web 缓存分为私有缓存和代理缓存。私有缓存存储在客户端中,一般是浏览器。公有(代理)缓存则一般缓存在 CDN 服务器中。

公有缓存

公有缓存会接受来自多个用户的访问,通过它可以更好地减少冗余的流量。

缓存命中

浏览器在请求某一条链接时,会先检查本地是否含有该链接指向的资源的副本,如果有,则直接使用该副本提供服务,这被称为 缓存命中(cache hit),

缓存未命中

如果本地没有该资源的副本,则会请求服务器获取一份新的资源提供服务,同时缓存一份新的副本,这被称为 缓存未命中(cache miss);

缓存命中 和 缓存未命中 同时也适用于代理缓存中。

公有缓存处理流程

缓存的处理步骤:

1、 接收:缓存从网络中读取抵达的请求报文。

缓存检测到一条网络连接上的活动,读取输入数据。

2、解析:缓存对报文进行解析,提取出 URL 和各种首部。

缓存服务器将请求报文解析为片段,将首部的各个部分放入易于操作的数据结构中。

3、查询:缓存查看是否有本地副本可用,如果没有,就获取一份副本(并将其保存在本地)

在上一步中获取了 URL,这一步则查找本地副本。本地副本可能存储在服务器内存、本地磁盘,甚至附近的另一台计算机中(redis/mongo 等)。

已缓存对象中包含了服务器响应主体和原始服务器响应首部,这样就会在缓存命中时返回正确的服务器首部。

4、新鲜度检测:缓存查看已缓存副本是否足够新鲜,如果不是,就询问服务器是否有任何更新。

HTTP 通过缓存将服务器文档的副本保留一段时间。在这段时间里,都认为文档是“新鲜的”,缓存可以在不联系服务器的情况下,直接提供该文档。但一旦已缓存副本停留的时间太长,超过了文档的 新鲜度限值(freshness limit),就认为对象“过时”了,在提供该文档之前,缓存要再次与服务器进行确认,以查看文档是否发生了变化。

5、创建响应:缓存会用新的首部和已缓存的主体来构建一条响应报文。

我们希望缓存的响应看起来就像来自原始服务器的一样,缓存将已缓存的服务器响应首部作为响应首部的起点。然后缓存对这些基础首部进行了修改和扩充。

缓存还会对首部进行改造,以便与客户端的要求相匹配。比如服务器返回较旧版本的 HTTP 响应,而客户端期待的是较新的 HTTP 响应,缓存则会将其进行相应的转换。除此之外,缓存还会插入新鲜度信息(Cache-Control、Age 以及 Expires 首部),而且通常会包含一个 Via 首部来说明请求是由一个代理缓存提供的。

6、发送:缓存通过网络将响应发回给客户端。

一旦响应首部准备好了,缓存就将响应回送给客户端。

图片来源:《HTTP 权威指南》

文档过期

通过特殊的 HTTP Cache-Control 首部和 Expires 首部,HTTP 让原始服务器向每个文档附加了一个“过期日期”。

在文档过期之前,缓存可以以任意频率使用这些副本,而无需与服务器联系,除非客户端请求中包含有阻止提供已缓存或未验证资源的首部。一旦已缓存文档过期,缓存就必须与服务器进行核对,询问文档是否被修改过,如果被修改过,就要获取一份新鲜(带有新的过期日期)的副本。

Expires 首部是 HTTP/1.0+ 中定义的首部,指定一个绝对的过期日期。

但是后来 HTTP 的设计者发现每一台服务器的时间都不相同,所以在 HTTP/1.1 中使用 Cache-Control: max-age 首部来定义过期的相对时间。

Cache-Control: max-age 首部是 HTTP/1.1 中定义的首部,定义了文档的最大使用期,以秒为单位,如: Cache-Control: max-age=900。

服务器再验证

仅仅是已缓存文档过期了并不意味着它和原始服务器上目前处于活跃状态的文档有实际的区别;这只是意味着到了要进行核对的时间了。这种情况被称为“服务器再验证”,说明缓存需要询问原始服务器文档是否发生了变化。

If-Modified-Since: Date

  • 如果再验证显示内容 发生了变化 ,缓存会获取一份新的文档副本,并将其存储在旧文档的位置上,然后将文档发送给客户端。

  • 如果再验证显示内容 没有发生变化,缓存只需要获取新的首部,包括一个新的过期日期,并对缓存中的首部进行更新就行了。

最常见的缓存再验证首部是 If-Modified-Since。

请求时带上 If-Modified-Since 头,服务器获取到该头信息指定的日期,如果自指定日期后,文档被修改了,服务器就会返回整个新的 HTTP 报文,包括新的头部以及文档主体。

如果自指定日期后,文档没被修改过,则会向客户端返回一个小的 304 Not Modified 响应报文,不返回文档的主体以节约资源。

控制缓存

服务器可以通过 HTTP 定义的几种方式来指定在文档过期之前可以将其缓存多长时间。按照优先级递减的顺序,分为:

  • 附加一个 Cache-Control: no-store 首部到响应中;
  • 附加一个 Cache-Control: no-cache 首部到响应中;
  • 附加一个 Cache-Control: must-revalidate 首部到响应中;
  • 附加一个 Cache-Control: max-age 首部到响应中;
  • 附加一个 Expires 日期首部到响应中去;

我们使用得比较多的有 Cache-Control: no-store/no-cache,Cache-Control: max-age, 和 Expires;

  • no-store/no-cache 响应首部

    no-store 响应禁止缓存对响应进行复制。缓存会向客户端转发一条 no-store 响应,然后删除对象;

    no-cache 响应允许存储在本地缓存区中。只是在与原始服务器进行新鲜度再验证之前,缓存不能将其提供给客户端使用。

  • max-age

    Cache-Control: max-age 首部表示的是从服务器将文档传来之时起,可以认为此文档处于新鲜状态的秒数。还有一个 s-maxage 首部,行为与 max-age 相似,但仅仅适用于公有缓存。

  • Expires

    Expires 首部指的是实际的过期时间而不是秒数。HTTP 设计者后来认为,由于很多服务器的时钟都不同步或者不正确,最好还是使用剩余秒数而不是绝对时间来标识过期时间。

Etag

ETag 首部一般用于传递验证令牌。

  • 服务器使用 ETag HTTP 标头传递验证令牌。
  • 验证令牌可实现高效的资源更新检查:资源未发生变化时不会传送任何数据。

假定在首次获取资源 120 秒后,浏览器又对该资源发起了新的请求。首先,浏览器会检查本地缓存并找到之前的响应。如果该响应现已过期,浏览器无法使用。此时,浏览器可以直接发出新的请求并获取新的完整响应。不过,这样做效率较低,因为如果资源未发生变化,那么下载与缓存中已有的完全相同的信息就没有太大用处了。

这正是验证令牌(在 ETag 标头中指定)旨在解决的问题。服务器生成并返回的随机令牌通常是文件内容的哈希值或某个其他指纹。客户端不需要了解指纹是如何生成的,只需在下一次请求时将其发送至服务器。如果指纹仍然相同,则表示资源未发生变化,浏览器就可以跳过下载。

操作一次

基本的概念了解清楚了,可以操作一次。

使用 curl 可以发起 HTTP 请求,我们用一个 Vue 的 CDN 文件做实验

1
2
3
4
5
6
7
8
9
10
$ curl -X GET -I https://cdn.bootcss.com/vue/2.6.10/vue.common.dev.js
HTTP/2 200
······
content-type: application/javascript;charset=utf-8
content-length: 319630
······
cache-control: public, max-age=2592000
expires: Mon, 13 Jan 2020 10:48:17 GMT
last-modified: Wed, 20 Mar 2019 06:56:47 GMT
······

我们发起一个 GET 请求并打印返回的 HTTP 首部信息。可以看到返回的首部里面含有 last-modified 首部,我们用这个头的值设置到 If-Modified-Since 请求头中,再发起一次请求:

1
2
3
4
5
6
7
8
9
curl -X GET -H 'If-Modified-Since: Wed, 20 Mar 2019 06:56:47 GMT' -I https://cdn.bootcss.com/vue/2.6.10/vue.common.dev.js
HTTP/2 304
······
content-type: application/javascript;charset=utf-8
content-length: 0
······
cache-control: public, max-age=2592000
expires: Mon, 13 Jan 2020 10:51:52 GMT
······

可以看到服务器返回了 304 状态码。

试一下使用 EtagIf-None-Match 首部。

1
2
3
4
5
6
7
8
$ curl -X GET -I https://www.tmall.com/
HTTP/2 200
server: Tengine
content-type: text/html; charset=utf-8
······
cache-control: max-age=0, s-maxage=120
etag: W/"37821-20eAwBJ0dOA9Su29LgtnN+OX5M0"
······

www.tmall.com 返回了 etag 首部,我们用这个首部的值发起另一个请求:

1
2
3
4
5
6
7
8
9
10
11
$ curl -X GET -I https://www.tmall.com/ -H 'If-None-Match: W/"37821-20eAwBJ0dOA9Su29LgtnN+OX5M0"'
HTTP/2 304
server: Tengine
content-type: text/html; charset=utf-8
······
······
cache-control: max-age=0, s-maxage=120
etag: W/"37821-20eAwBJ0dOA9Su29LgtnN+OX5M0"
······
age: 89
······

可见 www.tmall.com 使用服务器返回的 Etag 首部和客户端发起请求的 If-None-Match 首部控制缓存。

如果服务器同时返回 Etaglast-modified 首部,则客户端发起请求时需要同时发送 If-None-MatchIf-Modified-Since 首部,只有这两个值同时验证通过时,服务器才会返回 304 状态码。

参考

《HTTP 权威指南》

HTTP 缓存 - Google developers

Chrome memory cache VS disk cache

一些关于 Http Cache 的东西 —— 知乎