内容站构建后 CDN 缓存过期

重新构建上线了,访客看到的还是旧文章——CDN 边缘缓存、浏览器缓存、或者 HTML 还引用着旧的资源 hash。

你合并了一个内容修复,rebuild 顺利部署,但访客打开还是看上周的旧版本。刷新没用,隐身窗口有时管用有时不管用。磁盘上的 build 是对的,origin 返回的也是新 HTML,但用户和 origin 之间的 CDN 边缘还在发上周的版本。用户和文章之间的层次:浏览器磁盘缓存 > 浏览器 HTTP 缓存 > CDN 边缘缓存 > origin。每层都有自己的副本和 TTL,一次内容 rebuild 往往没法把所有层都失效掉。

本文按层逐个失效,并教你配缓存头让这件事不再发生。

常见原因

1. CDN 边缘缓存 TTL 还没过

Cloudflare / Vercel / Netlify 的边缘对 HTML 的 TTL 从几分钟到几小时不等。你 TTL 设了 4 小时、rebuild 才过去 30 分钟,边缘就会自然过期前一直发旧 HTML。

如何判断curl -sI https://yoursite.com/articles/x | grep -iE 'age|cache|cf-cache'——age: 显示缓存副本多老。

2. build 成功但 deploy hook 没 purge CDN

你用 GitHub Actions 构建、推到 S3,但 post-deploy 的 purge 步骤要么没写要么静默失败。origin 有新文件,edge 还是旧的。

如何判断:看 CI 日志——有没有明确的 “purge cache” 或 “invalidate” 步骤,跑成功了吗?

3. HTML 还引用着旧的资源 hash

Astro / Next.js 会给资源加指纹,比如 main.A1B2C3.js。HTML 缓存里仍然指向旧 hash,即使资源是新的,最终拿到的还是旧文件。

如何判断:在生产页面看源码;JS / CSS 的 hash 是否和最新构建的 dist/_astro/ 文件名匹配。

4. Service worker 太激进

如果之前构建注册过 service worker(PWA、Workbox),它会拦截所有请求,按自己的缓存发好几天。

如何判断:DevTools > Application > Service Workers——有注册的 SW 就能覆盖一切。

5. 浏览器 HTTP 缓存把旧 HTML 钉住

HTML 上写 Cache-Control: max-age=3600 意味着浏览器一小时都不会去问服务器。CDN 没问题,是浏览器。

如何判断:硬刷新(Cmd+Shift+R / Ctrl+F5)能看到新内容,就是浏览器 HTTP 缓存这层。

6. stale-while-revalidate 把问题掩盖了

stale-while-revalidate 立刻返回旧版本,后台再抓新版本。部署后第一次访问看到旧的、第二次看到新的——只测一次就被迷惑了。

如何判断:同一个 URL 连访两次。第二次新了就是 SWR 在起作用。

各层常见原因速查

浏览器磁盘缓存          >  Cmd+Shift+R 清掉
浏览器 HTTP 缓存        >  Cmd+Shift+R + DevTools "Disable cache"
Service Worker          >  DevTools > Application > Unregister
CDN 边缘缓存            >  CDN 后台 / API purge
Origin                  >  确认磁盘上是新文件

“Disable cache + 硬刷新 + 隐身”全都还是旧的,那就是 CDN 边缘过期了。往上推。

动手前先确认

  • 准备好一个出问题页面的精确 URL。
  • 确认最新构建部署成功(CI 绿、artifact 推送了)。
  • 记下部署完成时间——和 curl 的 age: 对比。

需要收集的信息

  • curl -sI <url> 输出,包括 age:cache-control:cf-cache-status: / x-vercel-cache: / x-nf-request-id:
  • 出问题页面的源码——JS/CSS 资源的 hash 文件名。
  • CDN 厂商和后台的缓存设置。
  • CI / deploy 日志,看 purge 步骤跑了没。
  • DevTools > Application > Service Workers 的状态。

一步步修复

Step 1:purge CDN 边缘缓存

Cloudflare:Dashboard > Caching > Configuration > Purge Everything(或者按 URL 精准 purge)。

# Cloudflare API(单个 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/"]}'

Vercel:部署会自动失效;不行就重新部署或者 vercel deploy --force

Netlify:Site settings > Build & deploy > Clear cache and retry deploy。

purge 完,curl -sI <url> 应该显示 age: 0 或接近 0。

Step 2:强制击穿浏览器 HTML 缓存

打开 DevTools 勾上 “Disable cache” 再硬刷新:

F12 / Cmd+Opt+I > Network 选项卡 > 勾选 Disable cache
Cmd+Shift+R / Ctrl+F5

这下出现新内容了,就是浏览器 HTTP 缓存这层。把 HTML 的 max-age 调低:

Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=60

max-age=0 让浏览器每次都来 revalidate;s-maxage=300 允许 CDN 缓存 5 分钟。

Step 3:注销捣乱的 service worker

DevTools > Application > Service Workers > Unregister。然后在站点里要么彻底删掉 SW 注册,要么放一个自毁开关:

// public/sw.js(把旧 SW 替换成会自注销的)
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
  const regs = await self.registration.unregister();
  const clients = await self.clients.matchAll();
  clients.forEach(c => c.navigate(c.url));
});

部署一次,所有老 SW 下次访问时自毁。

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 5:在 deploy 管线里加自动 purge

GitHub Actions 示例(Cloudflare):

- name: Purge CDN cache
  run: |
    curl -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}'

每次部署成功后的最后一步跑一次。每次 build 只一次 API 调用。

Step 6:站点配置里写合理的缓存头

Astro + Cloudflare Pages,建个 _headers

/*.html
  Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=60

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

/sitemap.xml
  Cache-Control: public, max-age=3600

带 hash 的资源给一年(immutable,rebuild 时 hash 会变)。HTML 给边缘短缓存加 SWR。sitemap 给一小时。

验证

  • 刚 purge 完 curl -sI <url> | grep age 返回 age: 0age: 10
  • 源码里资源 hash 和 dist/_astro/ 一致。
  • 硬刷新、软刷新、隐身窗口都看到新内容。
  • 等 5 分钟再刷新——内容保持新(SWR 没回退到旧副本)。

长期预防

  • 每次部署管线的最后一步都跑 CDN purge。
  • HTML 用短 s-maxage(5-10 分钟)加 stale-while-revalidate——快速 revalidate、快速首屏。
  • max-age 只给带指纹的不可变资源,永远别给 HTML。
  • 没有真离线需求就别在内容站注册 service worker;运维成本高。
  • 部署后用合成监控盯 age:cf-cache-status:,确认新内容传开了。

常见坑

  • 只 purge 首页就以为所有文章都刷了;每个文章 URL 有自己的缓存条目。
  • Cache-Control: no-cache 以为是禁用缓存;它其实允许缓存只是要 revalidate,期间还是从缓存读。
  • 忘了 Cloudflare 的 “Development Mode” 只关 3 小时缓存——容易留着不管,测试时被误导。
  • 不小心给 HTML 设了 max-age=31536000;用户要看一年旧内容,除非自己清缓存。
  • 只在一个浏览器 profile 里测;缓存可能就粘在某个 profile 上。

FAQ

Q:CDN 多久整体 purge 一次? A:每次会动 HTML 或 sitemap 的部署都要 purge。只动资源的部署靠 hash 变化自动失效。

Q:purge 会增加带宽吗? A:会一点点——purge 后第一次请求要回源。对内容站可以忽略。

Q:HTML 到底要不要让 CDN 缓存? A:要——s-maxage=300 给用户快的首屏。短 TTL + 部署时 purge 能两头都拿。

Q:CDN 不支持 API purge 怎么办? A:换 CDN,或者把后台 purge 当作发版必经一步。生产卫生需要自动缓存失效。

相关阅读

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