2. 缓存控制头深度解析:Cache‑Control 指令集、max‑age/s‑maxage/必须重新验证/stale‑while‑revalidate、Expires 与 ETag

2.1 Cache‑Control 指令集概述

HTTP/1.1 Cache‑Control 头是客户端和服务器之间协商缓存行为的主要手段。下表列出核心指令及其主要用途:

指令方向含义示例
max‑age=delta请求 / 响应资源被视为新鲜的最大秒数max‑age=3600
s‑maxage=delta响应共享缓存(如 CDN)专用最大新鲜时间,覆盖 max‑ages‑maxage=86400
must‑revalidate响应一旦过期,必须向源服务器重新验证,不可使用陈旧响应must‑revalidate
stale‑while‑revalidate响应在指定秒数内允许使用陈旧响应,同时后台异步重新验证stale‑while‑revalidate=86400
no‑cache请求 / 响应每次使用前必须向源服务器验证(但可存储)no‑cache
no‑store请求 / 响应禁止任何缓存存储no‑store
private响应仅允许私有缓存(浏览器)存储private
public响应允许任何缓存(包括 CDN)存储public
经验:Retrofit + OkHttp 默认缓存由 Cache‑Control 驱动。服务端未返回此头时,OkHttp 会回退到 Expires 和 Last‑Modified 启发式算法。

2.2 max‑age 与 s‑maxage

max‑age 是客户端或服务器声明的新鲜度基准:从响应生成时刻起,经过 delta 秒内可直接使用存储副本。s‑maxage 仅对共享缓存(如代理、CDN)生效,且优先级高于 max‑age。当同时出现时,非共享缓存(浏览器)忽略 s‑maxage。

设置示例(OkHttp 拦截器中添加缓存控制头):

// 在 OkHttp 网络拦截器中加入 Cache-Control 头
Interceptor networkInterceptor = chain -> {
    Request original = chain.request();
    Request.Builder requestBuilder = original.newBuilder()
            .header("Cache-Control", "max-age=60, s-maxage=300");

    Response response = chain.proceed(requestBuilder.build());
    // 也可在响应侧强控
    Response.Builder responseBuilder = response.newBuilder()
            .header("Cache-Control", "public, max-age=300, must-revalidate");
    return responseBuilder.build();
};

2.3 must‑revalidate 与 stale‑while‑revalidate

  • must‑revalidate:当资源过期后,缓存必须向源服务器发起条件验证(如 If‑None‑Match / If‑Modified‑Since),不允许返回陈旧响应。适用于对数据一致性要求极高的接口(如支付状态)。
  • stale‑while‑revalidate:允许在指定秒数内“临时”返回过期数据,同时后台悄无声息地触发重新验证。该指令能极大改善感知延迟,适合新闻、排行榜等可容忍短暂延迟的场景。
指令过期后行为适用场景
must‑revalidate阻塞等待验证结果订单、库存、用户资料
stale‑while‑revalidate立即返回陈旧数据,异步验证首页、文章列表、动态配置

服务端响应头配置示例:

# 服务端返回头(PHP / Nginx 均可)
HTTP/1.1 200 OK
Cache-Control: public, max-age=300, must-revalidate
# 或者使用 stale-while-revalidate
Cache-Control: public, max-age=300, stale-while-revalidate=86400
注意:若同时设置 must‑revalidate 和 stale‑while‑revalidate,must‑revalidate 优先(即过期后必须等待验证,不会返回陈旧数据)。两者通常是互斥的。

2.4 Expires 与 ETag

Expires 是 HTTP/1.0 的原始过期字段,指定一个 GMT 时间。若同时存在 Cache‑Control: max‑age,则 max‑age 覆盖 Expires(因为 Cache‑Control 优先级更高)。

ETag 提供一种强验证器:服务器为资源生成唯一标识(通常基于内容哈希)。客户端在后续请求中通过 If‑None‑Match 携带上次的 ETag,若资源未变则返回 304 Not Modified,不传输正文。配合 Cache‑Control 可实现“过期→条件验证→304 直接使用缓存”的流程。

典型条件验证过程(OkHttp 自动处理):

// 第一次请求响应
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
ETag: "abc123"

// 3600 秒后客户端发起条件请求
GET /data HTTP/1.1
If-None-Match: "abc123"

// 服务器返回 304,无 Body
HTTP/1.1 304 Not Modified

2.5 完整流程图:缓存决策路径

下图展示从客户端请求到缓存命中的完整决策链,突出 max‑age、s‑maxage、must‑revalidate 及 ETag 的交互:

缓存控制头决策流程 客户端请求(带 Cache‑Control) 检查本地缓存 是否在 max‑age 内? must‑revalidate 已过期 stale‑while‑revalidate ETag 条件验证 返回缓存 / 304 过期 & must‑revalidate 过期 & stale‑while‑revalidate 均需验证 max‑age > s‑maxage(共享缓存) must‑revalidate 禁止陈旧响应

2.6 调试与常见错误

调试第一步:在 OkHttp 的 EventListener 或日志拦截器中打印 Cache‑Control、Expires、ETag 和 Age 头,确认服务器是否按预期返回。
// OkHttp 日志拦截器示例(使用 HttpLoggingInterceptor)
HttpLoggingInterceptor logging = new HttpLoggingInterceptor(message -> {
    // 过滤出缓存相关头
    if (message.startsWith("Cache-Control") || message.startsWith("ETag")
            || message.startsWith("Expires") || message.startsWith("Age")) {
        Log.d("CacheHeaders", message);
    }
});
logging.setLevel(HttpLoggingInterceptor.Level.HEADERS);

常见错误列表:

  • s‑maxage 不生效:确认缓存是否标记为 public,且请求路径经过代理/CDN。
  • ETag 未携带:OkHttp 默认会在缓存过期后自动添加 If‑None‑Match,但若拦截器清除了请求头,ETag 会丢失。
  • must‑revalidate 导致请求阻塞:在弱网环境下,须在客户端设置超时(见上文 Netty/OkHttp 超时配置)。
  • Expires 与 max‑age 冲突:同时存在时 max‑age 胜出,但有些老旧 CDN 只认 Expires,务必双写。

2.7 性能与稳定性优化

优化点说明
合并使用 max‑age + stale‑while‑revalidate大幅降低首帧延迟,同时保证数据最终一致
为不同 API 设计差异化的 max‑age用户信息用 60s,产品列表用 600s,静态资源用 86400s
服务端生成强 ETag(内容哈希)避免弱 ETag(如 W/"...")导致的无效验证
共享缓存(CDN)启用 s‑maxage让 CDN 缓存更久,用户层 max‑age 可设较短以防内容过旧