Vercel / Netlify / Firebase 仪表盘绿勾打钩、commit hash 也对得上,但访客刷新还是看到昨天那版——这是部署管线里最让人怀疑人生的一类问题。原因几乎一定在”内容已经到边缘节点了,但请求没有走到新版本”这条链上:service worker 抢答、CDN edge 没失效、HTML 被中间代理缓存。本文按命中率排出 5 类常见来源,并给一条 10 分钟以内可以打完的诊断路径。
常见原因
按命中率从高到低。
1. Service Worker 抢在网络前返回了旧 HTML
PWA 模板(Workbox / Next PWA / Vite PWA)默认会注册一个 service worker 缓存 HTML 和静态资源。worker 在用户上一次访问时已经把旧版 index.html 缓存进 Cache Storage,下次刷新它直接 respondWith(cache),根本没有发起新请求。
// 典型的"先缓存后网络"策略,最容易卡住
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 面板里页面请求显示 (ServiceWorker) 作为来源。
2. CDN edge cache 没有失效
Vercel / Cloudflare / Firebase 都会在边缘节点缓存 HTML(默认 TTL 因 framework / 路由而异)。如果你只重新部署而没有触发 invalidation——例如把构建产物直接传到 S3、或者改了 origin 但没调用 purge API——edge 还在返回上一版。
如何判断:curl -I https://yourdomain.com/your-page 看到 x-vercel-cache: HIT / cf-cache-status: HIT / age: 3600 这种命中标记。
3. 浏览器 HTML 被 long-cache
如果你(或者历史上某次部署)把 HTML 的 Cache-Control 设成 public, max-age=31536000,浏览器一年内根本不会回源。Vercel 默认 HTML 是 s-maxage=0, must-revalidate,但自定义 headers 或 framework 配置(比如 Next.js 的 headers())可能误覆盖。
如何判断:DevTools → Network → 点 HTML 请求 → Response Headers 看 Cache-Control;如果出现 max-age= 大于 0 就有嫌疑。
4. 部署成功但用的是旧的 commit / build
CI 触发了部署,但 build step 用了缓存的 dist/,或者你 push 到了错误的分支。Vercel “Production” 默认绑定 main 分支,push 到 feature 分支只会生成 preview,控制台仍然显示”Ready”。
如何判断:查看部署详情里的 commit SHA,和你本地 git rev-parse HEAD 对比;不一致就是构建用的不是最新代码。
5. 域名指到旧 origin / 多个 origin
域名同时挂在 Vercel 和 Cloudflare Pages 上,或者历史上把 A 记录指向旧服务器从未清理。新部署在 Vercel 上,但 DNS 还把一部分流量带到旧 origin。
如何判断:dig +short yourdomain.com 拿到 IP 后反查归属(whois),看是不是当前宿主的 IP 段。
最短修复路径
按”先排除客户端、再排除 edge、最后查 origin”的顺序。前 2 步通常就解决了。
Step 1:用无痕窗口隔离客户端缓存
开一个 incognito 窗口,DevTools → Network 勾上 “Disable cache”,访问页面。
| 看到的版本 | 结论 |
|---|---|
| 新版本 | 普通窗口里的浏览器 / SW 缓存问题 → 进 Step 2 |
| 还是老版本 | 不是浏览器缓存,是 edge 或 origin → 跳到 Step 3 |
普通窗口里硬刷一次(Cmd/Ctrl+Shift+R)也可以一并验证。
Step 2:卸载 service worker + 清站点数据
DevTools → Application → Service Workers → 每个注册项点 “Unregister”。然后 Application → Storage → 勾全 → “Clear site data”,刷新页面。
如果想一键脚本化清掉所有用户那边的 SW(部署一次新 worker 时执行),可以临时发一个”自杀 worker”:
// public/sw.js(部署一次,等老用户上来命中,自动注销)
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
const regs = await self.registration.unregister();
const clients = await self.clients.matchAll();
clients.forEach(c => c.navigate(c.url));
});
Step 3:手动 purge edge cache
按宿主选一项:
# Vercel — 重新部署时跳过 build cache
vercel --prod --force
# Cloudflare — 仪表盘 Caching → Configuration → Purge Everything
# 或用 API:
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 失效
firebase deploy --only hosting --force
清完之后用 curl -I 确认:
curl -sI https://yourdomain.com/your-page | grep -iE 'cache|age'
# 期望: x-vercel-cache: MISS 或 cf-cache-status: MISS 且 age: 0
Step 4:核对部署用的 commit 是不是最新
git rev-parse HEAD
# 然后在 Vercel / Netlify 部署详情页对比 "Source" 那一行的 SHA
不一致就重新 push 一次、或在仪表盘点 “Redeploy” 并取消 “Use existing Build Cache”。
Step 5:确认 DNS 没指到旧 origin
dig +short yourdomain.com
# 把返回 IP 复制到 https://ipinfo.io/ 看 org,是不是当前宿主
如果发现还残留旧 A 记录(比如 GitHub Pages 的 185.199.x.x),删掉只保留宿主推荐的那条。
预防建议
- 资源文件名带 content hash(Vite / Next / Astro 默认都开),HTML 始终走短 TTL(
s-maxage=0, must-revalidate)。 - service worker 用 “network-first for navigation, cache-first for hashed assets” 策略,并在 install 阶段
self.skipWaiting()。 - 每次发版后把 commit SHA 写进一个
/version.json,前端启动时对比并提示用户刷新。 - 在 CI 里加 smoke test:部署后
curl首页,断言响应里包含本次 commit SHA。 - 一个域名只挂一个 origin;历史 A / CNAME 记录用
dig季度审一次。