2950 字
约 9 分钟
17
S14-A08 · 前端产物与部署:缓存策略、灰度与回滚

"发版后用户还在用旧页面,让他刷新一下就好了"——这句话混了三个对象:产物(构建出的文件)、缓存(谁在替源站回答)、版本(用户手上那套 HTML 与资源是否同一代)。混着讨论,结论必然是"清缓存试试"。

难点不在发版,而在发版之后两代产物同时在世界上活着。下面给判据,不写操作步骤。


1. 三类产物,三套缓存口径

分类依据不是文件类型,而是文件名是否随内容变化。

产物 含内容指纹 谁在缓存 口径 失效代价
入口 HTML 否,URL 固定 浏览器、CDN、SW no-cache + ETag 高:拿不到新入口
带哈希资源 是 浏览器、CDN max-age=31536000, immutable 极低:新内容即新 URL
API 响应 否 浏览器、CDN、应用层 no-store 或短 max-age + private 中:数据陈旧,不绑代码版本

常见现象由此解释:资源秒生效、页面纹丝不动——资源换了名字必然重下,入口 HTML 被钉住,旧入口引用的仍是旧资源名。

location = /index.html {
    add_header Cache-Control "no-cache, must-revalidate" always;
    etag on;
}
location ~* \.(js|css|woff2|png|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

判据:内容变而 URL 不变 → 必须校验;URL 随内容变 → 可永久缓存。

2. index.html 为什么只能是 no-cache

no-cache 不是"不缓存",而是可以存,每次使用前必须验证。命中回 304,流量成本近零,"哪一代"始终以源站为准。

写法 拿到新版的时间 代价
no-store 立即 每次全量下载,无法 304,白扔带宽
no-cache + ETag 下一次导航 一次轻量校验,通常 304
max-age=60 最多 60 秒后 窗口内新版不可见,窗口不可控
max-age=31536000 过期或强刷 发版等于没发

前提:HTML 的校验必须在 CDN 视角也是回源。给 *.html 配了非零边缘 TTL,源站头再对也没用;核对 CF-Cache-Status。

3. 哈希文件名是内容寻址,不是版本号

app.v1.2.3.js 与 app.9f2c1a7b.js 只差命名吗?工程性质完全不同。

维度 版本号命名 内容哈希命名
内容变而名字不变 会,同版本可多次构建 不会
长期缓存是否安全 不安全,靠刷新兜 安全,新内容必是新 URL
回滚是否可靠 取决于刷新是否彻底 天然可靠,旧文件名还在
多版本共存 需人为分区 天然并存,无冲突

内容寻址即 URL 由内容决定:名字相同则内容相同。

// vite.config.js:哈希来自内容,不是构建批次
output: {
  entryFileNames: 'assets/[name].[hash:8].js',
  chunkFileNames: 'assets/[name].[hash:8].js',
  assetFileNames: 'assets/[name].[hash:8][extname]'
}

别把构建批次混进名字(buildId、时间戳)。混入批次即退化成版本号:每次构建全是新文件名,命中率归零。

3.1 immutable 的成立条件

它不加强 max-age,只关掉"主动刷新时仍发校验请求"这一步。成立需同时满足:

条件 不满足时
文件名与内容一一对应 内容变了 URL 不变,用户永远旧版
部署不原地覆盖同名文件 immutable 让浏览器拒绝取新
不需要"紧急替换某文件" 只能改文件名或全量刷 CDN
无与之冲突的 no-cache 规则 以更严格者为准,immutable 失效

第四条最常见:一条全站 no-cache 覆盖源站头,长期缓存根本没发生。

4. Service Worker:多出来的一层不可控

SW 的 CacheStorage 是第二套、由前端代码管理、离线仍生效的缓存。危险不在复杂度,在于发布节奏与清理逻辑被搬进了客户端。

SW 行为 后果 判据
预缓存 index.html 源站 no-cache 被绕过 不预缓存 HTML,或改 network-first
skipWaiting 静默接管 接管时页面已加载旧资源 用"提示刷新"
缓存键只增不删 磁盘堆几代产物 activate 必须清理
回滚忘改版本常量 对已装 SW 的用户无效 回滚清单含 SW 常量

结论:SW 给回滚多加了一个清不空的副本;没有离线需求时,它是把发布问题变成一致性问题。

5. CDN 刷新的四种口径

口径 触发 生效(典型量级) 成本 适用
按 URL --url 秒级到一分钟 按条计费,量大昂贵 单个入口、紧急修正
按目录/前缀 --prefix 一分钟量级 中等 整代产物替换
按标签 Cache-Tag 头 一分钟量级 低,一次清一代 精确清某次发布
全站 控制台 分钟级到十几分钟 高,有速率限制 配置错后的兜底

要点:带哈希的资源不需要刷,URL 变了边缘自然回源;要刷的只有入口 HTML 与固定 URL 文件。

6. 灰度的三种维度在前端意味着什么

后端按流量切很自然,前端不同:用户拿到的是一整代产物,切换点是导航而不是请求。

维度 实现 前端特有差异 主要风险
按流量 边缘按比例分流 同一用户多次导航落到不同代,"刷新就变样" HTML 与资源版本错配
按用户 Cookie / ID 哈希固定分组 对单用户稳定、可解释 分组判定本身要能回滚
按版本共存 两套产物同时在线 天然隔离,回滚只改指向 保留窗口与存储成本
split_clients "${cookie_ab}" $web_root {
    10%  /srv/www/releases/20261109;
    *    /srv/www/releases/20261026;
}

关键是分组键稳定:随机数与会话 ID 会让用户在代间横跳,稳定性来自同一用户同一键值。

7. 共存窗口:白屏是怎么来的

发布不是原子操作,几十毫秒内也存在"旧 HTML 引用已删旧资源"的窗口。

组合 结果 原因
旧 HTML + 旧资源 正常 一致
新 HTML + 新资源 正常 一致
旧 HTML + 新资源(旧文件仍在) 正常 内容寻址天然兼容
旧 HTML + 新资源(旧文件已删) 白屏 / 404 引用了不存在的 URL

所以"覆盖部署 + 立即清理旧产物"最危险:把窗口变成必然 404。两条路必须选一条:保留窗口(覆盖一个发布周期)或加载兜底。

// 动态 import 失败时重载入口,而不是白屏
window.addEventListener('unhandledrejection', (e) => {
  if (/Loading chunk|dynamically imported module/.test(String(e.reason))) {
    const k = 'chunk-reload-at';
    if (Date.now() - Number(sessionStorage.getItem(k) || 0) > 30_000) {
      sessionStorage.setItem(k, String(Date.now()));
      location.reload();
    }
  }
});

兜底必须限频,否则资源真缺时会无限刷新。

8. 回滚:切回产物,还是重建

方式 可行性 耗时(典型量级) 前提 风险
切回上一代产物 高 秒级到一分钟 旧产物完整保留 库表或接口已变则不兼容
旧 commit 重新构建 中 分钟级(含装依赖) 构建可复现、依赖未覆盖 依赖漂移,重建的不是当初那版
靠删缓存"回滚" 低 分钟级且不确定 从未覆盖过产物 无旧文件,刷了也是 404

结论:回滚的对象是产物,不是代码。产线要保留可寻址的历史产物(按 commit 命名的目录),而不是"构建机上有个 dist"。

ln -sfn /srv/www/releases/20261026 /srv/www/current
nginx -t && nginx -s reload

# 上线前核对:入口引用的资源必须都存在
grep -o 'assets/[a-z0-9.]*\.js' /srv/www/current/index.html | head -5 | while read -r f; do
  test -f "/srv/www/current/$f" || echo "MISSING: $f"
done

第二段值得固化进流水线,提前发现"HTML 与资源不同代"。

9. 部署形态差异与监控口径

维度 静态站 SSR
回滚粒度 整个产物目录 代码版本 + 重启
回滚速度 秒级 分钟级(排空、重启)
首屏预热 无需,靠边缘缓存 需要(连接池、编译、JIT)
灰度最小单位 一套产物 一个实例
爆炸半径 全量用户同时变 旧会话仍走旧实例

SSR 回滚不是"切软链":重启排空连接、冷启动更慢。静态站的优势不在快,而在回滚是纯文件系统操作。

sum by (version) (rate(web_requests_total[5m]))                  # 版本分布
sum by (version) (rate(web_errors_total[5m])) / sum by (version) (rate(web_requests_total[5m]))  # 错误率按版本
指标 回答什么 用途
版本分布 旧版还剩多少流量 灰度是否真的生效
错误率(按版本) 缺陷属于哪一代 停灰度还是继续放量
资源 404 率 共存窗口是否被破坏 保留窗口是否够长

产物体积一句带过:首屏可交互前需下载并执行的 JS 有上限,之后的拆包、延迟、预取只是把成本挪走;缓存管第二次访问,第一印象由首包定。

10. 排错

现象 最可能原因 核对方式
老用户仍是旧页面 入口 HTML 被 max-age 缓存 看响应头,确认没被 CDN 覆盖
个别用户白屏、chunk 404 旧产物已被删除 保留窗口是否覆盖一个周期
刷新就好、不刷就坏 SW 预缓存了入口 HTML Application → Cache Storage
刷了 CDN 仍 HIT 只清了 URL,边缘按标签另存副本 查 X-Cache
回滚后仍是新版 改了目录但边缘持旧副本 回滚是否含入口 HTML 刷新

11. 边界

场景 结论 理由
内部系统、客户端统一 不必灰度 灰度成本高于故障成本
用户有强刷新习惯 策略可简化 入口仍要 no-cache
发布极低频(季度级) 不必灰度,保留窗口仍要做 低频下"留上一代"几乎零成本
SPA 无 SW、资源全哈希 immutable + 保留窗口 复杂灰度是过度工程
首屏极敏感、体积大 灰度必须按版本共存 按流量切会版本错配

12. 自测

  1. 入口 HTML 为什么该用 no-cache 而不是 max-age=0?
  2. immutable 成立需要哪四条?哪条最容易被 CDN 配置破坏?
  3. 为什么带哈希的资源发版后不需要刷 CDN?
  4. 按流量做前端灰度,最容易出现哪类版本错配?
  5. "旧 HTML + 新资源"在什么情况下白屏?保留窗口解决的是哪一段?
  6. 回滚失败更常见的原因是产物不在,还是边缘缓存没清?
S14-A08 · 前端产物与部署:缓存策略、灰度与回滚
https://www.clxhxhhr.top/posts/6192/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。