你合并了一个内容修复,rebuild 顺利部署,但访客打开还是看上周的旧版本。刷新没用,隐身窗口有时管用有时不管用。磁盘上的 build 是对的,origin 返回的也是新 HTML,但 origin 和访客之间有一层缓存还在发上周的副本。
最快的判断方法:对出问题的 URL 跑 curl -sI https://yoursite.com/articles/x/,只看一个响应头。如果看到 cf-cache-status: HIT(或 x-vercel-cache: HIT / STALE)且 age: 不为 0,说明是 CDN 边缘过期了——去后台或用 API purge(Step 1)。如果看到 cf-cache-status: DYNAMIC(或 MISS/BYPASS),那边缘就不是问题所在——旧副本在浏览器或 service worker 里,去硬刷新并查 DevTools(Step 2-3)。先定位是哪一层再动手;浏览器才是元凶时却去 purge CDN,只是白白浪费一次部署。
从访客往 origin 数,这些层依次是:
浏览器磁盘/HTTP 缓存 -> service worker -> CDN 边缘缓存 -> origin
每层都有自己的副本和 TTL,一次内容 rebuild 不会自动把所有层都失效掉。本文按层逐个排查,给出每种托管平台的精确 purge 方法,并教你配缓存头让这件事不再发生。
一个关键前提:多数平台会自动失效,Cloudflare 反代不会
先别慌,搞清楚到底是谁在缓存你的 HTML(2026 年 6 月核实):
| 平台 | HTML 默认在边缘缓存? | 部署时自动失效? |
|---|---|---|
| Vercel | 是(按部署生成的 key) | 是——每次部署都有唯一缓存 key,旧 HTML 自动无法被命中 |
| Netlify | 是(原子部署) | 是——每次部署自动让上一次部署的缓存上下文失效 |
| Cloudflare Pages | 否——HTML/JSON 默认是 DYNAMIC(不缓存),除非你加 Cache Rule | HTML 不涉及;资源带内容 hash |
| Cloudflare 反代在你自己的 origin 前 | 仅当你加了 “Cache Everything” / “Eligible for cache” 规则才缓存 | 否——必须手动 purge |
所以如果你用的是 Vercel 或 Netlify 却还看到旧 HTML,边缘几乎肯定早就刷新了,直接跳到浏览器/service worker 层(Step 2-3)。“部署后 CDN 过期”这个问题,绝大多数砸在用 Cloudflare 反代挡在自己 origin(S3、VPS、GitHub Pages)前面、并且配了缓存 HTML 的 Cache Rule 的人身上——那种情况下没有任何东西会自动 purge。
常见原因
1. CDN 边缘缓存 TTL 还没过(Cloudflare 反代 + 缓存规则)
如果你在 Cloudflare 反代上缓存 HTML、TTL 设了 4 小时,而 rebuild 才过去 30 分钟,边缘就会一直发旧 HTML,直到 TTL 自然过期或你手动 purge。
如何判断:curl -sI https://yoursite.com/articles/x/ | grep -iE 'age|cache-control|cf-cache-status'。cf-cache-status: HIT 加上不为 0 的 age: 就坐实了边缘有一份活副本。age: 表示这份副本有多少秒了。
2. build 成功但 deploy hook 没 purge CDN
你用 GitHub Actions 构建、推到 S3(或你自己的 origin),但 post-deploy 的 purge 步骤要么没写要么静默失败。origin 有新文件,Cloudflare 边缘还是旧的。
如何判断:看 CI 运行日志。有没有明确的 “Purge CDN cache” / “purge_cache” 步骤?它有没有返回 HTTP 200 且 "success": true?
3. HTML 还引用着旧的资源 hash
Astro 和 Next.js 会给资源加指纹,比如 index.A1B2C3.js。缓存里的 HTML 仍然指向旧 hash,那么即使资源是新的,最终拿到的还是旧 bundle——甚至 404。
如何判断:在生产页面看源码,把里面的 _astro/ hash 和你最新构建的 dist/_astro/ 文件名对一对。
4. service worker 缓存太激进
如果之前某次构建注册过 service worker(PWA、Workbox),它会拦截请求、按自己的缓存发好几天——硬刷新拦不住它,连 CDN purge 都拦不住,因为请求根本没出浏览器。
如何判断:DevTools > Application > Service workers。任何已注册的 worker 都能盖过它下面所有层。
5. 浏览器 HTTP 缓存把旧 HTML 钉住
HTML 文档上写 Cache-Control: max-age=3600,意味着浏览器一小时内根本不会去问服务器。CDN 没问题,是浏览器。
如何判断:硬刷新(macOS 上 Cmd+Shift+R / Windows 上 Ctrl+F5)。能看到新内容就是浏览器 HTTP 缓存这层。
6. stale-while-revalidate 把问题掩盖了
stale-while-revalidate 立刻返回旧副本,后台再抓新版本。部署后第一次访问看到旧的、第二次看到新的——只测一次就被迷惑了。
如何判断:同一个 URL 连访两次。第二次新了就是 SWR 在起作用(在 Vercel 上第一次命中会显示 x-vercel-cache: STALE)。
速查:你卡在哪一层?
先跑 curl,再从上到下对照这张表:
症状 -> 过期的层 -> 对应修复
cf-cache-status / x-vercel-cache: HIT, age > 0 -> CDN 边缘 -> Step 1(purge)
cf-cache-status: DYNAMIC/MISS/BYPASS,仍是旧的 -> 浏览器或 SW -> Step 2-3
硬刷新(Cmd+Shift+R)就出现新内容 -> 浏览器 HTTP 缓存 -> Step 2(调低 max-age)
DevTools 里有已注册的 service worker -> service worker -> Step 3(注销)
全新隐身窗口 + disable cache 仍是旧的 -> CDN 边缘(再查一次) -> Step 1
HTML 里的 hash 和 dist/_astro 不一致 -> 边缘上的旧 HTML -> Step 4(purge HTML 路由)
经验法则:如果 “DevTools 勾 Disable cache + 硬刷新 + 一个全新的隐身窗口” 全都还是旧的,那旧副本就在 CDN 边缘;否则它是浏览器本地的问题。
Cf-Cache-Status / X-Vercel-Cache 取值含义
整个排查过程你会反复读到这些值:
- HIT——从边缘缓存(Cloudflare/Vercel)直接发出。
age:不为 0 说明是一份存着的副本。 - MISS——边缘缓存里没有,这次回 origin 取的。purge 后第一次请求出现这个是正常的。
- EXPIRED / STALE——边缘有一份过期副本,正在 revalidate;Vercel 把 SWR 的情况标为
STALE。 - REVALIDATED——条目被显式失效,并在前台重新取回。
- DYNAMIC(Cloudflare)——响应不符合边缘缓存条件。对 Cloudflare 上的 HTML 这是默认值,意思是边缘根本没缓存你的页面。
- BYPASS——缓存被跳过(比如带了
Set-Cookie、no-store,或状态码不可缓存)。
动手前先确认
- 准备好一个出问题页面的精确 URL(如果你的路由带尾斜杠,就把它带上)。
- 确认最新构建部署成功(CI 绿、artifact 推送了)。
- 记下部署完成时间,好和 curl 的
age:对比。 - 搞清楚你的托管平台(Vercel / Netlify / Cloudflare Pages / Cloudflare 反代 + 自己 origin)——它决定了到底要不要手动 purge。
需要收集的信息
curl -sI <url>的输出,包括age:、cache-control:、以及cf-cache-status:/x-vercel-cache:/x-nf-request-id:。- 出问题页面的源码:JS/CSS 资源的 hash 文件名。
- CDN 厂商,以及后台里的 Cache Rules / 缓存设置。
- CI / deploy 日志里那一行,看 purge 步骤跑了没、有没有返回
"success": true。 - DevTools > Application > Service workers 的状态。
一步步修复
Step 1:purge CDN 边缘缓存
Cloudflare(后台):Caching > Configuration > Purge Cache > 用 “Purge by URL” 精准 purge,或者 “Purge Everything” 清整个 zone。
# Cloudflare API——purge 指定 URL(Free/Pro/Business 每次请求最多 100 个 URL)
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 '{"files":["https://yoursite.com/articles/x/"]}'
# 或者清整个 zone(截至 2026 年 6 月,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}'
成功的响应里会有 "success": true。API token 需要 Zone > Cache Purge > Purge 权限。
Vercel:部署会通过按部署生成的缓存 key 自动失效,基本不需要手动 purge。真要 purge,就重新部署,或用 Vercel-Cache-Tag 响应头打 tag,再配合 purge API / revalidateTag() 调用按 tag 失效。
Netlify:部署是原子的、会自动让上一次部署的缓存失效。要强制刷,去 Deploys > Trigger deploy > “Clear cache and deploy site”。
purge 完,下一次 curl -sI <url> 应该先显示 cf-cache-status: MISS,再下一次请求才显示 age: 0。
Step 2:强制击穿浏览器 HTML 缓存
打开 DevTools,勾上 disable cache,再硬刷新:
F12 / Cmd+Opt+I -> Network 选项卡 -> 勾选 "Disable cache"
Cmd+Shift+R(macOS) / Ctrl+F5(Windows)
这下出现新内容了,就是浏览器 HTTP 缓存这层。从源头修,把 HTML 文档的新鲜度调低:
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=60
max-age=0 让浏览器每次导航都来 revalidate;s-maxage=300 允许共享缓存(CDN)缓存 5 分钟;stale-while-revalidate=60 在刷新期间先发略旧的内容。
Step 3:注销捣乱的 service worker
DevTools > Application > Service workers > Unregister。service worker 能扛过 CDN purge,因为它是在浏览器内部应答的,所以当 “其他都看着修好了” 时它常常才是真元凶。然后在站点里要么彻底删掉注册,要么发一个会自毁的 worker:
// public/sw.js——把现有 worker 换成会自注销的
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
await self.registration.unregister();
const clients = await self.clients.matchAll();
clients.forEach((c) => c.navigate(c.url));
});
部署一次;每个老 service worker 都会在访客下次访问时自毁并重载页面。文件路径要和老 worker 一致,它才能真正替换掉旧的。
Step 4:核对 HTML 里的资源 hash
# 现网 HTML 引用了哪些 hash?
curl -s https://yoursite.com/articles/x/ | grep -oE '_astro/[^"]+' | sort -u
# 你最新构建里有哪些 hash?
ls dist/_astro/
不一致就说明缓存里的 HTML 比资源缓存还老。针对那几个 HTML 路由再 purge 一次(Step 1 的 “Purge by URL”)。提一句:给 _astro/* 设一年的 immutable 是安全的,正因为内容一变文件名 hash 就变,URL 本身就是缓存 key。
Step 5:在 deploy 管线里加自动 purge(仅 Cloudflare 反代需要)
如果你用 Cloudflare 反代挡在自己 origin 前、又缓存 HTML,那就把 purge 设成每次部署的最后一步。Vercel 和 Netlify 会自动做,那两个平台跳过这步。
- name: Purge CDN cache
run: |
curl -sf -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
-f 会让 curl 在非 2xx 响应时直接让这一步失败,这样 token 坏了也不会静默放行。代价是每次部署一次 API 调用。
Step 6:在站点配置里写合理的缓存头
Astro + Cloudflare Pages,在静态输出目录加一个 _headers 文件(放 public/ 让它进 dist/):
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/*.html
Cache-Control: public, max-age=0, must-revalidate
/sitemap.xml
Cache-Control: public, max-age=3600
带指纹的资源给一年加 immutable(rebuild 时 hash 会变,URL 就是缓存 key)。在 Cloudflare Pages 上 HTML 默认是 DYNAMIC(不在边缘缓存),所以 HTML 这条主要管的是浏览器;max-age=0, must-revalidate 既让回访保持新鲜,又允许浏览器走 304。如果你是通用的 Cloudflare 反代 + 自己 origin、配了 “Cache Everything” 规则,那 HTML 这条改成 s-maxage=300, stale-while-revalidate=60,让边缘只短暂缓存。注意 immutable 对 Cloudflare 的共享缓存没有任何作用——它只改变浏览器的 revalidate 行为。
如何确认修好了
curl -sI <url>在 purge 后第一次请求显示cf-cache-status: MISS,之后变成HIT且age:很小(Vercel 上是x-vercel-cache: HIT)。- 源码里的资源 hash 和
dist/_astro/一致。 - 硬刷新、软刷新、全新隐身窗口都看到新内容。
- DevTools > Application > Service workers 里要么没有 worker,要么是你那个会自毁的。
- 等 5 分钟再刷新——内容保持新(SWR 没回退到旧副本)。
长期预防
- 把 CDN purge 设成每次部署的最后一步(仅 Cloudflare 反代;Vercel/Netlify 自己会处理)。
- HTML 边缘缓存用短
s-maxage(5-10 分钟)加stale-while-revalidate——快速 revalidate、快速首屏。 - 长
max-age+immutable只给带指纹的资源,永远别给 HTML。 - 没有真离线需求就别在内容站注册 service worker;它能扛过 CDN purge,是 “幽灵旧页面” 的常见来源。
- 部署后用合成监控盯
age:和cf-cache-status:/x-vercel-cache:,确认新内容传开了。
常见坑
- 只 purge 首页就以为所有文章都刷了;每个文章 URL 是独立的缓存条目。要么按 URL 逐个 purge,要么整体 purge。
- 把
Cache-Control: no-cache当成 “不缓存”。它其实允许缓存只是要 revalidate;真正禁止存副本的是no-store。 - 把 Cloudflare 的 “Development Mode” 留着不管(它只关 3 小时缓存,到点又开),结果以为某次测试是干净的,其实只是缓存被临时绕过了。
- 不小心给 HTML 设了
max-age=31536000;访客会看到最长一年的旧内容,除非自己手动清浏览器缓存。 - 只在一个浏览器 profile 里测;service worker 或 HTTP 缓存可能就粘在那一个 profile 上。
- 指望 CDN purge 能清掉 service worker。它做不到——worker 是在浏览器内部应答的(Step 3)。
FAQ
Q:我 purge 了 Cloudflare,但 curl 还是显示旧页面,为什么?
A:看 cf-cache-status 头。如果是 DYNAMIC,说明 Cloudflare 从来没缓存那个 HTML,旧副本在你浏览器或 service worker 里,不在边缘。如果是 HIT 加很大的 age:,那大概率是你 purge 失败了(zone ID 写错,或 token 没有 Cache Purge 权限)——确认 API 返回了 "success": true。
Q:Vercel 和 Netlify 部署后需要手动 purge 吗? A:通常不用。两者都会自动失效——Vercel 用按部署生成的缓存 key,Netlify 用原子部署、自动让上一次部署的缓存上下文失效。那里手动 purge 只用于对长期缓存的响应做按 tag 失效。
Q:CDN 多久整体 purge 一次? A:如果你在 Cloudflare 反代上缓存 HTML,那么每次会动 HTML 或 sitemap 的部署都要 purge。只动资源的部署会自己失效,因为文件 hash(进而 URL)变了。
Q:HTML 到底要不要让 CDN 缓存?
A:对静态内容站,短 s-maxage(5 分钟)加 stale-while-revalidate 再配部署时 purge,既快又新鲜。在 Cloudflare Pages 上 HTML 默认不在边缘缓存,对低流量站点也完全够用。
Q:purge 会增加带宽吗?
A:会一点点——purge 后第一次请求是 MISS,要回 origin。对内容站可以忽略。能按 URL purge 就按 URL,避免把整站重新预热。
Q:我的 CDN 没有 purge API 怎么办? A:把后台 purge 当作发版必经一步,或者换到部署时自动失效的平台(Vercel、Netlify、Cloudflare Pages)。自动缓存失效是生产环境最基本的卫生。
外部参考
- Cloudflare: Purge cache——API、限速、按 URL purge 与整体 purge。
- Cloudflare: Default cache behavior——为什么 HTML 显示
DYNAMIC。 - Vercel: CDN cache and purge——按部署的缓存 key 与按 tag 失效。
- Netlify: Caching overview——原子部署与 cache ID。
相关阅读
标签: #content-site #运营 #排查