http 缓存机制解析
Http 缓存机制作为 web 性能优化的重要手段,对于从事 Web 开发的同学们来说,应该是知识体系库中的一个基础环节。针对浏览器的 http 缓存的分析也算是老生常谈了,每隔一段时间就会冒出一篇不错的文章,其原理也是各大公司面试时几乎必考的问题。
http 相关知识
HTTP 请求的过程
我们知道,在浏览器地址栏敲入域名后,会发生 DNS 解析,得到实际 IP,再经过中间多个代理服务器分发,最后到达源服务器。请求抵达源服务器后,在服务器上找到请求的资源,再通过代理服务器一层层的返回数据到浏览器端。
http 缓存要解决的核心问题就是减少客户端对源服务器的 HTTP 请求,提升性能。像一些更新不频繁的依赖文件或是基本不会改变的图片资源显然没有必要每次都从源服务器上获取。
HTTP 报文
HTTP 报文就是浏览器和服务器间通信时发送及响应的数据块。浏览器向服务器请求数据,发送请求(request)报文;服务器向浏览器返回数据,返回响应(response)报文。
报文信息主要分为两部分:
- 包含属性的首部(header),附加信息(cookie,缓存信息等)与缓存相关的规则信息,均包含在 header 中。
- 包含数据的主体部分(body),HTTP 请求传输的内容。
HTTP 缓存规则
HTTP 缓存有多种规则,根据是否需要重新向服务器发起请求来分类,我将其分为两大类:强缓存,协商缓存;强制缓存如果生效,不需要再和服务器发生交互,而协商缓存不管是否生效,都需要与服务端发生交互。两类缓存规则可以同时存在,强缓存优先级高于协商缓存,也就是说,当执行强缓存的规则时,如果缓存生效,直接使用缓存,不再执行协商缓存规则。
强缓存
浏览器强缓存什么意思呢,就是说当请求一个资源时,直接从本地的浏览器缓存中读取,不发起 HTTP 请求。对于强制缓存来说,响应 header 中会有两个字段来标明失效规则(Expires/Cache-Control)。
打开 chrome 控制台,暗颜色的 200 响应即是强缓存,包括了 from memory cache 和 from disk cache,这两个的区别我们后面会说。这里 network 里面看到 code 200 的响应报文是这个请求第一次访问资源返回的响应报文,浏览器将它缓存到了磁盘中,而并不是访问了服务器返回的报文。
Expires
在 http1.0 中,Expires 就是启用缓存和定义缓存时间的首部字段。Expires 的值对应一个 GMT(格林尼治时间),比如 Mon, 22 Jul 2002 11:12:01 GMT 来告诉浏览器资源缓存过期时间(绝对值),如果还没过该时间点则不发请求。这里注意GMT 时间+8 小时所得结果才是北京时间。
但是,响应报文中 Expires 所定义的缓存时间是相对服务器上的时间而言的,如果客户端上的时间跟服务器上的时间不一致(特别是用户修改了自己电脑的系统时间),那缓存时间可能就没啥意义了。所以 HTTP 1.1 的版本,使用了 Cache-Control 来替代它。
Cache-Control
针对上述的 Expires 时间是相对服务器而言,无法保证和客户端时间统一的问题,http1.1 新增了 Cache-Control 来定义缓存过期时间,若报文中同时出现了 Expires 和 Cache-Control,会以 Cache-Control 为准。
Cache-Control 常见的取值有 private、public、no-cache、max-age,no-store,默认为 private。
- private: 客户端可以缓存
- public: 客户端和代理服务器都可缓存
- max-age=xxx: 缓存的内容将在 xxx 秒后失效(相对值,相对于第一次请求的时间)
- no-cache: 需要使用协商缓存来验证缓存数据
- no-store: 所有内容都不会缓存,强缓存,协商缓存都不会触发
协商缓存
当浏览器强缓存过期后,就会触发协商缓存机制。这个时候需向服务器发送一个 http 请求,带上如下列表中的头部信息,如果符合规则(即服务器跟客户端资源一致),直接返回 304,不再返回资源内容;否则,返回状态码 200 与资源内容;最后,更新缓存头信息。
为了让客户端与服务器之间能实现缓存文件是否更新的验证、提升缓存的复用率,Http1.1 新增了几个首部字段来做这件事情。在较新的 nginx 上默认是同时开启了这两个功能的:
如下图,304 状态码即是协商缓存成功,服务器资源未发生改动:
Last-Modified
服务器将资源传递给客户端时,会将资源最后更改的时间以“Last-Modified: GMT”的形式加在实体首部上一起返回给客户端。
客户端会为资源标记上该信息,下次再次请求时,会把该信息附带在请求报文中一并带给服务器去做检查,若传递的时间值与服务器上该资源最终修改时间是一致的,则说明该资源没有被修改过,直接返回 304 状态码即可。
至于传递标记起来的最终修改时间的请求报文首部字段一共有两个:
1,If-Modified-Since: Last-Modified-value
示例为 If-Modified-Since: Thu, 31 Mar 2016 07:07:52 GMT
该请求首部告诉服务器如果客户端传来的最后修改时间与服务器上的一致,则直接回送 304 和响应报头即可。
当前各浏览器均是使用的该请求首部来向服务器传递保存的 Last-Modified 值。
2,If-Unmodified-Since: Last-Modified-value
告诉服务器,若 Last-Modified 没有匹配上(资源在服务端的最后更新时间改变了),则应当返回 412(Precondition Failed) 状态码给客户端。
当遇到下面情况时,If-Unmodified-Since 字段会被忽略:
1. Last-Modified值对上了(资源在服务端没有新的修改);
2. 服务端需返回2XX和412之外的状态码;
3. 传来的指定日期不合法
Last-Modified 说好却也不是特别好,因为如果在服务器上,一个资源被修改了,但其实际内容根本没发生改变,会因为 Last-Modified 时间匹配不上而返回了整个实体给客户端(即使客户端缓存里有个一模一样的资源)。
ETag
为了解决上述 Last-Modified 可能存在的不准确的问题,Http1.1 还推出了 ETag 实体首部字段。
服务器会通过某种算法,给资源计算得出一个唯一标志符(比如 md5 标志),在把资源响应给客户端的时候,会在实体首部加上ETag: 唯一标识符
一起返回给客户端。
客户端会保留该 ETag 字段,并在下一次请求时将其一并带过去给服务器。服务器只需要比较客户端传来的 ETag 跟自己服务器上该资源的 ETag 是否一致,就能很好地判断资源相对客户端而言是否被修改过了。
如果服务器发现 ETag 匹配不上,那么直接以常规 GET 200 回包形式将新的资源(当然也包括了新的 ETag)发给客户端;如果 ETag 是一致的,则直接返回 304 知会客户端直接使用本地缓存即可。
那么客户端是如何把标记在资源上的 ETag 传去给服务器的呢?请求报文中有两个首部字段可以带上 ETag 值:
1, If-None-Match: ETag-value
示例为 If-None-Match: "56fcccc8-1699"
告诉服务端如果 ETag 没匹配上需要重发资源数据,否则直接回送 304 和响应报头即可。
当前各浏览器均是使用的该请求首部来向服务器传递保存的 ETag 值。
2, If-Match: ETag-value
告诉服务器如果没有匹配到 ETag,或者收到了“*”值而当前并没有该资源实体,则应当返回 412(Precondition Failed) 状态码给客户端。否则服务器直接忽略该字段。
If-Match 的一个应用场景是,客户端走 PUT 方法向服务端请求上传/更替资源,这时候可以通过 If-Match 传递资源的 ETag。
需要注意的是,如果资源是走分布式服务器(比如 CDN)存储的情况,需要这些服务器上计算 ETag 唯一值的算法保持一致,才不会导致明明同一个文件,在服务器 A 和服务器 B 上生成的 ETag 却不一样。
需要注意的是 如果同时有 etag 和 last-modified 存在,在发送请求的时候会一次性的发送给服务器,没有优先级,服务器会比较这两个信息(在具体实现上,大多数做法针对这种情况只会比对 etag)。
memoryCache 和 diskCache
在服务器设置了缓存时间后,即开启强缓存时,浏览器获取缓存数据的来源可能有两种:内存和磁盘。这里我们将对请求进行模拟,来探索这两种分别会在什么时候出现,这里保证了资源都在有效期内,即强缓存有效,不会发起 http 请求。
MemoryCache 顾名思义,就是将资源缓存到内存中,等待下次访问时不需要重新下载资源,而直接从内存中获取。Webkit 早已支持 memoryCache。
目前 Webkit 资源分成两类,一类是主资源,比如 HTML 页面,或者下载项,一类是派生资源,比如 HTML 页面中内嵌的图片或者脚本链接,分别对应代码中两个类:MainResourceLoader 和 SubresourceLoader。虽然 Webkit 支持 memoryCache,但是也只是针对派生资源,它对应的类为 CachedResource,用于保存原始数据(比如 CSS,JS 等),以及解码过的图片数据。
diskCache 顾名思义,就是将资源缓存到磁盘中,等待下次访问时不需要重新下载资源,而直接从磁盘中获取,它的直接操作对象为 CurlCacheManager。它与 memoryCache 最大的区别在于,当退出进程时,内存中的数据会被清空,而磁盘的数据不会,所以,当下次再进入该进程时,该进程仍可以从 diskCache 中获得数据,而 memoryCache 则不行。
diskCache 与 memoryCache 相似之处就是也只能存储一些派生类资源文件。它的存储形式为一个 index.dat 文件,记录存储数据的 url,然后再分别存储该 url 的 response 信息和 content 内容。Response 信息最大作用就是用于判断服务器上该 url 的 content 内容是否被修改。
一般脚本、字体、图片会存在内存当中,而样式表一般在磁盘中,不会缓存到内存中去,因为 css 样式加载一次即可渲染出网页。但是脚本却可能随时会执行,如果脚本在磁盘当中,在执行该脚本需要从磁盘中取到内存当中来,这样的 IO 开销是比较大的,有可能会导致浏览器失去响应。
关闭页面后再一次进入页面,可以看到资源都是从磁盘获取。这里浏览器再从磁盘获取到缓存后,若是脚本、字体、图片,则会同时缓存一份到内存中,因为这些资源调用比较频繁,浏览器为了避免磁盘 IO 而将缓存写入内存中以提高性能:
而刷新后,第一次进入时缓存到内存的资源就起到作用了,可以发现 js 以及图片资源是从内存中获取的,而 css 是从磁盘加载(这里懒加载的 js 获取方式存在疑惑,在多次尝试中两种缓存获取方式都出现过):
从上面图中不难发现,虽然都不访问服务器,但是 from memory cache 耗时为 0,from disk cache 还是有几十或几百 ms 的耗时。即从内存中获取时间是远快于从磁盘中获取的,因为磁盘需要 IO 开销,这里花费还是挺大的。
不同浏览器策略是否一致
以上的数据及统计都是在 chrome 浏览器下进行的。在 Firefox 下并没有 from memory cache 以及 from disk cache 的状态展现,相同的资源在 chrome 下是 from disk/memory cache,但是 Firefox 统统是 304 状态码,即 Firefox 下会缓存资源,但是每次都会请求服务器对比当前缓存是否更改,chrome 不请求服务器,直接拿过来用,这也是为啥 chrome 比较快的原因之一吧,
在 Firefox 下,不论是刷新页面或是关闭页面后再次访问,资源请求结果都是 304:
缓存实践
现在打包工具日益完善,我们完全可以把缓存时间设置成很长(比如一年),然后在打包时把文件名设为文件内容的 hash 值,那么每次打包时,只要文件内容没有发生改变文件名也不会变化。这样每次更新发布,只有变动了的文件会重新请求服务器,没变动的文件依旧可以通过强缓存获取。
以 webpack 为例,我们可以这么配置:
接着在 nginx 配置了添加缓存时间配置,这里 expires 为超时时间,d 表示天:
这是阿里图库图标 js 文件的缓存时间,因为每次生成 js 都会改变文件名,所以不用担心内容改变了浏览器依旧使用缓存,可以看到设置的缓存时间有两年之久:
如果一个脚本文件响应给客户端并做了长时间的缓存,而服务端在近期修改了该文件的话,缓存了此脚本的客户端将无法及时获得新的数据。解决该困扰的办法也简单,常用的方法是在文件名或参数带上版本号、一串 md5 或时间标记符:
https://liyucang.club/test.js?v=123
其他
浏览器刷新
浏览器输入 url 之后敲下回车、刷新(F5) 与强制刷新(Ctrl + F5),在最新的 chrome 浏览器(版本 64.0.3282.140)中,我们经过实验得出:
- 浏览器输入 url 之后敲下回车和按 F5 刷新都是会先看本地 cache-control、expires 的情况。
- 强制刷新(Ctrl + F5)就是不携带任何条件的访问(包括协商缓存的 Last-Modified、ETag),相当于点了控制台 Disable cache 按钮。
首部字段 Date 和 Age
Date 理所当然是原服务器发送该资源响应报文的时间(GMT 格式),如果你发现 Date 的时间与“当前时间”差别较大,或者连续 F5 刷新发现 Date 的值都没变化,则说明你当前请求是命中了缓存。
浏览器会在第一次请求时把响应缓存到磁盘中,之后若命中了强缓存则浏览器会把第一次的相应模拟为当前请求的相应。所以 Date 值不会改变,依旧是第一次写入缓存时相应里的值。
这里的 Age 也是响应报文中的首部字段,它表示该文件在代理服务器中存在的时间(秒),如文件被修改或替换,Age 会重新由 0 开始累计。
通常满足这么个条件:
静态资源Age + 静态资源Date = 代理服务器第一次请求并缓存原服务器数据的时间
这里以阿里云图标库项目 js 为例:
将 Date 字段的时间加上 age 字段的秒数则是代理服务器第一次请求并缓存原服务器数据的时间。