深入学习HTTP缓存

超文本传输协议(HTTP)是一种无状态的应用级请求/响应协议,它使用可扩展的语义和自描述信息与基于网络的超文本信息系统进行灵活交互。它通常用于分布式信息系统,使用响应缓存可以提高性能。

HTTP 缓存是响应信息的本地存储,也是控制信息存储、检索和删除的子系统。缓存存储可缓存的响应,以减少未来同等请求的响应时间和网络带宽消耗。任何客户端或服务器都可以使用缓存,但在作为tunnel时不能使用。HTTP 缓存的目标是通过重复使用先前的响应信息来满足当前请求,从而显著提高性能。如果一个已存储的响应无需 "验证"(与源服务器检查缓存响应是否对当前请求有效)即可重复使用,那么缓存就认为该响应是 "fresh"。因此,每次缓存重用时,新响应都可以减少延迟和网络开销。

缓存种类

在 HTTP Caching 标准中,有两种不同类型的缓存:私有缓存共享缓存

私有缓存

私有缓存是绑定到特定客户端的缓存——通常是浏览器缓存。由于存储的响应不与其他客户端共享,因此私有缓存可以存储该用户的个性化响应。

另一方面,如果个性化内容存储在私有缓存以外的缓存中,那么其他用户可能能够检索到这些内容——这可能会导致无意的信息泄露。

如果响应包含个性化内容并且你只想将响应存储在私有缓存中,则必须指定 private 指令。

Cache-Control: private

个性化内容通常由 cookie 控制,但 cookie 的存在并不能表明它是私有的,因此单独的 cookie 不会使响应成为私有的。

请注意,如果响应具有 Authorization 标头,则不能将其存储在私有缓存(或共享缓存,除非 Cache-Control 指定的是 public)中。

共享缓存

共享缓存位于客户端和服务器之间,可以存储能在用户之间共享的响应。共享缓存可以进一步细分为代理缓存托管缓存

代理缓存

除了访问控制的功能外,一些代理还实现了缓存以减少网络流量。这通常不由服务开发人员管理,因此必须由恰当的 HTTP 标头等控制。然而,在过去,过时的代理缓存实现——例如没有正确理解 HTTP 缓存标准的实现——经常给开发人员带来问题。

Kitchen-sink 标头如下所示,用于尝试解决不理解当前 HTTP 缓存规范指令(如 no-store)的“旧且未更新的代理缓存”的实现。

Cache-Control: no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate

托管缓存

托管缓存由服务开发人员明确部署,以降低源服务器负载并有效地交付内容。示例包括反向代理、CDN 和 service worker 与缓存 API 的组合。

托管缓存的特性因部署的产品而异。在大多数情况下,你可以通过 Cache-Control 标头和你自己的配置文件或仪表板来控制缓存的行为。

例如,HTTP 缓存规范本质上没有定义显式删除缓存的方法——但是使用托管缓存,可以通过仪表板操作、API 调用、重新启动等实时删除已经存储的响应。这允许更主动的缓存策略。

也可以忽略标准 HTTP 缓存规范协议以支持显式操作。例如,可以指定以下内容以选择退出私有缓存或代理缓存,同时使用你自己的策略仅在托管缓存中进行缓存。

Cache-Control: no-store

在缓存中存储响应

缓存不得存储对请求的响应,除非:

  • 缓存可以理解请求方法;
  • 响应状态代码为最终代码 Status Codes;
  • 如果响应状态代码为 206 或 304,或存在理解并符合该响应状态代码要求的缓存;
  • 响应中不包含 no-store 缓存指令;
  • 如果缓存是共享的:私有响应指令要么不存在,要么允许共享缓存存储修改后的响应;
  • 如果缓存是共享的:请求中不存在Authorization字段,或者存在明确允许共享缓存的响应指令;
  • 响应至少包含以下内容之一:
    • 公共响应指令;
    • 如果缓存不是共享的,则应使用私有响应指令;
    • Expires 头信息字段;
    • max-age 响应指令;
    • 如果缓存是共享的:s-maxage 响应指令;
    • 允许缓存的缓存扩展;
    • 被定义为启发式缓存的状态代码。

从缓存中构建响应

在收到请求时,高速缓存不得重复使用已存储的响应,除非

  • 提交的目标 URI 与存储的响应 URI 匹配,以及
  • 与存储的响应相关联的请求方法允许将其用于提出的请求,以及
  • 存储的响应所指定的请求标头字段与提交的请求标头字段相匹配,以及
  • 除非成功验证,否则存储的响应不包含 no-cache 指令,以及
  • 存储的响应是以下其中之一:
    • fresh
    • 允许供应过期
    • 成功验证

请注意,缓存扩展可覆盖所列的任何要求。

当使用存储的响应来满足请求而不进行验证时,缓存必须生成一个 Age 标头字段,用等于存储的响应的 current_age 值来替换响应中的任何值。

缓存必须将使用不安全方法的请求写入源服务器;也就是说,缓存在转发请求并收到相应回复之前,不得生成对此类请求的回复。

缓存可以使用已存储或可存储的响应来满足多个请求,前提是允许缓存在相关请求中重复使用该响应。这样,高速缓存就能将请求合并,或在高速缓存缺失时将多个传入请求合并为一个前向请求,从而减少源服务器和网络的负载。但需要注意的是,如果高速缓存无法将返回的响应用于部分或全部折叠请求,则需要转发请求以满足这些请求,从而可能带来额外的延迟。

当存储了多个合适的响应时,缓存必须使用最新的响应(由Dateheader 字段决定)。缓存还可以使用 "Cache-Control: max-age=0 "或 "Cache-Control: no-cache "来转发请求,以区分使用哪个响应。

字段定义

Age

Age 响应标头字段表示发送方估计的自响应生成或在源服务器成功验证以来的时间。 年龄 "响应标头字段表示发送方估计的自响应生成或在源服务器成功验证以来的时间。

Age = delta-seconds

Age字段值为非负整数,以秒为单位表示时间。 虽然它被定义为单例头域,但缓存在遇到带有基于 Age 字段值的列表报文时,应使用该字段值的第一个成员,而丢弃其后的成员。 如果字段值(在舍弃其他成员后)无效(例如,它包含非负整数以外的内容),高速缓存应忽略该字段。 如果存在 Age 标头字段,则意味着该请求的源服务器没有生成或验证响应。但是,没有 Age 字段并不意味着原服务器被联系过。

Cache-Control

Cache-Contro 标头字段用于列出请求/响应链上的缓存指令。缓存指令是单向的,即在请求中存在指令并不意味着在响应中也存在或复制相同的指令。

无论代理是否实现了缓存,都必须在转发消息中传递缓存指令,而不管这些指令对该应用程序是否重要,因为这些指令可能适用于请求/响应链上的所有接收方。指令不可能针对特定的缓存。

缓存指令由一个标记标识,不区分大小写,并且有一个可选参数,该参数可以使用标记和引号字符串两种语法。对于下面定义了参数的指令,即使生成时需要特定的参数,接收者也应接受这两种形式。

Cache-Control   = #cache-directive
cache-directive = token [ "=" ( token / quoted-string ) ]

对于下面定义的缓存指令,除非另有说明,否则不定义(也不允许)参数。

请求指令

max-age

参数语法:delta-seconds

Cache-Control: max-age=5

max-age 请求指令表示客户端更偏向 age 小于或等于指定秒数的响应。除非同时存在 max-stale 请求指令,否则客户端不希望收到过期的响应。

max-stale

参数语法:delta-seconds

Cache-Control: max-stale=10

max-stale 请求指令表示客户端是否接受超过有效期的响应。如果存在一个值,则客户机愿意接受超过有效期但不超过指定秒数的响应。如果没有为 max-stale 赋值,则客户端将接受任何时间的过期响应。

min-fresh

参数语法:delta-seconds

Cache-Control: max-fresh=20

min-fresh 请求指令表示,客户端希望响应的有效期不小于其当前 Age 加上以秒为单位的指定时间。也就是说,客户端希望响应至少在指定的秒数内仍然是有效的。

no-cache

无缓存请求指令表示,客户端不希望重复使用响应,而是希望始终从服务器获取最新内容强制验证。

no-store

缓存不得存储该请求或对该请求的任何响应的任何部分。该指令既适用于私有缓存,也适用于共享缓存。此处的 "不得存储 "是指缓存不得有意将信息存储在非易失性存储器中,并且必须尽最大努力在转发信息后尽快将其从易失性存储器中删除。

该指令并非确保隐私的可靠或充分机制。特别是,恶意的或被破坏的缓存可能无法识别或遵守这一指令,通信网络也可能容易被窃听。

no-transform

表示客户端要求中间机构避免转换内容。

only-if-cached

only-if-cached 请求指令表示客户端只希望获得存储的响应。符合该请求指令的高速缓存在接收到该请求指令后,应响应与请求的其他限制条件一致的存储响应或 504(网关超时)状态代码。

响应指令

max-age

参数语法:delta-seconds

Cache-Control: max-age=5

max-age 响应指令表示,当响应的 age 大于指定的秒数时,响应将被视为过时。

must-revalidate

must-revalidate 响应指令表明,一旦响应过时,缓存不得重新使用该响应来满足另一个请求,直到该响应被源成功验证为止。支持某些协议功能可靠运行的必要条件。在任何情况下,缓存都不得忽略必须重新验证指令;特别是,如果缓存断开连接,缓存必须生成错误响应,而不是重复使用过时的响应。生成的状态代码应为 504(网关超时),除非其他错误状态代码更适用。

服务器必须使用 must-revalidate 指令,前提是且仅当验证请求失败可能导致错误操作(如静默未执行的金融交易)时。

must-understand

must-understand 响应指令将响应的缓存限制在能理解并符合响应状态代码要求的缓存中。

no-cache

在未转发验证并收到成功响应的情况下,不得将响应用于满足任何其他请求。这样,即使缓存被配置为发送过期响应,源服务器也能防止缓存在未与源服务器联系的情况下使用响应来满足请求。

no-store

no-store 表示缓存不得存储即时请求或响应的任何部分,也不得使用响应来满足任何其他请求。

no-transform

no-transform 表示媒介(无论其是否实现了缓存)不得转换内容。

private

无保留的private 表示共享缓存不得存储该响应(即该响应仅用于单个用户)。它还表示,私有缓存可以存储响应,但该响应无法被私有缓存启发式地缓存。

如果存在限定的 private 指令,且其参数列出了一个或多个字段名称,则只有列出的标头字段才仅限于单个用户使用:共享缓存不得存储原始响应中列出的标头字段,但可以存储不包含这些标头字段的响应消息的其余部分。

proxy-revalidate

proxy-revalidate 表示,一旦响应过时,共享缓存就不得重新使用该响应来满足另一个请求,直到该响应被源成功验证为止。这与 must-revalidate 类似,只是 proxy-revalidate 不适用于私有缓存。

proxy-revalidate 本身并不意味着响应是可缓存的。例如,它可以与 public 指令结合使用,这样就可以缓存响应,而只需要共享缓存在响应过时时重新验证。

public

public 表示,缓存可以存储该响应,即使该响应在其他情况下是被禁止的。public 明确地将响应标记为可缓存。例如,public 允许共享缓存重复使用对包含 Authorization 头字段的请求的响应。

s-maxage

s-maxage 响应指令表示,对于共享缓存,该指令指定的最大使用 age 优先于 max-age 指令或 Expires 标头字段指定的最大使用 ages-maxage 指令包含了共享缓存的 proxy-revalidate 响应指令的语义。共享缓存不得重复使用带有 s-maxage 的过期响应来满足另一个请求,除非该响应已被源成功验证。本指令还允许共享缓存重新使用对包含 Authorization 头字段的请求的响应,但须遵守上述有关最大 age 和重新验证的规定。

Expires

Expires 响应头字段给出了响应被视为过期的日期/时间。Expires 头字段的出现并不意味着原始资源将在该时间、之前或之后发生变化或不复存在。Expires 字段值是 HTTP 日期时间戳。

Expires: Thu, 01 Dec 1994 16:00:00 GMT

缓存接收方必须将无效的日期格式,尤其是值 "0",解释为代表过去的时间(即 "已过期")。 如果响应的 Cache-Control 头域包含 max-age ,则接收方必须忽略 Expires 头域。同样,如果响应包含 s-maxage 指令,则共享缓存接收方必须忽略 Expires 头信息字段。在这两种情况下,Expires 中的值只适用于尚未实施 Cache-Control 头域的接收方。

没有时钟的源服务器不得生成 Expires 头域,除非其值代表过去的固定时间(总是过期),或者其值已由带时钟的系统与资源相关联。

一直以来,HTTP 要求 Expires 字段值不得超过未来一年。虽然不再禁止更长的有效期,但已证明过大的值会导致问题(例如,由于使用 32 位整数作为时间值,会导致时钟溢出),而且许多缓存会比这更早驱逐响应。

常见的几种响应策略

Expires/max-age

在 HTTP/1.0 中,有效期是通过 Expires 标头来指定的。

Expires 标头使用明确的时间而不是通过指定经过的时间来指定缓存的生命周期。

Expires: Tue, 28 Feb 2022 22:22:22 GMT

但是时间格式难以解析,也发现了很多实现的错误,有可能通过故意偏移系统时钟来诱发问题;因此,在 HTTP/1.1 中,Cache-Control 采用了 max-age——用于指定经过的时间。

如果 Expires 和 Cache-Control: max-age 都可用,则将 max-age 定义为首选。因此,由于 HTTP/1.1 已被广泛使用,无需特地提供 Expires

Vary 响应

区分响应的方式本质上是基于它们的 URL:

使用 url 作为键

但是响应的内容并不总是相同的,即使它们具有相同的 URL。特别是在执行内容协商时,来自服务器的响应可能取决于 AcceptAccept-Language 和 Accept-Encoding 请求标头的值。

例如,对于带有 Accept-Language: en 标头并已缓存的英语内容,不希望再对具有 Accept-Language: ja 请求标头的请求重用该缓存响应。在这种情况下,你可以通过在 Vary 标头的值中添加“Accept-Language”,根据语言单独缓存响应。

Vary: Accept-Language

这会导致缓存基于响应 URL 和 Accept-Language请求标头的组合进行键控——而不是仅仅基于响应 URL。

使用 url 和语言作为键

此外,如果你基于用户代理提供内容优化(例如,响应式设计),你可能会想在 Vary 标头的值中包含“User-Agent”。但是,User-Agent 请求标头通常具有非常多的变体,这大大降低了缓存被重用的机会。因此,如果可能,请考虑一种基于特征检测而不是基于 User-Agent 请求标头来改变行为的方法。

对于使用 cookie 来防止其他人重复使用缓存的个性化内容的应用程序,你应该指定 Cache-Control: private 而不是为 Vary 指定 cookie。

验证响应

过时的响应不会立即被丢弃。HTTP 有一种机制,可以通过询问源服务器将陈旧的响应转换为新的响应。这称为验证,有时也称为重新验证

验证是通过使用包含 If-Modified-Since 或 If-None-Match 请求标头的条件请求完成的。

If-Modified-Since

以下响应在 22:22:22 生成,max-age 为 1 小时,因此你知道它在 23:22:22 之前是有效的。

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT
Cache-Control: max-age=3600
 
<!doctype html>

到 23:22:22 时,响应会过时并且不能重用缓存。因此,下面的请求显示客户端发送带有 If-Modified-Since 请求标头的请求,以询问服务器自指定时间以来是否有任何的改变。

GET /index.html HTTP/1.1
Host: example.com
Accept: text/html
If-Modified-Since: Tue, 22 Feb 2022 22:00:00 GMT

如果内容自指定时间以来没有更改,服务器将响应 304 Not Modified

由于此响应仅表示“没有变化”,因此没有响应主体——只有一个状态码——因此传输大小非常小。

HTTP/1.1 304 Not Modified
Content-Type: text/html
Date: Tue, 22 Feb 2022 23:22:22 GMT
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT
Cache-Control: max-age=3600

收到该响应后,客户端将存储的过期响应恢复为有效的,并可以在剩余的 1 小时内重复使用它。

服务器可以从操作系统的文件系统中获取修改时间,这对于提供静态文件的情况来说是比较容易做到的。但是,也存在一些问题;例如,时间格式复杂且难以解析,分布式服务器难以同步文件更新时间。

为了解决这些问题,ETag 响应标头被标准化作为替代方案。

Etag/If-None-Match

Etag 响应标头的值是服务器生成的任意值。服务器对于生成值没有任何限制,因此服务器可以根据他们选择的任何方式自由设置值——例如主体内容的哈希或版本号。

举个例子,如果 Etag 标头使用了 hash 值,index.html 资源的 hash 值是 deadbeef,响应如下:

HTTPCopy to Clipboard

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Etag: "deadbeef"
Cache-Control: max-age=3600

<!doctype html>
…

如果该响应是陈旧的,则客户端获取缓存响应的 Etag 响应标头的值,并将其放入 If-None-Match 请求标头中,以询问服务器资源是否已被修改:

HTTPCopy to Clipboard

GET /index.html HTTP/1.1
Host: example.com
Accept: text/html
If-None-Match: "deadbeef"

如果服务器为请求的资源确定的 Etag 标头的值与请求中的 If-None-Match 值相同,则服务器将返回 304 Not Modified

但是,如果服务器确定请求的资源现在应该具有不同的 Etag 值,则服务器将其改为 200 OK 和资源的最新版本进行响应。

强制重新验证

Cache-Control: no-cache

不使用缓存

Cache-Control: no-store

一般来说,实践中“不缓存”的原因满足以下情况:

  • 出于隐私原因,不希望特定客户以外的任何人存储响应。
  • 希望始终提供最新信息。
  • 不知道在过时的实现中会发生什么。

不与其他用户共享

如果具有个性化内容的响应意外地对缓存的其他用户可见,那将是有问题的。 在这种情况下,使用 private 指令将导致个性化响应仅与特定客户端一起存储,而不会泄露给缓存的任何其他用户。

Cache-Control: private

在这种情况下,即使设置了 no-store,也必须设置 private

每次都提供最新的内容

no-store 指令阻止存储响应,但不会删除相同 URL 的任何已存储响应。

换句话说,如果已经为特定 URL 存储了旧响应,则返回 no-store 不会阻止旧响应被重用。

但是,no-cache 指令将强制客户端在重用任何存储的响应之前发送验证请求。

Cache-Control: no-cache

如果服务端不支持条件请求,你可以强制客户端每次都访问服务端,总是得到最新的 200 OK 响应。

兼容过时的实现

作为忽略 no-store 的过时实现的解决方法,你可能会看到使用了诸如以下内容的 kitchen-sink 标头:

HTTPCopy to Clipboard

Cache-Control: no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate

推荐使用 no-cache 作为处理这种过时的实现的替代方案,如果从一开始就设置 no-cache 就没问题,因为服务器总是会收到请求。

如果你关心的是共享缓存,你可以通过添加 private 来防止意外缓存:

Cache-Control: no-cache, private

避免重新验证

永远不会改变的内容应该被赋予一个较长的 max-age,方法是使用缓存破坏——也就是说,在请求 URL 中包含版本号、哈希值等。

但是,当用户重新加载时,即使服务器知道内容是不可变的,也会发送重新验证请求。

为了防止这种情况,immutable 指令可用于明确指示不需要重新验证,因为内容永远不会改变。

Cache-Control: max-age=31536000, immutable

这可以防止在重新加载期间进行不必要的重新验证。

私有缓存

对于使用 cookie 进行个性化的响应(例如,在登录后),不要忘记同时指定 private

200 OK HTTP/1.1
Content-Type: text/html
Content-Length: 1024
Cache-Control: no-cache, private
Last-Modified: Tue, 22 Feb 2022 20:20:20 GMT
ETag: AAPuIbAOdvAGEETbgAAAAAAABAAE
Set-Cookie: __Host-SID=AHNtAyt3fvJrUL5g5tnGwER; Secure; Path=/; HttpOnly

参考链接

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Caching https://httpwg.org/specs/rfc9111.html