一、缓存基础: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 缓存优先级与决策流程
说了这么多,它们到底是怎么协作的?我画了一张图,把整个决策流程串联起来。你一看就明白了。
这张图把整个流程说清楚了。我简单帮你拆解一下:
- 客户端发起请求,先看强制缓存是否有效
- 如果
Cache-Control: max-age还没过期,直接走本地缓存,连网络请求都不发 - 如果过期了,客户端就带上
If-None-Match(基于 ETag)或If-Modified-Since(基于 Last-Modified)去问服务端 - 服务端判断资源有没有变化:没变就返回 304,客户端继续用旧的;变了就返回 200 和新数据
记住这个顺序:强制缓存 → 协商缓存 → 200 回源。一步接一步,缺了哪环都可能出问题。