聊聊浏览器HTTP缓存机制

Posted by Yeoman on 2019-01-15

1. 何为缓存

缓存就是对已有资源的重复利用,到达减少网络请求,I/O操作的优化目的。缓存在软件架构中的很多节点都会涉及,而对于前端(非Node.js)而言,我们最关心的,当然是浏览器的HTTP缓存策略,比如我们经常看到的304 Not Modified以及200 (from memory cache)200 (from disk cache)

网上关于浏览器HTTP缓存的文章非常多,这周文章旨在个人总结以及网文中未提及的一些细节的补充。

本文不涉及service worker(PWA)的内容。

2. 强缓存

缓存的策略可以有很多种,HTTP缓存策略主要分为两种,首先根据设置的时间来决定资源是否失效,也就是强缓存,然后根据文件内容是否变化来决定资源是否失效,也就是协商缓存。

其中强缓存只要命中了,就不再有服务端交互。而协商缓存则像它的名字一样,是每次都需要和服务器协商过后,确认资源内容没有变更才会命中。

2.1 强缓存

和强缓存相关的HTTP头部有两种,Expires和Cache-Control。

Expires是HTTP 1.0引入的控制强缓存实践的概念,而Cache-Control是HTTP 1.1中引入的概念,它提供了比Expires更灵活的控制方式,可是说是Expires的替代方案。同时,当我们同时设置了两个响应头,Cache-Control的优先级要更高。因此,本篇文章我们只用Cache-Control作为例子。

对于浏览器而言,最常见的静态资源就是js,css以及图片。我们试下当我们设置了Cache-Control之后的变化。

可以看到,当我对两个资源同时设置了Cache-Control,并且二次加载的时候,两个文件都会从缓存中读取。不同的是,css文件是from disk cache,而jpeg是from memory cache

2.2 from disk cache vs from memory cache

新版本的Chrome把from cache区分成了from disk cache vs from memory cache

对于这两种强缓存的区别,so上很多回答都很明确。一种是从硬盘中读取,另一种是从内存中读取,显然,从内存中读取会更快,因为没有I/O操作,从图中也可以看出,从内存中读取几乎是秒读,但是生命周期到浏览器窗口进程关闭就结束了。

但是我并没有找到Chrome是基于何种策略来决定是缓存到硬盘还是缓存到内存中的。包括google的开发者文档中也没有提及。

不过看到上图的表现,我的第一反应是Chrome对于不同类型的资源采用了不同的缓存策略。

为了验证这个猜测,我访问了一些其他的站点,发现js,css资源也有被缓存到内存的情况,显然,Chrome并不是根据文件类型来进行区分的。

对比了两个请求的response之后,我猜测最大可能是根据文件大小来决定缓存策略。于是我把css文件改成和图片文件相同的大小,结果如下:

不难发现,这时候css文件确实是被缓存到内存了,也就是说Chrome是只会对大文件缓存到内存,同时为了控制浏览器的内存占比,其他的小文件都是缓存到磁盘。这样想来也是非常合理呢。

这时候,当我们关闭掉浏览器窗口,将文件改回去,重新访问这个资源,会发现会重新从磁盘读取。

看到这里,难道这个加载策略就如此简单么?只是仅仅根据文件大小?我尝试查看了几个主流网站的各类型静态资源加载情况,简单总结了是否缓存到内存是以下几点综合作用的结果:

  1. 文件类型 - 比如js和图片文件在size较小的情况下就会被缓存到内存,而css则需要size很大才会缓存到内存
  2. 文件大小 - 这一点在上面已经演示过了
  3. 文件加载方式(Chrome调试工具里的Initiator)- 如果是通过script标签引入的js会被优先缓存到内存,而通过动态加载的js则会优先加载到磁盘
  4. 未知因素

可惜并没有找到官方的说明,不过这一点对于我们优化缓存策略来讲影响并不大。

2.3 默认强缓存策略

谈到这里已经基本讲清楚了Chrome强缓存的策略,但是似乎没有提及如果没有设置Cache-Control这个头,浏览器是否会默认缓存呢。

经过测试,发现只要开启了Last-Modified头部,就算不设置Cache-Control头部,Chrome也会对资源进行默认强缓存,而Firefox则不会。我们尝试在规范里找到HTTP 1.1规范的缓存部分

其中有这么一段描述:

Unless specifically constrained by a cache-control (section 14.9) directive, a caching system MAY always store a successful response (see section 13.8) as a cache entry, MAY return it without validation if it is fresh, and MAY return it after successful validation.

也就是说,规范里规定,如果没有受到Cache-Control这个请求头的约束,缓存系统可以在不重新验证的情况下直接返回缓存,也可以在重新验证之后再返回资源。这样看来,Chrome和Firefox都是遵循这个规范实现的。

同时从这一点也可以看出,了解规范对于我们理解一些知识点和现象还是比较重要的。浏览器的表现只是对诸多规范的一种实现,不同的浏览器实现可能存在不同程度的差异。

2.4 Cache-Control具体策略

讲了这么多,实际上我们还只用到了Cache-Control的一种策略:max-agemax-age可以设置缓存可以被保持多少秒。这里需要注意的是:当强缓存被命中,这个缓存时间就会被重置成max-age设置的时间。

实际上,Cache-Control的值还可以是publicprivateno-cacheno-store

关于这几种策略,Google开发者文档上有一张图可以很好地描述:

  1. 如果你想完全禁用缓存,那么就设置成no-store需要注意的是,这种策略会同时禁用掉协商缓存。
  2. 如果你想单纯禁用掉强缓存,那么就设置成no-cache
  3. publicprivate两种策略的区别是是否允许中间缓存对资源进行缓存,比如设置成private之后,就只有用户的浏览器可以缓存,而cdn则不行。
  4. 如果你还想控制强缓存的时间,这时候就可以通过max-age来进行控制。

3. 协商缓存

强缓存可以解决的问题非常明显,省去了网络请求。但是又要面临一个新的问题,如果没有网络请求,我们如何知道一个资源被更新了呢?

协商缓存可以解决这个问题,而与协商缓存相关的HTTP头部有两对,Last-Modified/If-Modified-SinceETag/If-None-Match

协商缓存的过程也很简单,每次在服务器资源变化的时候,响应头会根据资源内容生成一个ETag标记,浏览器在请求同一个资源的时候,会带上这个标记,服务器根据标记是否变化来决定是否打回304 NOT Modified

3.1 Last-Modified/If-Modified-Since vs ETag/If-None-Match

关于这两对HTTP头部的区别,大家可以看HTTP1.1规范中的13.3.4 Rules for When to Use Entity Tags and Last-Modified Dates

TL;DR

以下是我的一些总结:

  1. ETag/If-None-Match在HTTP1.0中不会生效,因此在HTTP1.0中只能使用Last-Modified/If-Modified-Since
  2. 在极少数情况下,单纯使用Last-Modified值作为验证器可能会导致一些问题(比如1. 文件被打开就会导致Last-Modified被更新 2. 服务器时间错误 3. Last-Modified的粒度只能到秒等)。因此在HTTP1.1下必须同时提供ETag/If-None-Match作为验证策略。
  3. ETag/If-None-MatchLast-Modified/If-Modified-Since同时被使用的情况下,前者的优先级高于后者。但是只有在两者同时被验证通过的情况下,会允许返回304 NOT Modified
  4. 看完规范才知道ETag原来是entity tag的缩写(逃

3.2 ETag的实现

关于ETag的具体实现,规范并没有定义。但是既然叫entity tag,肯定是根据响应的实体来生成的。

如果大家感兴趣,可以看下koa-etag引用的etag库实现。

4. 缓存在工程里的实践

缓存在工程中的应用策略要根据静态资源类型,业务场景来针对性的制定。

  1. 对于html文件:也就是大部分站点的首页而言,通常是不能设置强缓存的,因为首页通常是js,css的入口,如果首页被强缓存了。那么除非用户手动清除缓存,否则这个站点会一直无法更新。因此,对于html文件建议使用no-cache配置etag的策略。同时,新版本的Chrome对于html文件的默认策略就是no-cache,即使你设置了Cache-Control: max-age=10240也是无效的。
  2. 对于js,css,图片这种静态资源来说,每次去服务器获取的成本比较大,而且脚本更新的频率不会特别高,也没必要每次采用etag校验,因此都会设置一个时间比较长的强缓存,这样可以节省网络请求。然后在文件名中加上和文件内容相关的hash指纹,实现版本更新
  3. 如果不是一些非常敏感的数据,Cache-Control可以用默认的public。这样可以利用cdn的缓存,起到网络优化和减少源站的负载压力的作用,如果cdn缓存过期,则会发起回源请求,并继续缓存。
  4. 关于ajax请求,本身也只有GET方式的ajax可以被缓存,这也是get请求的一个重要优势之一。但是绝大部分的ajax请求响应体都不超过几K,所以对这种缓存的意义不大,通常不会缓存。如果是那种大批量数据的话,比如拿一个地址库,这种就可以通过协商缓存来进行缓存。
  5. 缓存可能会引入一个新的问题,就是当我们发布了新的html之后,会引用旧的静态资源。当然我们现在都是非覆盖式更新(因为文件名带上了文件指纹),因此也不必担心这个问题。每次我们先部署静态资源,再部署页面就可以了。

5. 现有工程改进

之前一直对静态资源的缓存没有太上心,总结完了上面的内容,看看我们现有的工程是否有可以优化的点。

举个例子,来看下企业购后台的资源请求情况:

  1. 我们发现首页是每次都会去服务器拿完整的资源,根据上面的总结,首页html资源我们可以加上协商缓存进行优化,可以优化请求内容大小
  2. js资源目前都是走的协商缓存,但是实际上js资源都已经带上了文件指纹,所以可以直接设置强缓存。这样可以省去网络请求的时间

5.1 解决问题

企业购后台是kapp项目,第一反应是难道Egg.js的默认静态资源缓存策略就是协商缓存么?

egg-static是egg的静态资源插件,这个插件是基于koa-static-cache这个库设置静态资源响应策略的。

但是我发现koa-static-cache确实是设置了Cache-Control头部的。为了防止有人不想点链接,贴上代码:

1
ctx.set('cache-control', file.cacheControl || 'public, max-age=' + file.maxAge)

显然,问题就是出在egg-static了,egg-static默认的max-age是0,而在prod环境下配置了一个比较大的max-age。

事实上,kapp为了环境统一管理,把线上的配置改成读取config.online.js了,因此这个配置一直没有生效。就等kapp的同学发版解决了~

虽然以上两点的优化带来的提升是非常小的,因为本身资源就很小,而且大部分后端系统用户的网络状况也比较好。但是有了系统的总结之后,后续遇到缓存的问题我们就可以有的放矢了。