HTTP缓存及服务器端实现

341 阅读9分钟

此文章为个人学习总结,如理解有误,请在 github 提交 issue,转载请标注原文链接。

文章中的完整代码示例仓库为 examples/node/cache

前言

为什么网上这么多讲解 HTTP 缓存的文章,而且都分析的非常好,我还要再写呢?因为,看文章经常不思考,而写文章是需要思考的。思考和不思考的区别和理解就很大了,这也是要自己一边思考一边写、总结的原因。

为什么需要缓存

通过复用以前获取的资源,可以显著提高网站和应用程序的性能。Web 缓存减少了等待时间和网络流量,因此减少了显示资源表示形式所需的时间,降低服务器压力。通过使用 HTTP 缓存,提升网站的响应速度等。

HTTP 缓存类型

HTTP 缓存分为强缓存和协商缓存。

对于强缓存,服务器返回的静态资源响应头会设置一个强制缓存的时间,在缓存时间内,如刷新浏览器请求相同资源,在缓存时间未过期的情况下,则直接使用已缓存资源。如缓存资源已过期,执行协商缓存策略。

协商缓存情况,服务器返回的静态资源时,响应头会携带 Last-Modified (文件在服务器最后修改的时间) 或 ETag(根据文件内容或文件其他信息生成的唯一标识)字段。当浏览器发送协商缓存请求时,请求头会携带 If-Modified-Since 或 If-None-Match 头字段 (后文会详细讲解这两个字段) 到服务器。其中 If-None-Match 优先级比 If-Modified-Since 高,服务器会根据优先级最高的字段,验证服务器资源是否为最新,如果是则返回最新资源即 statusCode 状态码 200。否则 statusCode 设置 304,告诉客户端继续使用缓存文件。

下面是一张整体的流程图,觉得这样图画的很清晰,恰一下。


图片来源自 浏览器缓存机制剖析 一文。

强缓存

是否启用强缓存一般由 Expires 和 Cache-Control 响应头字段控制。

字段用途示例优先级HTTP版本
Expires强缓存资源的过期日期Expires: Thu, 06 Aug 2020 14:36:18 GMT1.0
Cache-Control指定指令实现缓存机制Cache-Control: max-age=601.1

Expires

响应头 Expires 字段包含强缓存资源的过期时间,值为 0 表示资源已过期或非强缓存。使用 GMT 时间格式。服务器响应头设置了 Expires 缓存 7 天静态资源文件,7天内都直接使用缓存。


静态资源的响应头:


再刷新一下浏览器,直接从内存中读取缓存资源,不会再向服务器请求该资源文件:


资源使用强缓存,刷新后直接从缓存内存中读取。

Cache-Control

通用消息头字段,通过指令来实现缓存机制。Cache-Control 主要的指令包括:

指令作用
public响应的资源可被请求的客户端、代理服务器等缓存
private只能被单个用户缓存, 不能作为共享缓存(代理服务器不可缓存)等
no-cache强制要求发起请求给服务器进行验证 (协商资源验证)
no-store不使用任何缓存, 每次都需要服务器返回最新的资源文件
max-age=<seconds>设置缓存的最大周期, 比如 Cache-Control: max-age=60 超过 60s 缓存被认为过期, 需要向服务器获取最新资源
max-stale=<seconds>资源已过期多少秒后, 才向服务器获取最新资源文件, 否则继续使用过期资源
must-revalidate一旦资源过期 (比如已经超出 max-age), 在成功向原始服务器验证之前, 缓存不能用该资源响应的后续请求
...更多请查阅 MDN 文档


可以通过 Cache-Control: <指令> 来控制资源的缓存,下面以 max-age=<seconds> 指令为例,设置资源文件在浏览器缓存的最大周期(s)。

服务器设置静态资源的强缓存时间为 10s,10s内再次请求该资源时,直接使用缓存。


第一次请求服务器资源,有 Cache-Control 响应头字段:


当在 10s 内(资源不过期)刷新浏览器,再次请求已缓存的资源时:

Expires 和 Cache-Control 的区别

从上面的示例可以看到,Expires 和 Cache-Control 都可以设置强缓存策略,它们两者也存在一定的区别:

  • 时间区别
    • Expires 过期时间为绝对时间,指未来某个时间资源过期。如 2022年12月12日 x时x分x秒 资源过期
    • Cache-Control 为相对时间,相对于当前时间。如 60s 后过期
  • 优先级
    • Expires 的优先级低于 Cache-Control 字段
    • 同时存在 Cache-Control 和 Expires时,以 Cache-Control 指令为准
  • HTTP 版本
    • Expires 是HTTP/1.0 提出的,其浏览器兼容性更好
    • Cache-Control 是 HTTP/1.1 提出的,浏览器兼容性不佳,所以 Expires 和 Cache-Control 可以同时存在,在不支持 Cache-Control 的浏览器则以Expires 为准

协商缓存

协商缓存及获取资源前会向服务器验证资源的新鲜度,请求中携带一些参数由服务端判断是否命中协商缓存,如果命中则 statusCode 为 304,直接从缓存中读取资源。否则 statusCode 为 200,将最新的资源发送给客户端。

以下字段决定是否使用协商缓存,而非强缓存:

字段协商缓存优先级
PragmaPragma: no-cache
Cache-ControlCache-Control: no-cache 或者 Cache-Control: max-age=0

Pragma

Pragma 是一个在 HTTP/1.0 中规定的通用首部,如果 Cache-Control 不存在的话,它的行为与 Cache-Control: no-cache 一致。强制要求缓存服务器在返回缓存的版本之前将请求提交到源头服务器进行协商验证。

Pragma 只有一个值,就是 no-cache。并且它的优先级比 Cache-Control 高。两者同时存在时,以 Pragma 的值为准。

服务端可以这样设置响应头中的 Pragma 字段:

res.setHeader("Pragma", "no-cache")

Pragma 它用来向后兼容只支持 HTTP/1.0 协议的缓存服务器,那时候 HTTP/1.1 协议中的 Cache-Control 还没有出来。

Cache-Control

在上文的 强缓存 中已经介绍过 Cache-Control,它的指令既可用于强缓存也可应用于协商缓存策略中。

其中 Cache-Control: no-cache 和 Cache-Control: max-age=0 的作用一样,强制要求发起请求给服务器进行验证 (协商资源验证)。

服务端可以这样设置响应头中的 Cache-Control 字段:

res.setHeader("Cache-Control", "no-cache")


或者

res.setHeader("Cache-Control", "max-age=0")


都可以设置协商缓存策略。

那么第一次加载请求资源:


刷新,第二次加载资源,则协商后的结果为 304。因为服务器资源没有更新,所以缓存资源依然新鲜,直接使用缓存。


协商策略

如果使用了协商缓存 Cache-Control: no-cache 时,服务端响应头会根据静态资源返回不同的 Last-Modified 和 ETag 字段,下次请求将通过 If-Modified-Since 和 If-None-Match 携带 Last-Modified 和 Etag 返回的值发送到服务端做协商验证。

下面分别比较 Last-Modified、ETag 和 If-Modified-Since、If-None-Match:

Last-Modified 与 ETag 响应头字段比较
**

响应头字段对应的请求头字段描述优先级
Last-ModifiedIf-Modified-SinceGMT时间服务器缓存资源的最后是该日期
EtagIf-None-MatchW/"<etag_value>""<etag_value>"服务器缓存资源的文件信息或文件内容生成的哈希值


**If-Modified-Since 与 If-None-Match 请求头字段比较**
**
请求头字段对应的响应头字段描述优先级
If-Modified-SinceLast-ModifiedGMT时间验证 If-Modified-Since 的时间是不是服务器对应资源的最后修改时间, 如果是返回 304, 否则 200
If-None-MatchETagW/"<etag_value>""<etag_value>"验证服务器资源有没有被修改

Last-Modified / If-Modified-Since

Last-Modified 和 If-Modified-Since 是成对出现的。在协商缓存时,服务器将缓存资源在服务器上最后一次的修改时间通过响应头字段 Last-Modified: <T>  发送给客户端。在下次请求该资源时,请求头字段 If-Modified-Since: <T>  将 T  值原样的携带到服务器。服务器只需要比较 **文件的最后修改时间 **和 T 值是不是一致。

如果一致则说明客户端缓存资源是最新的,直接返回 statusCode 304 状态码,继续使用客户端缓存资源。否则,将该资源在服务器最新时间再通过 Last-Modified: <new T> 携带给客户端,并将最新资源发送给客户端且 statusCode 设置为 200。

Last-Modified 服务端实现 代码片段

let modified = req.headers["if-modified-since"]
// 将文件的时间转成 UTC
mtime = toUTCTime(stats.mtime)
 /**
 * 协商缓存, 如果客户端缓存资源依然新鲜, 服务器资源未发生改变, 则使用客户端缓存
 */
if (modified === mtime) {
  res.statusCode = 304
  return setHeader(res, () => {
    res.setHeader("Last-Modified", toUTCTime(mtime))
  })
}
// 给客户端响应最新的资源和时间
res.statusCode = 200;
res.setHeader("Last-Modified", toUTCTime(mtime))


通过这张图,更清晰的表达 Last-Modified 和 If-Modified-Since 交互关系:

ETag / If-None-Match

Etag / If-None-Match 也是成对出现的,但是 Etag/If-None-Match  的优先级比 Last-Modified/If-Modified-Since  高,两者同时存在时,以前者为准。若前者不存在,则以后者为准。

Etag 和 Last-Modified 的区别就是比较不同,ETag 是根据资源的内容生成的一串 hash 值,当请求该资源时只需比较 hash 值是否一致。而 Last-Modified 是比较的资源在服务器最后修改时间是否一致。

ETag 还分为强校验和弱校验。
_

ETag 服务端实现 代码片段

// 获取请求头携带来的 etag
let etag = req.headers['if-none-match']
// 根据服务器资源生成 etag
let weak = 'W/' + statTag(stats)
/**
 * 命中 Etag 协商缓存, 服务器资源为发生改变, 则使用客户端缓存
 */
if (etag && etag === weak) {
  res.statusCode = 304
  return setHeader(res, () => {
    res.setHeader("ETag", weak)
  })
}

// 给客户端响应最新的资源和ETag
res.statusCode = 200;
res.setHeader("ETag", weak)


ETag 的流程和上图差不多一致,只不过在服务器的比较有区别。

优缺点

场景:如果服务器的 a.css 文件先新增了一段 body: { color: red } 样式代码, 1 秒钟后再将新增的样式代码删除。

如果是 Last-Modified 比较,因为服务端 a.css 最后修改时间变了,内容并没有变化,还是会响应最新的文件到客户端,这就增加了不必要的传输。但是 ETag 是根据内容生成的 hash 来比较的,只要资源文件内容不变,就会应用客户端的缓存,减少不必要的传输。

所以,ETag 比 Last-Modified 缓存更精确、高效和节省带宽。