HTTP 缓存

使用缓存的优点(摘自 HTTP 权威指南)

缓存的拓扑结构

私有缓存

私有缓存通常指的就是个人在本地的缓存,不单单指浏览器缓存,上次在同事分享 npm 和 yarn 的时候,这些包管理器的缓存也属于此列。

公用缓存

一般代理服务器可能会允许缓存资源,当用户发送请求的时候,会先经过代理,如果代理上边缓存的资源足够新鲜,就可以直接返回而不需要向原始服务器进行请求。

缓存的处理步骤(摘自 HTTP 权威指南)

  1. 接收 —- 缓存从网络中读取抵达的请求报文
  2. 解析 —- 缓存对报文进行解析,提取 URL 和各种首部
  3. 查询 —- 缓存查看是否有本地副本可用,如果没有,就获取一份副本(并保存在本地)
  4. 新鲜度检测 —- 缓存查看已缓存副本是否足够新鲜,如果不是,就询问服务器是否有任何更新。
  5. 创建响应 —- 缓存会用新的首部和已缓存的主体来构建一条响应报文。
  6. 发送 —- 缓存通过网络将响应发回给客户端
  7. 日志 —- 缓存可选地创建一个日志文件条目来描述这个事务

其中 新鲜度检测 是重中之重,接下来大部分内容主要说这个问题。

缓存如何处理

在接收到请求报文并解析之后,浏览器首先在缓存中进行查找,判断其是否命中缓存。如果命中缓存且资源足够新鲜,则直接从浏览器自己的缓存中读取对应的资源,不会向服务器发请求。创建响应,并设置响应为 200,并进行响应头的改造等等。如果资源不够新鲜,就需要进行服务器再验证,再对资源进行一系列的操作。

上面的过程看似很合理,但是在开发中我们还会遇到一些由于缓存导致的问题。有一个很常见的场景:

一般情况下,开发者都会选择使用 CDN 服务器来托管静态资源,来加速静态资源的请求速度。对于这些资源,CDN 一般的设置是允许资源进行缓存。由于缓存命中之后,浏览器会直接从缓存中加载,那么即使线上的静态资源发生了变化,仍然没什么卵用。所以一般的解决办法是在静态资源上加一个哈希值,用来标识静态资源的版本,同时在 html 文件中实时更新资源的版本。这样的解决办法则要求开发者设置不允许浏览器每次加载 html 页面时候都要向服务器验证。当然也不推荐把 html 文档也放到 CDN 上“加速”。

缓存的新鲜度检测

当某个请求命中缓存的时候,响应状态值为 200。在 Chrome 中的开发工具 network 卡中其 size 值会为 from cache,判断是否 from cache 是通过其他的方法进行实现的,并不是根据响应的状态值进行判断。

缓存可以通过 Expires 或者 Cache-Control: max-age 这两个响应头来配置,都用来表示资源在客户端允许被缓存的时间。

其中 Expires 是 HTTP/1.0+ 提出的一个用来表资源过期时间的响应头,Cache-Control: max-age 则是 HTTP/1.1 推荐使用的。

他们解决的问题是相同的,不过 Expires 设定的时间是基于服务器时间的绝对时间,也就是会设置一个过期时间,超过这个时间点之后认为资源不够新鲜,需要更新。同时需要注意,使用过期时间的时候,所有的 HTTP 日期和时间都会在格林尼治(GMT)过期。也就是 0 时区。如果用户处在不同的时区内,就需要根据用户所在的时区进行定制过期时间,这样就会带来一系列的玄学问题。

比如期望一个资源在 2018 年 7 月 14 日凌晨 0 点过期,对于在东八区来说,格林尼治时间比东八区时间要晚 8 小时,那么首部则需要设置如下:

Expires: Fri, 13 Jul 2018, 16:00:00 GMT

换算到东八区刚好是 14 日凌晨 0 点。

Cache-Control: max-age 则是设置一个相对时间,max-age 的值是资源的最大的合法存活时间,以秒为单位。这个时间不会因为时差问题而导致差异,所以比较推荐使用。

当两个首部同时出现且 HTTP 版本都支持的话,后者的优先级较高。

前面的内容都是假设请求命中强缓存且资源并没有过期,那么如果命中了强缓存但是资源已经过期了呢?当然现实的场景是我们一般会设置缓存时间为一年,也就是 max-age 值为 315360000。但是这种场景也不是不存在,需要考虑。

这会涉及到一个称为 服务端再验证 的情况。当资源已经过期但是服务端的资源没有任何的变化,那么缓存只需要取得新的首部,包括一个新的过期日期,并对缓存中的首部进行更新。

服务端再验证

Cache-Control 优先级高于 Expires 首部,一般常用的 Cache-Control 的取值有以下三种:

取值      |        含义 --------------|-----------------------------   no-store    | 不允许缓存   no-cache    | 在回源验证前不允许复用缓存   max-age     | 文档最大合法存活时间

Cache-Control 可用取值有很多,其中响应可以出现的值有 9 种,请求可以出现的值有 7 种。支持自定义,只要服务端识别就 OK。

响应可取值       |        含义 --------------------|-----------------------------   public            | 允许代理和客户端缓存   private           | 只允许客户端缓存   no-store          | 不允许缓存,一般是资源有敏感信息   no-cache          | 在回源验证前不允许复用缓存   no-transform      | 不允许代理修改 Content-Type、Content-Encoding、Content-Range 等字段   must-revalidate   | 不允许使用过期资源,只要过期,必须进行回源验证(哪怕客户端声明愿意接受过期资源)   proxy-revalidate  | 对于代理,类似 ‘must-revalidate’   s-maxage          | 对于代理,效果覆盖 max-age   max-age           | 文档最大合法存活时间
  

请求可取值       |        含义 --------------------|-----------------------------   no-store          | 不允许缓存,一般是资源有敏感信息   no-cache          | 在回源验证前不允许复用缓存   max-age           | 可以接受文档最大合法存活时间   max-stable        | 可以接受文档过期的最大时间   min-fresh         | 希望请求到的资源在这个时间内是有效的   no-transform      | 不接受经过转化的内容   only-if-cached    | 只愿意接受已经缓存的资源,而不是重新请求

参考这篇文章

对于 Cache-Control: no-store,意味着完全禁止缓存对响应复制,缓存通常像非缓存代理服务器一样,向客户端转发一条 no-store 响应,然后删除对象。

对于 Cache-Control: no-cache,意味着响应实际上是可以存储在本地缓存区中的。只是在与原始服务器进行新鲜度再验证之前,缓存不能再提供给客户端使用。其实这个首部使用 do-not-serve-from-cache-without-revalidation 更恰当一些,但是它太长了。HTTP/1.1 同样提供了 Pragma: no-cache 首部,目的是为了兼容于 HTTP/1.0+。但是由于现在 HTTP/1.0+ 基本已经淘汰,故不再深入进行了解。只要是和 HTTP/1.1 或者 HTTP/2 应用进行交互时候,都应该使用 Cache-Control: no-cache 来进行交互。事实上 Pragma 的优先级最高,不过淘汰的东西就让它安静的消失就好了。

对于 Cache-Control: max-age,用来标识文档最大合法存活时间,对于共享缓存,还会有一个 s-maxage 行为和 max-age 相似,其单位同样为秒。当 max-age 值为 0 时候,每次访问的时候都会进行资源的请求。

对于 Expires 首部,则是 HTTP/1.0+ 提供用于控制缓存的首部,值为一个绝对的 GMT 时间,这个时间需要根据时区再进行转换。

nginx 是一个非常轻量级的 http 服务器,通常被用作负载均衡器。在这个项目中,笔者通过使用 nginx 的 add_header 为响应添加 Cache-Control 首部,并设置不同值,来观察 Cache-Control 不同值的作用,以及不同情况下缓存更新情况。

用条件方法进行再验证

HTTP 的条件方法可以高效的实现再验证。HTTP 允许缓存向原始服务器发送一个“条件 GET”,请求服务器只有缓存的对象和现有的副本不同时,才回送对象主体。只有条件为真时,Web 服务器才会返回对象,否则返回一个 304。

HTTP 定义了 5 个条件请求首部。对缓存在验证来说最有用的 2 个首部是 If-Modified-SinceIf-None-Match。所有条件首部都是以前缀 “If-” 开头。下面是缓存再验证中使用的条件请求首部。

     Header            |   Desc ---------------------------|----------------- If-Modified-Since: <date>  | 如果指定日期之后文档被修改过了,就执行请求方法。可以和 Last-Modified 服务器首部配合使用,实现只有在内容被修改后与已缓存版本有差异时才去获取内容 If-None-Match: <tags>      | 服务器可以为文档提供一个特殊的标签(例如 ETag),而不是将其与最近修改日期相匹配。如果标签不同,那么就会去获取内容

IMS 请求

If-Modified-Since 在验证请求通常可以叫做 IMS 请求。只有当这个首部的条件为真(即文档修改过),通常 GET 请求就会成功执行,携带新首部的新文档替换原来的缓存,同时会更新过期时间。如果文档没有被修改过,那么服务端返回一个小的 304 Not Modified 报文。这个报文不会包含文档主体,只会返回需要更新的新首部,一般会是一个新的过期时间。

If-Modified-Since 首部可以和 Last-Modified 服务器响应首部配合工作。服务端使用 Last-Modifed 首部来将最后修改日期附加给所提供的文档。当缓存要对已缓存的文档进行验证的时候,就会在 If-Modified-Since 带上携带有最后修改已缓存副本的日期。

If-None-Match

仅仅只有 IMS 对最后修改日期进行验证还是不够的,因为会有这样的情况:

为了解决这些问题,HTTP 允许用户对被称作 实体标签(ETag) 的版本标识符进行比较。ETag 目前没有一个明确的生成方法,各方可以自定义。在 Nginx 上可以通过 etag off 指令关闭。

Nginx 官方采用的格式是 文件最后修改时间(hex)-文件长度(hex)

由于 Etag 没有明确的生成方法,所以就有使用 Etag 来存储用户的 uid 的做法,来弥补使用 cookie 追踪用户的不足。当然现在也有使用浏览器指纹追踪的技术,具体可以参考这个

言归正传,当首部中带有 INM 首部的时候,服务端会根据 INM 提供的 Etag 值和当前文件实际的 Etag 值进行对比,如果二者相同的话,就会返回 304 Not Modified。

INM 和 IMS 都存在

当 INM 和 IMS 首部都存在的时候,客户端向服务端回送了实体标签和资源过期时间,那么只有两个验证都通过的时候,缓存才会被认为有效,服务端返回 304 Modified。否则需要返回资源并更新缓存。

三种刷新

  1. 开新页面,不会带缓存相关字段,可以命中本地缓存
  2. 普通刷新,文档会携带 Cache-Control: max-age=0,进行请求而不考虑是否过期,但是关联资源可以在不过期的情况下直接读取本地缓存
  3. 强制刷新,文档会携带 Cache-Control: max-age=0,和 Pragma: no-cache,关联资源也会刷新
  4. disable cache 之后的刷新,所有请求都走强制刷新。

但是,本地实验和上面的理论稍有差别,多番查找之后无果,不知道大家有没有什么见解。

Table of Contents