从 RFC 到 chromium 的缓存新鲜度定义

2020 • 6月 15 • 14 分钟阅读

本文是对 RFC HTTP Caching 的解读,主要探讨 HTTP 缓存的定义,实现方式等核心内容。结合 chromium 项目对 HTTP 模块的实现来学习生产环境中的 HTTP 缓存实现方式。

可缓存性

RFC 7234 Storing Responses in Caches 对可缓存性做出如下定义:

  1. Cache-Control 中指令不包含 no-store,如果是共享类型缓存,也不能包含 private 指令。
  2. 没有 Authorization 在请求头中出现。

    Authorization 的不可缓存性可被 Cache-Control 的指令覆写。

  3. 响应头包含一个 Expires 头。或
  4. 包含 max-age 指令。或
  5. 包含一个 s-maxage 指令。或
  6. 包含一个 Cache-Control 拓展来显式地指定响应可被缓存。或
  7. 拥有一个默认被定义为可缓存的 status code。或

    RFC 7231 中定义,可缓存 status code 特指 200203204206300301404405410414,和 501。除此之外的其他 status code 响应均为不可缓存的响应。

  8. 包含一个 public 指令。

Note that any of the requirements listed above can be overridden by a cache-control extension;

注意,以上所有条件均可被 Cache-Control覆写 其可缓存性。

对于一个没有定义特定缓存头的响应,且符合上述可缓存性的响应,默认认为是可缓存的响应。即表示在 http 响应中当 response header 未给定任何与生命周期相关的 header 时,客户端 可以(但不强制实现) 认为该响应是可缓存的,其声明周期依赖于一个 启发式算法

When a response is "fresh" in the cache, it can be used to satisfy subsequent requests without contacting the origin server, thereby improving efficiency.reference

在给定的生命周期内再次触发同一请求时,客户端可能会在不与服务端通信的情况下直接使用缓存值,而非发起真实请求。

const responseIsFresh = freshnessLifetime > currentAge

缓存新鲜度计算

The primary mechanism for determining freshness is for an origin server to provide an explicit expiration time in the future, using either the Expires header field (Section 5.3) or the max-age response directive (Section 5.2.2.8).

...

Since origin servers do not always provide explicit expiration times, caches are also allowed to use a heuristic to determine an expiration time under certain circumstances (see Section 4.2.2).

一个响应的新鲜度生命周期是指源服务器生成该响应到它过期的时间节点之间的时间段。在 RFC 7234 中定义了指定一个缓存新鲜度,只能通过 显式地 response header directive 来指定或 隐式地 通过客户端的 启发式算法 来计算。

在响应第一次到达客户端时,将进行以下步骤进行缓存新鲜度计算RFC 7234

  1. 当存在 public 指令(即 shared-type caching),且存在s-maxage 指令值时使用该值;或
  2. 当存在 max-age 指令时使用该值;或
  3. 当存在 Expires 响应头时,使用该值减去 Date 响应头的值作为当前缓存新鲜度;或
  4. 没有任何显式地指定过期时间时,客户端 可选地可以启用隐式的缓存新鲜度算法 计算当前响应新鲜度。

显式新鲜度

所谓显式新鲜度是指通过 response header directive 来显式地指定对应响应的新鲜度。如下:

  1. Expires
  2. Cache-ControlMDN

Expires

Expires 始于 http 1.0 协议,其规定了响应在 ExpiresDate 响应头之间的差值时间段内有效。反之,无效。其最大特点是,Expires 的值由 服务端 定义,且仅支持指定时间类型值。

Expires: Mon, Jun 01 2020 08:00:00 GMT

Cache-Control

Cache-Control 始于 http 1.1 协议,它不仅是 Expires 请求头的完全替代选择,还可以使用多种非时间类型指令。新增了对缓存的对象,行为的额外定义。

Cache-Control: <Cache-Control-Directive>

为什么有两种响应头定义缓存

为什么在 HTTP 1.0 已经定义的 Expires 头之外额外定义 Cache-Control 头来重复定义缓存指令?这个问题答案是 Cache-Control 增强了服务端和客户端对缓存定义的能力的拓展,不仅仅在生命周期上做了定义,还支持对缓存的存储,使用方式做出了额外定义。

  1. 支持多种生命周期长度,以 max-ages-maxage 指令为代表。

    • max-age 指定生命周期的有效 时间区间,而非 ExpiresDate 的差值来定义响应的生命周期。这一点规避了由服务端定义生命周期的缺陷。Cache-Control 始终以 客户端 的时间为准。
    • s-maxage 规定了共享缓存的生命周期,该类型缓存是对客户端缓存的补充。实现了中间代理服务器的缓存生命周期指定。且在同时存在 s-maxagemax-age 时,s-maxage 拥有更高优先级。
  2. 支持对缓存的存储方式定义,以 publicprivate 为代表。

    • public 指令实现了对缓存的 共享性 做了定义,即除了客户端之外,在整个通信链路中的额外中间代理服务器都可以缓存共享性缓存。
    • private 则与 public 相反,定义了私有缓存,即仅有客户端能够缓存当前响应。
  3. 支持对缓存的使用行为定义,以 no-cacheno-store 为代表。

    • 所有的 no-cache 缓存在被客户端使用之前,必须得到服务端的验证后才能使用。在行为上,等价于 max-age=0 指令。
    • 所有的 no-store 响应规定了不可被整个响应链路的任何节点缓存。

缓存指令优先级

前文已经讨论现行 RFC 存在两种响应头来指定缓存生命周期,那么它们同时出现在一个响应中,如何定义它们之间的优先级?

RFC 7234 5.3 Expires 中定义当同时存在 ExpiresCache-Control 响应头时,Cache-Control 始终具有更高的优先级。

If a response includes a Cache-Control field with the max-age directive (Section 5.2.2.8), a recipient MUST ignore the Expires field.

no-cache 和 max-age=0

在实践中,我们可以通过 no-cachemax-age=0 来定义当前响应应该始终在使用前通过服务端的缓存验证。在效果上,在验证请求的响应必须得到 web 服务器的 304 Not Modified 响应,才会跳过响应下载(如有),而使用本地缓存。

二者之间的差异在于:

  1. 语义区别

    1. max-age=0 指定当前响应的新鲜度为 0 秒;
    2. no-cache 指定在使用当前响应的本地缓存之前,必须得到 web 服务器的 304 Not Modified 响应。
  2. 实现差异

    1. max-age=0 仅表示当前响应新鲜度 0 秒过期后,web 服务器 SHOULD 应该验证(借助,If-not-matchIf-Not-Modified)响应缓存是否可继续使用。max-age=0 在客户端的实现路径始终遵循 max-age=n 的实现路径。这时在触发 0 秒验证,才会触发响应新鲜度验证。0 秒相当于 max-age=n 的管理周期中的一个特殊值。
    2. no-cache 在于不论何时,在得到服务器的 验证响应 前都 不应该 使用本地缓存。。

    The "no-cache" request directive indicates that a cache MUST NOT use a stored response to satisfy the request without successful validation on the origin server.

隐式新鲜度

隐式新鲜度仅在显式新鲜度无法计算时生效。在 RFC 中定义了在响应未通过任何缓存生命周期相关指令指定生命周期时,客户端应该通过 RFC 定义的 启发式算法 行为 隐式地 计算响应新鲜度。

Since origin servers do not always provide explicit expiration times, a cache MAY assign a heuristic expiration time when an explicit time is not specified, employing algorithms that use other header field values (such as the Last-Modified time) to estimate a plausible expiration time.reference

当一个响应没有被服务端显式地指定缓存生命周期,且该响应属于默认可缓存响应时,客户端会触发一个 启发式算法 来计算缓存新鲜度。该算法的 核心 是使用其他标识了当前响应的可用响应头来给当前响应一个合理的过期时间。

实现

一种推荐的实践是使用 Last-Modified 响应头。当响应中存在 Last-Modified 响应头时,RFC 鼓励使用该值距 Date 响应头的时间节点的时间段占比的一部分作为当前响应的隐式新鲜度,一个典型的占比为 10%

const freshnessLifeTime = (dateValue - lastModifiedValue) * 0.1

那么在以上启发式算法的作用下,任何符合第一节定义的 可缓存 响应始终会在服务端未显式定义新鲜度的情况下隐式地计算隐式新鲜度。

chromium 中缓存新鲜度算法

以浏览器客户端使用最为广泛的 chromium 项目为例,其在 net/http 模块实现了所有 HTTP Caching

chromium/86.0.4207.1

// From RFC 2616 section 13.2.4:
//
// The max-age directive takes priority over Expires, so if max-age is present
// in a response, the calculation is simply:
//
//   freshness_lifetime = max_age_value
//
// Otherwise, if Expires is present in the response, the calculation is:
//
//   freshness_lifetime = expires_value - date_value
//
// Note that neither of these calculations is vulnerable to clock skew, since
// all of the information comes from the origin server.
//
// Also, if the response does have a Last-Modified time, the heuristic
// expiration value SHOULD be no more than some fraction of the interval since
// that time. A typical setting of this fraction might be 10%:
//
//   freshness_lifetime = (date_value - last_modified_value) * 0.10
//
// If the stale-while-revalidate directive is present, then it is used to set
// the |staleness| time, unless it overridden by another directive.
//
HttpResponseHeaders::FreshnessLifetimes
HttpResponseHeaders::GetFreshnessLifetimes(const Time& response_time) const {
  FreshnessLifetimes lifetimes;
  // Check for headers that force a response to never be fresh.  For backwards
  // compat, we treat "Pragma: no-cache" as a synonym for "Cache-Control:
  // no-cache" even though RFC 2616 does not specify it.
  if (HasHeaderValue("cache-control", "no-cache") ||
      HasHeaderValue("cache-control", "no-store") ||
      HasHeaderValue("pragma", "no-cache")) {
    return lifetimes;
  }
  // Cache-Control directive must_revalidate overrides stale-while-revalidate.
  bool must_revalidate = HasHeaderValue("cache-control", "must-revalidate");
  if (must_revalidate || !GetStaleWhileRevalidateValue(&lifetimes.staleness)) {
    DCHECK_EQ(TimeDelta(), lifetimes.staleness);
  }
  // NOTE: "Cache-Control: max-age" overrides Expires, so we only check the
  // Expires header after checking for max-age in GetFreshnessLifetimes.  This
  // is important since "Expires: <date in the past>" means not fresh, but
  // it should not trump a max-age value.
  if (GetMaxAgeValue(&lifetimes.freshness))
    return lifetimes;
  // If there is no Date header, then assume that the server response was
  // generated at the time when we received the response.
  Time date_value;
  if (!GetDateValue(&date_value))
    date_value = response_time;
  Time expires_value;
  if (GetExpiresValue(&expires_value)) {
    // The expires value can be a date in the past!
    if (expires_value > date_value) {
      lifetimes.freshness = expires_value - date_value;
      return lifetimes;
    }
    DCHECK_EQ(TimeDelta(), lifetimes.freshness);
    return lifetimes;
  }
  // From RFC 2616 section 13.4:
  //
  //   A response received with a status code of 200, 203, 206, 300, 301 or 410
  //   MAY be stored by a cache and used in reply to a subsequent request,
  //   subject to the expiration mechanism, unless a cache-control directive
  //   prohibits caching.
  //   ...
  //   A response received with any other status code (e.g. status codes 302
  //   and 307) MUST NOT be returned in a reply to a subsequent request unless
  //   there are cache-control directives or another header(s) that explicitly
  //   allow it.
  //
  // From RFC 2616 section 14.9.4:
  //
  //   When the must-revalidate directive is present in a response received by
  //   a cache, that cache MUST NOT use the entry after it becomes stale to
  //   respond to a subsequent request without first revalidating it with the
  //   origin server. (I.e., the cache MUST do an end-to-end revalidation every
  //   time, if, based solely on the origin server's Expires or max-age value,
  //   the cached response is stale.)
  //
  // https://datatracker.ietf.org/doc/draft-reschke-http-status-308/ is an
  // experimental RFC that adds 308 permanent redirect as well, for which "any
  // future references ... SHOULD use one of the returned URIs."
  if ((response_code_ == 200 || response_code_ == 203 ||
       response_code_ == 206) &&
      !must_revalidate) {
    // TODO(darin): Implement a smarter heuristic.
    Time last_modified_value;
    if (GetLastModifiedValue(&last_modified_value)) {
      // The last-modified value can be a date in the future!
      if (last_modified_value <= date_value) {
        lifetimes.freshness = (date_value - last_modified_value) / 10;
        return lifetimes;
      }
    }
  }
  // These responses are implicitly fresh (unless otherwise overruled):
  if (response_code_ == 300 || response_code_ == 301 || response_code_ == 308 ||
      response_code_ == 410) {
    lifetimes.freshness = TimeDelta::Max();
    lifetimes.staleness = TimeDelta();  // It should never be stale.
    return lifetimes;
  }
  // Our heuristic freshness estimate for this resource is 0 seconds, in
  // accordance with common browser behaviour. However, stale-while-revalidate
  // may still apply.
  DCHECK_EQ(TimeDelta(), lifetimes.freshness);
  return lifetimes;
}

源码中以 FreshnessLifetimes 定义了缓存的新鲜度。从源码中 FreshnessLifetimes 的注释,不难看出 chromium 基本是按照 RFC 对缓存新鲜度的定义来计算当前响应的生命周期。其中缓存新鲜度由 GetFreshnessLifetimes 方法来计算,其伪代码如下:

HttpResponseHeaders::GetFreshnessLifetimes(const Time& response_time) const {

  if (是否存在 no-cache, no-store 指令) {
    return  0}

  当响应头中存在 must-revalidate 指令时,赋值 must_revalidate 变量为 true,反之,为 false

  if (是否存在 max-age 指令) {
    return max-age
  }

  if (是否存在 date 响应头) {
    // 注,当响应头中不存在 Date 响应头时,默认取当前收到响应的时间为 Date 时间
    赋值 date_value 变量为 Date 响应头的值
  }

  if (是否存在 expires 指令) {
    if (是否 expires_value 大于 date_value) {
      计算 expires_value 与 date_value 差值
      return 上述差值
    }

    // 此分支表示 expires 给定了一个过去的时间,那么该响应总是 stale 的
    return 0}

  if (http status code 是否为 200203206,且 must_revalidate 为 false) {
    // 启用 RFC 中规定的启发式算法,计算隐式新鲜度
    if (当存在 last-modified 响应头) {
      if (是否 last-modified 小于 date 响应头) {
        计算 date_value 和 last_modified_value 差值的 10%
        返回上述差值
      }
    }
    // 若没有成功通过 last-modified 创建隐式新鲜度,那么直到函数执行完成会返回初始 0 值
  }

  if (http status code 是否为 300301308410) {
    定义这一类响应是永不过期的响应
  }

  // 执行至此时,lifetimes 仍为初始 0 值
  返回 lifetimes
}

References