2699 字
约 8 分钟
17
S14-X07 · CDN、缓存头与前端资源版本管理

"发版后部分用户还是旧页面,强制刷新就好了"——这类问题几乎都出在缓存链路而不是代码。浏览器、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 低,兜底

三条实践要点:

  1. 两者同时存在时,ETag 优先。服务器返回 304 需同时匹配 ETag;只有 ETag 不匹配才看时间。nginx 默认同时输出两者,客户端也按这个优先级走。
  2. Last-Modified 的时间精度是秒。同秒内多次修改无法区分,if_modified_since exact 就是为这个场景准备的(默认 exact,另有 before 可选)。
  3. 多源站时间不同步会让 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 开关

四个要点:

  1. Vary: Accept-Encoding 必须存在。没有它,CDN 可能把 gzip 变体发给不支持压缩的客户端(或反之),表现为"偶发乱码"。
  2. CDN 自动压缩与源站预压缩叠加时,先确认 CDN 是否识别 Content-Encoding。开启 CDN 压缩后,源站预压缩的收益是省回源 CPU 与带宽,不是省边缘 CPU。
  3. 预压缩文件要有对应的 .gz/.br,构建产物体积决定回源与首次命中成本。Brotli 通常比 gzip 更小,代价是构建时间。别凭经验估,用构建产物实际体积对比。
  4. 预热:发版后主动请求新 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. 自测

  1. 上了 CDN 但 LCP 没改善,最可能的原因是什么?
  2. no-cache 与 no-store 的差别是什么,index.html 该用哪个?
  3. 为什么用 ?v=2 破缓存在某些 CDN 上会失效?
  4. ETag 与 Last-Modified 同时存在时,服务器按哪个判断 304?
  5. 源站开了预压缩、CDN 又开了自动压缩,需要先确认哪两件事?
  6. "旧 index 引用已删 JS 导致白屏"最直接的缓解手段是什么?
S14-X07 · CDN、缓存头与前端资源版本管理
https://www.clxhxhhr.top/posts/5717/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。