发版之后老用户白屏:旧资源 404、缓存与版本号的排查顺序

2026-07-29

数据截至 2026-07。文中命令默认 Linux/macOS 环境;各构建工具的产物命名规则、CDN 厂商的缓存配置项与刷新接口、浏览器对模块加载失败的报错措辞都随版本变化,以你所用版本与厂商的官方最新说明为准。

发版后老用户白屏,绝大多数时候不是浏览器缓存没清干净,而是你把上一版的静态产物从服务器上删了。 用户那个标签页在你发布之前就打开了,里面那份 HTML 是旧的,它引用的脚本文件名上带着旧的内容哈希。他点一下路由跳转,浏览器去拉那个按需加载的分包,服务器上已经没有这个文件,于是返回 404,或者被前端路由的兜底规则改写成一份 index.html 返回给你。浏览器拿到一段 HTML 却要按脚本模块去解析,直接失败,框架的渲染流程停在半路,页面一片空白。让用户清缓存确实”能好”,因为清完他重新拉了新 HTML——但这只是把成因盖住了,下次发版照样复现。

本篇只管一件事:发布这个动作本身对”此刻正在页面上的人”造成的破坏,以及怎么按顺序定位。站内另外两篇分工不同——构建缓存不生效 讲的是构建阶段产物没更新、你在本地打包时就已经带着旧代码上路;生产事故里 AI 的边界 讲的是线上出事的时候该不该让 AI 直接上手改、权限边界划在哪。这篇夹在中间:产物是对的,构建是对的,但发布方式让老会话踩了空。

一、先把三种白屏拆开

白屏这个词太糊,三种完全不同的故障长得一模一样。开工前先花两分钟做区分,能省掉后面一小时的乱撞。

第一种:所有人都白,包括你现在新开的无痕窗口。 这说明新版本本身有问题,或者部署根本没完整落地。这不在本篇范围内,你该去看新版本的运行时报错和部署产物完整性。

第二种:只有发布前就打开了页面的人白,新开窗口一切正常。 这是本篇的主场。特征非常明确:白屏往往不是发布瞬间发生的,而是用户在页面上做了某个操作——切路由、点开弹窗、进一个懒加载的子模块——之后才发生。因为按需分包是那一刻才去请求的。

第三种:刷新一次就好了,但过一会儿又白。 这通常是多实例混部或者灰度期间的资源不一致,用户在新旧两台机器之间被负载均衡来回甩。和第二种成因不同但表现相似,处置方式也不同。

区分手段就三样:无痕窗口开一次、在已经打开很久的标签页上操作一次、换一条网络(比如手机热点)再试一次。三个动作走完,你基本知道自己在哪一格。另外注意区分同一份代码在不同宿主环境下的行为差异,那属于运行时版本冲突的范畴,不要和发布时序问题混着查。

二、判别表:现象对成因

下面这张表按”从控制台和 Network 面板能直接看到的东西”来分因,照着走就行。

现象大概率成因怎么验证处置动作
请求某个带哈希的脚本返回 404,且该文件名在当前产物里搜不到旧版本产物被删除,老 HTML 引用的分包已不存在拿到 404 的文件名,在新构建产物目录里全局搜;再去对象存储或 CDN 目录里搜把旧产物放回去;发布策略改成保留最近若干版本
请求脚本返回 200,但响应体是一段 HTML服务端有”找不到就返回 index.html”的兜底规则,把缺失的静态资源也兜住了用 curl 直接请求该 URL 看 Content-Type,是 text/html 就实锤(HEAD 和 GET 的兜底行为可能不同,以 GET 为准)静态资源目录排除出兜底规则;缺失就老实返回 404
新开无痕窗口也白,且各地用户都白新版本自身崩溃或产物上传不完整无痕窗口复现 + 检查产物文件数量与体积走回滚,不要在这条线上继续查缓存
同一 URL 反复请求,ETag 或响应内容在两个值之间跳多实例新旧混部,静态资源从应用实例直出循环请求同一 URL 观察 ETag 是否抖动静态资源统一走 CDN 或对象存储,别从实例出
HTML 内容是旧的,强刷才更新入口 HTML 被 CDN 或浏览器长缓存看该 URL 响应头的 Cache-Control 与 AgeHTML 设为每次回源校验,发布后刷新该 URL
清浏览器缓存也没用,注销掉 Service Worker 才恢复站点注册过 Service Worker,旧 HTML 或旧资源被 SW 的缓存接管开发者工具的 Application 面板里看是否有已激活的 SW,以及它的缓存条目更新 SW 版本并在 activate 阶段清掉旧缓存,必要时提供注销入口

这张表的顺序是有讲究的:从”文件在不在”开始,再到”响应对不对”,最后才到”缓存层”。反过来查的人最容易一头扎进缓存配置里出不来。

三、按顺序验证:三处比对

定位这类问题的核心动作,是把同一个文件名放到三个地方去比对。

第一步,从用户侧拿到确切的文件名。让他打开开发者工具的 Network 面板,或者你自己复现一次,找到那条状态码异常的请求,把完整 URL 抄下来。没有这个文件名,后面全是猜。

第二步,直接打头看响应头,不要在浏览器里看,浏览器有自己的缓存层会骗你:

curl -sI https://example.com/assets/app-a1b2c3d4.js

重点看三行:状态码、Content-TypeCache-Control。状态码 404 就是文件真没了;状态码 200 但 Content-Type 是 text/html,就是被兜底规则改写了。

这里有个容易翻车的细节:-I 发的是 HEAD 请求,而不少框架的”找不到就返回 index.html”中间件只挂在 GET 上,HEAD 走的是另一条分支,结论可能和浏览器实际拿到的不一致。所以只要 HEAD 的结果和用户描述对不上,就改用 GET 再确认一次,只取状态码和类型、把响应体丢掉:

curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://example.com/assets/app-a1b2c3d4.js

浏览器实际发的就是 GET,以这一条的结果为准。顺手也看一眼入口 HTML 的缓存策略:

curl -sI https://example.com/ | tr -d '\r'

第三步,去产物里搜。在本次构建输出目录里找这个文件名,再去上一版的产物包里找。两个结果组合起来就能定性:新版没有、旧版有——说明是发布时删了旧文件;两版都没有——说明用户拿到的 HTML 来自更早的某一版,或者 CDN 回源指到了别的地方。

如果怀疑是多实例不一致,加一个循环观察:

for i in $(seq 1 10); do curl -sI https://example.com/ | grep -i '^etag'; done

十次里出现两种 ETag,就说明后端至少有两台机器在提供不同版本的 HTML,这时候你要处理的是发布编排,不是缓存。

四、三层处置:止血、本次发布、长期机制

当场止血。 最快的动作是把旧版本的静态产物恢复回去。这里的”恢复”指的是把旧文件重新放到原路径上,不是把代码 revert 再重新构建一遍——重新构建会生成新的内容哈希,文件名对不上,老 HTML 该 404 还是 404。这一点是很多团队在慌乱中踩空的地方。如果你手上没有可直接回切的产物包,那就只能靠前端兜底:推一个补丁,让脚本加载失败时自动刷新一次。

const FLAG = '__reload_once__';

function reloadOnce() {
  // 已经因为同类失败刷过一次,就不再刷,避免服务端永久缺文件时陷入死循环
  if (sessionStorage.getItem(FLAG)) return;
  sessionStorage.setItem(FLAG, '1');
  location.reload();
}

// 资源加载失败不冒泡,只能在 capture 阶段收
window.addEventListener(
  'error',
  (e) => {
    const el = e.target;
    if (!el || el === window) return;                 // 这是普通 JS 运行时错误,不是资源加载失败
    const tag = el.tagName;
    if (tag !== 'SCRIPT' && tag !== 'LINK') return;   // 样式表拿不到同样会让页面不可用
    reloadOnce();
  },
  true
);

// 动态导入失败不触发上面的 error,它走 Promise 拒绝,要单独接一次
window.addEventListener('unhandledrejection', (e) => {
  const r = e.reason;
  const msg = String((r && r.message) || r || '');    // reason 可能是 Error,也可能直接是字符串
  if (!/import|module|chunk|fetch/i.test(msg)) return;
  reloadOnce();
});

// 页面这次跑起来了,把标记清掉,让下一次发布仍然能自愈一次
window.addEventListener('load', () => sessionStorage.removeItem(FLAG));

一次性标记不能省。服务端如果真的永久缺文件,没有标记的兜底会把用户扔进无限刷新,比白屏还糟。判断动态导入失败只能靠关键词宽松匹配:各家浏览器对”模块脚本加载失败”的措辞不一样,别把某一款浏览器的报错原文硬编码进条件里,换个浏览器就失效了。上面的 load 清标记要放在最后:只有页面真的完整跑起来才清,否则等于没有标记。

本次发布层面。 缓存头分两类设:入口 HTML 每次都回源校验,带内容哈希的静态资源可以设很长的强缓存并标记为不可变。哈希本身就是版本号,文件内容变了文件名一定变,长缓存是安全的;HTML 文件名不变,就绝不能长缓存。这两条搞反,后面所有努力都白费。发布完成后,记得单独刷新入口 HTML 在 CDN 上的缓存,只刷这一个 URL,不用全站刷。

长期机制。 三件事值得固化下来。

一是产物版本化。发布时把新产物放进带版本号的新目录,用软链或路由配置切换指向,旧目录保留若干次发布的量,靠定时任务延后清理。对象存储同理,新文件只增不删,删除交给生命周期规则。这样老 HTML 引用的文件在一段时间内始终可达,用户不刷新也不会白。

二是版本探针。前端起一个低频轮询,拉一个不缓存的小文件比对构建标识,发现线上已经换版本了,就在界面上给一条轻量提示,让用户自己选时机刷新。

这里要和上面的兜底刷新划清界限,两者的触发条件不同,处置也就相反。资源已经加载失败、页面事实上已经废掉了,自动刷新一次是净收益,反正当前状态已经没法用,刷了还有一半概率救回来。探针只是”发现线上换了版本”,页面这一刻还完全能用,就绝不能替用户 reload——他可能正在填一个长表单,你一刷新输入全没了,这个体验损失比白屏还难解释。一句话概括判据:页面已经不可用就自动刷(且只刷一次),页面还可用就只提示不动手。

三是把”老会话”纳入验收。发布前留一个已经打开很久的标签页,发布后直接在那个页面上点几下关键路径。这一步和自动化测试是互补的,测试跑的是干净环境,天然测不出这类时序问题,属于典型的测试全绿但线上报错场景。

五、什么情况下别再折腾

排查这类问题最大的成本不是技术难度,是时间窗口——你每多查十分钟,就多一批用户撞上白屏。所以止损点要提前定死。

定时器:从发现到定位,给自己一刻钟左右的硬上限,到点没有明确结论就直接回滚。 这个时长按你们的流量规模和用户容忍度自己定,重点是发布前就写死在流程里,别到了现场临时讨价还价。回滚的判据也不是”我快查出来了”,而是”我现在能不能说清 404 的那个文件当前在哪”。说不清就回滚,回滚完再慢慢查,成本低得多。

回滚的正确形态是把旧产物放回原路径,而不是 revert 代码重新发布。 前面说过原因:重新构建的哈希对不上老 HTML。如果你的发布流水线只支持”重新构建并发布”,那它其实没有真正的回滚能力,这件事本身就该记成一条待改进项。顺带提醒,回滚过程中如果要靠 AI 快速改一版补丁代码,务必控制改动范围并全程盯着 diff,AI 改坏代码后怎么回滚那篇讲的原则在生产事故现场同样适用,甚至更严格。

换条路的信号有两个。 其一,404 的那个文件名在你所有历史版本的产物里都搜不到——那就不是发布删文件的问题,八成是域名指错、CDN 回源配置改动,或者请求根本没到你以为的那台服务器上,继续按缓存思路查是浪费时间。其二,白屏反馈量在你什么都没做的情况下自然衰减——说明老会话正在陆续自然关闭,问题在自愈,这时候做高风险的紧急变更,制造新故障的概率比解决问题的概率还高,稳住即可。

还有一种情况该主动停手:你已经确认成因,但修复动作需要改发布流水线、改 CDN 规则这类基础设施。这些改动不该在故障现场做。先用最小动作止血(恢复旧产物、上兜底刷新),把基础设施改造排进正常迭代,别在深夜带着肾上腺素去动全站缓存配置。

六、避坑清单

发布脚本先清空目录再解压。 为什么会踩:这么写最省事,目录看着也干净,本地测试完全正常,因为本地没有”发布前就打开的页面”。怎么避:改成目录版本化加软链切换,保留最近几版;如果暂时改不动脚本,至少把清理动作延后到下次发布时再删上上版。

入口 HTML 被 CDN 按后缀统一长缓存。 为什么会踩:CDN 的规则往往按文件类型批量配置,.html 被和其他静态资源一起设了长缓存时间,发布完了用户拿到的还是旧 HTML,你在服务器上怎么看都是新的。怎么避:单独给 HTML 设置每次回源校验,并把”发布后刷新入口 URL”写进流水线,不靠人记。

把 revert 重新构建当成回滚。 为什么会踩:Git 层面的 revert 感觉上就是回到过去,符合直觉。怎么避:记住内容哈希是随构建产生的,代码回到旧状态不代表文件名回到旧状态;真正的回滚要有可直接回切的产物制品。

兜底刷新没有一次性标记。 为什么会踩:加兜底的人当时只想着”刷一下就好了”,没考虑服务端永久缺文件的情况。怎么避:用会话级存储打标,二次失败时不再刷新,改为渲染一个可读的错误页并附上手动刷新按钮。

只在无痕窗口里验收发布。 为什么会踩:无痕窗口干净、可复现,是大家验收的习惯动作,但它每次都拿最新 HTML,天生测不出这个问题。怎么避:验收清单里强制加一条”在发布前已打开的标签页上操作”。

忘了 Service Worker 这一层。 为什么会踩:SW 是很久以前为了离线能力接的,接完就没人管了,它可能把旧 HTML 一并缓存住,用户清浏览器缓存都不一定管用。怎么避:发布流程里把 SW 版本和构建标识绑定,明确旧缓存的清理时机;如果站点已经不需要离线能力,评估直接下线并保留注销逻辑一段时间。

静态资源从应用实例直接提供。 为什么会踩:单机时代这么做最简单,扩容成多实例后没人回头改。怎么避:静态资源统一走 CDN 或对象存储,让所有实例指向同一份产物,灰度期间也就不会出现 HTML 来自新机器、脚本请求打到旧机器的错配。

收尾:一份自检清单

这类故障的本质是时间差——你的服务器状态变了,但用户浏览器里的那份 HTML 还停在过去。理解这一点,后面的所有措施都是在给这个时间差留缓冲。

下次发布前,对着过一遍:

  1. 入口 HTML 是每次回源校验,带哈希的资源是长缓存,两者没有配反。
  2. 旧版本产物在发布后仍然可访问,至少保留到下一次发布之后。
  3. 服务端的兜底规则不覆盖静态资源目录,缺失的资源老实返回 404。
  4. 前端有脚本加载失败的兜底逻辑,并且带一次性标记。
  5. 有版本探针,提示用户刷新而不是替他刷新。
  6. 验收环节包含”发布前就打开的标签页”这一步。
  7. 手里有可直接回切的产物包,回滚不依赖重新构建。

七条里前三条属于基础设施,做一次管很久;后四条属于工程习惯,需要写进流程才不会忘。真正让人半夜爬起来的,从来不是最难的那一条,而是没人认领的那一条。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。