发版后 30% 用户白屏:Cache-Control 与资源指纹的配合
白屏报 ChunkLoadError,强刷一下就好,因为 index.html 被缓存了七天而旧 chunk 已被删除。入口用 no-cache、带哈希的资源用 immutable、发版保留旧版本目录,再加上一层路由层的兜底捕获。
发版十分钟后,客服群开始报「白屏」。奇怪的是不是所有人都白屏——有人刷新一下就好了,有人反复刷新还是白屏,还有一部分人完全正常。
控制台里的报错很一致:
Failed to fetch dynamically imported module: https://ispt.top/assets/OrderList-a3f9c1.js
ChunkLoadError。这个文件名带哈希,说明它属于旧版本。新版本发布后,旧哈希的 chunk 被删掉了,而浏览器还在按旧的 index.html 去找它。
问题是:为什么浏览器还在用旧的 index.html?
一、现象:一部分人拿着旧入口
我先把几个关键事实列出来:
| 事实 | 值 | 含义 |
|---|---|---|
| 白屏用户占比 | 约 30% | 不是全量,说明有缓存分层 |
index.html 响应头 |
Cache-Control: max-age=604800 |
被缓存 7 天 |
| 带哈希的 JS | 同一条 Cache-Control |
也被缓存 7 天 |
| 发版方式 | 直接覆盖 dist 目录 |
旧文件被物理删除 |
| 强制刷新后 | 恢复正常 | 回到最新 index.html |
第一行和第四行加起来就是完整的因果:index.html 被缓存 7 天,发版把旧 chunk 删了,还没过期的那部分用户就卡在一个「入口是旧的、资源已经不存在」的状态里。
这不是 CDN 的锅,也不是构建工具的锅,是缓存策略配错了:给不该长期缓存的入口文件,配了和静态资源一样的强缓存。
二、强缓存和协商缓存,差的是一次请求
浏览器缓存只有两条路:
- 强缓存:
Cache-Control: max-age=N或Expires。在 N 秒内,浏览器完全不发请求,直接用本地副本。服务端连「有人来拿过」都不知道。 - 协商缓存:
Cache-Control: no-cache配合ETag/Last-Modified。浏览器每次都会问服务端,服务端比对后返回304 Not Modified(无 body)或200(新内容)。
两者的差别不在「快不快」,而在「能不能被更新」。max-age=604800 意味着七天内你没有任何手段让用户拿到新文件——换 CDN、重新部署、改文件名都没用,因为请求根本没发出来。
一个健康的组合是这样的:
| 资源类型 | 文件名特征 | 推荐头 | 理由 |
|---|---|---|---|
| HTML 入口 | index.html |
no-cache(配 ETag) |
必须每次校验,它是「版本指针」 |
| 带哈希的 JS/CSS | app-a3f9c1.js |
max-age=31536000, immutable |
内容变则文件名变,可永久缓存 |
| 图片/字体 | logo-v2.png |
max-age=2592000 |
变更不频繁,可接受一个月 |
| 接口响应 | /api/* |
no-store |
不能留任何副本 |
关键在第二行:immutable 让浏览器在用户按刷新时也不去校验这个文件。因为文件名里带内容哈希,同一个 URL 的内容永远不变,校验纯属浪费。
三、改法:把入口和资源分开
我原来只有一段笼统的配置:
# 错误:一刀切,入口和资源用同一套策略
location / {
root /var/www/ispt;
try_files $uri $uri/ /index.html;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
改成按路径分流。先给带哈希的资源单独一条,再让 HTML 走协商缓存:
server {
listen 443 ssl http2;
server_name ispt.top;
root /var/www/ispt;
# 1. 带内容哈希的静态资源:一年 + immutable
location ~* \.(?:js|css|woff2?|ttf|eot|svg|png|jpe?g|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404; # 资源缺失就该 404,不要回落 HTML
}
# 2. HTML 入口:每次都校验
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate";
add_header ETag ""; # 交给 nginx 自行生成
try_files $uri =404;
}
# 3. SPA 路由回落
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
三个地方值得单独说。
try_files $uri =404 而不是回落 index.html。 如果某个 JS 已经不存在,返回 HTML 会让浏览器拿到 200 和一段 HTML,然后在解析时报 Unexpected token '<',报错信息完全指不到「文件没了」这个事实。直接 404 才能一眼看出是版本错配。
location = /index.html 用精确匹配。 SPA 回落最终内部跳转到 /index.html,命中这条精确规则,才拿得到 no-cache;如果只写 location /,回落后拿到的还是笼统那条的头。
no-cache 不是「不缓存」。 这是最常见的误解。no-cache 允许缓存,但每次使用前必须向服务端校验;真正「不许存」的是 no-store。入口文件用 no-cache + ETag,未变更时返回 304,带宽成本几乎为零,这是最优解。
四、验证:别只看配置文件,要看响应头
改完 nginx -t && systemctl reload nginx,然后用 curl 直接看头,别靠浏览器缓存后的现象猜:
# HTML 入口
curl -sI https://ispt.top/ | grep -iE 'cache-control|etag|last-modified'
# 期望:cache-control: no-cache, must-revalidate
# etag: "66f3a1c2-5b8"
# 带哈希的 JS
curl -sI https://ispt.top/assets/app-a3f9c1.js | grep -iE 'cache-control|expires'
# 期望:cache-control: public, max-age=31536000, immutable
# 验证协商缓存真的生效:带 If-None-Match 请求,应返回 304
ETAG=$(curl -sI https://ispt.top/ | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -sI -H "If-None-Match: $ETAG" https://ispt.top/ | head -1
最后一条返回 HTTP/2 304 才算真的通了。如果返回 200,说明 ETag 没生成(某些编译参数下 etag off 会关掉它),协商缓存就退化成了每次都全量下载。
还有个容易漏的地方:CDN 会覆盖源站的头。如果前面有 CDN,源站改对了但 CDN 侧的缓存规则还是「HTML 缓存 7 天」,用户拿到的依然是旧头。这类问题我用 curl -I 分别打源站 IP 和域名来对比:
# 打源站(绕过 CDN)
curl -sI --resolve ispt.top:443:10.0.3.21 https://ispt.top/ | grep -i cache-control
# 打 CDN
curl -sI https://ispt.top/ | grep -i cache-control
两条输出不一致,就说明 CDN 侧要单独配置。
五、发版流程也得配合缓存策略
缓存头改对之后,还剩一个窗口期:用户手里那份旧 index.html 还没过期(no-cache 下窗口很短,但仍在)。如果发版时直接把旧的 assets 目录删掉,这个窗口里的用户还是会 404。
所以发版改成「先加后删」:
#!/bin/bash
set -euo pipefail
REL="/var/www/ispt/releases/$(date +%Y%m%d%H%M%S)"
KEEP=3
# 1. 新版本放到独立目录,不动线上
rsync -a --delete dist/ "$REL/"
# 2. 原子切换软链
ln -sfn "$REL" /var/www/ispt/current.new
mv -Tf /var/www/ispt/current.new /var/www/ispt/current
# 3. 只保留最近 3 个版本,旧 chunk 因此还能被访问到
ls -1dt /var/www/ispt/releases/*/ | tail -n +$((KEEP+1)) | xargs -r rm -rf
保留 3 个版本,等于给「旧入口 + 旧资源」留了一个安全窗口。真正要彻底解决,还得配合前端:路由懒加载失败时捕获 ChunkLoadError,提示用户刷新一次。
router.onError((error, to) => {
const isChunkError = /Loading chunk [\w-]+ failed|dynamically imported module/.test(error.message)
if (!isChunkError) return
// 用 sessionStorage 防止刷新死循环
const key = 'chunk-reload:' + to.fullPath
if (sessionStorage.getItem(key)) return
sessionStorage.setItem(key, '1')
window.location.reload()
})
这段逻辑不是「兜底缓存策略」,而是「兜住已经发生的错配」。加 sessionStorage 标记是必须的:如果新版本本身有问题,没有这个标记会变成刷新死循环。
六、几个反复出现的误判
| 现象 | 容易误判为 | 实际原因 |
|---|---|---|
| 只有部分用户白屏 | 前端代码 bug | 缓存分层,老用户手里是旧 HTML |
| 强刷就好 | CDN 抽风 | 强刷绕过了强缓存 |
| 图片更新了但页面没变 | 部署失败 | 图片同名同 URL,被 max-age 挡住 |
| 304 越来越多 | 缓存失效 | 正常现象,说明协商缓存生效 |
| CDN 命中率低 | 配置错 | no-cache 的 HTML 本来就不该高命中 |
第三条我踩过:换了一张 banner.png,同名覆盖上传,结果一半用户还在看旧图,因为那张图的 URL 没变。后来所有静态资源都走构建工具的内容哈希,人工替换的运营图则加版本号 banner-20261107.png,不再同名覆盖。
小结
- 强缓存期间浏览器一个请求都不发,服务端没有任何补救手段;入口 HTML 绝不能配
max-age。 no-cache是「每次校验」,no-store才是「不存」,入口文件要的是前者。- 带内容哈希的资源用
max-age=31536000, immutable,HTML 用no-cache+ETag,这是固定搭配。 - 资源 404 要真返回 404,回落
index.html会制造Unexpected token '<'这种误导性报错。 - 验证以
curl -I的响应头为准,并分别打源站和 CDN,两边头不一致就是 CDN 覆盖了。 - 发版保留最近几个版本目录,再加一层
ChunkLoadError捕获,才能兜住窗口期的老用户。
正在加载评论…