Cache-Control头的秘密:max-age、no-cache、no-store、must-revalidate等指令详解

HTTP 缓存这件事,说简单也简单,说复杂能让你抓狂。我刚开始做网络优化的时候,以为在响应头里加个 Cache-Control: max-age=3600 就完事了。结果呢?有的接口该缓存的不缓存,不该缓存的却命中了……后来才意识到,Cache-Control 里那几个指令,每个都能决定缓存的行为走向。今天咱们就把它们掰开揉碎,看看背后到底藏着什么门道。

指令速查表

先列个总表,后面再逐个拆解。这张表我放在手边,每次写缓存策略都会对照着看。

指令含义典型值 / 用法适用场景
max-age资源可被缓存的最大秒数max-age=3600静态资源、不频繁变化的数据
no-cache可以缓存,但每次必须向源服务器验证no-cache需要实时验证但允许本地存储的接口
no-store禁止任何缓存(内存、磁盘都不行)no-store敏感数据、一次性令牌
must-revalidate缓存过期后必须向源服务器验证,不能使用过期缓存must-revalidate银行交易、订单状态
proxy-revalidate与 must-revalidate 类似,但仅约束共享缓存(如 CDN)proxy-revalidate需要 CDN 校验但允许浏览器使用的场景
public允许任何缓存(包括 CDN、代理)缓存public公开静态资源
private仅允许私有缓存(浏览器)缓存private用户个性化数据
s-maxage专用于共享缓存(CDN 等)的最大存活时间s-maxage=600给 CDN 单独设置更短或更长的缓存
immutable过期前永远不会改变,浏览器可以放心不重新验证immutable带 hash 的文件(如 app.a1b2c3.js)

max-age:缓存时间的“指挥棒”

max-age 是最常用的指令,它告诉接收方:从响应生成那一刻起,这个资源在多少秒内是新鲜的。比如 max-age=86400 就是一天。

嗯,这里要注意:max-age 的计时起点是 Date 头(或响应生成时间),而不是客户端收到的时间。我在项目中遇到过一个坑——服务端和客户端时钟不同步,导致缓存过早过期或超期使用。所以后来我养成了习惯,服务端一律用 UTC 时间,并且故意把 Age 头加进去,方便排查。

关键点: 如果客户端本地时间比服务器慢,max-age 可能被“提前消耗”。解决方案是配合 Last-Modified 或 ETag 做条件请求兜底。

no-cache 与 no-store:一字之差,天壤之别

这两个名字太容易混淆了。我第一次看到 no-cache 时,还以为是“不缓存”。其实它恰恰相反——允许缓存,但每次使用前必须去源站做验证(如果服务器说资源没变,就用缓存;变了就返回新的)。

而 no-store 才是真正的“禁止缓存”。浏览器和中间代理连副本都不能存,每次请求都得从服务器拿。我之前处理过一个金融 App 的接口,返回的密钥必须用完即焚,那就必须上 no-store。但凡用了 no-cache,都可能被恶意代理留下记录。

避坑指南: 我见过有人把 Cache-Control: no-cache 写在 POST 响应里,以为能阻止缓存。实际上 no-cache 不阻止缓存,只是强制验证。如果真想避免缓存,请用 no-store 或者在 Vary 头里下功夫。

must-revalidate:过期后的“安全锁”

这个指令的实用场景很典型:假设你的页面显示用户余额,如果缓存过期了,你不允许客户端继续用旧数据。加上 must-revalidate 后,浏览器一旦发现 max-age 到期,就必须发起条件请求(If-Modified-Since / If-None-Match)。如果服务器返回 304,才能用缓存;否则必须用新数据替换。

我曾经在一个订单系统的 GET API 上只设了 max-age=60,没加 must-revalidate。有用户断网后看到的还是 60 秒前的订单状态,差点重复提交。从那以后,涉及金钱、状态变更的接口,我必加 must-revalidate。

小提示: 和 must-revalidate 配合 ETag 使用效果最佳。因为 ETag 是资源指纹,比时间戳更精确。我在实践中的做法是:Cache-Control: max-age=0, must-revalidate,这样每次请求都会走验证,但 304 返回时可以节省带宽。

指令组合的艺术

单个指令往往不够用。比如你可能需要“对 CDN 缓存 10 分钟,但对浏览器缓存 1 小时”,怎么办?用 s-maxage 和 max-age 的组合:

Cache-Control: public, max-age=3600, s-maxage=600

这样 CDN 按 600 秒(10 分钟)更新,浏览器按 3600 秒(1 小时)缓存。如果服务器想指示 CDN 必须每次都验证,可以再加个 proxy-revalidate。

再举个例子,一个带 hash 的 JS 文件:

Cache-Control: public, max-age=31536000, immutable

这样浏览器可以放心缓存一年,并且不会浪费请求去验证(因为 immutable 告诉浏览器:文件内容到过期前绝不会变)。这个指令在 Chrome 以外的浏览器支持度不一,但加上无害。

Cache-Control 指令决策流程

下面这张图总结了我在项目中常用的决策路径。它帮你快速定位应该用哪些指令。

Cache-Control 指令决策流程 收到 HTTP 响应 允许缓存? no-store / 不缓存 public / private + max-age 需要每次验证? no-cache must-revalidate / 无条件缓存 共享缓存?加 s-maxage 说明:图中展示了从响应接收到最终缓存策略的典型分支路径,实际使用可根据业务灵活组合。

常见误区与避坑

  1. 同时使用 no-cache 和 max-age 会怎样? 如果 no-cache 存在,max-age 会被忽略。浏览器的行为是:必须验证,与 max-age 无关。
  2. no-store 在 HTTPS 下是否安全? HTTPS 只保护传输,但浏览器或代理可能将明文缓存到磁盘。no-store 能彻底阻止缓存。我处理过支付回调的敏感数据,坚定地加了 no-store 和 Pragma: no-cache 双重保险。
  3. must-revalidate 和 no-cache 的区别? 两者都会触发验证。no-cache 是“每次都要验证”,哪怕缓存没过期;must-revalidate 是“过期后必须验证”。因此 must-revalidate 更常用,能减少不必要的验证请求。
  4. CDN 缓存与浏览器缓存不一致? 用 s-maxage 和 max-age 分别控制。如果希望 CDN 缓存更短,比如 s-maxage=60,而浏览器可以缓 10 分钟,这是一种常见的“近快远慢”策略。

实战建议:Retrofit 中的 Cache-Control 应用

在 Android 的 OkHttp 底层,Retrofit 默认的缓存拦截器会将服务器返回的 Cache-Control 指令转成对应的缓存行为。我在项目里通常这样配置:

// OkHttp 缓存配置
val cache = Cache(cacheDir, 10 * 1024 * 1024) // 10MB

val client = OkHttpClient.Builder()
    .cache(cache)
    .addInterceptor { chain ->
        val request = chain.request()
        // 如果需要强制刷新,添加 Cache-Control: no-cache
        val newRequest = if (forceRefresh) {
            request.newBuilder()
                .header("Cache-Control", "no-cache")
                .build()
        } else request
        chain.proceed(newRequest)
    }
    .build()

然后在后端接口上设置合理的 Cache-Control 头。比如用户信息接口:private, max-age=300, must-revalidate;商品列表接口:public, max-age=600, s-maxage=120。这样就做到了浏览器和 CDN 的差异化缓存。

总结一下

Cache-Control 看似简单,但组合起来能玩出很多花样。核心思路就是:先判断能不能缓存,再决定缓多久,最后确认过期后是否验证。我个人习惯在项目设计阶段就把每个接口的缓存策略画成上面的流程图,然后和后台同学对齐,避免上线后手忙脚乱。记住,缓存策略没有银弹,但理解这些指令背后的行为,你就能游刃有余。