一、缓存基础:HTTP缓存原理与核心头部详解

说到 Retrofit 缓存,很多人第一反应就是加个 @Headers("Cache-Control: max-age=3600") 就完事了。嗯,我以前也这么干过,直到线上出了个数据不一致的问题——用户看到的列表跟服务器差了半小时。后来一查,是缓存策略没搞明白导致的。你想想看,一个 App 里那么多网络请求,要是不把缓存机制吃透,出问题的时候可真够你头疼的。

这一章咱们就把 HTTP 缓存的底子打牢。后面做 Retrofit 调优的时候,你才能知道每一步在干什么。

1.1 为什么需要缓存?

说白了,缓存就是为了「少跑路」。每次网络请求都要经过 DNS 解析、TCP 握手、TLS 协商、数据传输……一圈下来几百毫秒就没了。要是能把一部分数据存在本地,直接复用,那体验提升可不是一星半点。

  • 减少延迟:从本地读数据,毫秒级响应
  • 节省带宽:不用每次都传全量数据
  • 提升稳定性:弱网或无网环境下也能展示内容
  • 降低服务器压力:少一次请求,后端就少一分负载

我记得在做一个资讯类 App 的时候,首页列表图片多、接口数据量大。没加缓存前,每次冷启动都要等 3 秒以上。加了一层合理的缓存策略后,首屏加载直接降到 800 毫秒以内。用户反馈的「卡顿感」一下就没了。

1.2 HTTP缓存原理

HTTP 缓存分两大类:强制缓存 和 协商缓存。它们的核心区别在于——要不要问服务器一声。

  • 强制缓存:浏览器或客户端直接拿本地副本,不跟服务器打招呼。条件没到期,连请求都不发。
  • 协商缓存:先发个请求问问服务器「我的缓存还能用吗?」。服务器说「行,没变」,就返回 304,客户端继续用本地缓存。

强制缓存靠的是 Cache-Control 和 Expires 两个头。协商缓存靠的是 ETag 和 Last-Modified。后面咱一个一个说。

核心结论:强制缓存优先于协商缓存。只要强制缓存没失效,压根不会走到协商那一步。

1.3 Cache-Control 头

这是我个人用得最多的一个头。它用指令(directive)来控制缓存行为,灵活又精准。

指令 含义 示例
max-age=秒 资源从请求时刻起,多少秒内有效 max-age=3600 表示 1 小时内不走网络
no-cache 每次都要向服务器验证,不能直接使用本地缓存 相当于强制走协商缓存
no-store 完全禁止缓存,每次都得重新拉取 敏感数据场景使用
public 任何中间节点(如 CDN)都可以缓存 静态资源常用
private 只有客户端可以缓存,中间节点不能 用户个性化数据
s-maxage 专给 CDN 或代理服务器用的过期时间 优先级高于 max-age

实战中我习惯这样搭配:

// 静态资源:CDN 和客户端都缓存
Cache-Control: public, max-age=86400

// 用户信息:只让客户端缓存,CDN 别插手
Cache-Control: private, max-age=300

// 重要数据:每次都验证一下
Cache-Control: no-cache

建议:max-age 的值不要乱设。我个人经验是——动态接口设 5~10 分钟,静态资源设 24 小时以上。太长了数据滞后,太短了又没起到缓存效果。

1.4 Expires 头

Expires 是老牌的缓存头,它给的是一个时间。比如:

Expires: Wed, 21 Oct 2025 07:28:00 GMT

表示在这个时间之前,缓存都有效。听起来挺直观的对吧?但我踩过一个坑——服务器时间没同步。

我之前维护过一个旧项目,服务器在海外,NTP 同步没配好,系统时间慢了 15 分钟。结果 Expires 返回的时间比实际更「靠后」,导致客户端以为缓存还没到期,实际上数据已经变了。用户刷了半天都是旧数据。

从那以后,我基本都用 Cache-Control 代替 Expires。Cache-Control 是相对时间,不依赖时钟同步,更可靠。如果两个头同时存在,Cache-Control 的优先级更高。

注意:Android 上 Expires 的解析有兼容性问题。某些厂商的 ROM 对 HTTP 日期格式处理不一致,容易导致缓存提前失效或永不过期。建议优先使用 Cache-Control。

1.5 ETag 与 Last-Modified

这两个是协商缓存的主力。强制缓存失效后,客户端带着它们去问服务器:「我的缓存还能用不?」

  • Last-Modified:服务器返回资源最后修改的时间。客户端下次请求时带上 If-Modified-Since,服务器比对时间。
  • ETag:服务器返回一个资源唯一标识(通常是文件内容的 hash)。客户端下次请求时带上 If-None-Match,服务器比对标识。

我个人的习惯是优先用 ETag。为啥?因为 Last-Modified 有精度问题——如果资源在 1 秒内被修改了多次,Last-Modified 无法区分。而 ETag 是内容级别的,改没改一眼就能看出来。

在 Retrofit 里配合 OkHttp 使用,其实你不需要手动处理这些头。OkHttp 的缓存拦截器会自动管理。但理解它们的原理,能让你在调优时更有底。

// 服务端返回示例
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "abc123xyz"
Last-Modified: Tue, 15 Aug 2024 12:00:00 GMT
Cache-Control: max-age=0

// 客户端下次请求时自动携带
If-None-Match: "abc123xyz"
If-Modified-Since: Tue, 15 Aug 2024 12:00:00 GMT

// 如果资源未变,服务器返回
HTTP/1.1 304 Not Modified
// 空 body,客户端继续用本地缓存

小技巧:ETag 分为强验证和弱验证两种。强验证(如文件内容的 MD5)能精确到字节级别;弱验证前面带 W/ 前缀,比如 W/"abc123",只保证语义相等。后端开发时,根据业务场景选择合适的验证强度。

1.6 缓存优先级与决策流程

说了这么多,它们到底是怎么协作的?我画了一张图,把整个决策流程串联起来。你一看就明白了。

HTTP 缓存决策流程 发起网络请求 强制缓存有效? Cache-Control / Expires ✅ 使用本地缓存 携带条件头请求服务端 资源是否变化? ETag / Last-Modified 304 Not Modified 200 OK + 新数据 命中,缓存未过期 未命中,缓存已过期 If-None-Match / If-Modified-Since 未变化 → 304 已变化 → 200 📌 核心要点:强制缓存优先于协商缓存;Cache-Control 优先级高于 Expires ETag 比 Last-Modified 更精确,建议优先使用

这张图把整个流程说清楚了。我简单帮你拆解一下:

  1. 客户端发起请求,先看强制缓存是否有效
  2. 如果 Cache-Control: max-age 还没过期,直接走本地缓存,连网络请求都不发
  3. 如果过期了,客户端就带上 If-None-Match(基于 ETag)或 If-Modified-Since(基于 Last-Modified)去问服务端
  4. 服务端判断资源有没有变化:没变就返回 304,客户端继续用旧的;变了就返回 200 和新数据

记住这个顺序:强制缓存 → 协商缓存 → 200 回源。一步接一步,缺了哪环都可能出问题。