Next.js ISR 重新验证卡住、一直返回旧页面 —— 排查与修复

ISR 页面 revalidate 窗口早就过了还在发旧 HTML,几乎都是 CDN 边缘缓存盖住 ISR、构建产物错位,或 revalidatePath 调用的路径不匹配。

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: HITage: 接近 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-cachenext.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-controlx-vercel-cachex-nextjs-cacheagex-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: HITage 超过 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-maxagerevalidate 对齐:

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 修通。

标签: #排查 #Next.js #isr #cache #Vercel