仪表盘绿勾打钩、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 显示 HIT 或 age 很大 | 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)指向的是正确的后端。
怎么确认真的修好了
curl -sI https://yourdomain.com/your-page返回MISS(Cloudflare 第一次命中可能是EXPIRED),且age:很小。- HTML 响应体里包含一个只属于新版部署的字符串(改过的标题,或一个 build SHA——见预防建议)。
- 一个全新的无痕窗口直接看到新版,不需要手动清缓存。
- 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” 策略,在
install里self.skipWaiting()、在activate里clients.claim(),并且/sw.js用Cache-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: EXPIRED 或 MISS,之后才回到 HIT。Vercel 的 edge 在新部署上线时立即更新。Firebase 每次重新部署会自动清 CDN。
为什么只有一部分访客看到旧版?
这种现象指向域名分流或两个 origin(原因 5):不同用户解析到不同记录,或者还残留着上一个宿主的 A 记录。跑 dig +short,确认每个返回的 IP 都属于当前宿主。
curl -I 够用吗,还是得用真浏览器?
curl -I 让你看到边缘 / origin 这一层的缓存状态(x-vercel-cache、cf-cache-status、age),中间没有任何浏览器缓存干扰——这正是 Step 3 该用它的原因。只在 Step 1 测客户端那一层时才需要用真浏览器(无痕)。