一次前端发版,index.html 更新了,而用户刷新后仍是旧页面。查下来:index.html 的 ETag 没变——因为 nginx 用"文件时间 + 大小"生成 ETag,而那次发版"内容变了、大小恰好相同、时间被 tar 保留成了旧值"。
条件请求(ETag/If-None-Match)是"省带宽"的机制,而它有个"隐性依赖":ETag 必须"随内容变化"。用"时间 + 大小"这种弱信息生成,就会出现"内容变了、ETag 没变"——这个"一直拿缓存"的体验很糟:用户"强制刷"也刷不掉(因为浏览器把 304 当"没变"),于是"支持只能让用户清缓存"——而多数人不会。
于是"客户端一直拿缓存"。
1. 条件请求的三个组件
| 组件 | 作用 |
|---|---|
ETag |
资源版本的"指纹" |
If-None-Match |
客户端带上"我有的版本" |
304 Not Modified |
服务端说"你那份还是新的" |
第三项的"收益"是"省 body":响应头仍然往返,但"几百 KB 的 HTML/JS"不再下载第三项的"成本"是"一次 RTT":于是它省带宽、不省延迟——在"高延迟链路"上,"304"仍然要"一个来回",只是"内容为空"。
。
2. ETag 的两种"强度"
| 类型 | 含义 |
|---|---|
强 ETag("abc") |
字节级相同 |
弱 ETag(W/"abc") |
语义等价(可能格式不同) |
第二项的"用处"在"动态生成":W/"size-hash" 表示"语义相同"——于是"每次生成都不同但内容等价"的页面也能命中缓存。
第二项适合"内容一样但换行不同"的场景;而**"压缩与否"通常用"强 ETag"区分**——否则"gzip 与原始"会互相命中。
3. "ETag 不变"的三种成因
| 成因 | 表现 |
|---|---|
| 用"mtime + size"生成 | 内容变、大小不变、mtime 被保留 |
多台机器 mtime 不同 |
同一文件不同 ETag(缓存失效) |
代理重写了 ETag |
与源站不一致 |
第一项在"构建产物"里最常见:**tar 打包会"保留 mtime"**第一项的"讽刺"在于"看起来更严谨的方案"(时间+大小)反而"更容易撞":因为"大小恰好相同"在"改一个字符"时很常见——而"哈希"不会。
,于是"服务器上的文件时间"可能是"三个月前"。
"ETag 是否生效"可以用 curl 两步验证,而不必"清缓存试":
// 两步:拿 ETag → 带上 If-None-Match 再请求
etag=$(curl -sI https://host/app.js | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -sI -H "If-None-Match: $etag" https://host/app.js | head -1
// 期望:HTTP/2 304;若是 200 → ETag 没生效(或代理改写了)
4. "该用什么做 ETag"的三档
| 做法 | 可靠性 |
|---|---|
mtime + size |
低(如上) |
内容哈希(如 sha1) |
高 |
| 构建产物"内容哈希文件名" | 最高(且能"永久缓存") |
第三项的"配套"是"HTML 必须"每次验证"":HTML 里引用"带哈希的 JS"——于是"HTML 一变,引用就变",而"JS 本身可以永久缓存"。
第三项是"现代前端"的标准:app.3f2a1b.js ——文件名含内容哈希,于是"内容变 → URL 变 → 不可能命中旧缓存"。
// 两种策略(推荐"内容哈希 + 长缓存")
// 1 带哈希的静态资源:永久缓存
location ~* \.(js|css|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
// 2 入口 HTML:必须"每次校验"
location = /index.html {
add_header Cache-Control "no-cache"; # 注意:no-cache 不是"不缓存",是"每次验证"
}
"缓存策略"要"按资源类型"分档,而它只有三类:
// 三档策略(前端发版的标配)
1 入口 HTML:no-cache(每次验证)
2 带哈希的静态资源:immutable + max-age=1y
3 不带哈希的资源(favicon、字体):max-age=1d + 每次校验可选
// 判据:这个资源"内容变了,URL 会不会变"
5. "no-cache 与 no-store"的差别
| 指令 | 含义 |
|---|---|
no-cache |
可缓存,但"每次使用前验证" |
no-store |
完全不缓存 |
max-age=0, must-revalidate |
等价于 no-cache |
第一项是"入口 HTML 的正解":**它允许"用 304 省带宽",同时保证"永远是最新"**第一项的"常见误解"是"no-cache = 不缓存":它其实"允许缓存 + 每次验证"——而"不缓存"才是 no-store。
——而"no-store"会让"每次都全量下载"。
"部署后仍是旧版"的排查要"按层看",而不是"清缓存":
// 四层排查(从近到远)
1 浏览器:DevTools 的 "Disable cache" 勾上再刷新(排除本地)
2 服务端:curl -sI 看 ETag/Last-Modified 是否更新
3 CDN:查 CDN 的 "缓存命中/回源" 与"刷新记录"
4 代理:企业网关可能"二次缓存"
// 判据:每一层都能"单独证伪",而不是"一起清"
6. 排错:五种"缓存相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 刷新还是旧版 | ETag 未变 |
改内容哈希 |
| 部署后部分用户旧版 | CDN 缓存 | 刷新 CDN/改 URL |
| 每次全量下载 | 用了 no-store |
改 no-cache |
| 同一资源两套 ETag | 多机 mtime 不同 | 用内容哈希 |
| gzip 与原始冲突 | 未区分 Vary |
加 Vary: Accept-Encoding |
第五项的"后果"是"随机失败":同一用户"这次拿到 gzip、下次拿到原始",而它按 Content-Encoding 解——于是"偶发乱码"。
第五项很实际:同一 URL 的 gzip 与未压缩版本,若不区分 Vary,代理会"混着发"——于是"客户端解压失败"。
"Vary"这条容易被忽略,而它影响"多语言/多编码"的所有资源:
// 两个必须的 Vary(否则代理会"混着发")
add_header Vary "Accept-Encoding"; # gzip 与原始
add_header Vary "Accept-Language"; # 多语言页面
// 判据:同一 URL "返回内容随请求头变化" → 必须 Vary
7. 边界:什么情况不必纠结 ETag
| 场景 | 为什么 | 做法 |
|---|---|---|
| API 响应(JSON,短) | 省不了多少 | 可不设 |
| 一次性文件(下载) | 不重复请求 | 可不设 |
| 内网、低带宽成本 | 收益小 | 可不设 |
| 已有"文件名哈希" | URL 变了 | ETag 冗余 |
判断口径一句话:"这个资源会不会被"同一个客户端重复请求"。最后一条与"CDN"有关的边界:"CDN 会缓存 304 吗"——多数不缓存,但"它会缓存"源站的 ETag 与"响应体"**;于是"源站改了内容、CDN 未刷新"时,ETag 仍是旧的——部署流程里"刷 CDN"这一步不能省。
会(HTML、CSS、JS、图片),ETag(或文件名哈希)值得做;不会(一次性下载、API 短响应),它只增加复杂度。
最后一个"现实"提醒:"清缓存"这个动作,在排查里应当"最后做"——因为它把"证据"一起清掉了。先看 ETag/Last-Modified 有没有变、再看 CDN 有没有回源、最后才清本地缓存。而"一上来就让用户清缓存",等于"放弃了定位",也把"这类问题会再次发生"写进了流程。
而"ETag 不变"这件事,本质上是"版本标识没有跟着内容走"——换任何缓存层(CDN、网关、Service Worker)都会复现同一个问题,所以修在"生成端"(内容哈希)比修在"清理端"(刷缓存)持久。
这句话也适用于所有"缓存"问题。
把它记住,比记住任何 Cache-Control 组合都省事。
8. 自测
- 条件请求的三个组件是什么?
304省下的是什么? - 强/弱
ETag的差别是什么?为什么"压缩与否"要区分? - "mtime + size"为什么会"内容变、ETag 不变"?
tar在这里起了什么作用? - "内容哈希文件名"为什么"不可能命中旧缓存"?
no-cache与no-store的差别是什么?为什么入口 HTML 该用前者?- 判断"是否需要 ETag"的那个问题是什么?