"发版后用户还在用旧页面,让他刷新一下就好了"——这句话混了三个对象:产物(构建出的文件)、缓存(谁在替源站回答)、版本(用户手上那套 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. 自测
- 入口 HTML 为什么该用
no-cache而不是max-age=0? immutable成立需要哪四条?哪条最容易被 CDN 配置破坏?- 为什么带哈希的资源发版后不需要刷 CDN?
- 按流量做前端灰度,最容易出现哪类版本错配?
- "旧 HTML + 新资源"在什么情况下白屏?保留窗口解决的是哪一段?
- 回滚失败更常见的原因是产物不在,还是边缘缓存没清?