把博客缓存刷新收回到一条链路里:Next.js、源站预热和腾讯云 CDN
之前博客的 CDN 刷新逻辑主要在部署后跑。后来我准备把文章发布、修改也接进来,才发现问题不是“要不要调腾讯云 API”,而是站点现在至少有两层会返回旧内容的地方:
- 腾讯云页面 CDN。
- Next.js 自己的 Data Cache / Full Route Cache,浏览器里还有 Router Cache。
如果没有把它们拆开看,页面旧了时就只能靠猜。最容易出现的假象是:我刚刷新了 CDN,公网响应看起来是 miss,可页面内容还是旧的。这个时候 CDN 确实已经回源了,只是源站给它的仍然是 Next.js 的旧 HTML,于是 CDN 又把旧内容缓存了一遍。
单纯按“更新文章 → 刷 CDN”的顺序做,等于把这个不确定性放大了。
这篇把原来的部署链路、这次改造后的链路、文章更新时的时序,以及我准备怎么判断“到底是哪层没有失效”都记下来。这里写的是当前代码和本地静态验证的结果,不是假装已经完成了生产 CDN 联调。
原来的部署和 CDN 刷新是怎么跑的
原来的部署还是很常规的 Docker 发布:
scripts/purge-cdn.mjs 当时同时做了几件事:
- 自己读取
.env里的腾讯云SecretId/SecretKey。 - 根据变更文件猜要刷哪些路径。
- 在容器里直接调用
PurgePathCache或PurgeUrlsCache。 - 没有变更文件时就把根路径当成全站刷新。
这个版本能解决“部署完成后 CDN 还留着旧页面”的问题,但它有几个边界不太对。
首先,GitHub Actions 的变更计算原本是 HEAD~1..HEAD。一次 push 里如果有多笔 commit,实际部署的是最新镜像,但刷新范围可能只来自最后一个 commit。
其次,脚本是在容器启动后直接跑的。原来的健康检查只是等几秒看一下状态,即使还是 starting 也不会阻止后面的刷新。CDN 刷新发生时,新的 Next.js 进程不一定已经能稳定回源。
更重要的是,这套逻辑只知道“哪些文件改了”,不知道“这个页面在刷新 CDN 前是不是已经从源站重新生成”。它直接调腾讯云,所以也无法复用文章更新时必须做的 Next.js 失效和预热逻辑。
文章运行时更新更明显。后台原来分散着一串 revalidateTag、revalidatePath:有的接口刷详情和首页,有的刷标签,有的只刷当前路径。每个接口单看似乎合理,合起来就很难知道旧标签页、旧分类页、旧合集页有没有遗漏。
一开始想过等一秒,后来放弃了
我最早想的是:
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 去重。
文章更新的关键点不是“更新后有哪些标签”,而是必须保留更新前的快照和旧合集关系,再取更新后的关系。因为标题、日期、路径、标签、分类、合集任何一个变了,只看新值都会漏掉旧页面。
现在按字段分影响范围:
content、description、cover、layout:详情页、RSS、当前所属合集详情。title、date、path:旧/新详情页、首页、归档、RSS、sitemap、标签、分类、合集。hide、is_delete:旧详情和所有旧关系页面。tags、category:标签/分类索引,以及旧、新标签和分类页。- 加入、移出、排序合集:合集详情、合集列表、首页书架和相关文章详情。
likes、visitors、RAG 状态、向量化日志:不刷公开页面和 CDN。
/timeline 目前不读取文章数据,所以不进入文章影响范围。
顺便修了一个原来就藏着的问题:服务层过去只在标题变化时重算文章 path,只改发布日期不会变 URL。现在标题或发布日期变化都会重新生成 path,旧、新 URL 才真的能进入同一份计划。
文章更新时的实际时序
运行时写入和部署变更最终都会走同一套收集器、源站预热器和腾讯云刷新器。文章接口本身只以数据库写入结果为准,缓存链路是尽力而为的。
这里有几个刻意的选择:
revalidateTag/revalidatePath在数据库成功后立即标记,接口不等待后续 CDN 调用。- 单实例进程内用一条 Promise 链串行处理后台刷新,避免同一批次彼此穿插。
- 不做固定等待、不轮询、不建 Redis 队列、不存任务表,也不保存腾讯云
RequestId。 - 一个预热目标失败不会阻塞其他路径;Next.js 失效、预热、CDN 刷新失败都不会把已经写入的文章回滚。
- 缓存影响快照读取也按尽力而为处理,不能让“为了刷新缓存而读关系失败”反过来让文章保存报错。
预热请求直接打 127.0.0.1:3000 的原始路径,并带 no-store / no-cache。普通 HTML 页面需要看到新的 next-rendered-at;文章详情还要比对 data-post-id 和 data-post-updated-at。RSS 和 sitemap 是 XML,本身不依赖 HTML 标记,只要源站返回成功即可。
旧 URL 有一个不能一刀切的地方。标题或日期变化后,有些旧地址会 404;有些旧日期地址可能被详情页按标题兜底到同一篇文章。
- 旧地址确认 404:说明源站已经不再提供旧内容,可以刷新 CDN 的旧对象。
- 旧地址仍返回 200:只有确认它输出的是新
updated版本,才允许刷新 CDN。 - 旧地址返回旧版本、错误页或不相干文章:跳过刷新,避免把旧源站结果重新写回 CDN。
这就是“刷新 CDN 前先预热和验证”的实际意义。
腾讯云 CDN 目标也不是一类
页面路由和静态文件不应该一概按目录刷新:
- 首页
/、/rss.xml、/sitemap.xml、public/**用PurgeUrlsCache精确 URL。 - 普通页面路由用
PurgePathCache,并指定FlushType: "delete"。 - 根 layout、全局样式、公共前台 Provider,或者无法安全确定范围的公共渲染组件,才降级为根目录全站刷新。
同一进程里有一个短时 Map<string, number> 合并重复目标。文章连续保存,或者部署刚好和文章更新撞在一起,同一 URL 不会在短时间内重复向腾讯云提交。腾讯云的 RequestId 只进普通日志,不保存、不查状态、不失败重试。
页面 CDN 和 COS 静态资源也分开:页面默认对应 www.nnnnzs.cn,已经是完整 URL 的资源保持自己的域名,例如 static.nnnnzs.cn,不会被改写成页面域名。
现在的部署架构
部署变更不再让宿主机脚本直接调用腾讯云。现在的职责拆开了:
具体变化是:
- 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_SECRETBearer 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 把源站旧页面重新缓存进去。