楔子
想象一下这样的场景:当你使用浏览器访问某个web站点的时候,你的浏览器需要下载很多的资源文件。而你下载的这些资源文件中有一部分在某一段时间甚至是很长时间内都不会发生变化,所以如果你每次访问这个站点都需要把它们重新下载一遍的话,那将是一种巨大的资源浪费,这不仅仅会拖慢你打开网页的速度,还会占用你的网络带宽,浪费你宝贵的流量,对于响应你的请求的源服务器也是一种巨大的负担。所以,浏览器可以将这部分资源文件缓存到本地,以便将来你需要的时候可以直接从浏览器缓存读取,而不用重新再从源服务器下载了,这就是浏览器缓存。
本文的目标,旨在尽可能阐述清楚你需要知道的关于浏览器缓存的所有重要知识点,包括什么是浏览器缓存、它的意义和价值、缓存的目标、缓存的工作原理、缓存的更新机制等等。在写这篇文章之前,我看过很多官方和个人介绍浏览器缓存的博客文章,它们是本文重要的信息来源,而且我会在它们的基础之上,结合一些实际测试的结论,补充讲解一些它们未曾涉及到但是却非常重要的、与实战紧密相关的知识点,来帮助大家更好地理解浏览器缓存的运行机制,以便将来可以在实际的工作当中更好地运用浏览器缓存。
本文讲解的重点是浏览器缓存,但是浏览器缓存也只是HTTP缓存的一种。所以在此之前,我们有必要先来了解一下HTTP缓存(以下简称缓存)。
1. 缓存的概念
缓存是指将某个请求的响应内容(包括响应头和响应主体)缓存下来,以便下次相同请求过来时能直接使用缓存进行响应,而不用重新再从源服务器下载。这样做有三个好处:
第一,加快了响应的速度。由于缓存服务器通常距离客户端更近,所以响应速度也更快。浏览器缓存其实就充当了一个安装在客户端本地的缓存服务器,所以响应的速度是极快的。
第二,减轻了源服务器的压力,相同的请求不用每次都由源服务器来进行响应,特别是一些很长时间甚至是根本不会发生变动的资源文件,例如:JavaScript库文件、CSS库文件以及图片文件等等;
第三,节省了用户的流量资源,降低了对网络资源的占用,可以有效缓解网络拥堵的情况。
所以,对于我们web开发者而言,深入地了解和利用好缓存是非常重要的,它对于提升我们的web应用的性能具有非常重要的意义。
2. 缓存的分类
缓存主要分为两大类:公有缓存(Shared Cache)和私有缓存(Local Cache)。顾名思义,公有缓存就是可以服务多个客户端请求的缓存服务器,例如CDN缓存;私有缓存就是只服务单个客户端的缓存服务器,例如浏览器缓存。其他的缓存类型包括:网关缓存、反向代理缓存以及负载均衡器等等。由于CDN缓存经常会在浏览器和源服务器的通信中间扮演一个重要的角色,而且会对浏览器的缓存行为产生一些重要的影响,所以后面我们也会重点关注一下CDN缓存。
3. 缓存的目标
理论上所有类型请求(GET、POST、PUT等等)的响应内容都可以被缓存下来,但是通常我们只缓存GET请求的响应。一般情况下缓存的key值就是请求的URI(也有URI和请求头的组合key值的情况,后面会讲到)。缓存的目标包括以下几种:
1、响应码为200的GET请求的响应内容,例如:HTML文档、图片等;
2、响应码为301的永久重定向响应;
3、响应码为404的空页面响应;
4、响应码为206的部分响应;
5、其他适合缓存的非GET请求的响应。
以我们的贝贷首页(https://jr.beibei.com/loan/borrow-index.html)为例,来看看浏览器都为它缓存了哪些内容。第一步先清空浏览器缓存(推荐使用Chrome插件Clear Cache),然后访问贝贷首页,第二步直接左上角刷新页面,我们从下面的截图中来看看它们的网络资源访问情况。
从上图中可以看到,第一次访问页面时,由于没有浏览器缓存,所以所有的资源文件都是新下载的,可以从红框中看到它们的文件大小和加载耗时;第二次访问页面时,由于部分资源文件已经被浏览器所缓存,所以它们是从浏览器缓存中直接读取的,不会真正发送网络请求(如果此时你用Charles抓包看的话,你会发现根本看不到它们的请求)。从图中可以看到,它们中有的是从内存中(from memory cache)读取的,且加载耗时都是0ms,有的是从磁盘中(from disk cache)读取的,耗时也不过几毫秒。至于为什么有些缓存文件会放在内存中,有些会放在磁盘上,这估计就跟Chrome浏览器的缓存管理机制有关了。
4. 缓存的机制
4.1 基本原理
前面提到,最大限度地利用缓存可以有效提升客户端页面的打开速度,还能减轻源服务器的压力,进而提升源服务器的响应效率。所以通常情况下,我们希望缓存文件存活的时间越长越好。但是当源服务器上的资源文件有更新时,我们也希望能尽快更新缓存服务器上的缓存文件。
但是,由于HTTP协议是一种基于请求/响应模式的网络协议,它只能由客户端主动向服务器发起请求,然后服务器才能给予响应,服务器是没有办法主动与客户端进行通信的。所以源服务器在资源文件有更新时是无法主动通知各级缓存服务器更新缓存文件的。基于这个前提之下,源服务器在响应缓存服务器的请求时,必须要指明缓存文件的有效期。
双方约定,在这个有效期内的缓存文件是有效的,缓存服务器可以直接用来响应客户端的请求,超过这个有效期的缓存文件就失效了,缓存服务器就不能直接用来响应客户端的请求了,它必须先向源服务器发送校验请求以确认缓存文件的有效性,然后再决定该如何响应客户端的请求。
当源服务器收到缓存服务器发来的缓存文件有效性校验请求时,它要么确认缓存文件依然有效,然后返回304 Not Modified以及新的响应头(不包含响应主体,即不包含资源文件内容)更新缓存文件的有效期,要么发现缓存文件已经失效,此时直接返回200 OK以及完整的响应内容(包含响应头以及响应主体),此时就好像响应一个普通的请求一样。当缓存服务器接收到源服务器返回的304 Not Modified响应时,它会先更新缓存文件的有效期,然后使用缓存文件响应客户端的请求,如果它接收到的是200 OK响应,那么它会重新缓存文件,并用新的缓存文件响应客户端的请求。
下面,我用一张图来解释一下“一般性的缓存工作基本原理”(注意,下图中的缓存服务器可以是CDN缓存服务器或者是浏览器缓存服务器):
我们还是以贝贷首页的HTML文件为例,来看看真实的情况如何。同样,我们还是分两步走,第一步清空缓存后访问页面,第二步直接访问页面,然后我们来看看它们的响应内容为何物(为了方便一张图观察到全部的重要信息,这里我使用的是Charles抓包截图):
可以看到,第一次请求时,服务器返回了200 OK以及完整的响应内容,耗时128ms,文件大小2.37KB,而第二次请求时,服务器只返回了304 Not Modified以及部分响应头,并没有响应主体,耗时59ms,文件大小不足1KB(因为只有304 Not Modified和部分响应头而已)。这说明了三个问题:第一,该HTML文件被浏览器缓存住了;第二,虽然它被缓存住了,但是当它被请求时,浏览器依然向源服务器发送了校验请求;第三,源服务器在接收到校验请求之后,验证缓存依然有效,所以只返回了304 Not Modified以及部分响应头。
上面的例子讲的是浏览器缓存,这里再附上一张来自MDN的讲解CDN缓存机制的示意图,其实大致的过程都是一样的:
4.2 缓存的控制
我们知道,为了能够在HTTP协议请求/响应模式的限制之下,尽可能地平衡好“让缓存的时间更长”和“资源有更新时尽早刷新缓存”的冲突,源服务器会在响应请求时,通过加入一些缓存控制字段来指明“响应是否可缓存”、“如果可以缓存有效期是多长”、“如果缓存过期失效该如何发送校验请求”等等重要信息。具体字段可以参考一下下面的这张图:
看过上面的这张图之后,你的脑海中也许会有一些疑问,我曾经也有过这些疑问,那么我就来尝试解答一下你的这些问题(下面的这些结论,我都使用Chrome浏览器亲自验证过)。
问题一:Cache-Control: no-cache和Cache-Control: no-store都是禁止缓存的意思吗?
不是。只有Cache-Control: no-store才是真正的禁止缓存,响应头中有这个字段的话,无论是公有缓存还是私有缓存都不会缓存这个响应。而Cache-Control: no-cache的真正含义是:在使用缓存之前,必须先向源服务器发起请求校验缓存的有效性。
问题二:那Cache-Control: no-cache和Cache-Control: must-revalidate的作用是完全一样的吗?
不是。它们之间的共同点在于:使用缓存之前都会向源服务器发送请求校验缓存的有效性。但是它们之间不同的是,Cache-Control: must-revalidate是在缓存已过期时才会向源服务器发起校验请求,而Cache-Control: no-cache则强制要求必须要向源服务器发起校验请求,无论缓存是否已过期。
问题三:Cache-Control: max-age=N和Cache-Control: s-maxage=N有什么相同点和不同点?
他们的相同点是,都可以用来指示缓存的有效时长是多少秒,且都对公有缓存生效。但Cache-Control: s-maxage=N只对公有缓存生效(这个s应该指的是server),对私有缓存不生效。当响应头中同时包含max-age和s-maxage时(例如:Cache-Control: max-age=100, s-maxage=100),公有缓存优先读取s-maxage的值。
问题四:那Expires和上面的两个值又有什么相同点和不同点呢?
它们的不同点在于,Expires指定的是缓存的过期时间点,是一个绝对时间,而max-age/s-maxage指定的是缓存的有效时长,是一个相对时间。
它们有几个相同点,第一是都是用于指定缓存的过期时间,第二是缓存服务器拿到它们之后,都是基于本地时间去计算缓存的实际过期时间的,无论是CDN缓存服务器还是浏览器缓存服务器都是如此。所以,如果使用绝对时间的话,那么由于每个缓存服务器的本地时间各不相同,会导致各自的实际缓存过期时间千差万别。但是,如果使用相对时间的话,那么缓存服务器会依据本地的相对时间差值来计算缓存的过期时间,相比之下误差会更小。
另外,缓存服务器会优先使用max-age/s-maxage计算缓存的失效时间,如果max-age/s-maxage不存在才会去取Expires的值计算缓存的失效时间。
问题五:Cache-Control: public和Cache-Control: private有哪些相同点和不同点?
它们的不同点是,public针对公有缓存和私有缓存都生效,而private仅针对私有缓存生效。它们的相同点是,实际中很少会被用到,所以对它们仅作了解即可。
问题六:Cache-Control: no-cache和Pragma: no-cache有什么相同点和不同点?
相同的是它们的作用一模一样,但是Pragma是HTTP/1.0的规范,所以添加它一般只是为了向下兼容。
问题七:Date和Age对于缓存失效时间的计算有什么影响?
Date是源服务器响应请求时的源服务器系统时间(注意!是源服务器,不是缓存服务器!)。Age是缓存文件在CDN服务器上已消耗的有效时长,如果浏览器接收到的响应含有Age字段,说明该响应来自CDN服务器,而不是来自源服务器。这两个值对于缓存过期时间的计算至关重要,接下来会详细说明。
4.3 浏览器缓存过期时间的计算(基于Chrome v70测试)
接下来,我们一起来探讨一下,浏览器缓存的过期时间是如何计算的。先来看一下下面的流程图:
可以看到,整个计算过程还是比较复杂的,我们一起来把其中的重要环节梳理一遍:
1、浏览器客户端发起网络请求,此时先不考虑命中浏览器缓存的情况,于是浏览器顺利收到响应,先记录一下此时的客户端系统时间为CDate,接下来开始判断是否需要缓存该响应。
2、检查响应头中是否有Age字段,如果没有则把Age置为0。如果存在Age值,则说明该响应来自CDN服务器。这个值代表的是,CDN服务器从上一次向源服务器发起请求更新缓存(可能是200 OK也可能是304 Not Modified)到本次响应浏览器请求所经过的时长,单位为秒。
3、检查响应头中是否有Date字段,如果没有则取Date为客户端系统时间。注意,这个Date值是源服务器输出该响应时的源服务器系统时间,如果该响应来自CDN服务器,那么说明你收到的响应是CDN服务器上的缓存,而这个Date值是上一次CDN服务器向源服务器发起请求更新缓存(可能是200 OK也可能是304 Not Modified)时源服务器添加在响应头中的源服务器系统时间,而不是此次CDN服务器响应浏览器请求时的CDN服务器时间,这一点一定要搞清楚!
4、检查响应头中是否有Cache-Control字段,如果有的话:
4.1 检查它的值是否包含no-store,包含的话则说明该响应不允许被缓存,任何缓存服务器不得缓存该响应。
4.2 否则,再检查它的值是否包含no-cache,如果包含的话,再检查响应头中是否有ETag或者Last-Modified字段,包含则缓存该响应并标记为已失效,否则就不缓存该响应。
4.3 否则,再检查它的值是否包含max-age字段,如果包含的话,再结合Age字段、Date字段以及客户端本地时间CDate值进行后续的缓存过期时间的计算,完整的判断过程都在图中,主要分为三种情况,我在这里说一下我个人的一个理解。第一种情况:Date + MaxAge <= CDate,说明此时即便服务器时间加上缓存的完整有效时长都比客户端本地时间要晚,因为计算出来的缓存过期时间永远是跟客户端本地时间做对比的,所以客户端可以直接认为这份缓存已经失效了,然后再查看响应头中是否有ETag或者Last-Modified字段来判断是缓存该响应并标记为已失效还是直接不缓存该响应;第二种情况:Date + MaxAge > CDate && Date + Age <= CDate,这种情况下我测试出来的缓存过期时间为Date + MaxAge,其实Date + Age就是此时源服务器的当前时间,也就是说此时源服务器的时间要早于客户端本地时间,基于目前测试的结果,缓存过期时间是Date + MaxAge而不是CDate + LeftAge,我只能暂且认为,源服务器宁可让客户端本地缓存早点过期早点发缓存有效性验证请求,也不希望当源服务器资源有更新时,客户端缓存不能及时更新;第三种情况:Date + Age > CDate,说明此时源服务器的时间要晚于客户端本地时间,那么缓存的过期时间为CDate + LeftAge也在意料之中,因为这样浏览器缓存和CDN缓存就都会在LeftAge秒之后过期,跟源服务器一开始约定好的缓存过期时间点就匹配上了。
5、如果响应头中不包含Cache-Control但包含Expires的话,那么缓存的过期时间就等于Expires减去Age的值,也就是Expires往前推Age秒之后的时间值。
6、如果Cache-Control和Expires都没有的话,那么查看是否存在Last-Modified字段,如果存在的话,那么先使用其他博客中所谓的“启发式缓存算法”计算出LeftAge的值,也就是Date和Last-Modified差值的十分之一,然后判断Date和CDate谁更小,用那个更小的值加上LeftAge就可以计算出缓存的过期时间了。
7、如果最后连Last-Modified都没有的话,那么再查看是否有ETag字段来判断是缓存该响应并标记为已失效还是直接不缓存该响应。
8、上面某些情况下缓存过期时间计算出来之后有可能是小于客户端本地时间的,也就是说缓存会立即变成失效状态,那么这个时候仍然要通过判断ETag和Last-Modified字段是否存在来判断是缓存该响应并标记为已失效还是直接不缓存该响应。
好了,以上就是浏览器缓存过期时间计算的整个过程了,需要提前说明的是,上面的所有结论我都用Chrome浏览器亲自验证过,但我不保证所有的浏览器或者web内核的处理方式都是这样的,所以整个过程仅供参考。
接下来,我会尝试用一段伪JS代码来描述整个过程:
4.4 缓存的有效性校验
上一节我们讲的是浏览器如何计算缓存的过期时间,这一节我们再来聊聊当缓存过期时,缓存服务器如何向源服务器发送校验请求,源服务器又是如何响应缓存服务器的校验请求的。其实主要的过程已经在4.1 基本原理中说明过了,这里我们只需要把一些技术细节和注意事项补充讲解一下就可以了。
源服务器在响应缓存服务器的请求时,会在响应头中加入一些字段,一些是为了指明缓存的过期时间,还有一些就是为了在缓存失效时做校验用的。这些校验字段分为强校验字段(ETag)和弱校验字段(Last-Modified)两种。
强校验字段ETag,通常是由源服务器根据资源文件的内容生成的唯一hash值,资源文件内容不变时hash值不变,资源文件内容有任何变化时hash值也会跟着变化,所以可以用来检验文件是否有改动。缓存服务器在发现缓存过期且缓存响应头包含ETag时,会向源服务器发送带有If-None-Match请求头的校验请求,它的值就是缓存响应头ETag的值,源服务器通过比对If-None-Match的值和当前资源文件的hash值是否相同即可判断出缓存服务器上的缓存文件是否需要更新。
弱校验字段Last-Modified,顾名思义,就是文件的最后修改时间,也可以用来检测文件是否有改动。同样,缓存服务器在发现缓存过期且缓存响应头包含Last-Modified时,会向源服务器发送带有If-Modified-Since请求头的校验请求,它的值就是缓存响应头Last-Modified的值,源服务器通过查看当前资源文件的Last-Modified日期是否等于If-Modified-Since日期即可判断出缓存服务器上的缓存文件是否需要更新。
同样,我们还是用一张流程图来说明整个过程:
我们还是以贝贷首页为例,先清空缓存再访问页面,返回了200 OK响应,如下图所示:
分析一下上面的截图,有以下几个关键信息:
1、返回了Cache-Control: max-age=0, s-maxage=300,说明源服务器希望浏览器缓存该文件但立即过期,也说明它希望CDN能缓存该文件且有效时长是300秒;
2、没有Age字段的返回,说明该响应来自源服务器而不是CDN服务器,事实上我们也没有把它放到CDN上,所以上面的s-maxage=300其实是无用的;
3、返回了Date: Sun, 09 Dec 2018 01:06:49 GMT就是此时源服务器的时间;
4、返回了Last-Modified: Thu, 06 Dec 2018 09:39:33 GMT,指明了缓存过期后发起校验请求的方式;
接下来,我们刷新页面,返回了304 Not Modified响应,如下图所示:
仔细看你就会发现,第二次请求时请求头中多了一个If-Modified-Since字段,它的值就是第一次请求时返回的Last-Modified的值,而且本次请求返回的Last-Modified值跟上次一样无变化,说明文件的最后修改时间没有发生变化,且响应主体也是空的。
之所以称Last-Modified是弱校验字段,原因就在于它只能精确到秒,如果资源文件在一秒钟之内被修改了多次,那么就有可能导致缓存服务器读取到的资源文件内容不是最新的,而且由于Last-Modified值没变化,从而导致缓存无法被更新。另外,有些资源文件是服务器动态生成的,那么就会出现某些资源文件虽然内容无变化但是Last-Modified值却一直在变的情况,导致一些不必要的刷新缓存操作。而且,在分布式系统当中使用Last-Modified时,也务必要保证各个服务器的时间是同步的,否则也会造成一些“该刷新的没刷新,不该刷新的却刷新了”的误判情况。
不过,使用ETag也有一些情况需要注意一下,也同样是在分布式系统当中,我们要保证各个系统生成资源文件的ETag值的算法是一致的,否则也会造成误判的情况。
除了ETag/If-None-Match和Last-Modified/If-Modified-Since之外,其实还有一些其他的不怎么常用的校验字段,这里就不一一列举了,有兴趣的小伙伴可以自行查阅。
五、Vary响应头
前面我们说过,缓存的key值就是请求的URI,这样的设计足够简单,也有利于缓存机制的推广,但是也会有不满足需求的情况。
例如,某电商网站提供了多国语言版本的官网,需要在用户访问同一官网地址时根据用户设定的浏览器语言返回相对应的版本。如果它也想使用缓存的话,那么只把请求的URI作为缓存的key值显然是不行的。所以,HTTP协议设计了Vary响应头,它指示缓存服务器命中该缓存的条件除了URI要相同之外,Vary中指定的请求头也都要匹配得上才能命中缓存。所以,针对上述案例,我们可以在响应头中加入Vary: Accept-Language,这样缓存服务器就会缓存不同语言版本的官网,然后根据用户请求时传过来的Accept-Language值返回相对应语言版本的官网。
再次盗用一张MDN的图,虽然它是以文件编码格式Accept-Encoding为例做的讲解,但其实意思是一样的:
六、如何禁用浏览器缓存
在Chrome的开发者工具的Network中勾选Disable cache可以让所有的请求都不检查浏览器缓存而直接发出,但是返回的资源该缓存的仍然会被缓存,只不过这个选项被勾中的话,在发请求阶段不会去检查浏览器缓存而已。它的实际做法就是在发请求阶段,给所有的请求头都加上Pragma: no-cache和Cache-Control: no-cache来达到跳过检查浏览器缓存阶段的效果的。
或者,你也可以使用Chrome插件Clear Cache来清空Chrome的浏览器缓存,这个插件的做法才是真正的清空浏览器缓存。
在Charles中勾选No Caching也可以达到相同的效果,它们都是通过在请求头中加入Pragma: no-cache和Cache-Control: no-cache来达到禁用缓存的效果的。在Chrome浏览器中,你还可以通过刷新按钮的右键菜单选择强制刷新,或者干脆使用来得更方便。
另外,如果你直接在浏览器的地址栏访问某个本可以被缓存的资源文件地址的话,它是不会直接走浏览器缓存的,而是每次刷新时,浏览器都会向源服务器发送校验请求,就好像这个资源文件的响应头被设置了Cache-Control: no-cache一样(其实并没有)。