HTTP 缓存

325 阅读11分钟

前言

上一篇文章中,我们梳理了一下 HTTP 通信的相关步骤,里面在讲到浏览器缓存的时候,漏了一个比较重要的知识点,那就是我们今天要细说的 HTTP 缓存。

HTTP 缓存方案

HTTP 缓存是 web 性能优化中非常重要的一环,HTTP 缓存策略分为两种,一种是不需与服务器沟通,在本机就处理完成的“强缓存”;另一种是需要发送 HTTP 请求,与服务器协商之后再确定要不要使用缓存的“协商缓存”。有一点我们要始终明确,那就是不论是强缓存还是协商缓存,缓存资源真正存放和使用的的地方都是客户端。

HTTP 缓存的发展历程

在正式开始之前,我们先来探讨一个问题,我们为什么要使用缓存 ?

我们先来看一个例子。

假如,我们现在要从服务器拿一个资源,这个资源叫 index.js,在没有缓存的情况下,我们每次都要构建 HTTP 请求、三次握手建立 TCP 连接、发送请求数据包、接受响应数据包,然后进行网络下载等等。每次都要走这么长的一个流程去拿这个资源,1 是浪费渲染性能,2 是浪费网络流量,怎么算都是很不划算的。

Expires

于是,聪明的 HTTP 开发工程师们想了一个办法,他们在返回资源的响应头里加了一个字段 Expires,这个字段是干嘛的呢 ?这个字段其实就是服务器和浏览器做的一个约定,这个字段里存了一个时间,相当于服务器告诉浏览器说,这个资源,你先存个副本在你本地,在我给你的这个时间之前,你如果再想要拿这个资源,你就从你本地的副本里直接拿,不用再绕那么一大圈问我要了,等到这个时间到期了,你再来问我要。这样的话,index.js 这个资源我们只要请求和下载一次,后面的很长一段时间我们就能直接从本地磁盘里面读取了,完美的解决了上一段中我们提到的两个问题。

但是,事情真的就这么简单的圆满了吗 ?答案是,并没有。

很快,这个方案在真正实行的过程中暴露出来了很多问题,其中一个最致命的问题就是,服务器上取的时间和浏览器上使用的时间,有可能并不一致。这样问题就大了,如果这个时间都没办法统一,那么这个方案的底层逻辑就直接崩坏了。

Cache-Control

于是,开发工程师们在 HTTP 1.1 里马上对这个问题进行了修复,他们在响应头里加了一个新的字段 Cache-Control。Cache-Control 的实现思路相对 Expires 而言其实并没有大改,它只是把原来那个固定的,服务器告诉浏览器的那个确切的时间改成了一个相对时间,也就是说,服务器现在不是直接告诉你,这个资源在哪个时间几时几分几秒过期了,它只告诉你说,从现在你接收完这个资源起,往后多长时间内,你是可以直接读本地缓存的。

Cache-Control 的设置样例:

Cache-Control:max-age=3600 // 单位:秒

上面这段配置的意思就是,浏览器从接收完这个资源起,往后的 1 小时内如果再需要使用这个资源,浏览器就可以从本地磁盘副本里直接拿,不用找服务器要了。

好了,那么到目前为止,问题解决了吗 ?问题解决了,你可以松一口气了。但是,新的问题又出现了。

Last-Modified

这些新的问题,其实不算是 BUG,但是确实是问题,而且很有解决的必要,什么问题呢 ?

比如说,我现在还是从服务器去拿那个 index.js 资源,第一次请求到了,也下载好了缓存好了,那么当过期时间到了之后,无论 index.js 有没有变化,我们都会去重新请求一次,这是第一个问题。第二个问题是,按照目前的缓存方案,如果缓存未到期,无论 index.js 被更改了多少次,浏览器用的始终还是第一个版本,这样的滞后理论上来讲是不允许的。

基于这两个问题,工程师们又引入了一个新的字段 Last-Modified。

Last-Modified 翻译过来就是“最后修改时间”,是服务器对资源在被修改时打的一个标记。这个字段是怎么使用的呢 ?

服务器每次在给浏览器返回资源的时候,响应头上都会带上这个字段,对应的,浏览器接受到之后会将这个字段保存下来,当再次请求这个资源,就会在请求头中带上一个叫做 If-Modified-Since 字段,而 If-Modified-Since 的值就是 Last-Modified 的值,这样,服务器在拿到这个值之后,就会和这个资源当前时间下的 Last-Modified 值作对比,如果 If-Modified-Since 里的时间小于 Last-Modified 的值,说明浏览器端的资源不是最新的,服务器就会将最新的资源返回给浏览器,否则就会返回一个 304 的状态码,告诉浏览器本地的缓存可以继续使用。

但是,Last-Modified 有一个精确度的问题,Last-Modified 的值只能精确到秒,这个问题会产生什么后果呢 ?比如说,我现在通过请求拿到了 index.js 资源并且缓存好了,设置了缓存过期时间一个小时,然后,服务器上的 index.js 在发送后接下来的一秒钟内被修改了很多次,而这个操作是不会改变 Last-Modified 的实际值的,因为它只能精确到秒,这样一来,一个小时后当浏览器再来请求资源的时候,服务器对比 If-Modified-Since 和 Last-Modified 的值发现没有变化,直接就给浏览器返回了 304,这样虽然 index.js 已经被修改了很多次,但是浏览器上使用的依然是老版本的资源。

ETag

上面这个问题相对 HTTP 缓存之前的完善程度来讲,算是有点刁钻了,但是工程师们还是不负众望给出了解决方案。他们是怎么做的呢 ?

他们把 Last-Modified & If-Modified-Since 这个模式做了一个升级,引入了一个新的 ETag 字段。ETag 的实现逻辑是什么样的呢 ?ETag 其实只是在 Last-Modified 的基础上做了一个小小的改动,ETag 里面记录的是服务器根据当前资源的内容生成的唯一标识码,也就是说,只要资源的内容有变动,这个值就会改变。ETag 使用策略和 Last-Modified 就一模一样了,都是在响应头里先传给浏览器,然后浏览器在下次请求服务器的时候带上这个值,只不过装这个值的字段换成了 If-None-Match,后面的逻辑就和 Last-Modified 一样了。

Last-Modified 与 ETag

在更新的精准度上 ETag 是要比 Last-Modified 更优秀的,ETag 是根据资源内容的变化去给资源更新生成标识的,它不像 Last-Modified 存在以下几点局限性:

  1. Last-Modified 记录的是资源的最后修改时间,假如我们现在,编辑了该资源文件但是并没有修改里面的内容,我们就是打开来看了一下,而这个动作也是会更新掉Last-Modified 的值的,最终结果就是,缓存被意外失效了,服务器又会给浏览器重新发送一份。

  2. 第二问题就是我们上面讲到的,Last-Modified 的值只能精确到秒。

性能上 Last-Modified 策略是要优于 ETag 的,毕竟 Last-Modified 需要记录的只有一个时间戳,而 ETag 则需要根据文件的具体内容生成哈希值。但是又但是,在策略挑选上,ETag 的方式还是更受欢迎的。

浏览器的缓存位置

前面讲了这么多关于缓存的策略,那么浏览器的缓存到底是存在什么地方的呢?

浏览器的缓存一共有 4 种,优先级由高到低分别是:Service Worker,Memory Cache,Disk Cache 和 Push Cache。

Service Worker:Service Worker 我们先放一放,到后面我们会专门写一篇文章来进行讲解,今天我们先看后面的。

Memory Cache:Memory Cache 指的是内存缓存,它的优点是存取最快,缺点是周期最短,当渲染进程结束后,内存缓存就不存在了。

Disk Cache:Disk Cache 指的是存储在计算机磁盘中的缓存,它的优点是存储量不限,存储时间长,缺点是存取相对较慢。

Push Cache:Push Cache 学名叫“缓存推送”,现在应用的并不广泛。

Memory Cache 与 Disk Cache

这两个缓存区是浏览器主要使用的缓存区,它们之间优缺点也相对明显,浏览器在进行缓存的时候为了均衡效率,采取了一定的策略。

  1. 空间占用比较大的缓存资源一般都会直接丢进 Disk Cache 里,反之则会被放进 Memory Cache 里。
  2. 在内存的使用率比较高的时候,缓存资源不分大小,都会优先进入 Disk Cache 里。

Cache-Control 的其他配置

上面我们线条性的将讲了一下 Http 缓存的具体过程以及相关策略,关于 Cache-Control 的部分,我们讲的稍微简陋了点,接下来我们再回过头来看一下 Cache-Control 的其他内容。

上面讲 Cache-Control 的时候,我们只拿出来一个 max-age,在响应头的 Cache-Control 里其实还有其他很多可配置的字段,比如说:

private:配置了这个字段后,服务端的中间代理服务器就不能对该资源进行缓存了,缓就只能存在客户端。

public:这个配置意思就是说,代理服务器对这个资源的缓存不受限制。

no-cache:这个字段就是服务器告诉浏览器说,这个资源你就不要在本地做强缓存了,你需要的时候你就直接找我要,我们走缓存协商。

no-store:这个字段配置之后,浏览器和服务器都不会进行缓存。

s-maxage:这个字段定义的也是相对缓存失效时间,只是它是单独针对代理服务器的,对浏览器无效。

请求头中的 Cache-Control

在最初理解 Cache-Control 的时候,有个问题就一直困扰着我,那就是请求头中的 Cache-Control 和响应头中的 Cache-Control 到底有啥区别?它们控制的实现是一样的,还是说这里面另有门道?

带着这个问题,我寻访了各大博主和大佬的留笔,其中找到了一篇富有探究性的文章,这篇文章详细记录了作者在相同问题上的一些实验,实验的具体步骤和进行方式我在这里就不细述了,咱们直接来看一下结果:

场景一:当客户端和服务端都不设置 max-age 时,每次获取资源都需要重新下载,没有走缓存策略。

场景二:当服务端设置了 max-age 而客户端不设置时,第二次加载时资源直接命中了强缓存,浏览器的请求被终止。

场景三:当服务端没有设置,客户端设置了 max-age 时,实验结果和场景一一样,也就是说,这个场景下的设置并没有生效。

场景四:当服务端和客户端都设置了 max-age,但是 max-age 的值不一样时发现,缓存的真实失效时间遵循的是服务端设置的时间。

场景五:当客户端设置 max-age = 0,而服务端设置 max-age = 3600 时,客户端会跳过强缓存直接去和服务器走协商缓存。

这个实验的结论这位大佬最后也帮我们总结了一下:

  1. 缓存只能由服务端开启,客户端单独配置无效
  2. 只有当服务端开启了缓存,客户端不想走强缓存时,在请求头里配置 cache-control: no-store | no-cache | max-age = 0 才会生效。