14:00 改了内容上线,页面配的是 revalidate: 60,预期 14:01 看到新标题。结果 14:30 还是旧的。给 URL 加个 ?t=${Date.now()} 又是新的,访问正常 URL 又变旧。这几乎从来不是 ISR “坏了”,而是:CDN 边缘缓存挡在 ISR 前面、上次构建留下的 prerender-manifest.json 错位、revalidatePath 调用了一个不再匹配路由的路径,或者 getStaticProps 在后台 revalidate 时抛了异常,Next.js 默默继续发旧页面。
常见原因
按 Vercel + Next.js 13/14/15 上的实际频率排序。
1. CDN 边缘缓存命中盖住了 ISR 层
Vercel 边缘缓存位于 Next.js ISR 之前。如果响应带 Cache-Control: public, s-maxage=3600(或你框架的默认值),边缘会按 1 小时保留,无视你 ISR 的 revalidate: 60。
如何识别:curl -I https://your-site/path 看到 x-vercel-cache: HIT 且 age: 接近 3600。cache-control 里如果 s-maxage 大于 revalidate,赢家就是边缘。
2. on-demand revalidatePath 调错路径
revalidatePath('/blog/[slug]') 不会匹配 /blog/my-post。要么传字面路径 revalidatePath('/blog/my-post'),要么 App Router 下传动态路由 token 形式 revalidatePath('/blog/[slug]', 'page'),写错很常见。
如何识别:触发调用 revalidatePath 的 webhook 后再请求一次,x-nextjs-cache 还是 HIT,内容还是旧的。
3. 后台 revalidate 时 getStaticProps / RSC fetch 抛异常
ISR 后台 revalidate 中数据获取抛异常,Next.js 只是默默写日志,继续发旧页面。没有自动重试。除非下一次后台 revalidate 成功或者重新部署,否则一直旧。
如何识别:函数日志反复打印这条页面 fetch 抛异常,但 URL 一直 200 + 旧内容。
4. prerender-manifest.json 在不同部署间错位
部署中途失败会留下指向上一次构建 HTML 的 prerender manifest。新 revalidate 写到新位置但读还是走旧的。失败但被强制 promote 的部署最容易出这种问题。
如何识别:同一 URL 在不同边缘节点(用 curl --resolve 切换 IP)返回不同 HTML,说明 manifest 不同步。
5. cookies / headers 把缓存键搞碎了
App Router 的页面里用 cookies() 或 headers(),Next.js 会把这条路由从静态渲染里踢出去。你以为是 ISR,其实是 dynamic-but-cached,重验语义完全不同。
如何识别:构建日志这条路由前面是 (λ) 或 (d),不是 (SSG) / (ISR)。Vercel “Functions” 标签里能看到这条路由有调用量。
6. webhook 打到了错误的区域 / 部署
revalidatePath 只对收到调用的那个部署生效。如果 webhook 指向 your-site.vercel.app 但生产域名其实别名指向另一个部署,那 invalidation 落在没人看的部署上。
如何识别:webhook 返回 200 OK,但生产页面还是旧的。检查 webhook URL 是不是生产域名,而不是 preview 或 branch 别名。
7. unstable_cache / fetch 的缓存盖过 ISR
App Router 页面里的 fetch 自带缓存层(force-cache、next.revalidate)。两层缓存不一致时,时间长的赢。
如何识别:页面级 revalidate = 60,但 fetch(..., { next: { revalidate: 3600 } }) 在里面。fetch 那条赢。
开始排查前
- 用
curl -I在未登录会话外面确认 staleness(cookies 会把路由踢出 ISR)。 - 对比时间戳:什么时候上线改动、页面
revalidate多少、响应age多少。 - 弄清楚是哪个 Router:Pages Router(
getStaticProps + revalidate)还是 App Router(export const revalidate/ fetch revalidate /revalidateTag)。 - 有管理员权限可以手动调
revalidatePath,作为强制重验的手段。
需要收集的信息
- 完整响应头:
cache-control、x-vercel-cache、x-nextjs-cache、age、x-vercel-id。 - 页面的
revalidate配置以及内部每个fetch的缓存配置。 - 最近 10 分钟该路由的函数日志(盯后台 revalidate 抛的异常)。
- on-demand revalidate 的 webhook payload + URL,加上响应码。
- 路由是否用了
cookies()、headers()、searchParams。 - 生产环境当前别名指向的部署 ID(
vercel ls --prod)。
分步修复
按性价比排序。
步骤 1:看响应头定位到底哪一层缓存在发旧
curl -I "https://your-site.com/blog/my-post"
重点看:
x-vercel-cache: HIT→ 边缘缓存。x-nextjs-cache: HIT→ Next.js ISR。age: 3500→ 这条响应已经存了 3500 秒,无视你revalidate。cache-control: ...s-maxage=N→ 边缘按 N 秒保留。
如果 x-vercel-cache: HIT 且 age 超过 revalidate,问题在边缘层,不在 ISR。
步骤 2:用 on-demand API 强制重新验证
写一个 route handler:
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get("secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const path = req.nextUrl.searchParams.get("path");
if (!path) return NextResponse.json({ ok: false }, { status: 400 });
revalidatePath(path);
return NextResponse.json({ ok: true, revalidated: path });
}
用字面路径调用:
curl -X POST "https://your-site.com/api/revalidate?secret=$REVALIDATE_SECRET&path=/blog/my-post"
再 fetch 一次,如果变新了,说明 on-demand 是通的,把它正确接进 CMS webhook 即可。
步骤 3:让 s-maxage 不大于 revalidate
App Router 页面不要手动设 Cache-Control,Next.js 自己设是对的。如果某个 middleware 或响应头覆盖了它:
// middleware.ts — 删掉或改正类似这种行:
// response.headers.set("Cache-Control", "public, s-maxage=3600, ...");
如果确实要自定义,至少要让 s-maxage 与 revalidate 对齐:
response.headers.set(
"Cache-Control",
`public, s-maxage=60, stale-while-revalidate=86400`,
);
步骤 4:盯后台 revalidate 的抛错
触发 revalidate 时同步看日志:
vercel logs --follow --since 5m
revalidate 窗口附近有 stacktrace,就说明数据 fetch 在抛。Next.js 不重试也不告警,会一直发旧页面。对应服务端错误类型见 vercel 500 errors。
步骤 5:确认 App Router 的 fetch 缓存没盖过页面级
审一遍页面里的每个 fetch(...):
// 顶层数据 fetch 都用页面级 revalidate
const res = await fetch(url, { next: { revalidate: 60 } });
或者用 revalidateTag 按逻辑分组:
const res = await fetch(url, { next: { tags: ["post-list"], revalidate: 3600 } });
// 别处:
import { revalidateTag } from "next/cache";
revalidateTag("post-list");
步骤 6:确认 webhook 打的是生产部署
vercel ls --prod
确认 webhook URL 是生产域名 your-site.com,不是 preview 别名 your-site-git-main-team.vercel.app。invalidation 是按部署来的,打错就静默无效。
步骤 7:强制清边缘缓存(兜底)
实在要立刻见效:
- 改一行无关代码触发重新构建 —— 新构建会拿到新的边缘缓存。
- 或者用 Vercel REST API
POST /v6/deployments/<id>/promote强行提一个干净部署。
别拿这一招当日常做法,它只是盖住了真正的修复。
验证
- 对路径
curl -I时,x-vercel-cache: HIT必须发生在真正的 revalidate 窗口已过、内容已新的情况下。 - POST 到
/api/revalidate?path=...后 2-3 秒内 refetch 拿到新内容。 - 函数日志显示后台 revalidate 都是 200 且没有抛异常。
- CMS 编辑 + webhook 测试在配置的
revalidate窗口内传到线上。
长期预防
- 全站
s-maxage始终 ≤revalidate,边缘 TTL 不许超过 ISR 窗口。 - 所有数据 fetch 用 try/catch 兜住、返回 sentinel 而不是抛异常,避免后台 revalidate 静默失败。
- 用
revalidateTag("posts"、"site-config")做逻辑分组重验,比一条一条 path 强。 - 在 CI 里测重验 webhook:部署、改测试数据、调 webhook、断言 HTML 已更新。
- 每次
revalidatePath/revalidateTag调用都记录 path 和结果便于回溯。 - 全站统一 Pages Router 或 App Router,混用相当于跑两套缓存模型,互相影响很难调。
常见坑
- 调
revalidatePath('/blog/[slug]')期望刷新所有 blog 文章 —— App Router 不带第二个参数'page'时它只匹配字面字符串。 - “为安全”设
revalidate = 0—— 这等于把路由从静态生成里踢出去,每次请求都付函数调用代价。 - 忘了 server component 里用
cookies()或headers()会让整条路由退出 ISR,把这些挪到 Route Handler 里。 - 以为改了 layout、util 之类的非内容代码会触发 ISR —— 它们只在部署时生效,revalidate 阶段不会重读。
- 低流量页面期望 ISR 按时刷新 —— ISR 是请求驱动的,没流量就不会 revalidate。相关”静态站像没更新”模式见 deploy succeeded page old。
常见问答
Q: 我的页面 revalidate: 60,但日志里完全看不到后台 revalidate?
ISR 后台 revalidate 只在窗口过后下一次请求来时触发。从第一分钟起就没流量的页面会一直缓存。关键低流量页面建议定时发合成 ping。
Q: 怎么一次性 invalidate 整站?
revalidatePath('/', 'layout') 会 invalidate 根 layout 实际上等于所有子页面。慎用,会触发一波集中重生成。
Q: 部署完了页面还是旧的,新构建也没用。
新构建会重置 prerender manifest,但 CDN 仍可能按 s-maxage 持有旧响应。等一个 s-maxage 窗口或者用 Vercel REST API 清。
Q: 要不要干脆切到全动态?
只有当 revalidate 模型从根本上不能覆盖你的需求(按用户、按区域)时才切。全动态在计算量和 TTFB 上是 10-100 倍代价。先把 ISR 修通。