缓存的种类

实际上所谓的缓存只有一种——它是请求资源的副本。试想一下,如果每一个资源我们客户端都会保存一份副本,这会怎么样?客户端会炸掉,开发者会疯掉!所以我们需要一份协议来处理缓存,可以让开发者控制缓存的建立和删除。谁呢?还能有谁,HTTP呗。HTTP协议里定义了很多关于缓存的请求和响应字段,这也是接下来我们重点要逼逼叨的对象,研究下究竟是哪些字段怎么影响缓存的。

缓存好处有很多:
1. 缓解服务器压力(不用每次去请求资源);2. 提升性能(打开本地资源速度当然比请求回来再打开要快得多);3. 减少带宽消耗(我相信你可以理解);

缓存在宏观上可以分成两类:私有缓存共享缓存。共享缓存就是那些能被各级代理缓存的缓存(咋觉得有点绕)。私有缓存就是用户专享的,各级代理不能缓存的缓存。

1. 浏览器缓存

我相信只要你经常使用某个浏览器🌎(Chrome,Firefox,IE等),肯定知道这些浏览器在设置里面都是有个清除缓存功能,这个功能存在的作用就是删除存储在你本地磁盘上资源副本,也就是清除缓存。缓存存在的意义就是当用户点击back按钮或是再次去访问某个页面的时候能够更快的响应。尤其是在多页应用的网站中,如果你在多个页面使用了一张相同的图片,那么缓存这张图片就变得特别的有用。😏

2. 代理服务器缓存

代理服务器缓存原理和浏览器端类似,但规模要大得多,因为是为成千上万的用户提供缓存机制,大公司和大型的ISP提供商通常会将它们设立在防火墙上或是作为一个独立的设备来运营。(下文如果没有特殊说明,所有提到的缓存服务器都是指代理服务器。)
由于缓存服务器不是客户端或是源服务器的一部分,它们存在于网络中,请求路由必须经过它们才会生效,所以实际上你可以去手动设置浏览器的代理,或是通过一个中间服务器来进行转发,这样用户自然就察觉不到代理服务器的存在了。🤥
代理服务器缓存就是一个共享缓存,不只为一个用户服务,经常为大量用户使用,因此在减少相应时间和带宽使用方面很有效:因为同一个缓存可能会被重用多次。

3. 网关缓存

也被称为代理缓存或反向代理缓存,网关也是一个中间服务器,网关缓存一般是网站管理员自己部署,从让网站拥有更好的性能。🙂CDNS(网络内容分发商)分布网关缓存到整个(或部分)互联网上,并出售缓存服务给需要的网站,比如国内的七牛云、又拍云都有这种服务。

4. 数据库缓存

数据库缓存是指当我们的应用极其复杂,表自然也很繁杂,我们必须进行频繁的进行数据库查询,这样可能导致数据库不堪重负,一个好的办法就是将查询后的数据放到内存中,下一次查询直接从内存中取就好了。关于数据库缓存本篇不会展开。🙃

浏览器的缓存策略

缓存的目标:

  • 一个检索请求的成功响应: 对于 GET请求,响应状态码为:200,则表示为成功。一个包含例如HTML文档,图片,或者文件的响应;
  • 不变的重定向: 响应状态码:301;
  • 可用缓存响应:响应状态码:304,这个存在疑问,Chrome会缓存304中的缓存设置,Firefox;
  • 错误响应: 响应状态码:404 的一个页面;
  • 不完全的响应: 响应状态码 206,只返回局部的信息;
  • 除了 GET 请求外,如果匹配到作为一个已被定义的cache键名的响应;

以上,对于我们可以和应该缓存的目标有个了解。🤗
浏览器对于缓存的处理是根据第一次请求资源时返回的响应头来确定的。
那么浏览器怎么确定一个资源该不该缓存,如何去缓存呢❓响应头!响应头!响应头!重要的事情说三遍。✌️
我们看🌰:

  1. Age:23146
  2. Cache-Control:max-age=2592000
  3. Date:Tue, 28 Nov 2017 12:26:41 GMT
  4. ETag:W/"5a1cf09a-63c6"
  5. Expires:Thu, 28 Dec 2017 05:27:45 GMT
  6. Last-Modified:Tue, 28 Nov 2017 05:14:02 GMT
  7. Vary:Accept-Encoding

1. 强缓存阶段

以上请求头来自百度首页某个CSS文件的响应头。我去除了一些和缓存无关的字段,只保留了以上部分。我们来分析下,Expires是HTTP/1.0中的定义缓存的字段,它规定了缓存过期的一个绝对时间。Cache-Control:max-age=2592000是HTTP/1.1定义的关于缓存的字段,它规定了缓存过期的一个相对时间。优先级上当然是版本高的优先了,max-age > Expires。这就是强缓存阶段,当浏览器再次试图访问这个CSS文件,发现有这个文件的缓存,那么就判断根据上一次的响应判断是否过期,如果没过期,使用缓存。加载文件,OVER!✌️Firefox浏览器表现为一个灰色的200状态码。Chrome浏览器状态码表现为:

200 (from disk cache)或是200 OK (from memory cache)

多说一点:关于缓存是从磁盘中获取还是从内存中获取,查找了很多资料,得出了一个较为可信的结论:Chrome会根据本地内存的使用率来决定缓存存放在哪,如果内存使用率很高,放在磁盘里面,内存的使用率很高会暂时放在内存里面。这就可以比较合理的解释了为什么同一个资源有时是from memory cache有时是from disk cache的问题了。那么当这个CSS文件过期了怎么办?ETagLast-Modified就该闪亮登场了。先说Last-Modified,这个字段是文件最后一次修改的时间;ETag呢?ETag是对文件的一个标记,嗯,可以这么说,具体生成方式HTTP并没有给出一个明确的方式,所以理论上只要不会重复生成方式无所谓,比如对资源内容使用抗碰撞散列函数,使用最近修改的时间戳的哈希值,甚至只是一个版本号。

2. 协商缓存阶段

利用这两个字段浏览器可以进入协商缓存阶段,当浏览器再次试图访问这个CSS文件,发现缓存过期,于是会在本次请求的请求头里携带If-Moified-Since(对应Last-Moified)If-None-Match(对应Etag)这两个字段,服务器通过这两个字段来判断资源是否有修改,如果有修改则返回状态码200和新的内容,如果没有修改返回状态码304,浏览器收到200状态码,该咋处理就咋处理(相当于首次访问这个文件了),发现返回304,于是知道了本地缓存虽然过期但仍然可以用,于是加载本地缓存。然后根据新的返回的响应头来设置缓存。(这一步有所差异,发现不同浏览器的处理是不同的,chrome会为304设置缓存,firefox则不会)😑具体两个字段携带的内容如下(分别和上面的Last-ModifiedETag携带的值对应):

  1. If-Moified-Since: Tue, 28 Nov 2017 05:14:02 GMT
  2. If-None-Match: W/"5a1cf09a-63c6"

Etag 主要为了解决 Last-Modified 无法解决的一些问题,Etag的优先级高于Last-Modified
到这协商缓存结束。

3. 启发式缓存阶段

我们把上面的响应头改下:

  1. Age:23146
  2. Cache-Control: public
  3. Date:Tue, 28 Nov 2017 12:26:41 GMT
  4. Last-Modified:Tue, 28 Nov 2017 05:14:02 GMT
  5. Vary:Accept-Encoding

发现没?浏览器用来确定缓存过期时间的字段一个都没有!那该怎么办?有人可能会说下次请求直接进入协商缓存阶段,携带If-Moified-Since呗,不是的,浏览器还有个启发式缓存阶段😎根据响应头中2个时间字段 Date 和 Last-Modified 之间的时间差值,取其值的10%作为缓存时间周期。这就是启发式缓存阶段。这个阶段很容让人忽视,但实际上每时每刻都在发挥着作用。所以在今后的开发过程中如果遇到那种默认缓存的坑,不要叫嚣,不要生气,浏览器只是在遵循启发式缓存协议而已。

缓存 - 图1
👌对于缓存策略介绍到这,接下来再细细分析不同的HTTP首部字段的内容,以及它们之间的关系。

HTTP中和缓存相关的首部字段

HTTP报文是什么呢?就是HTTP报文,这是一个概念,主要由以下两部分构成:
1. 首部(header):包含了很多字段,比如:cookie、缓存、报文大小、报文格式等等)2. 主体(body):HTTP请求真正要传输的部分,比如:一个HTML文档,一个js文件
以上我们知道浏览器对于缓存的处理过程,也简单的提到了几个相关的字段。🤧接下来我们具体看下这几个字段:

1. 通用首部字段

字段名称 说明
Cache-Control 控制缓存具体的行为
Pragma HTTP1.0时的遗留字段,当值为”no-cache”时强制验证缓存
Date 创建报文的日期时间(启发式缓存阶段会用到这个字段)

2. 响应首部字段

字段名称 说明
ETag 服务器生成资源的唯一标识
Vary 代理服务器缓存的管理信息
Age 资源在缓存代理中存贮的时长(取决于max-age和s-maxage的大小)

3. 请求首部字段

字段名称 说明
If-Match 条件请求,携带上一次请求中资源的ETag,服务器根据这个字段判断文件是否有新的修改
If-None-Match 和If-Match作用相反,服务器根据这个字段判断文件是否有新的修改
If-Modified-Since 比较资源前后两次访问最后的修改时间是否一致
If-Unmodified-Since 比较资源前后两次访问最后的修改时间是否一致

4. 实体首部字段

字段名称 说明
Expires 告知客户端资源缓存失效的绝对时间
Last-Modified 资源最后一次修改的时间

浏览器缓存控制

HTTP/1.1一共规范了47种首部字段,而和缓存相关的就有以上12个之多。接下来的两个小节会一个一个介绍给大家。🤓

1. Cache-Control

通过cache-control的指令可以控制告诉客户端或是服务器如何处理缓存。这也是11个字段中指令最多的一个,我们先来看看请求指令:

指令 参数 说明
no-cache 强制源服务器再次验证
no-store 不缓存请求或是响应的任何内容
max-age=[秒] 缓存时长,单位是秒 缓存的时长,也是响应的最大的Age值
min-fresh=[秒] 必需 期望在指定时间内响应仍然有效
no-transform 代理不可更改媒体类型
only-if-cached 从缓存获取
cache-extension 新的指令标记(token)

响应指令

指令 参数 说明
public 任意一方都能缓存该资源(客户端、代理服务器等)
private 可省略 只能特定用户缓存该资源
no-cache 可省略 缓存前必须先确认其有效性
no-store 不缓存请求或响应的任何内容
no-transform 代理不可更改媒体类型
must-revalidate 可缓存但必须再向源服务器进确认
proxy-revalidate 要求中间缓存服务器对缓存的响应有效性再进行确认
max-age=[秒] 缓存时长,单位是秒 缓存的时长,也是响应的最大的Age值
s-maxage=[秒] 必需 公共缓存服务器响应的最大Age值
cache-extension - 新指令标记(token)

请注意no-cache指令很多人误以为是不缓存,这是不准确的,no-cache的意思是可以缓存,但每次用应该去想服务器验证缓存是否可用(即忽视强缓存,走协商缓存)。no-store才是不缓存内容。另外部分指令也可以组合使用,比如:

  1. Cache-Control: max-age=100, must-revalidate, public

上面指令的意思是缓存的有效时间为100秒,之后访问需要向源服务器发送请求验证,此缓存可被代理服务器和客户端缓存。

2. Pragma

这是HTTP/1.0里面的一个字段,但优先级很高,测试发现,Chrome和Firefox中Pragma的优先级高于Cache-Control和Expires,为了向下兼容,这个字段依然发挥着它的作用。🤔一般可能我们会这么用:


    1. <meta http-equiv="Pragma" content="no-cache">

    Pragma属于通用首部字段,在客户端上使用时,常规要求我们往html上加上上面这段meta元标签(而且可能还得做些hack放到body后面去。事实上这种禁用缓存的形式用处很有限:
    1. 仅有IE才能识别这段meta标签含义,其它主流浏览器仅能识别Cache-Control: no-store的meta标签;
    2. 在IE中识别到该meta标签含义,并不一定会在请求字段加上Pragma,但的确会让当前页面每次都发新请求(仅限页面,页面上的资源则不受影响)。读者可以自行拷贝后面模拟服务端决策的代码进行测试。
    服务端响应添加'Pragma': 'no-cache',浏览器表现行为和强制刷新类似。

    3. Expires

    这又是一个HTTP/1.0的字段,上面也说过了定义的是缓存到期的绝对时间。同样,我们也可以在html文件里直接使用:


    1. <meta http-equiv="expires" content="Thu, 30 Nov 2017 11:17:26 GMT">

    如果设置的是已经过去的时间会怎样呢?YES!!!则刷新页面会重新发送请求。Pragma禁用缓存,如果又给Expires定义一个还未到期的时间,那么Pragma字段的优先级会更高。🤖🤖Expires有一个很大的弊端,就是它返回的是服务器的时间,但判断的时候用的却是客户端的时间,这就导致Expires很被动,因为用户有可能改变客户端的时间,导致缓存时间判断出错,这也是引入Cache-Control:max-age指令的原因之一。

    4. Last-Midified

    接下来这几个字段都是校验字段,或者说是在协商缓存阶段发挥作用的字段。第一个就是Last-modified,这个字段不光协商缓存起作用,在启发式缓存阶段同样起到至关重要的作用。在浏览器第一次请求某一个URL时,服务器端的返回状态码会是200,响应的实体内容是客户端请求的资源,同时有一个Last-Modified的属性标记此文件在服务器端最后被修改的时间。like this:


    1. Last-Modified : Fri , 12 May 2006 18:53:33 GMT

    If-Modified-Since

    当浏览器第二次请求这个URL的时候,根据HTTP协议规定,浏览器会把第一次Last-Modified的值存储在If-Modified-Since里面发送给服务端来验证资源有没有修改。like this:


    1. If-Modified-Since : Fri , 12 May 2006 18:53:33 GMT

    服务端通过If-Modified-Since字段来判断在这两次访问期间资源有没有被修改过,从而决定是否返回完整的资源。如果有修改正常返回资源,状态码200,如果没有修改只返回响应头,状态码304,告知浏览器资源的本地缓存还可用。
    用途:

  • 验证本地缓存是否可用

    If-Unmodified-Since

    这个字段字面意思和If-Modified-Since相反,但处理方式并不是相反的。如果文件在两次访问期间没有被修改则返回200和资源,如果文件修改了则返回状态码412(预处理错误)。用途:

  • 与含有 If-Range消息头的范围请求搭配使用,实现断点续传的功能,即如果资源没修改继续下载,如果资源修改了,续传的意义就没有了;

  • POST、PUT请求中,优化并发控制,即当多用户编辑用一份文档的时候,如果服务器的资源已经被修改,那么在对其作出编辑会被拒绝提交

😈Last-Modified有几个缺点:没法准确的判断资源是否真的修改了,比如某个文件在1秒内频繁更改了多次,根据Last-Modified的时间(单位是秒)是判断不出来的,再比如,某个资源只是修改了,但实际内容并没有发生变化,Last-Modified也无法判断出来,因此在HTTP/1.1中还推出了ETag这个字段👇

5. ETag

服务器可以通过某种自定的算法对资源生成一个唯一的标识(比如md5标识),然后在浏览器第一次请求某一个URL时把这个标识放到响应头传到客户端。服务器端的返回状态会是200。


    1. ETag: abc-123456

    ETag的值有可能包含一个 W/ 前缀,来提示应该采用弱比较算法(这个是画蛇添足,因为 If-None-Match 用且仅用这一算法)。🙄

    If-None-Match

    If-None-Match和If-Modified-Since同时存在的时候If-None-Match优先级更高。当浏览器第二次请求这个URL的时候,根据HTTP协议规定,浏览器回把第一次ETag的值存储在If-None-Match里面发送给服务端来验证资源有没有修改。like this:


    1. If-None-Match: abc-123456

    Get请求中,当且仅当服务器上没有任何资源的ETag属性值与这个首部中列出的相匹配的时候,服务器端会才返回所请求的资源,响应码为200。如果没有资源的ETag值相匹配,那么返回304状态码。POST、PUT等请求改变文件的请求,如果没有资源的ETag值相匹配,那么返回412状态码。

    If-Match

    在请求方法为 GET) 和 HEAD的情况下,服务器仅在请求的资源满足此首部列出的 ETag之一时才会返回资源。而对于 PUT或其他非安全方法来说,只有在满足条件的情况下才可以将资源上传。用途:

  • For GET和 HEAD 方法,搭配 Range首部使用,可以用来保证新请求的范围与之前请求的范围是对同一份资源的请求。如果 ETag 无法匹配,那么需要返回 416(范围请求无法满足) 响应

  • 对于其他方法来说,尤其是 PUT, If-Match 首部可以用来避免更新丢失问题。它可以用来检测用户想要上传的不会覆盖获取原始资源之后做出的更新。如果请求的条件不满足,那么需要返回412(预处理错误) 响应

当然和Last-Modified相比,ETag也有自己的缺点,比如由于需要对资源进行生成标识,性能方面就势必有所牺牲。😕关于强校验和弱校验:

ETag 1 ETag 2 Strong Comparison Weak Comparison
W/“1” W/“1” no match match
W/“1” W/“2” no match no match
W/“1” “1” no match match
“1” “1” match match

🐝服务端缓存控制

ExpiresCache-Control:max-age=xxx同时存在的时候取决于缓存服务器应用的HTTP版本。应用HTTP/1.1版本的服务器会优先处理max-age,忽略Expires,而应用HTTP/1.0版本的缓存服务器则会优先处理Expires而忽略max-age。接下来看下和缓存服务器相关的两个字段。

6. Vary

Vary用来做什么的呢?试想这么一个场景:在某个网页中网站提供给移动端的内容是不同的,怎么让缓存服务器区分移动端和PC端呢?不知道你是否注意,浏览器在每次请求都会携带UA字段来表明来源,所以我们可以利用User-Agent字段来区分不同的客户端,用法如下:


    1. Vary: User-Agent

    再比如,源服务器启用了gzip压缩,但用户使用了比较旧的浏览器,不支持压缩,缓存服务器如何返回?就可以这么设定:


    1. Vary: Accept-Encoding

    当然,也可以这么用:


    1. Vary: User-Agent, Accept-Encoding

    这意味着缓存服务器会以User-AgentAccept-Encoding两个请求首部字段来区分缓存版本。根据请求头里的这两个字段来决定返回给客户端什么内容。

    7. Age

    这个字段说的是资源在缓存服务器存在的时长,前面也说了Cache-Control: max-age=[秒]就是Age的最大值。这个字段存在的意义是什么呢?用来区分请求的资源来自源服务器还是缓存服务器的缓存的。🤧但得结合另一个字段来进行判断,就是Date,Date是报文创建的时间。

    Date

    如果按F5频繁刷新发现响应里的Date没有改变,就说明命中了缓存服务器的缓存以下面的一个响应为🍐:

  1. Accept-Ranges: bytes
  2. Age: 1016859
  3. Cache-Control: max-age=2592000
  4. Content-Length: 14119
  5. Content-Type: image/png
  6. Date: Fri, 01 Dec 2017 12:27:25 GMT
  7. ETag: "5912bfd0-3727"
  8. Expires: Tue, 19 Dec 2017 17:59:46 GMT
  9. Last-Modified: Wed, 10 May 2017 07:22:56 GMT
  10. Ohc-Response-Time: 1 0 0 0 0 0
  11. Server: bfe/1.0.8.13-sslpool-patch

如上来自百度首页某个图片的响应字段。我们可以看到Age=1016859,说明这个资源已经在缓存服务器存在了1016859秒。如果文件被修改或替换,Age会重新由0开始累计。

Age消息头的值通常接近于0。表示此消息对象刚刚从原始服务器获取不久;其他的值则是表示代理服务器当前的系统时间与此应答消息中的通用消息头 Date的值之差。

上面这个结论归结为一个等式就是:


    1. 静态资源Age + 静态资源Date = 原服务端Date

    🐲用户操作行为对缓存的影响

    搜索了很久有没有关于这方面的权威总结,最后竟然在百度百科找到了也是很惊讶,我自己加了一条用户强制刷新操作浏览器的反应。强制刷新,window下是Ctrl+F5,mac下就是command+shift+R操作了。😌
操作 说明
打开新窗口 如果指定cache-control的值为private、no-cache、must-revalidate,那么打开新窗口访问时都会重新访问服务器。而如果指定了max-age值,那么在此值内的时间里就不会重新访问服务器,例如:Cache-control: max-age=5 表示当访问此网页后的5秒内不会去再次访问服务器.
在地址栏回车 如果值为private或must-revalidate,则只有第一次访问时会访问服务器,以后就不再访问。如果值为no-cache,那么每次都会访问。如果值为max-age,则在过期之前不会重复访问。
按后退按扭 如果值为private、must-revalidate、max-age,则不会重访问,而如果为no-cache,则每次都重复访问.
按刷新按扭 无论为何值,都会重复访问.(可能返回状态码:200、304,这个不同浏览器处理是不一样的,FireFox正常,Chrome则会启用缓存(200 from cache))
按强制刷新按钮 当做首次进入重新请求(返回状态码200)

😉如果想在浏览器点击“刷新”按钮的时候不让浏览器去发新的验证请求呢?办法找到一个,知乎上面一个回答,在页面加载完毕后通过脚本动态地添加资源:

  1. $(window).load(function() {
  2. var bg='http://img.infinitynewtab.com/wallpaper/100.jpg';
  3. setTimeout(function() {
  4. $('#bgOut').css('background-image', 'url('+bg+')');
  5. },0);
  6. });

模拟实现服务端决策

如下,使用node原生代码简单的模拟下服务器发送响应的过程,包括对于协商缓存的处理过程:

  1. var http = require('http');var fs = require('fs');var url = require('url');
  2. process.env.TZ = 'Europe/London';
  3. let tag = '123456';
  4. http.createServer( function (request, response) {
  5. var pathname = url.parse(request.url).pathname;
  6. console.log("Request for " + pathname + " received.");
  7. const fileMap = {
  8. 'js': 'application/javascript;
  9. charset=utf-8',
  10. 'html': 'text/html',
  11. 'png': 'image/png',
  12. 'jpg': 'image/jpeg',
  13. 'gif': 'image/gif',
  14. 'ico': 'image/*',
  15. 'appcache': 'text/cache-manifest'
  16. }
  17. fs.readFile(pathname.substr(1), function (err, data) {
  18. if (request.headers['if-none-match'] === tag) {
  19. response.writeHead(304, {
  20. 'Content-Type': fileMap[pathname.substr(1).split('.')[1]],
  21. 'Expires': new Date(Date.now() + 30000),
  22. 'Cache-Control': 'max-age=10, public',
  23. 'ETag': tag,
  24. 'Last-Modified': new Date(Date.now() - 30000),
  25. 'Vary': 'User-Agent'
  26. });
  27. } else {
  28. response.writeHead(200, {
  29. 'Content-Type': fileMap[pathname.substr(1).split('.')[1]],
  30. 'Cache-Control': 'max-age=10, public',
  31. 'Expires': new Date(Date.now() + 30000),
  32. 'ETag': tag,
  33. 'Last-Modified': new Date(Date.now() - 30000),
  34. 'Vary': 'User-Agent'
  35. });
  36. response.write(fs.readFileSync(pathname.substr(1)));
  37. }
  38. response.end();
  39. });
  40. }).listen(8081);

如上代码。如果你没使用过node,拷贝下代码存为file.js,安装node,命令行输入node file.js,可以在同目录下建立index.html文件,在html文件中引用一些图片,CSS等文件,浏览器输入localhost:8081/index.html进行模拟。🤓