页面改完、发布成功、浏览器里看到的还是旧版本;换个浏览器或开无痕就正常。这不是发布没生效,而是请求在到达源站之前就被某一层缓存应答了。HTTP 缓存是协议层的能力,它的规则写在响应头里,谁能存、能存多久、过期后怎么验证,全部由头决定。
这篇按"改了为什么看不到新版"拆成四步:缓存在哪几层 → 凭什么不请求 → 过期后怎么验证 → 怎么让它立刻失效。
1. 四个位置,四套开关
| 位置 | 谁在存 | 生效范围 | 关掉的方式 |
|---|---|---|---|
| 浏览器私有缓存 | 每个用户的浏览器 | 单个用户 | 用户清缓存;页面头改为 no-store |
| 共享代理 | 公司/运营商代理 | 该代理后所有用户 | Cache-Control: private 禁止共享缓存存储 |
| CDN 边缘节点 | CDN 厂商 | 一个大区域 | 源站头 + 控制台缓存规则 + purge |
| 服务端应用缓存 | 你的应用/网关 | 全站 | 应用配置,与 HTTP 头无关 |
三层 HTTP 缓存遵循同一套语义,但配置入口不同:源站的头是"建议",CDN 的缓存规则通常可以覆盖它。排查时必须先确认响应到底由谁返回——看 Server、Via、X-Cache、CF-Cache-Status 一类头。
2. 强缓存:不请求就回答
Cache-Control: public, max-age=31536000, immutable
Expires: Thu, 31 Dec 2026 23:59:59 GMT
| 指令 | 含义 | 常见误用 |
|---|---|---|
max-age=N |
从响应生成起 N 秒内可直接复用 | 与 Expires 同时写且互相矛盾 |
s-maxage=N |
只对共享缓存生效,覆盖 max-age |
以为它也管浏览器 |
immutable |
有效期内连"重新验证"都省掉 | 用在会变的 URL 上 |
public |
允许共享缓存存储 | 与 private 混写 |
private |
只允许浏览器存 | 给 CDN 用,导致 CDN 不缓存 |
Expires 是绝对时间,依赖客户端时钟;机器时间不准就会提前过期或过期后仍命中。Cache-Control 存在时Expires 被忽略,所以现代站点只写前者。immutable 的意义在刷新行为:普通 max-age 下按 F5 会发一次验证请求,带 immutable 的响应在有效期内连验证都不发。
3. 协商缓存:过期后的一次问价
强缓存过期不等于必须重传。客户端带上验证信息问一句"我这份还能用吗",服务端答 304 就继续用本地副本。
| 组合 | 请求头 | 响应头 | 比较方式 |
|---|---|---|---|
| ETag | If-None-Match: "abc123" |
ETag: "abc123" |
精确匹配(W/ 前缀为弱比较) |
| Last-Modified | If-Modified-Since: <date> |
Last-Modified: <date> |
时间不晚于该值即命中 |
| 两者都有 | 两个头同时发 | 服务端以 ETag 优先 | 按 RFC 9111 |
304 的语义是"用你的副本",它不带响应体,只回少量头(ETag、Cache-Control、Date)。所以 304 省的是传输字节,不是一次往返——RTT 依然要付。Last-Modified 精度只到秒,同一秒内改两次会误判未变;ETag 由服务端生成,内容或元数据变化就该变。Nginx 默认对静态文件生成 ETag(etag on),格式按版本核对。
location ~* \.(js|css|png|woff2)$ {
etag on;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
etag on;
add_header Cache-Control "no-cache";
}
4. no-cache 与 no-store:一字之差
| 指令 | 能否存储 | 使用前是否必须验证 | 典型用途 |
|---|---|---|---|
no-cache |
能存 | 必须,每次带 If-None-Match 回源验证 |
HTML、API 的 GET |
no-store |
不能存(含磁盘) | 不适用 | 含敏感信息的响应 |
must-revalidate |
能存 | 过期后禁止用 stale,必须验证 | 库存、价格等强一致数据 |
stale-while-revalidate=60 |
能存 | 60 秒内先用旧值,同时后台刷新 | 首页列表、非关键内容 |
max-age=0 |
能存 | 等价于立即过期,仍需验证 | 兼容老客户端的写法 |
把 no-store 写成 no-cache,是"退出登录后按后退还能看到个人信息"的常见来源:no-cache 允许副本留在磁盘上,只是用前要验证,而某些场景(离线、代理)连验证都省了。
stale-while-revalidate 是"以旧换快"的折中:用户先看到略旧的内容,后台再刷新。它只影响"过期后的一小段时间",不能替代正确的内容版本管理。
5. Vary:被忽略的缓存键
Vary 声明"这份响应随哪些请求头变化",缓存会把这些头的值并入缓存键。忘写 Vary: Accept-Encoding 的后果是:CDN 把 gzip 版本存下来,再发给一个不声明支持压缩的客户端。
Vary: Accept-Encoding, Accept-Language
| 写法 | 效果 | 风险 |
|---|---|---|
Vary: Accept-Encoding |
压缩与非压缩分开存 | 命中率略降 |
Vary: Accept-Language |
多语言版本分开存 | 变体数量乘以语言数 |
Vary: Cookie |
几乎每个用户一个变体 | CDN 命中率接近 0,等于没缓存 |
不写 Vary |
只按 URL 缓存 | 返回错误变体(压缩/语言/设备) |
Vary: * 是"永不命中"的写法。判断是否需要 Vary 的方法:响应内容是否依赖某个请求头;若依赖,就必须写,否则跨用户串内容。
6. 指纹文件名与 HTML:两种缓存策略
长缓存的正确用法不是"给所有资源设一年",而是内容哈希进文件名:
<script src="/assets/app.9f2c1a.js"></script>
<link rel="stylesheet" href="/assets/main.4b7e0d.css">
文件名一变,URL 就变,旧缓存自然不会再被请求;新文件名第一次访问必定回源。于是 app.9f2c1a.js 可以设 max-age=31536000, immutable。
| 资源 | 建议策略 | 原因 |
|---|---|---|
| 带哈希的 JS/CSS/字体 | max-age=31536000, immutable |
URL 与内容绑定,可永久缓存 |
index.html |
no-cache + ETag |
URL 固定但内容每次发布都变 |
API GET |
no-cache 或短 max-age |
数据可能随时变 |
| 用户私有接口 | private, no-store |
不允许任何中间层留存 |
HTML 长缓存等于发布通道被堵死:用户不会去取新的 HTML,也就永远拿不到新的哈希文件名。这是"改了没生效"的头号原因,且清缓存能"修好",容易误判为偶发。
7. CDN:缓存键、Age 与 purge
CDN 的缓存键默认是 URL(含查询串),可配置忽略指定参数。purge 的成本按粒度上升:
| 方式 | 影响面 | 回源压力 |
|---|---|---|
| 按 URL 精确 purge | 单个文件 | 小 |
| 按目录/前缀 purge | 一批文件 | 中 |
| 全站 purge | 全部 | 大,流量瞬间打回源站 |
判断边缘副本有多旧,看 Age 与 Date:
Date: Fri, 09 Nov 2026 12:00:00 GMT
Age: 3500
Cache-Control: public, max-age=3600
Age 表示这份副本已在缓存里放了 3500 秒,max-age 是 3600,只剩 100 秒新鲜。Age 接近 max-age 时,边缘会回源验证;如果源站返回 304,CDN 续期并继续服务。Age 为 0 或缺失,说明这次可能没命中缓存。
8. 强制刷新的三种方式
| 操作 | 实际发出的请求 | 原理 |
|---|---|---|
Ctrl-F5 / Cmd-Shift-R |
带 Cache-Control: no-cache(部分浏览器还带 Pragma) |
跳过强缓存,强制回源验证 |
DevTools 勾 Disable cache |
同上,且持续生效 | 只对当前开着的 DevTools 有效 |
URL 加 ?v=2 |
全新 URL | 改变缓存键,旧副本仍在但不再被请求 |
Ctrl-F5 不会清掉 CDN 的副本,它只影响你这台浏览器;CDN 上是旧内容时,强刷多少次都是旧的。反过来,清浏览器缓存也不影响 CDN。排障时要分清"哪一层的副本"。
9. 最小示例:看头、看 304、看哈希
# 1) 第一次请求:看缓存相关响应头
curl -sI https://example.com/assets/app.9f2c1a.js | grep -iE 'cache-control|etag|age|date'
# 2) 带上 ETag 再请求:期望 304,且无响应体
curl -sI -H 'If-None-Match: "9f2c1a"' https://example.com/assets/app.9f2c1a.js
# 3) 改内容后重新构建,文件名变 9f2c1b,再请求:期望 200 且是新内容
curl -sI https://example.com/assets/app.9f2c1b.js
三步分别验证:强缓存头是否配对、协商缓存是否真的返回 304、指纹是否随内容变。若第 2 步返回 200 而不是 304,检查服务端 ETag 是否每次请求都变(比如基于 mtime 且被回源改写),这类"永远不命中"比不缓存更浪费带宽。
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 发布后仍看到旧页面 | HTML 被长缓存 | 给 HTML 设 no-cache |
| 强刷能好、普通刷新不行 | 强缓存 max-age 过长 |
缩短或改指纹命名 |
| 换浏览器正常 | 本机浏览器缓存 | 清缓存并核对响应头 |
| CDN 上仍是旧文件 | 未 purge 或缓存键含被忽略参数 | 精确 purge 对应 URL |
| 压缩版本串到不支持客户端 | 缺 Vary: Accept-Encoding |
补 Vary |
| 每次请求都 200,从不 304 | ETag 每次变化 / 未开 etag |
修正 ETag 生成依据 |
| 登录后按后退仍见隐私页 | 该页用了 no-cache 而非 no-store |
改 no-store |
11. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 秒级更新的数据 | 短 max-age + 前端轮询/推送 |
HTTP 缓存粒度是秒级 |
| 需要按用户区分的内容 | private + 应用层缓存 |
共享缓存会把 A 的内容给 B |
| 大文件分发 | CDN + 分片/断点续传 | 缓存只解决重复请求 |
| 需要"立刻全量失效" | 版本化 URL 或灰度发布 | purge 有延迟与成本 |
HTTP 缓存解决"同一 URL 的重复请求",不解决"内容该不该更新"。真正的发布控制靠文件名版本化,缓存头只是配套。
12. 自测
- 响应同时有
Cache-Control: max-age=60和已过期的Expires,浏览器听谁的? 304省掉的是什么,没省掉的是什么?no-cache与no-store差在哪一步?- 为什么带哈希的静态资源可以设一年,而 HTML 不行?
Vary: Accept-Encoding漏写会导致什么现象?Ctrl-F5能清掉 CDN 上的旧副本吗?