CacheControl 与缓存指令:max‑age、no‑cache、no‑store、only‑if‑cached

1. 概念与原理

HTTP Cache-Control 头部是客户端与服务器协商缓存行为的核心机制。OkHttp 通过 CacheControl 类封装这些指令,开发者可以精确控制请求应如何使用缓存,以及响应应如何被缓存。指令分为两大类:请求指令(客户端发往服务器,影响中间缓存和源站的处理方式)与 响应指令(服务器返回给客户端,限定本地缓存策略)。本章重点介绍四个最常用的指令:max‑age、no‑cache、no‑store、only‑if‑cached。

核心认知: no-cache 名字易误导——它并不禁止缓存,而是强制每次使用缓存前都向服务器验证资源是否新鲜。真正禁止缓存的是 no-store。
指令类型含义适用方向
max-age=s请求/响应指定缓存的最大有效秒数请求:告知服务器愿意接受多久内未过期的缓存
响应:告知缓存端该资源在 s 秒内无需验证
no-cache请求/响应强制每次使用缓存前向服务器重新验证请求:要求服务器返回完整响应,不得使用缓存
响应:缓存可以存储,但每次使用必须验证
no-store请求/响应禁止任何形式的缓存存储(内存/磁盘)请求/响应:缓存必须忽略该资源
only-if-cached请求只从缓存返回,如果缓存未命中则返回 504 Gateway Timeout请求:适用于纯离线模式,不发起网络请求

2. 流程图:指令如何影响缓存决策

下图展示请求携带不同 Cache-Control 指令时,OkHttp 缓存拦截器的核心判断分支。注意 only-if-cached 会跳过网络请求,no-cache 会导致即使有缓存也要重新验证。

CacheControl指令决策流程图 收到请求 only-if-cached ? 本地缓存命中? 直接返回缓存响应 返回504 否 → 检查 no-cache 有缓存且新鲜? 返回缓存 发起网络请求 说明 ① no-cache 即使有新鲜缓存也要求验证 ② only-if-cached 不走网络 ③ no-store 直接禁止存储到缓存

3. OkHttp 中的 CacheControl API

OkHttp 提供 CacheControl.Builder 来构建指令,通过 Request.Builder.cacheControl() 或 Response.cacheControl() 应用。以下示例展示常用指令组合:

// 构建请求:max-age=60,且只接受缓存(only-if-cached)
val cacheControl = CacheControl.Builder()
    .maxAge(60, TimeUnit.SECONDS)
    .onlyIfCached()
    .build()

val request = Request.Builder()
    .url("https://api.example.com/data")
    .cacheControl(cacheControl)
    .build()

// 响应端检查指令
val response = client.newCall(request).execute()
val responseCC = response.cacheControl()
println("max-age = ${responseCC.maxAgeSeconds}")       // 或 -1 表示未设置
println("no-cache = ${responseCC.noCache}")            // true/false
println("no-store = ${responseCC.noStore}")            // true/false
println("only-if-cached = ${responseCC.onlyIfCached}") // 仅在请求有效
注意: only-if-cached 只能作为请求指令使用,如果服务器或中间缓存未命中,OkHttp 将返回 504 状态码且不发起网络请求。此指令非常适合离线模式或飞行模式下的降级策略。

4. 实现流程:在缓存拦截器中注入自定义 CacheControl

通过自定义 Interceptor,可以在运行时动态添加或修改 CacheControl 指令。典型场景:根据网络状态(WiFi/移动数据/离线)切换缓存策略。

  1. 创建拦截器,实现 Interceptor 接口。
  2. 在 intercept() 方法中获取原始 Request。
  3. 构造新的 CacheControl,合并业务逻辑(例如:当前为弱网络时增加 max‑age,检测到离线强制 only‑if‑cached)。
  4. 使用 request.newBuilder().cacheControl(newCC).build() 替换请求。
  5. 调用 chain.proceed(newRequest) 获取响应。
  6. 可选:根据响应状态或业务需求修改响应端缓存指令(例如替换 Cache-Control 响应头)。
class DynamicCacheInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()

        // 假设当前网络为离线状态
        val isOffline = true
        val newCacheControl = if (isOffline) {
            CacheControl.Builder()
                .onlyIfCached()
                .maxAge(Int.MAX_VALUE, TimeUnit.SECONDS) // 允许使用任何过期的缓存
                .build()
        } else {
            CacheControl.Builder()
                .maxAge(30, TimeUnit.SECONDS)
                .build()
        }

        val modifiedRequest = originalRequest.newBuilder()
            .cacheControl(newCacheControl)
            .build()

        val response = chain.proceed(modifiedRequest)
        // 若想在响应端强制 no-cache,可修改响应头(需使用 Response.Builder)
        return response
    }
}

5. 调试与验证

  • 日志拦截器:使用 HttpLoggingInterceptor 输出完整的请求/响应头,查看 Cache-Control 是否按预期发送。
  • OkHttp 事件监听器:通过 EventListener 的 cacheHit()/cacheMiss() 事件判断缓存决策是否正确。
  • 抓包工具:使用 Charles 或 Wireshark 抓取 HTTP 报文,验证 Cache-Control 指令的头部值。
// 日志拦截器示例
val logger = HttpLoggingInterceptor { message -> Log.d("OKHTTP", message) }
logger.setLevel(HttpLoggingInterceptor.Level.HEADERS)

val client = OkHttpClient.Builder()
    .addInterceptor(logger)
    .cache(Cache(File(cacheDir, "http_cache"), 5L * 1024 * 1024))
    .addInterceptor(DynamicCacheInterceptor())
    .build()

6. 常见错误与陷阱

错误后果正确做法
将 no-cache 当作“禁用缓存”缓存仍然存储了资源,但被频繁验证需禁用缓存时使用 no-store
仅设置了响应端 max-age,但请求端未配合客户端可能因其他原因忽略响应指令(如 private 等)明确在响应头中提供完整的 Cache-Control
only-if-cached 未与缓存过期时间联合使用旧数据可能被无限期返回配合 max-stale 或 max-age 控制陈旧容忍范围
混淆 max-age 与 Expires优先级错误(max-age 高于 Expires)统一使用 Cache-Control,废弃 Expires

7. 性能与稳定性优化

  • 合理设定 max‑age:对于不常变动的静态资源(图标、CSS),设置较长值(如 86400 秒)可大幅减少带宽消耗与延迟。
  • 离线降级:监听网络变化,在断网时启用 only-if-cached + max-stale(Int.MAX_VALUE),让应用仍然可用。
  • 动态切换策略:根据用户行为(如滑动列表)区分即时数据与背景数据;即时数据使用 no-cache,背景数据使用 max-age。
  • 避免过度使用 no-store:每次请求都走网络会增加服务器压力和网络延迟,仅在敏感信息(如、支付表单)中使用。
实践建议: 在 OkHttp 的缓存拦截器中,将 CacheControl 指令与 NetworkInfo、用户偏好、资源类型解耦,通过策略对象(Strategy Pattern)灵活构建不同的指令组合,便于测试和维护。