2. HTTP 缓存协议基础:Cache-Control、ETag、Last-Modified、Expires 与优先级

HTTP 缓存协议是 OkHttp 实现离线拦截与网络加速的核心依赖。本章从协议层面解析客户端与服务器之间协商缓存命中与失效的六个关键字段,并梳理它们的应用优先级。掌握这些基础是理解 OkHttp CacheInterceptor 工作流程的前提。

HTTP 缓存命中与验证流程 客户端请求 本地缓存 新鲜判定 条件请求 服务器响应 直接使用缓存 新鲜 过期 新鲜度 < 0 304 / 200 关键判定字段 1. Cache-Control: max-age 2. Expires (HTTP/1.0) 3. ETag (条件请求) 4. Last-Modified (回退) 5. 启发式缓存

2.1 Cache-Control 指令体系

Cache-Control 是 HTTP/1.1 引入的核心缓存控制头,服务器使用它显式告知客户端和中间代理如何缓存响应。OkHttp 的缓存策略直接解析此头确定缓存有效期。

指令方向含义OkHttp 影响
max-age=<seconds>响应资源在生成后的最大有效秒数计算新鲜度生命周期
s-maxage=<seconds>响应共享缓存专用 max-age,优先级高于 max-age仅对代理生效,OkHttp 为私有缓存忽略此字段
no-cache请求/响应每次使用前必须向服务器验证强制走条件请求,不直接使用本地缓存
no-store请求/响应禁止缓存任何内容OkHttp 完全不缓存此响应
public响应任何缓存(包括代理)均可缓存允许 OkHttp 缓存,即使默认私有
private响应仅私有缓存(浏览器/OkHttp)可缓存OkHttp 正常缓存
must-revalidate响应过期后必须到源站验证,不可使用 stale 缓存禁用 OkHttp 的 stale-while-revalidate 机制
immutable响应资源永远不会变化,无需条件请求OkHttp 4.0+ 支持,直接使用缓存
实践建议:服务端返回 Cache-Control: public, max-age=3600 表示资源可被任意缓存且 1 小时内有效。OkHttp 的 CacheStrategy 在计算 freshness 时优先解析 max-age,忽略 Expires 的冲突值。

2.2 Expires 字段(HTTP/1.0 兼容)

Expires: <HTTP-date> 是一个过期时间,如 Expires: Wed, 21 Oct 2025 07:28:00 GMT。当 Cache-Control 同时存在时,max-age 的优先级高于 Expires。OkHttp 在解析响应头时先检查 max-age,若缺失才退化为 Expires。

// OkHttp 内部计算新鲜度的简化逻辑
if (responseHeader("Cache-Control").contains("max-age=")) {
    int maxAge = parseMaxAge(responseHeader("Cache-Control"));
    expiryTime = responseReceivedTime + TimeUnit.SECONDS.toMillis(maxAge);
} else if (responseHeader("Expires") != null) {
    expiryTime = parseDate(responseHeader("Expires"));
} else {
    // 使用启发式缓存
    expiryTime = responseReceivedTime + heuristicFreshness(response);
}

2.3 ETag 与条件验证

ETag 是服务器为资源生成的唯一标识符(通常是文件哈希或版本号)。缓存过期后,OkHttp 发送 If-None-Match: "<etag-value>" 进行条件 GET 请求。服务器比对后若资源未修改,返回 304 Not Modified(无 body),OkHttp 复用本地缓存并重置新鲜度。

ETag 类型格式特点
强 ETag"a1b2c3d4"资源每个字节都相同才匹配,精度最高
弱 ETagW/"a1b2c3d4"语义等效即匹配,适用于内容无意义变动(如注释)
调试要点:使用 curl -H "If-None-Match: \"xyz\"" https://api.example.com/resource 可手动验证服务器是否返回 304。OkHttp 日志中打印 cache: miss 表明条件请求未命中本地缓存。

2.4 Last-Modified / If-Modified-Since

Last-Modified 是资源在服务器上的最后修改时间。当 ETag 不存在时,OkHttp 使用 If-Modified-Since 携带缓存中的旧修改时间进行条件验证。相比 ETag,时间戳粒度仅为秒,且容易受代理节点影响。

// OkHttp 条件验证头构造伪代码
String etag = cacheEntry.getEtag();
String lastModified = cacheEntry.getLastModified();

if (etag != null) {
    requestBuilder.header("If-None-Match", etag);
} else if (lastModified != null) {
    requestBuilder.header("If-Modified-Since", lastModified);
}
常见误区:不要同时设置 no-store 和 ETag。如果服务器返回 Cache-Control: no-store,OkHttp 会直接丢弃响应,不会存入缓存,后续条件验证也无从谈起。

2.5 字段优先级与缓存策略选择

OkHttp 的 CacheStrategy 在决定是否使用缓存时严格遵循以下优先级顺序。理解这个顺序可以帮助开发者排查缓存不生效或过度缓存的问题。

  1. Cache-Control: no-store —— 最高级,完全禁止缓存,忽略其他所有字段。
  2. Cache-Control: no-cache —— 要求每次验证,即使缓存未过期也发送条件请求。
  3. Cache-Control: max-age —— 计算新鲜度时不参考 Expires。
  4. Expires(HTTP/1.0 回退) —— 仅当完全没有 Cache-Control 时使用。
  5. ETag 条件验证 —— 缓存过期后,优先使用 ETag 进行验证。
  6. Last-Modified 条件验证 —— 当 ETag 不存在时的备选方案。
  7. 启发式缓存 —— 当服务器未返回任何显式过期信息时,OkHttp 根据 Last-Modified 与当前时间差估算新鲜度:freshness = (now - lastModified) * 0.1。
场景推荐配置说明
静态资源(JS/CSS/图标)Cache-Control: public, max-age=31536000, immutable长期缓存,无需条件验证
用户头像Cache-Control: private, max-age=86400 + ETag私有缓存,日级过期,ETag 保证更新及时
实时数据Cache-Control: no-cache每次请求都携带 ETag 验证,减少不必要传输
敏感信息Cache-Control: no-store完全禁止缓存,防止隐私泄露
性能优化:对于需要离线可用的移动应用,推荐服务端对列表页、文章详情返回 Cache-Control: private, max-age=7200, stale-while-revalidate=604800。OkHttp 支持 stale-while-revalidate 扩展,允许在后台异步刷新时直接返回过期缓存,显著提升离线加载速度。

2.6 调试与验证方法

使用 curl 模拟 OkHttp 缓存行为可以帮助快速定位协议问题:

# 查看响应缓存头
curl -I https://api.example.com/user/profile

# 发送条件请求(手动模拟 OkHttp 缓存验证)
ETAG="\"abc123\""
curl -I -H "If-None-Match: $ETAG" https://api.example.com/user/profile

# 强制不走缓存(模拟 no-cache 行为)
curl -I -H "Cache-Control: no-cache" https://api.example.com/user/profile

在 Android 应用中,通过 OkHttpClient 的 eventListener 或 networkInterceptors 打印缓存决策日志:

class CacheLoggingInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val response = chain.proceed(request)
        // 查看响应中的 cache-control、etag、last-modified
        Log.d("CacheLog", "Cache-Control: ${response.header("Cache-Control")}")
        Log.d("CacheLog", "ETag: ${response.header("ETag")}")
        Log.d("CacheLog", "Last-Modified: ${response.header("Last-Modified")}")
        return response
    }
}
常见错误:服务器返回 Cache-Control: public, max-age=0 时,OkHttp 计算新鲜度为 0 秒,直接标记为过期,每次请求都会触发条件验证。这与 no-cache 效果相同,但语义上更偏向“立即过期而非禁止缓存”。注意不要与 no-store 混淆。

2.7 性能与稳定性优化

  • 避免频繁过期的短 max-age:如 max-age=60 会导致每分钟一次条件请求,增加服务器压力;对不变资源应使用 max-age=86400 以上并结合 immutable。
  • ETag 生成成本:强 ETag 通常使用文件内容 Hash,计算开销大;弱 ETag W/"version" 可通过版本号获取,更轻量但精度降低。
  • 代理干扰:公共 WiFi 代理可能剥离 Cache-Control 或修改 Expires,导致 OkHttp 失效。可通过 Https + 私有缓存策略(private)减少风险。
  • 缓存键冲突:OkHttp 使用 URL + 变体(如 Accept-Encoding)作为缓存键,但忽略 Authorization 头。如果同 URL 不同用户获取不同资源,应禁用缓存或使用自定义键。