HTTP 缓存秘笈——强缓存与协商缓存

64 阅读14分钟

写在前面

缓存的作用:缓存对于网页性能优化极其重要,合理设置缓存策略能够显著提升网页性能和用户体验,同时还能减轻服务器负担并为用户节约流量,甚至还可以让用户在没有网络连接的情况下继续访问部分或全部网页内容。

在讨论强缓存和协商缓存之前,我们首先要了解缓存的位置及其优先级。下面是缓存位置以及优先级的排序(从高到低):

  1. Service Worker:这是运行在浏览器背后的独立线程,用于实现自定义缓存,优先级最高。关于此部分,本篇不做详细说明。
  2. Memory Cache:内存缓存。其有效期与标签页的生命周期相关,一旦标签页关闭,缓存将失效。
  3. Disk Cache:磁盘缓存。相比内存缓存,磁盘缓存具有更长的有效期和更大的容量。
  4. Push Cache:推送缓存,用得较少,所以不是很了解。

在学习强缓存和协商缓存的过程中,我一直困惑于这两个概念的确切定义以及它们的来源。我发现在MDN(Mozilla Developer Network)和RFC7234文档中都没有找到关于这两个概念的详细解释。甚至在知乎上,也有其他用户提出类似的疑问:"请问强缓存和协商缓存这种说法是从哪定义的呢?"其中的一个回答引起了我的兴趣,回答的链接:www.zhihu.com/question/42…,感兴趣的可以去看一下。

虽然在 MDNRFC7234文档 中没有明确的定义,但强缓存和协商缓存是Web开发中广泛使用的术语,被认为是HTTP缓存的两种重要策略,下面的介绍都是基于我对这两个概念的理解来解释它们的工作原理和应用场景。

强缓存

所谓的强缓存,其实是一种 本地缓存 的方式,可以简单理解为 浏览器在加载资源时,无需访问网络,而是直接从本地缓存中读取资源。当强缓存命中时=时,在控制台的 Network 中可以看到响应状态码为 200。强缓存是通过HTTP响应头中的 ExpiresCache-Control 字段来控制的。除此之外,还有一个与强缓存相关的请求头 Pragma

Expires

Expires 是 HTTP1.0 的产物,它的值是一个 GMT 格式的 时间点。然而,由于 Expires 的值依赖于客户端的时间设置,如果客户端的时间被修改,可能会导致缓存失效。标准的 Expires 响应格式如下:Expires: Fri, 14 Jul 2023 03:56:19 GMT。这意味着浏览器在该时间点之前再次请求该资源时,可以直接从本地缓存中获取。

Cache-Control

Cache-Control 是 HTTP1.1 时期的产物,它既是请求头也是响应头,在 Cache-Control 中一般使用 max-age 来控制缓存时间,例如:Cache-Control: max-age=60。max-age 的单位是秒(s),这个响应表示一分钟内再次请求该资源时,可以直接从本地缓存中获取。如果 Cache-Control 和 Expires 同时出现在响应头中的话 Cache-Control 优先级于 Expires,但是如果客户端不支持HTTP1.1协议时,Expires 就会发挥作用。

除了 max-age 指令之外, Cache-Control 还有很多常用的指令,这些指令可以组合使用(多个指令之间使用逗号隔开)。常见的指令和说明如下:

  • public: 表示响应可以被任意缓存, 包括客户端和代理服务器。
  • private: 表示资源只有客户端才能缓存,代理服务器不可以缓存。(代理服务器如果也想要缓存也是可以的,因为代理服务器可以拦截请求和响应,举个例子:在使用ngixn服务器做代理时可以配置 proxy_ignore_headers Cache-Control; 来忽略服务端对 Cache-Control 请求头的声明,从而实现自定义缓存逻辑)
  • max-age: 用于设置资源缓存的最大有效时间,单位为秒。max-age=N 设置资源缓存的最大有效时间为 N 秒,N 一般是一个非负整数,因为是负数在不同的浏览器下表现可能不一样。
    • N为正整数: 表示 N 秒内再次请求该资源时,可以直接从本地缓存中获取缓存副本,无需与服务器重新验证。
    • N=0: 当 N 为 0 时,即 max-age=0 时,表示资源已经过期, 请求时必须向服务器进行确认缓存副本是否有效,而不是直接返回本地缓存副本。
    • N为负数包括-0: N为负数时在 Chrome 和 Firefox 上表现不一致。
    • N为小数:MDN 上说N可以是任意非负整数,但是经过我在 Chrome92 浏览器上测试发现缓存有效期依旧是有效的,但N会向下取整。(不建议使用小数和负数,因为在不同的浏览器可能存在差异)
  • s-maxage: 与 max-age 类似,但仅适用于共享缓存,比如代理服务器。这个指令在共享缓存中会覆盖max-age。
  • no-cache: 表示缓存服务器在使用缓存副本之前必须先确认其有效性。尽管这个指令叫做no-cache,但它并不是说明不使用缓存,而是要求验证缓存是否过期的。
  • no-store:表示客户端和代理服务器不应该缓存对应的响应,每次请求都必须向原始服务器获取完整的响应。相比于 no-cache 这个指令才是真正的不缓存指令。
  • max-stale:用于设置客户端可以接收过期时间已过的响应,单位也是秒,也就是说客户端可以接受的资源缓存时间为 max-age + max-stale。虽然 Cache-Control 有该指令,但是主流的浏览器暂时都还不支持该指令。经过测试 chrome104 也不支持,引用 MDN 上的一句话:

Note that the major browsers do not support requests with max-stale.

  • min-fresh: 用于设置客户端希望在指定的时间内获取一个新鲜(未过期)的响应,单位也是秒。和 max-slate 一样虽然 Cache-Control 有该指令,但是主流的浏览器暂时都还不支持该指令。经过测试 chrome104 也不支持,引用 MDN 上的一句话:

Note that the major browsers do not support requests with min-fresh.

  • no-transform: 这个指令用于告知中间代理服务器(如代理、网关、CDN等)不要对响应的实体内容进行任何形式的转换或修改。中间代理服务器可能会对传输的实体内容进行压缩、编码、加密或其他形式的转换,以便优化网络传输或满足特定的需求。但在某些情况下,原始的实体内容可能需要保持不变,特别是对于敏感数据或特定文件类型。
  • immutable: 用于通知缓存服务器和客户端,该资源在其生命周期内是不会改变的(在 Firefox 中只对 HTTPS 资源生效)。然而,与 max-age 搭配使用时,在 Chrome(版本 104)和 Firefox(版本 94)上表现不一致:
    • 在 Firefox 中,当资源到期后,浏览器会重新请求资源,而不是验证资源是否有效,响应状态码为 200。当 max-age 为负数(包括 -0)时,浏览器会直接读取缓存,状态码同样是 200。
    • 在 Chrome 中,当资源过期后,浏览器会验证资源是否有效,响应状态码为 304(如果资源未修改)。当 max-age 为负数(包括 -0)时,immutable 指令不生效,浏览器会校验资源的有效性,状态码同样是 304(如果资源未修改)。
    • immutable 相关的文章可以查阅:www.cnblogs.com/ziyunfei/p/…www.cnblogs.com/ziyunfei/p/…

当然,除了以上提到的指令之外,还有其他的一些会用到的指令:only-if-cachedstale-if-errorstale-while-revalidateproxy-revalidatemust-revalidate

下面表格是我在测试时候归纳的数据:

Cache-Control响应头的指令/指令组合Chrome(104)Firefox(94)
max-age=6资源有效期 6s资源有效期 6s
max-age=6.9资源有效期 6s资源有效期 6s
max-age=0每次请求都需要验证资源有效性每次请求都需要验证资源有效性
max-age=-0或者max-age=-3每次请求都需要验证资源有效性读取缓存副本,多久不清楚
immutable, max-age=6资源有效期 6s,过期后校验,返回 304 状态码资源有效期 6s,过期后不校验,直接获取新的资源,返回 200 状态码
immutable, max-age=6.9同上同上
immutable, max-age=0每次请求都需要验证资源有效性,返回 304 状态码每次请求都需要验证资源有效性,返回 200 状态码
immutable, max-age=-0或者immutable, max-age=-3每次请求都需要验证资源有效性,返回304 状态码读取缓存副本,多久不清楚,返回200状态码

注意:这些数据是基于 Chrome 版本 104 和 Firefox 版本 94 的测试结果,后端使用的是 node + express。

Pragma

除了 ExpiresCache-Control,还有一个与强缓存相关的请求头: Pragma。该请求头只有一个指令: Pragma: no-cachePragma: no-cache 的效果与 Cache-Control: no-cache 表现一致,都是要求浏览器将本地资源和线上资源进行验证。Pragma 是 HTTP 1.0 的产物,但是它在 HTTP 响应中的行为没有明确的规范,因此不能可靠地替代 Cache-Control。一般建议只在需要兼容 HTTP 1.0 客户端的场景下使用 Pragma 首部。

协商缓存

协商缓存是一种需要协商的缓存机制,它指的是当浏览器请求缓存过的资源时,必须向服务器验证资源文件是否有更新。如果文件没有更新,浏览器直接读取本地资源;否则,获取服务器上的新资源文件。与协商缓存相关的响应头包括:Last-ModifiedETag,而请求头则包括:If-Modified-SinceIf-None-Match,这四个首部不区分大小写。

image.png

Last-ModifiedIf-Modified-Since 是 HTTP1.0 时期的产物,这两个头部是搭配在一起使用的。ETagIf-None-Match 这两个头部也是搭配在一起使用的,但是后者是 HTTP1.1 时期的产物,因此优先级更高,即 ETag 的优先级高于 Last-Modified。现代浏览器在验证资源更新时,会向服务器发送两个请求头:If-Modified-SinceIf-None-Match

  • 命中协商缓存,返回304和空的响应体 image.png
  • 未命中协商缓存,返回200和新的资源文件 image.png

通过上面的两张截图(来源于github首页的同一资源的请求截图)可以清楚地看出,当命中协商缓存时,响应的大小和时间都显著减少,这样大幅减少了网络传输和数据处理的时间,加快了页面加载速度。

Last-Modified & If-Modified-Since

当浏览器访问资源时,服务器会在返回资源的同时附带一个名为 Last-Modified 的响应头,该头部的值是一个 GMT格式的时间戳,记录了资源文件的最后修改时间。当浏览器再次请求该资源时,会将上一次响应头中的 Last-Modified 的值作为请求头 If-Modified-Since 的值发送给服务器, 然后服务器根据 If-Modified-Since 的值来验证资源文件是否自上次请求后发生过修改。如果资源文件自上次请求后未被修改,服务器将返回状态码 304 和空的响应体;否则,服务器将返回状态码 200 和更新后的资源文件,并更新 Last-Modified 响应头的值。下图是命中 Last-Modified 协商缓存的案例:

image.png

但是 Last-Modified 响应头有一定的弊端:

  1. 秒级精度限制:Last-Modified 的值仅提供秒级精度,这可能导致客户端在一秒之内多次修改资源的情况下无法获取到最新的资源版本。因为同一资源在一秒内被多次修改,它的Last-Modified是一样的,这样就会导致服务器无法正确识别资源是否发生了改变。
  2. 无法准确检测内容变化:Last-Modified 只表示资源的最后修改时间,并不能准确地指示资源内容是否发生了实质性的变化。有时候,即使资源的内容并未更新,某些操作(比如在 vscode 中不修改文件但是多次 Ctrl+S 保存同一文件)也可能会导致资源的最后修改时间发生变化。

因此为了更好地处理缓存和资源更新,HTTP/1.1 引入了 ETag 标头字段,它提供了更精确的资源版本标识和验证机制。

ETag & If-None-Match

在支持 HTTP1.1 协议的浏览器中,当用户访问服务器上的资源时,服务器会在返回资源的同时附带一个响应头 ETagETag 是服务器为当前资源文件生成的唯一标识,当资源发生变化时,服务器就会重新生成 ETag,以标识资源的最新状态。当浏览器再次请求该资源时,请求头中会携带 If-None-Match,它的值就是 ETag 的值 。服务器就会对比 If-None-Match 的值服务器上该资源的 ETag 值是否一致。如果一致服务器将返回状态码 304 和空的响应体;否则,服务器将返回状态码 200 和更新后的资源文件,并更新 ETag 响应头的值。 下图是命中 ETag 协商缓存的案例:

image.png

Last-Modified 和 ETag 对比

特性Last-ModifiedETag
数据类型时间戳(Date/Time)字符串(通常是资源内容的哈希值或唯一标识符)
精度秒级精度高精度,基于资源内容生成的标识符
更新频率资源修改时更新,内容不一定改变资源内容变化时更新
缓存控制
条件请求使用If-Modified-Since头进行验证使用If-None-Match头进行验证
性能影响如果资源在短时间内频繁修改,可能导致缓存失效更可靠,不受资源修改频率影响
优先级低,对于动态生成的资源较为适用高,对于静态资源和CDN部署较为适用
推荐使用场景不经常更新的资源频繁更新的资源

浏览器刷新行为

下面表格是在缓存在有效期内,且支持 HTTP1.1 协议的浏览器下整理的:

刷新方式FirefoxChrome
F5普通刷新,不使用强缓存,使用协商缓存,状态码 304普通刷新,使用强缓存,状态码 200
点击刷新按钮同上同上
右键刷新同上同上
Ctrl + R (或 Cmd + R)同上同上
地址栏回车同上同上
Ctrl + F5 (或 Cmd + Shift + R)强制刷新,忽略所有缓存(请求头中携带:Cache-Control: no-cache),强制从服务器重新获取页面内容,状态码 200强制刷新,忽略所有缓存(请求头中携带:Cache-Control: no-cache),强制从服务器重新获取页面内容,状态码 200
禁用缓存刷新类似于 Ctrl + F5 的强制刷新,会忽略缓存类似于 Ctrl + F5 的强制刷新,会忽略缓存

总结 - 缓存机制

强缓存优先级高于协商缓存,当强缓存(Expires或者Cache-Control)生效时,浏览器直接从本地缓存中获取响应,并返回状态码200,无需与服务器进行通信。若强缓存失效,浏览器会和服务器协商,即使用协商缓存(Last-Modified & If-Modified-Since 和 Etag & If-None-Match)判断缓存是否过期。若未过期,服务器返回状态码304,指示浏览器继续使用本地缓存,节省带宽和资源。若协商缓存也失效,服务器将返回状态码200,并提供最新的资源文件。主要的过程如下图所示:

image.png