把博客缓存刷新收回到一条链路里:Next.js、源站预热和腾讯云 CDN

2026年07月31日12 次阅读0 人喜欢
Next.jsCDN腾讯云缓存架构设计App Router小破站建设CI/CD

之前博客的 CDN 刷新逻辑主要在部署后跑。后来我准备把文章发布、修改也接进来,才发现问题不是“要不要调腾讯云 API”,而是站点现在至少有两层会返回旧内容的地方:

  • 腾讯云页面 CDN。
  • Next.js 自己的 Data Cache / Full Route Cache,浏览器里还有 Router Cache。

如果没有把它们拆开看,页面旧了时就只能靠猜。最容易出现的假象是:我刚刷新了 CDN,公网响应看起来是 miss,可页面内容还是旧的。这个时候 CDN 确实已经回源了,只是源站给它的仍然是 Next.js 的旧 HTML,于是 CDN 又把旧内容缓存了一遍。

单纯按“更新文章 → 刷 CDN”的顺序做,等于把这个不确定性放大了。

这篇把原来的部署链路、这次改造后的链路、文章更新时的时序,以及我准备怎么判断“到底是哪层没有失效”都记下来。这里写的是当前代码和本地静态验证的结果,不是假装已经完成了生产 CDN 联调。

原来的部署和 CDN 刷新是怎么跑的

原来的部署还是很常规的 Docker 发布:

flowchart LR Push["push main / tag"] --> GH["GitHub Actions"] GH --> Build["构建并推送 Docker 镜像"] Build --> SSH["SSH 到服务器"] SSH --> Pull["git pull + docker pull"] Pull --> Restart["停止旧容器并启动新容器"] Restart --> Script["把 purge-cdn.mjs 复制进运行中的容器"] Script --> Tencent["脚本直接调用腾讯云 CDN API"]

scripts/purge-cdn.mjs 当时同时做了几件事:

  1. 自己读取 .env 里的腾讯云 SecretId / SecretKey
  2. 根据变更文件猜要刷哪些路径。
  3. 在容器里直接调用 PurgePathCachePurgeUrlsCache
  4. 没有变更文件时就把根路径当成全站刷新。

这个版本能解决“部署完成后 CDN 还留着旧页面”的问题,但它有几个边界不太对。

首先,GitHub Actions 的变更计算原本是 HEAD~1..HEAD。一次 push 里如果有多笔 commit,实际部署的是最新镜像,但刷新范围可能只来自最后一个 commit。

其次,脚本是在容器启动后直接跑的。原来的健康检查只是等几秒看一下状态,即使还是 starting 也不会阻止后面的刷新。CDN 刷新发生时,新的 Next.js 进程不一定已经能稳定回源。

更重要的是,这套逻辑只知道“哪些文件改了”,不知道“这个页面在刷新 CDN 前是不是已经从源站重新生成”。它直接调腾讯云,所以也无法复用文章更新时必须做的 Next.js 失效和预热逻辑。

文章运行时更新更明显。后台原来分散着一串 revalidateTagrevalidatePath:有的接口刷详情和首页,有的刷标签,有的只刷当前路径。每个接口单看似乎合理,合起来就很难知道旧标签页、旧分类页、旧合集页有没有遗漏。

一开始想过等一秒,后来放弃了

我最早想的是:

text 复制代码
更新文章
→ 让 Next.js 缓存过期
→ 等一秒
→ 请求源站预热
→ 再刷 CDN

“一秒”其实没有任何语义。Next.js 这一轮有没有真的重新生成、请求是不是又命中旧的 Full Route Cache、文章详情是不是已经对应到刚写入的版本,都不能靠睡眠时间证明。

所以后来把判断条件换成了:不是“等多久”,而是“请求源站后看到什么”。

先给完整 HTML 留一个生成时间

layout.tsx 现在会输出:

html 复制代码
<meta
  name="next-rendered-at"
  content="2026-07-31 16:20:30"
  data-cache-scope="full-route"
/>

时间固定为 Asia/Shanghai,格式就是 YYYY-MM-DD HH:mm:ss。这个值表示这份路由 HTML 的生成时间,不是当前请求的时间。首页、归档页、合集页和不同文章详情的值可以不同。

文章详情页还有:

html 复制代码
<meta
  name="post-cache-debug"
  data-post-id="123"
  data-post-updated-at="2026-07-31T08:20:30.000Z"
/>

这两个字段不是给用户看的,而是给预热器和排查用的。文章正文更新后,只看到一个“时间变了”还不够;还要确认这份 HTML 确实是预期文章、预期 updated 版本。

排查时要分别请求服务器本机源站和公网页面。下面的 文章路径 要换成真实路径:

bash 复制代码
curl -sS http://127.0.0.1:3000/文章路径 | grep -o 'name="next-rendered-at"[^>]*'
curl -sS https://www.nnnnzs.cn/文章路径 | grep -o 'name="next-rendered-at"[^>]*'

判断逻辑很直接:

现象 优先结论
源站标记还是旧的 Next.js 的 Full Route / Data Cache 还没有得到新版本。
源站标记更新,公网标记还是旧的 CDN 仍然在返回旧对象。
公网显示 cache_miss,但源站标记还是旧的 CDN 已经回源,问题仍在 Next.js。
源站和公网标记都更新,但浏览器页面仍旧 再看浏览器 Router Cache、客户端状态或请求是否真的拿了新的 HTML。

不要用 Middleware 里塞一个“当前时间”响应头替代这个标记。Middleware 只能说明请求经过了 Middleware,不能证明 Full Route Cache 的 HTML 重新生成。客户端用 <Link> 跳转也不够,因为 Router Cache 可能复用根 layout;判断缓存时应该完整刷新,或者直接抓 HTML。

影响范围收成一份计划

这次新增的是代码级 CacheImpactPlan,不建数据库表,也不存任务 ID:

ts 复制代码
interface CacheImpactPlan {
  source: "post" | "collection" | "deploy";
  nextTags: string[];
  nextPaths: string[];
  warmupTargets: WarmupTarget[];
  cdnPagePaths: string[];
  cdnAssetUrls: string[];
}

固定页面路径、动态路由工厂、URL 编码和末尾斜杠规范化都放在同一个模块里。每次执行前再用 Set 去重。

文章更新的关键点不是“更新后有哪些标签”,而是必须保留更新前的快照和旧合集关系,再取更新后的关系。因为标题、日期、路径、标签、分类、合集任何一个变了,只看新值都会漏掉旧页面。

现在按字段分影响范围:

  • contentdescriptioncoverlayout:详情页、RSS、当前所属合集详情。
  • titledatepath:旧/新详情页、首页、归档、RSS、sitemap、标签、分类、合集。
  • hideis_delete:旧详情和所有旧关系页面。
  • tagscategory:标签/分类索引,以及旧、新标签和分类页。
  • 加入、移出、排序合集:合集详情、合集列表、首页书架和相关文章详情。
  • likesvisitors、RAG 状态、向量化日志:不刷公开页面和 CDN。

/timeline 目前不读取文章数据,所以不进入文章影响范围。

顺便修了一个原来就藏着的问题:服务层过去只在标题变化时重算文章 path,只改发布日期不会变 URL。现在标题或发布日期变化都会重新生成 path,旧、新 URL 才真的能进入同一份计划。

文章更新时的实际时序

运行时写入和部署变更最终都会走同一套收集器、源站预热器和腾讯云刷新器。文章接口本身只以数据库写入结果为准,缓存链路是尽力而为的。

flowchart LR Write["数据库写入成功"] --> Plan["生成 CacheImpactPlan"] Plan --> Next["revalidateTag / revalidatePath"] Next --> Response["返回文章发布成功"] Next --> Warm["127.0.0.1 源站预热"] Warm --> Verify{"生成时间和文章版本正确?"} Verify -- 是 --> CDN["腾讯云 CDN 主动刷新"] Verify -- 否 --> Log["记录日志并跳过该路径"]

这里有几个刻意的选择:

  1. revalidateTag / revalidatePath 在数据库成功后立即标记,接口不等待后续 CDN 调用。
  2. 单实例进程内用一条 Promise 链串行处理后台刷新,避免同一批次彼此穿插。
  3. 不做固定等待、不轮询、不建 Redis 队列、不存任务表,也不保存腾讯云 RequestId
  4. 一个预热目标失败不会阻塞其他路径;Next.js 失效、预热、CDN 刷新失败都不会把已经写入的文章回滚。
  5. 缓存影响快照读取也按尽力而为处理,不能让“为了刷新缓存而读关系失败”反过来让文章保存报错。

预热请求直接打 127.0.0.1:3000 的原始路径,并带 no-store / no-cache。普通 HTML 页面需要看到新的 next-rendered-at;文章详情还要比对 data-post-iddata-post-updated-at。RSS 和 sitemap 是 XML,本身不依赖 HTML 标记,只要源站返回成功即可。

旧 URL 有一个不能一刀切的地方。标题或日期变化后,有些旧地址会 404;有些旧日期地址可能被详情页按标题兜底到同一篇文章。

  • 旧地址确认 404:说明源站已经不再提供旧内容,可以刷新 CDN 的旧对象。
  • 旧地址仍返回 200:只有确认它输出的是新 updated 版本,才允许刷新 CDN。
  • 旧地址返回旧版本、错误页或不相干文章:跳过刷新,避免把旧源站结果重新写回 CDN。

这就是“刷新 CDN 前先预热和验证”的实际意义。

腾讯云 CDN 目标也不是一类

页面路由和静态文件不应该一概按目录刷新:

  • 首页 //rss.xml/sitemap.xmlpublic/**PurgeUrlsCache 精确 URL。
  • 普通页面路由用 PurgePathCache,并指定 FlushType: "delete"
  • 根 layout、全局样式、公共前台 Provider,或者无法安全确定范围的公共渲染组件,才降级为根目录全站刷新。

同一进程里有一个短时 Map<string, number> 合并重复目标。文章连续保存,或者部署刚好和文章更新撞在一起,同一 URL 不会在短时间内重复向腾讯云提交。腾讯云的 RequestId 只进普通日志,不保存、不查状态、不失败重试。

页面 CDN 和 COS 静态资源也分开:页面默认对应 www.nnnnzs.cn,已经是完整 URL 的资源保持自己的域名,例如 static.nnnnzs.cn,不会被改写成页面域名。

现在的部署架构

部署变更不再让宿主机脚本直接调用腾讯云。现在的职责拆开了:

flowchart LR Push["GitHub push / tag / 手动部署"] --> CI["GitHub Actions 构建镜像"] CI --> Registry["Docker Hub"] CI --> SSH["SSH 到部署服务器"] SSH --> Diff["读取旧容器 commit 和新镜像 commit"] Diff --> Manifest["写入 .cdn-purge/pending.json"] Manifest --> Restart["切换到新容器"] Restart --> Healthy{"健康检查通过?"} Healthy -- 是 --> Internal["容器内请求内部消费接口"] Internal --> Plan["collectDeployCacheImpact"] Plan --> Warm["源站预热并验证"] Warm --> Purge["腾讯云 CDN 刷新"] Healthy -- 否 --> Stop["不消费清单"]

具体变化是:

  • GitHub Actions 对 main push 计算完整的 github.event.before..github.sha,不再只比较 HEAD~1..HEAD;tag 和手动部署最终仍以服务器的已部署基线为准。
  • 部署服务器优先读取旧容器镜像 label 中的 commit,再读取刚拉下来的新镜像 label 中的 commit,计算完整 oldCommit..newCommit
  • 如果历史基线拿不到,明确写一个“全站刷新”的清单,而不是假装得到精确路径。
  • scripts/purge-cdn.mjs 现在只负责原子写 .cdn-purge/pending.json。它不读取腾讯云密钥,也不创建腾讯云 SDK 客户端。
  • .cdn-purge 目录挂载到新容器的 /app/.cdn-purge
  • 新容器健康后,服务器脚本通过 docker exec 从容器内部请求 http://127.0.0.1:3000/api/internal/cache/purge-deploy
  • 这个内部接口要求本机 Host 和独立的 CDN_PURGE_SECRET Bearer Token,外部不能提交任意 URL 或任意变更文件。
  • 接口先把 pending.json 原子改名为 pending.json.processing,处理结束后无论成功失败都删除。普通容器 restart 因此不会重复刷新上一轮 CDN。

CDN_PURGE_SECRET 只用于这个“部署脚本调用容器内部接口”的鉴权,不是腾讯云 SecretKey。它只放部署服务器的 .env,不需要放进 GitHub Actions 的构建环境。真正调用腾讯云 CDN 的 SecretId / SecretKey 也只在服务器运行环境里使用。

部署文件映射也从原来的粗略规则改成了代码常量:根 layout / 全局样式 / 公共前台 Provider 刷全站;公开 page.tsx 刷对应路由;文章详情公共组件刷全部文章详情;标签、分类、归档、合集组件刷对应页面族;public/** 刷精确静态 URL;API、后台 /c/**、测试和文档默认不刷 CDN。无法证明不影响前台的共享组件,宁可降级全站。

还没有做的事

当前部署是单实例 Next.js standalone,所以没有设计多实例之间的缓存广播。如果以后扩成多个实例,revalidateTag 只发生在一个进程内就不够了,到时需要再决定 Redis pub/sub、平台缓存 API 或统一的再验证入口。

腾讯云生产刷新没有在本地测试里真的执行。当前做过的是类型检查、字段映射单测、预热失败跳过 CDN 的单测、部署脚本语法检查和清单生成验证。生产上线后,第一轮应该按前面的源站 / 公网标记方法抓一次真实 HTML,确认 next-rendered-at 和 CDN 命中情况。

至少现在缓存问题不再是“感觉应该刷一下 CDN”。可以先确认旧内容在 CDN 还是 Next.js,再决定需要失效什么、预热什么,以及什么情况下绝对不能让 CDN 把源站旧页面重新缓存进去。

加载评论中...