内容站重新构建后仍发旧文章(CDN 缓存过期)

重新构建上线了,访客看到的还是旧文章。判断到底是哪一层缓存过期——CDN 边缘、浏览器 HTTP 缓存、service worker,还是 HTML 还引用着旧的资源 hash——再 purge 对的那一层。

你合并了一个内容修复,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 RuleHTML 不涉及;资源带内容 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-Cookieno-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,之后变成 HITage: 很小(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)。自动缓存失效是生产环境最基本的卫生。

外部参考

相关阅读

标签: #content-site #运营 #排查