部署成功但页面还是老内容:5 个原因 + 修复路径

Vercel / Firebase / Cloudflare 都说部署成功了,访客刷新还是昨天那版。本文给出按命中率排序的 5 类原因,以及一条从客户端到 origin 的 10 分钟诊断路径。

仪表盘绿勾打钩、commit SHA 也对得上,但刷新还是看到昨天那版。平台说的是真话——构建产物确实发上去了——但在 origin 到浏览器之间,某一层缓存把它挡住了。

最快的修复(约 70% 的情况能解决): 用无痕窗口打开页面。如果无痕里能看到新版,那就是客户端缓存(浏览器或 service worker),跟你的部署无关。回到正常窗口清掉它:DevTools → Application → Service Workers,每个注册项点 Unregister,再到 Application → Storage 点 Clear site data,然后刷新。如果无痕里是旧版,那缓存在 edge 或 origin 这一层——直接跳到 Step 3。

下面按命中率从高到低排出 5 类原因,再给一条从客户端往 origin 逐层排除的 10 分钟诊断路径。

常见原因

按命中率从高到低。

1. Service Worker 抢在网络前返回了旧 HTML

PWA 模板(Workbox / next-pwa / vite-plugin-pwa)默认会注册一个 service worker,把 HTML 和静态资源缓存进 Cache Storage。下次访问时它直接 respondWith(cache.match()),根本没有发起网络请求——于是你的部署在这个 SW 被注销前完全不起作用。这是迄今最常见的原因,因为它把 edge 和 origin 一起绕过了。

// 典型的"先缓存后网络"策略,最容易卡住
self.addEventListener("fetch", e => {
  e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));
});

如何判断:DevTools → Application → Service Workers 显示 “Activated and is running”;Network 面板里页面请求的 Size 列显示来源是 (ServiceWorker)

2. CDN edge cache 没有失效

Vercel / Cloudflare / Firebase 都会在边缘节点缓存响应。如果你只是把构建产物直接传到对象存储(S3 / GCS),或者改了 origin 但没触发 purge,edge 就会一直返回上一版,直到它自己的 TTL 过期。

如何判断curl -I https://yourdomain.com/your-page 看到 x-vercel-cache: HIT、或 cf-cache-status: HIT、或一个很大的 age:(比如 age: 3600)。

3. 浏览器把 HTML 长缓存了

如果你(或历史上某次部署)给 HTML 响应加上了 Cache-Control: public, max-age=31536000——可能来自 vercel.json 里自定义的 headers、Next.js 的 headers() 规则、firebase.json 的 header glob,或上一次部署——浏览器一整年都不会回源校验。截至 2026 年 6 月,Vercel 静态 HTML 的默认值是 Cache-Control: public, max-age=0, must-revalidate,所以这个坑只有在覆盖了默认值时才会踩到。

如何判断:DevTools → Network → 点 HTML 请求 → Response Headers,看 Cache-Control;只要非静态资源(HTML)响应上出现 max-age= 大于 0,就有嫌疑。

4. 部署成功但用的是旧 commit / 旧 build

CI 触发了部署,但 build step 复用了缓存的 dist/,或者你 push 到了错误的分支。Vercel 的 Production 默认绑定生产分支(一般是 main);push 到其他分支只会生成 Preview 部署,但仪表盘照样显示 “Ready”——很容易被误当成生产部署。

如何判断:部署详情页有一行 Source commit SHA,拿它和本地 git rev-parse HEAD 对比;不一致就说明构建用的不是最新代码(或者没有 promote 到生产)。

5. 域名指到了旧 origin

域名同时挂在两个宿主上(比如 Vercel Cloudflare Pages),或者历史上指向旧服务器(GitHub Pages / Netlify / 某台 EC2)的 A 记录从未清理。一部分用户——取决于解析到哪条记录——会落到旧 origin。

如何判断dig +short yourdomain.com 返回一个或多个 IP;到 ipinfo.io 或用 whois 反查归属,确认每个 IP 都属于当前宿主。

我属于哪一类?

症状最可能的原因去哪一步
无痕里是新版、正常窗口是旧版service worker / 浏览器缓存(1、3)Step 2
无痕里也是旧版;curl 显示 HITage 很大edge cache 没 purge(2)Step 3
curl 显示 MISS 但 HTML 还是旧的构建用了旧 commit,或 HTML 被长缓存(3、4)Step 4
有的用户看到新版、有的看到旧版域名分流 / 旧 origin(5)Step 5

最短修复路径

按”先排客户端、再排 edge、最后查 origin”的顺序走。前 2 步通常就解决了。

Step 1:用无痕窗口隔离客户端缓存

开一个无痕窗口,DevTools → Network 勾上 “Disable cache”,访问页面。无痕窗口不会运行你已安装的 service worker,也不读你正常窗口的浏览器缓存,所以它展示的就是一个全新访客看到的内容。

看到的版本结论
新版本缓存在正常窗口里(浏览器或 SW)→ 进 Step 2
还是老版本不是客户端缓存,是 edge 或 origin → 跳到 Step 3

正常窗口里硬刷一次(Cmd/Ctrl+Shift+R)可以一并验证,但要注意:硬刷并不会绕过正在运行的 service worker,只有注销它才行。

Step 2:卸载 service worker + 清站点数据

DevTools → Application → Service Workers,每个注册项点 Unregister。然后 Application → Storage 点 Clear site data,刷新页面。

如果想让老用户下次访问时自动清理,可以临时发一个”自杀 worker”:它立刻接管页面、删掉所有缓存、再注销自己。

// public/sw.js(部署一次,老用户上来命中后自动注销)
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", e => {
  e.waitUntil((async () => {
    await self.clients.claim();
    const keys = await caches.keys();
    await Promise.all(keys.map(k => caches.delete(k)));
    await self.registration.unregister();
    const clients = await self.clients.matchAll({ type: "window" });
    clients.forEach(c => c.navigate(c.url));
  })());
});

关键:/sw.js 这个文件本身要用 Cache-Control: no-cache(或 max-age=0)来发。如果旧的问题 worker 文件被长缓存,浏览器可能根本不会去取这个 kill-switch worker,用户就永远卡住了。

Step 3:手动 purge edge cache

按宿主选一项:

# Vercel — 重新部署时跳过 build cache
vercel --prod --force
#   (或在项目里设 VERCEL_FORCE_NO_BUILD_CACHE=1,让它每次都跳过)

# Cloudflare — 仪表盘 Caching → Configuration → Purge Everything
#   或用 API(注意:Free 套餐限速约 5 次/分钟):
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

# Firebase Hosting — 普通的重新部署就会自动清掉 CDN 缓存。
#   它没有 "purge everything" 按钮,重新部署本身就是失效操作。
firebase deploy --only hosting

关于 Firebase 的提醒:firebase deploy--force 不是缓存参数——它跳过确认提示,并删除本地源码里已经不存在的 Cloud Functions。Hosting 用不上它,一条普通的 firebase deploy --only hosting 就会让 CDN 失效。

清完之后用 curl -I 确认:

curl -sI https://yourdomain.com/your-page | grep -iE 'cache|age'
# Vercel:     期望  x-vercel-cache: MISS  且 age: 0
# Cloudflare: purge 后的第一次请求显示 cf-cache-status: EXPIRED 或 MISS;
#             要等后续请求才会回到 HIT

Step 4:核对部署用的 commit 是不是最新

git rev-parse HEAD
# 然后在 Vercel / Netlify 部署详情页对比 "Source" 那一行的 SHA

不一致就重新 push 一次,或在仪表盘点 Redeploy取消勾选 “Redeploy with existing Build Cache”(截至 2026 年 6 月就是这个标签)。在 Vercel 上还要确认这个部署被分配到了 Production,而不是某个 Preview 别名。

Step 5:确认 DNS 没指到旧 origin

dig +short yourdomain.com
# 把每个返回的 IP 粘到 https://ipinfo.io/ 看 org,是不是当前宿主

残留的旧 A 记录(比如 GitHub Pages 的 185.199.108-111.153)要删掉,只保留宿主推荐的那条。如果用 Cloudflare 做前置代理,还要确认橙云(proxied)指向的是正确的后端。

怎么确认真的修好了

  1. curl -sI https://yourdomain.com/your-page 返回 MISS(Cloudflare 第一次命中可能是 EXPIRED),且 age: 很小。
  2. HTML 响应体里包含一个只属于新版部署的字符串(改过的标题,或一个 build SHA——见预防建议)。
  3. 一个全新的无痕窗口直接看到新版,不需要手动清缓存。
  4. DevTools → Application → Service Workers 要么没有 worker,要么新 worker 显示 “Activated and is running”,且没有多余的 waiting 项。

预防建议

  • 资源文件名带 content hash(Vite / Next / Astro 默认都开),HTML 始终走短 TTL——Vercel 的默认 public, max-age=0, must-revalidate 就是对的,别去覆盖它。
  • service worker 用 “network-first for navigation, cache-first for hashed assets” 策略,在 installself.skipWaiting()、在 activateclients.claim(),并且 /sw.jsCache-Control: no-cache 来发。
  • 每次发版把 commit SHA 写进一个 /version.json,前端启动时对比并弹出”有新版本,请刷新”的提示。
  • 在 CI 里加 smoke test:部署后 curl 首页,断言响应里包含本次 commit 的 SHA。
  • 一个域名只挂一个 origin;历史的 A / CNAME 记录用 dig 每季度审一次。

常见问题

仪表盘显示 “Ready” / 绿勾,不就是上线了吗? 对你正在测的那个 URL 不一定。绿勾只确认构建成功、产物存在。在 Vercel 上,push 到非生产分支会生成一个同样显示 “Ready” 的 Preview 部署,但它没有 promote 到生产域名。要核对 Source SHA,以及这个部署是不是指向 Production。

我清了浏览器缓存、也按了 Cmd+Shift+R,还是旧页面。 硬刷不会绕过正在运行的 service worker。你必须把它注销(DevTools → Application → Service Workers → Unregister),或者用无痕窗口测——无痕不会运行你已安装的 worker。

CDN purge 多久生效? Cloudflare 的 “Purge Everything” 在各数据中心几秒内传播完;purge 后第一次请求会显示 cf-cache-status: EXPIREDMISS,之后才回到 HIT。Vercel 的 edge 在新部署上线时立即更新。Firebase 每次重新部署会自动清 CDN。

为什么只有一部分访客看到旧版? 这种现象指向域名分流或两个 origin(原因 5):不同用户解析到不同记录,或者还残留着上一个宿主的 A 记录。跑 dig +short,确认每个返回的 IP 都属于当前宿主。

curl -I 够用吗,还是得用真浏览器? curl -I 让你看到边缘 / origin 这一层的缓存状态(x-vercel-cachecf-cache-statusage),中间没有任何浏览器缓存干扰——这正是 Step 3 该用它的原因。只在 Step 1 测客户端那一层时才需要用真浏览器(无痕)。

相关阅读

标签: #部署 / 托管 #排查 #排查