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

2026年07月31日21 次阅读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,不会被改写成页面域名。

顺便做的:前端轮询削峰

缓存刷新是服务端的事,但前端也有类似问题。首页同时轮询部署历史、提交活动、部署状态、站内通知和 AI 任务状态,多标签页打开时请求量翻倍,而且每次请求都要查 Redis 甚至 GitHub API。

原来用的是固定周期 setInterval,每个组件各自管各自的。部署状态每 10 秒查一次,通知每 30 秒查一次,提交活动每 60 秒查一次。三个标签页打开就是三倍请求,Redis 缓存过期时还会集中回源 GitHub。

这次顺便做了一次改造,思路和缓存刷新一样——收敛。

跨标签页选主。同一个浏览器里只让一个标签页发轮询请求,其他标签页通过 BroadcastChannel 接收更新。选主用 Web Locks API,不支持时降级到 localStorage 租约(12 秒过期)。标签页切到后台时自动放弃 leader 身份,回到前台时重新竞争。

自适应频率。有变化时高频轮询(最短 10 秒),没变化时逐步降频(最长 5 分钟),请求失败时指数退避。每次轮询函数返回一个 nextDelayMs,hook 根据这个值决定下次间隔,而不是写死周期。

Redis 单飞锁。多个 Next.js 实例同时收到请求时,只有一个实例回源 GitHub API,其他实例用 Redis 里的快照响应。快照有过期时间,过期后下一个请求触发单次回源。用带所有权令牌的短期锁防止并发回源。

部署历史和提交活动也改成了懒加载——页面请求时按 5 分钟、10 分钟新鲜度判断是否需要刷新,不依赖部署方式初始化。部署 webhook 只维护实时状态和增量,查询时与 GitHub 完整历史按 runId 合并。

改完以后首页的网络请求从固定 3-4 个降到了 0-1 个(空闲状态下),多标签页也不再重复请求了。

测试覆盖

cache-impact.ts 有 275 行测试,覆盖了几个容易出错的场景:

  • 文章正文更新只刷详情页、RSS 和当前合集,不刷标签和分类页。
  • 标题/日期/路径变更时,旧路径和新路径、旧标签和新标签、旧合集和新合集都进入影响范围。
  • 文章隐藏/删除后,旧详情页和旧关系页仍然被正确标记。
  • 已经处于隐藏/删除状态的文章再编辑,不产生任何公开页面的缓存影响。
  • 点赞数、访问量、RAG 内部字段变更不触发缓存刷新。
  • 合集成员变更和排序变更共享相同的公共影响范围。
  • 部署文件映射:src/app/archives/ 只刷归档页,public/ 下的资源精确刷新,共享组件降级全站。
  • normalizeCacheImpactPlan 去重和路径规范化。
  • CDN 目标分类:首页和 XML 走精确 URL,普通路由走目录前缀。
  • 源站验证:预热时 next-rendered-at 旧的路径不会被提交给 CDN。

测试本身不难,但字段组合太多了。hide 字段变了要刷归档页,rag_status 变了完全不需要刷,titlepath 同时变了只需要刷新路径不需要重复刷旧路径——这种边界靠人记住迟早会漏。测试的价值不是证明代码正确,而是让以后加新字段时能立刻看到影响范围的变化。

现在的部署架构

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

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 把源站旧页面重新缓存进去。

加载评论中...