HTTP 缓存

祝鑫奔,YMFE 工程师,猫奴,沉迷于吸猫无法自拔。负责 QReact、QRN-Web 的开发和维护。

HTTP 缓存是一种非常实用的技术,可以大幅度提升 Web 应用的加载速度并降低服务器的压力,减少冗余数据传输。本文通过 HTTP 规范解读常见缓存控制首部各项指令的意义,向读者展示一个文件是在什么样的策略下被缓存并使用的。

原文链接:HTTP 缓存

HTTP 缓存

什么是缓存

缓存是一种可以自动保存常见资源副本并可以在下一次请求中直接使用副本而非再次获取的技术。当请求到达时,如果本地有已缓存的副本,且满足一系列条件,则会直接使用本地的副本而不会从原始服务器再次请求文档。缓存是一种非常实用的技术,正确使用不但可以提高 Web 应用的性能,还可以降低服务器的压力,降低网络瓶颈,减少流量传输。

为什么要使用缓存

使用缓存有几个很明显的优点:

  1. 缓存可以减少冗余的数据传输。不需要重复传输的文档。
  2. 缓存可以缓解网络瓶颈的问题。在相同的流量下可以减少带宽的使用。
  3. 缓存可以降低原始服务器要求。在相同的服务器压力下可以使用更少的服务器。
  4. 缓存可以降低请求的距离时延。从较远的原始服务器获得文档要比从缓存获得文档要花更久的时间。

冗余的数据传输

当很多客户端访问同一份文档的时候,原始服务器一遍又一遍地返回给不同的客户端相同内容的文档,这些重复的文档造成了数据的冗余传输,在极端情况下可能会耗尽网络带宽造成网络拥堵或者造成服务器过载而宕机。设想一下,如果若干台客户端连接着一台缓存服务器,缓存服务器在第一次访问该资源的时候便把文档缓存下来,那么从第二个请求开始,缓存服务器可以直接返回本地的副本而不用再次向原始服务器请求,大大降低了原始服务器的压力,降低了在网络上传输的流量,也大大降低了延迟。

带宽瓶颈

缓存还可以缓解带宽瓶颈的问题。在大部分情况下,客户端访问代理服务器的速度总是比访问原始服务器更快(带宽大、延迟低),因此如果代理服务器能够提供一份完整的副本,则远远比从原始服务器获取来的快且省流量——尤其针对大文件来说。

假设 GiiHub 的用户通过 10 MBit/s 的网络下载一个 50 MB 的二进制文件,如果从原始服务器获取,那么大约需要 40s,而如果代理服务器中已经有一份完整的副本的话,直接通过 100 MBit/s 的局域网返回,那么只需要大约 4s。这不但可以提高该用户的访问速度,还可以为其他用户省下不必要的带宽浪费——尤其对于很多人共用一条出口带宽的情况。

瞬时流量

突发事件(比如爆炸性的新闻、12306 抢票)会使大量用户访问同一份文档,由此带来的瞬时流量可能会导致网络或者 Web 服务器过载而宕机。但是如果缓存合理配置并使用的话,可以将很大一部分流量拦截在缓存上,而不会发送至原始服务器上,这可以大大降低网络和服务器的瞬时流量,避免过载而宕机。

物理距离

即使客户端和服务器的带宽都很充裕,物理距离也会实打实地降低 Web 应用的性能。假设在中国北京访问 GitHub 的站点,其首页中有 24 张图片,浏览器和服务器建立了 3 条并行连接,每条连接负责传输 8 张图片,从中国北京到美国弗吉尼亚阿什本(GitHub 服务器所在地)直线距离约为 15000 千米,携带信息的光在光缆中的传播速度大约为 200,000 KM/s,也就是说排除其他所有延迟,仅仅海底光缆单程就需要大约 75 ms,一个请求来回就是至少 150 ms,每条连接顺序处理 8 张图片就需要 1200 ms。这个时间对于目前每秒都很珍贵的 Web 应用来说,并不是非常理想,但是如果代理服务器中有这些图片的副本,那么可能只需要 50 ms 就能获得 24 张图片了,如果浏览器缓存中有,那么大约 10 ms 就够了。

缓存的一些概念

如果某个请求的结果是由已缓存的副本提供的,被称作缓存命中 (cache hit),如果缓存中没有可用的副本或者副本已经过期,则会将请求转发至原始服务器,这被称作缓存未命中 (cache miss)。

再验证

原始服务器上的内容可能会随时变化,缓存需要经常检测某个已缓存的文档是否已过期,这种操作被称为 HTTP 再验证 (revalidatation)。HTTP 定义了数种特殊的请求以便不传输完整的文档内容即可检测文档是否是最新的。缓存可以在任何时刻以任意的频率再验证某个资源是否新鲜。但是为了避免浪费带宽,大部分缓存都只会在客户端发起请求且本地副本已经不够新鲜的时候才进行再验证。

缓存对本地副本进行再验证的时候,会向原始服务器发送一个很小的再验证请求。如果文档的内容没发生变化,则会以一个不包含正文的 304 Not Modified 进行响应。缓存在收到该响应之后,便知道该文档的本地副本还是新鲜的,会将该文档标记为暂时新鲜,并提供给客户端,这被称作再验证命中 (revalidation hit) 或者缓慢命中 (slow hit)。这种方式仍然需要和原始服务器进行核对,不过不需要传输整个文档,因此这种方式比未命中要快,但是比直接命中要慢。

HTTP 提供了几个可以用于验证文档是否需要更新的首部,最常见的就是 If-Modified-Since,其值为一个 GMT 时间,当请求含有这个首部的时候,服务器会根据该文档的实际情况做出不同的响应,这里列出三种不同情况下(服务器内容未修改、服务器内容已修改或者服务器上的文档已删除)服务器所作出的响应:

  • 再验证命中

    如果服务器上的文档在那之后未发生过修改,则会以一条只有起始行和首部的 304 Not Modified 座位响应。缓存在收到 304 Not Modified 响应之后会直接使用本地副本。

  • 再验证未命中

    如果服务器上的文档在那之后发生过修改,则会返回一条普通的、带有完整文档内容的 200 OK 响应。缓存会将新文档的副本保存到本地,以供下次请求使用。

  • 对象被删除

    如果服务器上的文档已被删除,则会返回一条 404 Not Found 的响应。缓存会将该文档的本地副本删除。

命中率或字节命中率

由缓存的本地副本返回所有请求所占的比例称为缓存命中率 (cache hit rate,也称作缓存命中比例)。命中率在 0 到 1 之间,通常用百分数表示。缓存的管理者希望缓存命中率接近 100%,不过这是不可能的事情,网络上的文档无时无刻不在更新,缓存也就需要无时无刻保持着文档的新鲜。

Web 站点上的文档并不是同一尺寸的,比如 HTML 文档就比图片要小得多,因此单纯的缓存命中率并不能说明缓存所降低的数据流量,在大部分情况下,大尺寸的文档占数据流量的比例远远多于小尺寸的文档的数据流量。因此有些时候用字节命中率 (byte hit rate) 会比使用缓存命中率更能说明缓存的效率。

缓存的结构

缓存类型

缓存有两种类型,一种是私有缓存,最常见的就是浏览器的缓存;另一种是共享缓存,最常见的有缓存服务器、CDN、反向代理服务器缓存等。

私有缓存和共享缓存

私有缓存

私有缓存,顾名思义就是自己使用的缓存,是不共享的,最常见的就是浏览器缓存,一个浏览器的缓存是不会共享给另一浏览器的。浏览器缓存通常会根据文档的响应头缓存指定的文档,为浏览器提供向前 / 向后的导航,通过减少对服务器的请求来提高站点响应速度、降低服务器压力。

共享缓存

共享缓存是特殊的共享代理服务器,称作缓存代理服务器 (cache proxy server),或者更常见的是代理缓存 (proxy server)。代理缓存在收到用户的请求之后,会从本地缓存给用户提供文档,如果缓存中的文档不够新鲜,则会代表用户与服务器进行通信,以获得最新的文档,并保存副本。共享缓存会接受多个用户的访问,因此会比私有缓存更好地减少冗余流量、降低访问延迟。

代理缓存的层次结构

在实际情况中,缓存可能是分了很多层的,多层级的缓存可以提高缓存的效率。较低层级的缓存未命中的请求会导向较大的父缓存,由父缓存来为这些过滤过的请求提供服务。下图显示了一个两级的缓存层次结构。最基本的思想就是层级越低,使用的缓存越小型和廉价,在较高的层级使用一些大型的缓存提供服务。为终端用户提供一个大型的缓存是没有必要的,一个终端用户获取的文档数量和种类都有限,大型缓存的几乎所有容量都会被浪费掉,因此只需要为终端用户提供一个小型的缓存即可。用户数量不断增多的情况下,则有必要使用一个容量更大的缓存来保存更多用户的文档副本。用户数量越多,其获取到的文档种类和数量也会越多,缓存能保存的文档副本种类和数量也会越多,用户能够直接从缓存获取的文档也会越多,缓存的价值也就越明显。

代理缓存的层次结构

如果客户端自带了缓存(比如 Web 浏览器),那么上图就是个三级缓存层次结构。我们希望请求能够被较低层级的缓存处理,因为越多缓存层级意味着越多次数的请求拦截,拦截次数越多也就意味着越大的性能损耗以及越长的响应时间。

缓存的处理步骤

对一条 HTTP GET 请求的基本缓存处理包括了 7 个步骤:

  1. 接收。缓存从网络中接收请求报文。
  2. 解析。缓存对到达的报文进行解析,获得 URL 以及首部等信息。
  3. 查询。缓存在获取到 URL 以后,开始查找本地副本。本地副本可能存在内存或者本地磁盘中。缓存会采用高效算法查找本地是否有该文档已缓存的副本,如果有直接返回给客户端;如果没有,向原始服务器请求该文档,返回给客户端,并保存到本地。
  4. 新鲜度检测。检测该副本是否已过期。如果一个副本在本地缓存中存在的时间超过了新鲜度限值,缓存就会认为其“不足够新鲜”了,在将该文档返回给客户端之前,缓存将联系服务器确认本地副本是否还足够“新鲜”。
  5. 创建响应。缓存用新的首部和文档来构建一条响应报文。缓存可能会根据客户端的要求修改响应。比如客户端要求的是 HTTP/1.1 的响应,而原始服务器返回的是 HTTP/1.0 的响应,这时缓存服务器就会把原始服务器的 HTTP/1.0 响应转换为 HTTP/1.1 的响应。缓存也可能会在响应中添加一个 Via 首部表明该响应来自于缓存。
  6. 发送。将报文通过网络返回给客户端。
  7. 日志。可选的记录此条事务,以便记录足够的数据对缓存策略进行优化。

保持副本的新鲜

HTTP 缓存中最重要的部分就是如何保持本地副本的新鲜。因特网上的文档总是在不断变化的,如果一个缓存提供的文档不是最新的,那么缓存便毫无用处。因此缓存中保存的副本要保证和原始服务器的一致。HTTP 有一些简单的机制可以保证缓存中的文档和原始服务器的一致,这些机制称为文档过期服务器再验证

针对响应的缓存控制首部

HTTP 中有两个首部可以控制文档的过期时间,分别是 Cache-ControlExpires。这两个首部可以提供文档的过期时间,就像牛奶盒子上印的过期时间一样,表明在这个时间之前文档都是新鲜的。

Expires 的例子:

1
2
3
4
HTTP/1.1 200 OK
Date: Sat, 10 Mar 2018 09:53:08 GMT
Content-Type: text/html; charset=utf-8
Expires: Tue, 10 Apr 2018 09:51:41 GMT

Cache-Control 的例子:

1
2
3
4
HTTP/1.1 200 OK
Date: Sat, 10 Mar 2018 09:53:08 GMT
Content-Type: text/html; charset=utf-8
Cache-Control: max-age=484200

在这两个首部的指定的过期时间之前,缓存可以无限次使用本地副本给客户端提供服务,不需要和原始服务器进行确认,除了客户端的请求中提供了不允许使用已缓存副本或未经验证资源的首部的情况外。但是一旦超过了这两个首部所提供的过期时间,缓存就需要向原始服务器验证本地副本是否需要更新,如果需要更新则从原始服务器获取新的文档,并覆盖本地已缓存的副本。

Expires

Expires 是 HTTP/1.0 开始提供的首部,Expires - RFC2616 中的定义:

The Expires entity-header field gives the date/time after which the entity should be considered stale. This allows information providers to suggest the volatility of the resource, or a date after which the information may no longer be valid. Applications must not cache this entity beyond the date given. The presence of an Expires field does not imply that the original resource will change or cease to exist at, before, or after that time. However, information providers that know or even suspect that a resource will change by a certain date should include an Expires header with that date. The format is an absolute date and time as defined by HTTP-date in Section 3.3.

An example of its use is

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

If the date given is equal to or earlier than the value of the Date header, the recipient must not cache the enclosed entity. If a resource is dynamic by nature, as is the case with many data-producing processes, entities from that resource should be given an appropriate Expires value which reflects that dynamism.

Note: Applications are encouraged to be tolerant of bad or misinformed implementations of the Expires header. A value of zero (0) or an invalid date format should be considered equivalent to an “expires immediately.” Although these values are not legitimate for HTTP/1.0, a robust implementation is always desirable.

Expires 首部的值是一个绝对的 HTTP 时间,表明在这个时间之前,该文档可以认为是新鲜的,一旦超过这个时间,则应将其认为是过期的。如果 Expires 所提供的时间已经等于或者超过请求时间,则不应将其缓存。并且,如果一个资源是动态生成的,比如通过大量计算产生的资源,应该给其一个合理的 Expires 首部来指明该资源是动态生成的。如果 Expires 首部提供的是 00 是非法值)或者一个非法的时间,等于表明该资源“立即过期”。

Expires 首部能提供的缓存策略有限,远不及 Cache-Control

Cache-Control

Cache-Control 首部则是从 HTTP/1.1 开始提供的,根据 Cache-Control - RFC2616 中的定义:

The Cache-Control general-header field is used to specify directives that MUST be obeyed by all caching mechanisms along the request/response chain. The directives specify behavior intended to prevent caches from adversely interfering with the request or response. These directives typically override the default caching algorithms. Cache directives are unidirectional in that the presence of a directive in a request does not imply that the same directive is to be given in the response.

可以看出,Cache-Control 是一个通用首部,请求 / 响应经过的所有缓存都必须遵守该首部所定义的指令。而且 Cache-Control 在一般情况下会覆盖默认缓存策略。缓存指令是单向的,也就是说请求中所指定的缓存指令并不一定会和响应中的缓存指令一致。

Cache-Control 首部的结构如下:

1
2
Cache-Control   = "Cache-Control" ":" 1#cache-directive
cache-directive = cache-request-directive | cache-response-directive

Cache-Control 的值是一条请求缓存指令或者响应缓存指令,响应缓存指令可选的值有:

  1. "public"
  2. "private" [ "=" <"> 1#field-name <"> ]
  3. "no-cache" [ "=" <"> 1#field-name <"> ]
  4. "no-store"
  5. "no-transform"
  6. "must-revalidate"
  7. "proxy-revalidate"
  8. "max-age" "=" "delta-seconds"
  9. "s-maxage" "=" "delta-seconds"
  10. cache-extension

根据 Response Cacheability - RFC2616

Unless specifically constrained by a cache-control (section 14.9) directive, a caching system MAY always store a successful response (see section 13.8) as a cache entry, MAY return it without validation if it is fresh, and MAY return it after successful validation. If there is neither a cache validator nor an explicit expiration time associated with a response, we do not expect it to be cached, but certain caches MAY violate this expectation (for example, when little or no network connectivity is available). A client can usually detect that such a response was taken from a cache by comparing the Date header to the current time.

除非响应被 Cache-Control 限制,否则缓存可以将所有成功的响应保存为副本,并可以在不经过原始服务器验证的情况下将其作为响应返回给客户端。不过如果一个响应既没有指定验证副本的策略或者过期时间,则我们不希望此响应被缓存,但是在某些情况(网络很差或者无网络)下,缓存可能将此副本作为响应返回给客户端。客户端可以比较通过 Date 首部很容易的知道该响应是从缓存中获取的而非从原始服务器获取的。

public

public 表示该请求可以被任何缓存服务器缓存,即使该请求可能是无法缓存的或者是只能被私有缓存所保存的。

private

private 表示该请求的所有内容都不能被共享缓存保存。这可以让原始服务器表明该响应是针对某一个特定用户的,对其他用户而言该响应没有意义。私有缓存可以保存该响应的副本。

注意:这条指令只能控制响应可以在什么地方被保存为副本,并不能保证隐私。

no-cache

如果 no-cache 指令没有指定首部,那么缓存在没有和原始服务器对文档进行验证的情况下禁止提供给客户端。有些缓存的配置允许将过时的文档副本作为响应返回给客户端,no-cache 可以让原始服务器阻止缓存的这种行为。

如果 no-cache 指令指定了一个或多个首部,那么缓存可以根据其他的缓存限制将本地副本提供给后续的请求。但是,在未经过原始服务器验证之前,该指令所指定的首部禁止提供给后续的请求。这可以让原始服务器阻止缓存重用特定的首部,但是其余部分还是可以被缓存所使用。

no-store

no-store 指令的目的就是为了防止敏感信息在无意间泄露。no-store 指令可以在请求或者响应中使用,且应用于整条消息,也就是说,不论在请求还是响应中添加了 Cache-Control: no-store,私有缓存和共享缓存都禁止保存该请求 / 响应的任何内容。在当前上下文中,“禁止保存”的意思是,缓存禁止有意地将请求 / 响应的任何部分保存在非挥发性的存储器(比如硬盘)中,而且要在转发完消息之后最短的时间内尽最大努力将消息的所有内容从挥发性的存储器(通常指内存)中抹除。

不过用户还是可以将响应保存在缓存之外,比如使用浏览器的“另存为”功能。浏览器的历史缓存在某些情况下可以保存这些响应。

该指令是为了满足某些用户和服务提供商不希望自己的一些敏感信息被意外泄露或者被外人访问的需求而设计的。虽然使用 no-store 在某些情况下可以降低隐私泄露的风险,但是这并不是一个可以依赖的方法,恶意的缓存可以直接忽略这些指令,而且通信很容易被监听(参见HTTPS 中间人攻击及其防范)。

no-transform

某些缓存可能会对特定类型的缓存文档进行一些转换。比如一个非透明代理可能会为了降低已缓存文档所占的存储空间或者减少网络拥堵而将某些文件(比如图片)进行压缩。但是这样做可能会导致某些关键系统失灵或者导致一些无法预料的后果,比如医疗影像、学术资料以及所有需要二进制相等的应用。因此,如果响应中包含了 no-transform 指令,缓存禁止更改响应的某些首部(参见规范 13.5.2 节),也禁止修改文档实体的任何内容。

must-revalidate

有些缓存可能会被配置成可以向客户端提供过期的副本,而且客户端可能会在请求中提供 max-stale 指令,这两种情况下缓存都可以向客户端提供过期的副本。不过 HTTP 也提供了一个指令可以让原始服务器要求缓存在每次提供已过期的副本之前必须向原始服务器进行验证。如果一个响应中包含了 must-revalidate 指令,那么缓存禁止于后续的客户端请求中在没有经过再验证的情况下向客户端提供已过期的本地副本。如果缓存无法完成再验证动作,必须向客户端返回一个 504 Gateway Timeout 响应。

proxy-revalidate

proxy-revalidate 指令和 must-revalidate 指令基本一致,唯一不同的就是 proxy-revalidate 对私有缓存无效。

max-age

Expires 首部可以指定文档的过期时间,同样的,Cache-Control 首部一样可以指定文档的过期时间。如果下一个请求到来的时间与该请求所对应的文档副本被保存的时间的差值(以秒计算)大于Cache-Control: max-age=delta-seconds 所指定的 delta-seconds 值,则该副本就被认为是过时的副本。除非有别的缓存指令限制,否则 max-age 指令的出现就表示该响应是可以被缓存的。

如果在一个可缓存的响应中既出现了 Expires 首部,又出现了 Cache-Control 首部,则 Cache-Control 首部的值会覆盖 Expires 首部的值。这种策略可以让原始服务器给 HTTP/1.1 缓存相较于 HTTP/1.0 缓存提供更长的过期时间,因为在某些情况下,HTTP/1.0 缓存可能因为时钟不同步而导致过期时间计算错误。

比如:

1
2
3
4
5
HTTP/1.1 200 OK
Date: Sat, 10 Mar 2018 09:00:00 GMT
Content-Type: text/html; charset=utf-8
Expires: Sat, 15 Mar 2018 09:00:00 GMT
Cache-Control: max-age=864000

该响应的 Expires 首部指定文档在2018/3/15 09:00:00 过期,而 Cache-Control 首部则指定文档在 864000 秒之后,即 2018/3/20 09:00:00 过期,由于上述 Cache-Control 覆盖 Expires 的策略,该文档在 HTTP/1.1 的缓存中会在 2018/3/20 09:00:00 过期,而在 HTTP/1.0 缓存中则会在 2018/3/20 09:00:00 过期。

s-maxage

s-maxage 是一个只对共享缓存有效的指令。如果一条响应中包含 s-maxage,则其对于非私有缓存相对于 max-age 指令或者 Expires 首部的优先级更高。私有缓存会直接忽略 s-maxage 指令。

针对请求的缓存控制首部

相对的,请求缓存指令的可选值有:

  1. "no-cache"
  2. "no-store"
  3. "max-age" "=" "delta-seconds"
  4. "max-stale" [ "=" "delta-seconds"]
  5. "min-refresh" "=" "delta-seconds"
  6. "no-transform"
  7. "only-if-cached"
  8. cache-extension

Cache-Control

no-cache

no-cache 表示客户端要求缓存在提供其已缓存的副本之前必须先和原始服务器对该文档进行验证。

no-store

在请求中 no-store 和在响应中的表现一致,表示缓存禁止将该请求和其响应的任何部分保存在任何不易挥发的的存储介质上,并应该及时删除易挥发存储介质上的副本。

max-age

如果 max-age 指令出现在请求中,表示客户端希望接收到的副本(如果缓存有)的已缓存时间不应该超过指定的值,比如 max-age=600 表示缓存返回的副本(如果有)距离该副本被保存在本地的时间不能超过 60 秒。

max-stale

max-stale 指令允许缓存返回已过期的副本,如果没有指定时间,那么已过期的副本,不论过期多久,都可以作为响应返回给客户端;如果该指令指定了时间,那么意味着客户端希望接收到的副本超过过期时间的长度不长于该值。比如 max-stale=600 表示缓存可以返回过期不超过 600 秒的本地副本。

min-refresh

min-refresh 指令必须指定一个值,该值表示缓存如果返回本地副本,该本地副本距离过期的时间必须大于 min-refresh 指令指定的值。比如 min-refresh=600 表示若缓存返回本地副本,则要确保该副本在未来的至少 600 秒内是新鲜的。

no-transform

在请求中的 no-transform 和在响应中的 no-transform 指令表示一样的意思,即缓存禁止对原始服务器响应的文件进行任何形式的处理。

only-if-cached

该指令表示客户端希望接收到的响应只是来自于本地缓存的。缓存在接收到这条指令时,要么再根据请求中别的指令返回一条使用本地副本的缓存或者返回 504 Gateway Timeout 响应。

再验证

很多缓存指令都会要求缓存进行再验证。再验证就是一种“条件 GET”请求,即若缓存中的副本和原始服务器中的文档不一致时才返回完整的文档实体。对于如何确定某个文档是否已修改,HTTP 有五个首部可以帮助确定,分别是 If-MatchIf-Modified-SinceIf-None-MatchIf-Range 以及 If-Unmodified-Since。针对缓存最常用的两个首部分别是 If-Modified-SinceIf-None-Match,因此只介绍这两个首部,其余可参见 HTTP 规范。

If-Modified-Since

If-Modified-Since 首部的值是一个 HTTP 时间,如果文档在 If-Modified-Since 首部指定的时间之后没有修改过,则返回一个没有实体的 304 Not Modified 响应,以便降低传输的数据量,提高性能,响应中一般会包含一个新的过期时间;否则返回一个正常的,包含新文档实体的 200 OK 响应。If-Modified-Since 首部一般会和 Last Modified 首部搭配使用,原始服务器会在响应中添加 Last Modified 首部,表明文档的最后修改日期,缓存在需要再验证时,会将 Last Modified 首部的值添加到 If-Modified-Since 首部中,以便原始服务器决定是否返回一个新文档。

If-None-Match

有些情况下仅仅使用 Last-Modified 来判断某个文档是否修改过是不准确的,比如:

  • 有些文档是机器自动生成的,比如一个定时执行的程序每次都会产生一个文档,有些时候文档的内容不会发生变化,只是生成时间改了,这其实没必要让缓存更新其本地副本。
  • 有些文档可能被修改过,但是这些修改无关紧要,比如代码中的注释,这也没必要让缓存更新本地副本。

HTTP 提供了一个 ETag (实体标签)首部来解决这些问题。原始服务器可以给文档加上 ETag,这可以是文档的指纹或者版本号、序列号,只要能表示其唯一性就可以。当原始服务器上的文件发生的变化足够让缓存更新其本地副本的话,就可以修改该文档的 ETag,这样,缓存就可以利用 If-None-Match 首部中的值来使用“条件 GET”判断其本地副本是否足够新鲜了。

如果原始服务器上该文档的实体标签和请求中的不一致,那么就会返回一个 200 OK 的正常响应,并在首部中提供新的 ETag;否则返回一个 304 Not Modified 响应。

强弱验证器

上面提到了有些修改可能对文件的含义没有影响,也就没必要让缓存更新其本地副本。ETag 分为强验证器和弱验证器,强验证器要求文档的每个字节都相等,而弱验证器只要求文档的含义相等。因此如果文档发生了修改,强验证器一定会发生变化,而如果文件的含义没有修改(比如在代码中增加注释),则弱验证器不会发生变化,缓存也就不会更新本地副本了。服务器会用 W/ 前缀表明这是一个弱验证器,比如:

1
ETag: W/"e027gqjdsywux6234ds"
验证器的选择

如果原始服务器只提供了 Last-Modified 首部,则缓存在再验证时只会发送 If-Modified-Since 首部;如果原始服务器只提供了 ETag,则缓存验证时只会发送 If-None-Match 首部;原始服务器可能也会同时提供两者。前两种情况下,只要满足一个条件,原始服务器就可以返回 304 Not Modified 响应,而最后一种情况需要两者都满足才可以返回 304 Not Modified 响应。

控制缓存的行为

Cache-Control

按照优先级递减的顺序,可以在响应的中添加以下首部来控制缓存的行为:

  • Cache-Contro: no-store
  • Cache-Contro: no-cache
  • Cache-Contro: must-revalidate
  • Cache-Contro: max-age
  • Expires
  • 不提供过期信息
no-store

no-store 指令禁止缓存对该请求及其响应进行复制,并会令缓存在完成该请求之后删除与该请求相关的所有内容,即禁止缓存。

no-cache

no-cache 指令并不是指缓存禁止保存副本,而是指缓存在与原始服务器进行再验证通过之前禁止将已缓存的本地副本提供给客户端。

must-revalidate

缓存可以进行适当配置,给客户端提供已过期的副本, must-revalidate 指令告诉缓存在与原始服务器进行再验证通过之前禁止将过期的副本提供给客户端。must-revalidate 表示的是本地副本在过期之前可以直接提供给客户端,而如果本地副本已过期,则在与原始服务器的再验证通过之前禁止将本地副本提供给客户端;而 no-cache 表示的则是本地副本立即过期,与原始服务器进行再验证,通过才可以将本地副本提供给客户端。

max-age

原始服务器可以将 max-age 置为 0,从而让缓存每次都从原始服务器获取文档。

1
Cache-Control: max-age=0

s-maxage 仅针对共享缓存有效。

1
Cache-Control: s-maxage=0

Expires

不建议使用 Expires 首部,因为某些缓存的时间可能不正确,而 Expires 首部指定的是一个绝对时间,这可能会导致一些本地副本不会在正确的时间过期,最好使用 max-age 指令。

有些服务器可能会提供一个 Expires: 0 的首部,试图让文档永远处于过期的状态,而本文前面部分已提到过这种语法是非法的,缓存应该接受上游服务器的这种语法,但是不应该对下游使用这种语法。

Pragma

Pragma 首部定义于 HTTP/1.0,可以控制缓存对请求的处理方式,HTTP/1.0 已定义的指令只有一个 no-cache。根据 Pragma - RFC2616

HTTP/1.1 caches SHOULD treat “Pragma: no-cache” as if the client had sent “Cache-Control: no-cache”. No new Pragma directives will be defined in HTTP.

如果用户在请求中提供了 Pragma: no-cache,实现了 HTTP/1.1 规范的缓存应该将其等同于 Cache-Control: no-cache 处理。

试探性过期

有些原始服务器可能既没有提供 Cache-Control 首部也没有提供 Expires 首部,这时缓存无法确定文档准确的过期时间,不过缓存可以计算一个试探性最大使用期。试探性最大使用期的计算可以用任意算法,不过如果计算出来的试探性最大使用期超过了 24 小时,缓存就应该向首部添加一个 Heuristic Expiration Warning(试探性过期警告)首部。

HTTP 规范(Expiration Calculations - RFC2616)中并没有给出试探性过期时间的具体算法,不过一般可以把当前时刻与文档上一次修改的时刻之间差值的一部分作为试探性过期的最大使用期,比如一份文档上次修改时刻(Last-Modified)距离当前时刻过去了 10 天,取其 10% 作为试探性过期的最大使用期,那么缓存就可以设置该本地副本的过期时间为 1 天后。通常试探性过期的最大使用期都会人为地设置一个上限(比如最长不超过 7 天),防止出现一些意外情况。

如果原始服务器连最后修改日期都没有提供的话,缓存连试探性过期都无法实现了,只好每次都与原始服务器进行再验证。