理解 HTTP 缓存技术

401 阅读16分钟

前言

HTTP 缓存技术是提高网页加载速度和减少服务器负载的重要手段。通过缓存,客户端可以在本地存储已经获取过的资源,从而在后续请求中直接使用缓存的数据,而不必重新向服务器请求。

为了减少资源请求次数,加快资源访问速度,浏览器会对资源文件如图片、css文件、js文件等进行缓存,而浏览器缓存策略又分为强缓存协商缓存,什么是强缓存?什么是协商缓存?两者之间的区别又是什么?接下来本文就带大家深入了解这方面的知识。

基础知识

缓存位置

  • 浏览器缓存:存储在客户端浏览器中的缓存
  • 代理缓存:存储在代理服务器中的缓存
  • 网关缓存:存储在网关服务器中的缓存

缓存控制头

http 在协议设计之初就考虑到了缓存技术,因此 http 协议的头部有一些是专门用于缓存的字段。http 头部字段用于控制缓存行为的字段主要包括以下几个:

  • Cache-Control:用于指定缓存策略
  • Expires:指定资源的过期时间(绝对时间)
  • ETag:资源的实体标签,某个版本响应内容的唯一标签
  • Last-Modified:资源的最后修改时间

Cache-Control 头部字段

Cache-Control 头部字段是最常用的缓存控制方法,它可以包含多个指令:

  • public:表示响应可以被任何缓存存储
  • private:表示响应只能被单个用户缓存,不能被共享缓存(如代理服务器)存储
  • no-cache:强制每次请求直接向服务器验证缓存
  • no-store:不缓存任何响应数据(关闭缓存功能,适用于对数据有强一致性要求的场景)
  • max-age:指定资源可以缓存的最大时间,以秒为单位(相对时间)
  • must-revalidate:在缓存过期后必须向服务器验证缓存

示例

Cache-Control: public, max-age=3600

Expires 头部字段

Expires 头部字段指定资源的过期时间,使用的是 HTTP 日期格式。例如

Expires: Wed, 21 Oct 2024 07:28:00 GMT

ETag 头部字段

ETag 头部字段提供资源的唯一标识符,当资源发生变化时,ETag 也会随之改变。客户端在后续请求中可以使用 If-Nonn-Match 头部字段来验证缓存。例如返回的响应中的头部字段为

ETag: "686897696a7c876b7e"

后续客户端需要验证缓存时可以在请求中携带如下的头部字段

If-None-Match: "686897696a7c876b7e"

服务器收到后,和对应资源当前的 ETag 进行比对

  • 如果资源未改变,在响应中返回 304 Not Modified
  • 如果资源改变,返回新的资源和新的 ETag

Last-Modified 头部字段

Last-Modified 头部字段表示资源的最后修改时间。客户端在后续请求中可以使用 If-Modified-Since 头部字段来验证缓存。例如返回的响应的头部字段如下

Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT

后续客户端需要验证缓存时可以在请求中携带如下头部字段

If-Modified-Since:Wed, 21 Oct 2024 07:28:00 GMT

服务端收到后,和对应资源当前的最后修改时间进行比对

  • 如果资源未改变,返回 304 Not Modified
  • 如果资源已经改变,则返回最新资源和资源的最新修改时间Last-Modified

缓存机制

上述这些字段刚好对应了 http 缓存的两种实现方式,分别是强制缓存和协商缓存。

强制缓存(强缓存)

强制缓存指的是在缓存有效期内,客户端不会向服务器发送请求,而是直接使用缓存数据。Cache-Control 的 max-age 和 Expires 头部字段用于实现强制缓存。

协商缓存

协商缓存指的是客户端在缓存过期后,需要通过与服务器协商来决定是否使用缓存数据,实际上就是通过请求和请求头中特殊的头部字段询问服务器某个资源是否发生了改变。Etag 和 Last-Modified 头部字段用于实现协商缓存。

示例

  1. 设置强制缓存时对应的服务器响应头部字段
Cache-Control: public, max-age=3600

Expires: Wed, 21 Oct 2023 07:28:00 GMT

2. 设置协商缓存时对应的服务器响应头部字段

ETag: "686897696a7c876b7e"

Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT

对应的客户端请求头部字段

If-None-Match: "686897696a7c876b7e" 

If-Modified-Since: Wed, 21 Oct 2023 07:28:00 GMT

缓存处理的整体流程

image.png

  • 当用户请求到达时,会首先判断用户请求的资源是否已经在本地缓存。
  • 如果请求资源已经缓存,则进一步判断是否命中强缓存
    • 如果又命中了强缓存,则直接使用浏览器本地缓存中的数据响应客户端请求
    • 如果没有命中强缓存,则需要进一步判断是否命中协商缓存
  • 协商缓存一般有两种类型的头部配合实现,后面会单独展开介绍。如果命中了协商缓存,则同样使用本地缓存的资源回复客户端并更新资源的最后修改时间。如果没有命中协商缓存,那么回源获取最新的资源,在获取到最新资源后更新本地缓存并将最新资源提供给客户端。

通过这个总体流程可以知道,http 缓存的核心实际上就是两种实现方式,即强制缓存和协商缓存。而他们的存在实际上满足了不同的应用场景,比如在一些数据变更概率非常小但是请求量特别大的场景下,非常适合使用强制缓存,以保证服务的高吞吐量。而在一些对数据一致性要求相对比较高的场景下,强制缓存的选项可以设置成 no-cache 从而强制要求每次请求都直接向服务器验证缓存,虽然还是需要通过网络请求,但是由于命中缓存时服务器无需传输资源数据,因此可以省去这部分的资源开销和时间开销。最后对于那些数据有强一致性需求的场景,直接将 Cache-Control 头部字段指令设置为 no-store,表示不要缓存资源,全部回源。下面具体来看这两种缓存实现方式。

强缓存细节介绍

强缓存指的是只要浏览器判断缓存没有过期,就直接使用浏览器的本地缓存,决定是否使用缓存的主动权是在浏览器这边的。

所谓强缓存,可以理解为强制缓存的意思,即浏览器在访问某个资源时会判断本地缓存里是否已经存在资源文件,如果资源缓存存在且没有过期就直接使用浏览器的本地缓存而无需回源向源服务器发送网络请求。在强缓存模式下,决定是否使用缓存的主动权是在浏览器这边的。使用本地缓存就意味着不会发送网络请求到服务器,这就减轻了服务器的负载,而且由于资源直接从本地读取,也大大加快了响应速度。

强缓存的具体工作流程可以描述为:当浏览器第一次请求远程服务器的某个资源时,如果服务器希望浏览器得到该资源后的一段时间内不要再发送请求过来,而是直接从浏览器的缓存里获取,就可以在响应头里设置 Cache-Contrl: max-age=3153600,max-age 代表缓存时间,单位为秒,这里的秒数换算一下刚好是一年,也就是在一年内浏览器都无需再向服务器发送请求。

在之前的示例中已经给出强缓存涉及到的两个 http 响应头部字段 Cache-Control 和 Expires,它们都是用来表示资源在客户端缓存的有效期的,区别是

  • Cache-Control 是一个相对时间
  • Expires 是一个绝对时间

由 Expires 字段控制的强缓存

Expires 值是一个 GMT 时间格式的字符串,浏览器进行第一次请求时,服务器会在返回头部加上 Expires。之后的请求只要落在 Expires 之前都意味着命中强缓存,直接使用缓存中的资源,无需回源。

Expires是 http/1.0 规范,Cache-Control是 http/1.1 规范,Expires返回一个具体的时间值(如下图所示),代表缓存的有效期,在该日期内浏览器不会向服务器发起请求,而是直接从缓存里获取资源。

因为Expires参照的是本地客户端的时间,而客户端的时间是可以被修改的,所以会有误差产生的情况,这也是Expires的一个缺点,所以有了后来 http/1.1 规范的Cache-control

  • 首次访问响应中头部字段信息(关注 Expires 字段)

image.png

  • 再次访问服务器(强缓存命中)

image.png

下面是一段使用 Node.js 和 Express 框架的服务端代码,通过设置 Expires 头部字段,服务器可以指示客户端在指定时间内使用缓存的资源而不必重新请求服务器,这可以显著提高性能并减少服务器的负载。

app.get('/', (req, res) => {
    const cssContent = path.join(__dirname, './html/index.html');
    fs.readFile(cssContent, function(err, data) {
        res.setHeader("Expires", new Date(Date.now() + 2592000000).toUTCString());
        res.end(data);
    })
});

由 Cache-Control 控制的强缓存

Cache-Control 利用 max-age 判断缓存的生命周期,以秒为单位,如果请求落在缓存有效期内,那么命中强缓存。

image.png

下面的代码段用于处理 HTTP 请求并设置缓存头,通过设置 Cache-Control 头部字段,服务器指示客户端缓存该资源 259000 秒即 30 天,期间每次请求都直接从本地缓存返回无需回源。

app.get('/', (req, res) => {
    cosnt cssContent = path.join(__dirname, './html/index.html');
    fs.readFile(cssContent, function(err, data) {
        res.setHeader("Cache-Control", "max-age=259000");
        res.end(data);
    })
});

而且如果 http 响应头部中同时设置了这两个字段的话,Cache-Control 的优先级要高于 Expires。

这里可能主要有两个原因:

  1. Cache-Control 头部字段的指令更多,可以实现更加精细的缓存控制,因此更建议使用 Cache-Control 实现强缓存。
  2. Expires 首部指定的是实际的过期日期而不是秒数。HTTP 设计者后来认为,由于很多服务器的时钟都不同步(所有 google 后来设计了原子钟),或者不正确,所以最好还是使用剩余秒数而不是绝对时间来表示过期时间。因此不推荐使用 Expires 头部字段。

因此重点看下使用 Cache-Control 实现强缓存的基本流程:

  1. 当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在响应头部加上 Cache-Control 字段,并设置好了过期时间(如上面的服务端代码所示,这个过程可以由 http handler 自动触发)
  2. 当浏览器再次请求访问服务器中的该资源时,会先通过请求资源时的时间、上次响应返回时间与 Cache-Control 中设置的过期时间一起判断出缓存的资源是否过期,如果没有过期则将该缓存资源提供给客户端,否则回源并将最新资源更新到缓存后提供给客户端。
  3. 在新的响应头部中 Cache-Control 已经完成了更新。

from memory cache 和 from disk cache 的区别。

from memory cache 从字面上理解就是从内存中获取缓存的资源,当关闭页面时该资源随之就被释放了,再次重新打开相同页面时就不会重现 from memroy cache 的情况了。

from disk cache 从字面上理解就是从磁盘获取缓存的资源,很显然该资源由于缓存在磁盘中不会随页面的关闭而释放,因此下次打开相同页面时仍然显示 from disk cache。

这应该是两者最大的不同之处,一个缓存在内存中,生命周期仅限于页面打开期间,另一个缓存在磁盘上,生命周期不受页面生命周期的影响。

一般来说,浏览器会将较大的资源缓存到disk cache,而较小的资源则被缓存到memory cache里。内存缓存与磁盘缓存相比,访问速度要更快一些!

协商缓存细节介绍

上面介绍了强缓存,下面看下协商缓存是如何工作的吧。在使用浏览器的过程中,我们经常会看到某些请求的响应码是 304,这实际上是服务器告知客户端缓存内容有效可以继续使用,而这种由浏览器询问服务器并由服务器告知客户端是否可以使用缓存的方式被称为协商缓存。

在强缓存里,是否使用缓存是由浏览器来确定的,而协商缓存则是由服务器来告诉浏览器缓存资源是否可用,即决定是否使用缓存的主动性在服务器这边。

协商缓存可以由两组匹配的头部字段实现。

  1. 响应头部的 Last-Modified 字段和请求头部的 If-Modified-Since 字段
  2. 响应头部的 ETag 字段和请求头部的 If-None-Match 字段

Last-Modified , If-Modified-Since(基于时间实现)

Last-Modified:代表了服务端收到请求时,被请求资源的最后修改时间,由响应头部返回。

If-Modified-Since:浏览器在资源过期时,将上次响应中的 Last-Modified 设置为请求头部的 If-Modified-Since 字段,为服务端判断缓存是否过期提供必要上下文。

下面这段服务端代码很好地说明两个字段之间的协作:

app.get('/', (req, res) => {
    const cssContent = path.join(__dirname, './html/index.html')
    fs.stat(cssContent, (err, start) => {
        if (req.headers['if-modified-since'] === start.mtime.toUTCString()) {
            res.writeHead(304, 'Not Modified');
            res.end();
        } else {
            fs.readFile(cssContent, function (err, data) {
                let lastModified = start.mtime.toUTCString();
                res.setHeader('Last-Modified', lastModified);
                res.writeHead(200, 'OK');
                res.end(data);
            })
        }
    })
});

ETag , If-None-Match(基于唯一标识实现)

通过之前的分析我们可以知道通过 Last-Modified , If-Modified-Since 这两个字段可以实现协商缓存。但实际上在有些场景下,仅判断资源的最后修改日期并不能很好地验证资源有效性。比如

  • 有些资源会被周期性重写,虽然内容没有变化,但是最后修改时间改变了。
  • 又比如我们使用 vscode 写代码时,每过一会就习惯性按 command + s 保存一下,源代码内容可能没有改变,但是你查看一下文件的修改时间发现已经改变了。
  • 最后 Last-Modified 无法精确到毫秒级,无法应对资源更新频率小于一秒的场景。

ETag 和 If-None-Match 组合可以很好的解决上述问题。

ETag:是资源内容的唯一表示,由服务器响应的头部字段返回

浏览器初次请求资源,服务器返回资源,同时生成一个Etag值携带在响应头里返回给浏览器,当浏览器再次请求资源时会在请求头里携带If-None-Match,值是之前服务器返回的Etag的值,服务器收到之后拿该值与资源文件最新的Etag值做对比。(如图 1 和图 2 所示)

  • 如果没有变化则返回304,告诉浏览器继续使用缓存(不返回资源文件)。
  • 如果发生变化,则返回 200 和最新的资源文件给浏览器使用(还有最新的 Etag)。

image.png图 1

image.png图 2

下面这段服务端代码可以很好的说明这两个字段是如何配合实现协商缓存的。

app.get('/home', (req,res) => {
    cosnt cssContent = path.join(__dirname, './html/index.html')
    fs.stat(cssContent, (err, start) => {
        let etag = md5(cssContent); // 对文件内容做 md5 校验和计算
        if (req.headers['if-none-match'] === etag) {
            res.writeHead(304, 'Not Modified');
            res.end();
        } else {
            fs.readFile(cssContent, function (err, data) {
                res.setHeader('Etag', etag);
                res.writeHead(200, 'OK');
                res.end(data);
            });
        }
    });
});

如果服务端返回的 http 响应头部同时有 ETag 和 Last—Modified 字段,那么客户端下一次验证缓存时,如果同时带上了这两个字段,那么 ETag 的优先级更高,服务器会先判断 ETag 是否发生了变化,如果没有发生变化再去判断 Last-Modified。为什么 ETag 优先级更高呢?

  1. 在没有修改文件内容的情况下文件的最后修改时间也可能发生改变(还有可能无法获取准确时间),这会导致缓存失效的误判;
  2. 有些文件是在秒级以内进行修改的,If-Modified-Since 能检查到的粒度是秒级的,而是用 ETag 能够保证在这种需要场景下客户端能够在 1s 内刷新多次;

注意:协商缓存这两个字段都需要配合强制缓存中的 Cache-Control 字段来使用,只有在未能命中强缓存的时候才能发起带有协商缓存字段的请求。

协商缓存工作过程图解

  1. 第一次访问

image.png

  1. 第二次访问

image.png

总结

强缓存就是浏览器本地根据服务器设置的过期时间来判断是否使用缓存,未过期则从本地缓存里拿资源,已过期则重新请求服务器获取最新资源。

协商缓存则是浏览器本地每次都向服务器发起请求,由服务器来告诉浏览器是从缓存里拿资源还是返回最新资源给浏览器使用。