4. Cache-Control 头解析:max-age、no-cache、no-store、must-revalidate、stale-while-revalidate

4.1 本章概述

HTTP 缓存控制的核心是由服务器通过 Cache-Control 响应头传达的。在 OkHttp 拦截器中实现离线缓存,本质上是正确解析这些指令并驱动缓存策略。本章深入解析 5 个最关键指令的行为差异、标准语义以及在 Android 离线场景下的实际影响。

4.2 核心指令对比表

指令 标准语义 对离线缓存的影响 典型适用场景
max-age=seconds 缓存可被使用的最长时间(从响应生成时刻起) 决定缓存条目的过期时间戳;离线时只要未过期即可直接返回 静态资源(图片、样式)、新闻列表
no-cache 缓存不能直接使用,必须先向服务器验证(条件请求) 离线时拒绝返回任何缓存内容(即使有存储数据) 实时交易数据、用户余额
no-store 缓存禁止存储响应内容 拦截器直接丢弃响应体,不写入缓存数据库 支付令牌、一次性密钥、隐私敏感数据
must-revalidate 缓存过期后必须验证,禁止使用过期缓存(即使服务器不可达) 离线且无法回源时,直接返回504而非过期缓存 配置参数、API 元数据
stale-while-revalidate=seconds 允许在后台异步验证的同时,短时间内使用过期缓存(窗口期) 离线且在窗口期内可返回过期缓存;窗口期后回退到标准逻辑 用户首页、推荐流、仪表盘数据

4.3 解析流程与决策树

一个完整的 Cache-Control 解析模块必须在拦截器处理响应时依次检查以下指令,优先级从高到低:

  1. no-store:若存在,直接跳过一切缓存写入逻辑。
  2. no-cache:若存在,缓存可写入存储但标记为“需验证”;离线时禁止返回。
  3. max-age:计算过期时间戳 Date + max-age 并存储。
  4. must-revalidate:覆盖离线回退逻辑,禁止使用过期缓存。
  5. stale-while-revalidate:设置一个额外的“宽限期”;从原始过期时间点开始计算窗口期。
⚠️ 常见陷阱:max-age 与 Expires 同时出现时,max-age 优先级更高。no-cache 不等于不缓存,而是“每次使用前必须验证”——这与许多初学者的理解正好相反。

4.4 内联 SVG:Cache-Control 解析状态机

Cache-Control 解析与缓存决策状态机 收到 HTTP 响应 no-store ? 丢弃响应 no-cache ? 写入 + 标记需验证 max-age ? 计算 expiresAt must-revalidate / stale-while-revalidate ? 设置宽限 / 强制验证 🔍 离线决策影响 must-revalidate + 离线 = 504 stale-while-revalidate + 离线 = 窗口内可用

4.5 拦截器中解析 Cache-Control 的核心实现

以下代码片段展示了在自定义拦截器中如何解析 Cache-Control 头并提取关键参数。将这段逻辑嵌入 CacheInterceptor.intercept() 中是实现离线缓存的第一步。

import okhttp3.Response;
import java.util.concurrent.TimeUnit;

public class CacheControlParser {

    public static CacheControlResult parse(Response response, long currentTimeMillis) {
        String cacheControl = response.header("Cache-Control");
        CacheControlResult result = new CacheControlResult();

        if (cacheControl == null || cacheControl.isEmpty()) {
            // 无 Cache-Control 时保守处理:标记为不可缓存
            result.isNoCache = true;
            return result;
        }

        // 按逗号分割,去除空格后逐个判断
        String[] directives = cacheControl.split(",");
        for (String directive : directives) {
            String trimmed = directive.trim().toLowerCase();

            if (trimmed.equals("no-store")) {
                result.isNoStore = true;
                return result; // no-store 优先级最高,直接返回
            }

            if (trimmed.equals("no-cache")) {
                result.isNoCache = true;
                // 不立即返回,继续解析其他指令
            }

            if (trimmed.equals("must-revalidate")) {
                result.isMustRevalidate = true;
            }

            if (trimmed.startsWith("max-age=")) {
                long maxAgeSeconds = Long.parseLong(trimmed.substring(8));
                result.expiresAt = currentTimeMillis + TimeUnit.SECONDS.toMillis(maxAgeSeconds);
            }

            if (trimmed.startsWith("stale-while-revalidate=")) {
                long windowSeconds = Long.parseLong(trimmed.substring(23));
                result.staleWindowMillis = TimeUnit.SECONDS.toMillis(windowSeconds);
            }
        }

        return result;
    }

    public static class CacheControlResult {
        boolean isNoStore = false;
        boolean isNoCache = false;
        boolean isMustRevalidate = false;
        long expiresAt = 0L;
        long staleWindowMillis = 0L;

        public boolean isCacheable() {
            return !isNoStore;
        }

        public boolean isUsableOffline(long currentTimeMillis) {
            if (isNoCache) return false; // no-cache 离线不可用
            if (currentTimeMillis <= expiresAt) return true; // 未过期
            if (isMustRevalidate) return false; // 过期 + must-revalidate = 不可用
            // 检查 stale-while-revalidate 窗口
            if (staleWindowMillis > 0) {
                long staleEnd = expiresAt + staleWindowMillis;
                if (currentTimeMillis <= staleEnd) return true;
            }
            return false;
        }
    }
}

4.6 调试与验证方法

  • 使用 Charles / mitmproxy:截获真实 API 响应,手动修改 Cache-Control 头(如 max-age=0 或 no-cache),观察 isUsableOffline() 返回值的变化。
  • OkHttp 日志拦截器:在自定义拦截器中打印解析后的 CacheControlResult 字段,确认解析逻辑与预期一致。
  • Edge Case 测试列表:
    • 响应头包含 Cache-Control: no-store, max-age=3600 → 期望 isNoStore = true,不写入缓存。
    • 响应头包含 Cache-Control: no-cache, max-age=3600 → 期望 isNoCache = true,但 expiresAt 仍被正确计算。
    • 响应头包含 Cache-Control: max-age=60, stale-while-revalidate=300 → 离线 350 秒内可用;超过 360 秒不可用。
📌 性能优化提示:解析 Cache-Control 时避免在每一条请求上重复调用 response.header() 多次。建议在拦截器中一次性提取原始字符串,再用上方的 CacheControlParser.parse() 方法集中解析。

4.7 本章总结