"发版后部分用户还是旧页面,强制刷新就好了"——这类问题几乎都出在缓存链路而不是代码。浏览器、CDN 边缘节点、回源服务器三层各有一份缓存,各自的过期规则由响应头决定;前端能控制的只有"文件名里带上内容指纹"这一件事,剩下的必须靠头部配置配合。
这篇按五步走:CDN 的边界 → Cache-Control 分组 → 两种破缓存方式 → 压缩与回源 → 灰度与刷新,末尾列坑。
1. CDN 解决什么,不解决什么
| 问题 | CDN 能否解决 | 说明 |
|---|---|---|
| 跨地域访问慢 | ✅ | 就近接入边缘节点 |
| 源站带宽被静态资源占满 | ✅ | 命中缓存不回源 |
| 突发流量把源站打挂 | ✅ | 边缘分摊,前提是命中率够高 |
| 首屏 JS 体积过大 | ❌ | CDN 不压缩你的打包决策 |
| 首页 TTFB 慢(HTML 不可缓存) | ❌ | HTML 回源,要靠 SSR/边缘渲染 |
| 接口响应慢 | ❌ | 动态请求仍回源 |
常见误诊:看到带宽跑满就上 CDN,上完 LCP 没变——LCP 由 JS 体积与执行时间主导,CDN 只改了传输段。先看产物体积与长任务,再看网络。
CDN 吃满收益的前提是高命中率:HTML 不缓存、资源没带哈希、响应头写 no-store 的部署,上 CDN 等于加一跳。
2. Cache-Control 的关键分组
| 指令 | 语义 | 典型用法 |
|---|---|---|
max-age=31536000 |
浏览器缓存 1 年 | 带哈希的 JS/CSS/字体 |
s-maxage=86400 |
只对共享缓存(CDN)生效,覆盖 max-age |
想让 CDN 存得比浏览器久/短 |
immutable |
告诉浏览器"响应体内不会再变",刷新时不校验 | 带哈希资源 |
no-cache |
可以存,但每次必须校验 | index.html |
no-store |
完全不存 | 含隐私数据的接口 |
must-revalidate |
过期后不许用陈旧副本 | 需要强一致的资源 |
stale-while-revalidate=60 |
后台异步更新,同时先给旧副本 | 容忍短暂陈旧的资源 |
# index.html:每次校验,命中 304 就省流量
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate" always;
etag on;
}
# 带哈希资源:可长缓存
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off;
}
两个高频误解:
no-cache不是"不缓存"。它表示"可以存、每次用前必须回源校验"。想彻底不存是no-store。把index.html写成no-store也不会错,代价是每次都要完整下载 HTML,白丢一次 304。immutable只应给带内容指纹的资源。给index.html加上它,用户一年内都拿不到新版本,只能靠清缓存。
3. 两种破缓存方式:文件名哈希 vs 查询串
// vite.config.ts:让产物名带内容指纹
export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
},
manifest: true, // 生成 .vite/manifest.json,供服务端做版本校验
},
})
| 维度 | 文件名哈希 | 查询串 ?v=1 |
|---|---|---|
| CDN 识别 | 稳定,绝大多数 CDN 按完整 URL 缓存 | 部分 CDN 的缓存键默认忽略查询串,破缓存失效 |
| 缓存命中 | 新文件新 URL,天然不冲突 | 同一路径反复失效,命中率低 |
| 上传策略 | 可以"只增不删" | 覆盖同名文件,回源与边缘可能不一致 |
| 适合 | JS/CSS/字体/图片 | 少量无法改名的资源(如第三方约定路径) |
"CDN 忽略查询串"是最隐蔽的坑:本地测试一切正常(浏览器不忽略),上线后用户拿到的是旧文件——因为边缘节点把 app.js?v=2 和 app.js?v=1 当成同一个缓存对象。发布前用 curl -sI "https://cdn/.../app.js?v=2" 看返回的 Age、X-Cache 头,若命中旧对象就能确认。
结论:能用文件名哈希就别用查询串。Vite 的 [hash] 按内容计算,内容不变则哈希不变,缓存利用率最高。
4. ETag 与 Last-Modified 的优先级
location = /index.html {
etag on; # 默认开启,强校验
if_modified_since exact; # 时间一致才回 304,容忍精度差
add_header Cache-Control "no-cache" always;
}
| 请求头 | 对应响应头 | 优先级 |
|---|---|---|
If-None-Match |
ETag |
高,存在即忽略后者 |
If-Modified-Since |
Last-Modified |
低,兜底 |
三条实践要点:
- 两者同时存在时,
ETag优先。服务器返回304需同时匹配 ETag;只有 ETag 不匹配才看时间。nginx 默认同时输出两者,客户端也按这个优先级走。 Last-Modified的时间精度是秒。同秒内多次修改无法区分,if_modified_since exact就是为这个场景准备的(默认exact,另有before可选)。- 多源站时间不同步会让
Last-Modified抖动(nginx 的etag由 mtime+size 生成,跨机器可能不同)。静态站最好把内容哈希写进文件名,校验交给文件名而不是 ETag。
5. 预压缩与回源
# 构建期生成 .gz / .br,回源直接给压缩文件
location /assets/ {
gzip_static on; # 需编译带 --with-http_gzip_static_module,用 nginx -V 核对
# brotli_static on; # 需第三方 ngx_brotli
gzip on;
gzip_vary on; # 输出 Vary: Accept-Encoding
gzip_types text/css application/javascript application/json image/svg+xml;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
| 组合 | 结果 |
|---|---|
只开 gzip on(动态压缩) |
每次回源都压,占 CPU |
只开 gzip_static on |
有 .gz 就发,没有就发原文,最省 |
| 两者都开 | 优先静态文件,缺失时动态压,推荐 |
| 源站压缩 + CDN 再压缩 | 可能双重压缩/头部冲突,需确认 CDN 的 compression 开关 |
四个要点:
Vary: Accept-Encoding必须存在。没有它,CDN 可能把 gzip 变体发给不支持压缩的客户端(或反之),表现为"偶发乱码"。- CDN 自动压缩与源站预压缩叠加时,先确认 CDN 是否识别
Content-Encoding。开启 CDN 压缩后,源站预压缩的收益是省回源 CPU 与带宽,不是省边缘 CPU。 - 预压缩文件要有对应的
.gz/.br,构建产物体积决定回源与首次命中成本。Brotli 通常比 gzip 更小,代价是构建时间。别凭经验估,用构建产物实际体积对比。 - 预热:发版后主动请求新 HTML 与入口 JS,让边缘提前拉取。只预热首页不够,首屏引用的 chunk 都要覆盖;按
manifest.json的资源清单遍历最可靠。
6. 灰度发布与 CDN 刷新
// scripts/purge.ts:发版后刷新入口与 HTML,其余靠文件名哈希自然生效
const CDN_PURGE_ENDPOINT = 'https://api.cdn.example.com/purge'
const TOKEN = process.env.CDN_TOKEN!
async function purge(paths: string[]) {
const res = await fetch(CDN_PURGE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` },
body: JSON.stringify({ paths, ignoreQuery: true }),
})
if (!res.ok) throw new Error(`purge 失败: ${res.status} ${await res.text()}`)
return res.json()
}
// 只刷 HTML 入口;带哈希资源不需要刷
await purge(['/', '/index.html'])
<!-- App.vue:检测到新版本时提示刷新,避免用户停在旧 index -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const hasUpdate = ref(false)
const localVersion = __APP_VERSION__ // vite define 注入
onMounted(async () => {
const res = await fetch(`/version.json?t=${Date.now()}`, { cache: 'no-store' })
const { version } = await res.json()
hasUpdate.value = version !== localVersion
})
</script>
| 手段 | 作用范围 | 注意 |
|---|---|---|
| 刷新 URL | 精确,粒度最小 | 有速率限制 |
| 刷新目录前缀 | 一次一批 | 部分 CDN 限制前缀数量 |
| 文件名哈希 | 天然免疫缓存 | 需要构建产物带 [hash] |
| 灰度分流 | 按 Cookie/IP/比例 | 需在边缘层做,源站改不了已缓存内容 |
灰度与缓存天然冲突:缓存对象不含"用户属于哪一组"这个维度。要灰度就得在边缘按 key 分流(不同版本用不同 URL),别指望源站动态返回不同内容——Vary 只能按请求头分,用它做灰度会把缓存切碎,命中率崩掉。
7. 排错:六个现象
| 现象 | 原因 | 处理 |
|---|---|---|
| 发版后一片白屏,控制台 404 | 旧 index.html 被缓存,引用了已删除的旧哈希 JS |
HTML 设 no-cache;保留最近若干个版本的 assets/ |
?v=2 更新无效 |
CDN 缓存键忽略查询串 | 改文件名哈希 |
| 偶发 JS 乱码/解析报错 | 缺 Vary: Accept-Encoding |
补上并刷新 |
| CDN 上出现大量 404 缓存 | 负缓存 TTL 未限制 | 明确不缓存 404,或设极短 TTL |
| 刷新后仍然旧页面 | 只刷了 HTML,未刷入口 JS;或 immutable 设错层级 |
按 manifest 刷,检查 immutable 是否给了无哈希资源 |
| 同机房用户看到不同版本 | 多源站内容不一致 | 上传完成后再刷新,或先切版本再回源 |
"白屏 + 404"的最佳缓解是保留旧资源:assets/ 只提交不删除,定期清理到只留最近 5~10 个版本。这样即使 HTML 被缓存一周,它引用的 JS 仍然在。
8. 边界:这些场景本就不该指望 CDN
| 场景 | 更合适 | 理由 |
|---|---|---|
| HTML 每次都需最新 | SSR / 边缘渲染 + no-cache |
纯静态 HTML 的缓存粒度太粗 |
| 强一致数据 | 直接回源,no-store |
缓存只会制造不一致 |
| 首屏体积问题 | 代码分割、依赖裁剪 | CDN 不改变字节数 |
| 内网小规模应用 | 直接 nginx | 就近接入没有收益 |
| 需要按用户定制内容 | 边缘计算 / 分流 URL | 共享缓存装不下用户维度 |
一句话:CDN 是把静态字节搬到离用户更近的地方,优化传输,不优化渲染、体积与一致性。
9. 自测
- 上了 CDN 但 LCP 没改善,最可能的原因是什么?
no-cache与no-store的差别是什么,index.html该用哪个?- 为什么用
?v=2破缓存在某些 CDN 上会失效? ETag与Last-Modified同时存在时,服务器按哪个判断 304?- 源站开了预压缩、CDN 又开了自动压缩,需要先确认哪两件事?
- "旧 index 引用已删 JS 导致白屏"最直接的缓解手段是什么?