14:00 改了内容上线,页面配的是 revalidate: 60,预期 14:01 看到新标题。结果 14:30 还是旧的。给 URL 加个 ?t=${Date.now()} 又是新的,访问正常 URL 又变旧。
最快的判断: 跑一句 curl -sI "https://your-site.com/path",读 x-vercel-cache 响应头。如果是 STALE,说明 ISR 在正常工作 —— 后台渲染完成后下一次请求就是新的,多刷两次正常 URL 即可。如果是 HIT 且 age 比你的 revalidate 大,那是边缘 CDN(不是 ISR)在持有旧页面,需要用 revalidatePath 强制重验(见步骤 2)。一次 refetch 基本就能分清自己属于哪一类。
这几乎从来不是 ISR “坏了”。截至 2026 年 6 月,真正的原因是:页面正在后台重生成、你太早读到了那个本就预期内的 STALE 响应;边缘或下游 CDN 用过长的 s-maxage 盖住了 ISR;半失败的部署留下错位的 prerender-manifest.json;revalidatePath 调用了一个不再匹配路由的路径;或者数据 fetch 在后台 revalidate 时抛了异常,之后 Next.js 默默继续发旧页面,且不会自动重试。
第一步:读懂 x-vercel-cache 响应头
后面所有判断都依赖这一个值。Vercel CDN 会在每个响应上设 x-vercel-cache,它的官方含义(Vercel response-headers 文档)直接告诉你你在和哪一层缓存较劲:
| 值 | 含义 | 卡住了吗? |
|---|---|---|
HIT | 从 CDN 返回,内容仍然新鲜。 | 只有当 age 超过你的 revalidate 才有问题 —— 那是边缘 TTL 太长。 |
STALE | 从缓存返回,但已过期,于是触发了一次后台重生成。 | 没卡。这是健康的 ISR。重生成完成后的下一次请求就是新的。 |
MISS | 缓存里没有,从源站取。动态 / runtime-cache 路由很正常。 | 一般没卡。动态路由即便数据来自 runtime cache,也照样显示 MISS。 |
PRERENDER | 从静态存储返回(如 fallback: true)。 | 没卡。 |
REVALIDATED | 缓存被删除(不带 lifetime 的 revalidatePath/revalidateTag),前台重新渲染。 | 没卡 —— 这是 on-demand 清除之后紧接着的那一次响应。 |
BYPASS | 完全跳过缓存。 | 路由实际上是动态的,查一下为什么。 |
最常见的误判:上线一个改动,刷一次,看到 STALE,就断定 ISR 坏了。它没坏。STALE 表示后台重生成已经在跑了;再隔一两秒刷一两次,就该拿到新页面(随后变 HIT)。真正卡住只有两种情况:(a) HIT 且 age 远超 revalidate;(b) STALE 一直不变新,因为后台渲染反复失败。
关于 x-nextjs-cache:那是 Pages Router 时代的信号。现代 Vercel 部署上,边缘状态的权威来源是 x-vercel-cache,优先信它。
常见原因
按 Vercel + Next.js 13/14/15 上的实际频率排序。
1. CDN 边缘缓存命中盖住了 ISR 层
Vercel 边缘缓存位于 Next.js ISR 之前。如果路由发出 Cache-Control: public, s-maxage=3600(来自 middleware、vercel.json 或手动设的响应头),边缘会按 1 小时保留,无视你 ISR 的 revalidate: 60。Next.js-on-Vercel 的 ISR 默认头是 s-maxage={revalidate}, stale-while-revalidate=31536000;问题几乎总是有东西用更大的 s-maxage 盖掉了 这个默认值。
响应头优先级很关键:Vercel-CDN-Cache-Control 是 Vercel 专属、优先级最高,压过 CDN-Cache-Control,再压过普通 Cache-Control。如果你只设了 Cache-Control 而没设 CDN 专属头,Vercel 会在发给浏览器前把 s-maxage 和 stale-while-revalidate 剥掉,所以光在浏览器里看不出问题 —— 要在服务端用 curl 查。
下游 CDN 是另一个高频元凶(2026 的新场景):如果你在 Vercel 前面套了 Cloudflare 之类的代理,并在那条 ISR 路由上设了 Edge TTL,那个代理会缓存旧 HTML,让 Vercel 的 stale-while-revalidate 周期永远跑不完。规则:ISR 路由绝不要在外部设 Edge TTL,让 Vercel 的 s-maxage/stale-while-revalidate 头原样透传。
如何识别:curl -sI 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'),要么对动态路由传 token 形式加 type:revalidatePath('/blog/[slug]', 'page')(App Router)。当路径含动态段时,第二个参数 type('page' | 'layout')是必填的,只有字面路径才能省略 —— 这是最常见的错。
如何识别:触发调用 revalidatePath 的 webhook 后再请求一次。正确的清除会返回 x-vercel-cache: REVALIDATED(随后是带新内容的 HIT);如果还是 HIT 加旧 HTML,说明路径根本没匹配上。
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. 路由根本没进 prerender manifest(cookies / headers / 动态 API)
revalidate 要生效,路由必须在构建时被登记进 Vercel 的 prerender manifest。如果某个 server component 调了 cookies()、headers() 或读了 searchParams,Next.js 会把这条路由从静态渲染里踢出去,于是它一直是全动态的,你的 export const revalidate 就成了空操作。你以为是 ISR,其实是 dynamic-but-cached,重验语义完全不同。
如何识别:构建日志这条路由前面是 (λ) / (ƒ) / (d),不是 (SSG) / (ISR) / (○)。Vercel “Functions” 标签里能看到这条路由大部分请求都有调用量,而且即使不在任何 revalidate 窗口内,x-vercel-cache 也读 MISS。
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 -sI "https://your-site.com/blog/my-post"
重点看:
x-vercel-cache: STALE→ 已过期但正在后台重生成。多 refetch 一两次,下一条响应就该是新的。没卡。x-vercel-cache: HIT且age大于revalidate→ 边缘 / CDN 层把它持有过了 ISR 窗口。这才是真正的 bug。x-vercel-cache: MISS→ 动态 / runtime-cache 路由(不是经典 ISR)。见原因 5。age: 3500→ 这条响应已经存了 3500 秒,无视你revalidate。cache-control: ...s-maxage=N→ 边缘按 N 秒保留。x-vercel-id: ...→ 请求命中的区域,只有部分区域旧时很有用。
如果一直看到 STALE,就隔一两秒多打几次。要是始终变不新,说明后台渲染在失败 —— 跳到步骤 4。
步骤 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 时同步看实时 runtime 日志。截至 2026 年 6 月,CLI 用 --follow(简写 -f)做实时流式输出,最多跑 5 分钟:
vercel logs --follow
# 或锁定某个部署:
vercel logs --follow --deployment dpl_xxxxx
revalidate 窗口附近有 stacktrace,就说明数据 fetch 在抛。Next.js 不重试也不告警,会一直发旧页面(也就是”STALE 永远变不新”那种情况)。对应服务端错误类型见 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:强制清边缘缓存(兜底)
实在要立刻见效:
- 改一行无关代码触发重新构建 —— 新构建会拿到新的边缘缓存。
- 或在面板里清:Project → Settings → Caches → 按 tag invalidate(或 delete)。这是官方支持的 on-demand 清除路径,对应上面那张表里的
STALE/REVALIDATED状态。 - 要回退到某个干净的已知好部署,就重新 promote:Project → Deployments → 该部署的菜单 → Promote to Production。
别拿这一招当日常做法,它只是盖住了真正的修复。
怎么确认修好了
- 对路径
curl -sI返回新内容且x-vercel-cache: HIT(之前的STALE在请求后几秒内稳定翻成HIT)。 - POST 到
/api/revalidate?path=...后第一次 refetch 返回x-vercel-cache: REVALIDATED,随后 2-3 秒内拿到新内容。 - 函数日志显示后台 revalidate 都是 200 且窗口内没有抛异常。
- CMS 编辑 + webhook 测试在配置的
revalidate窗口内传到线上 —— 在干净的未登录会话里验证,因为 cookies 会把路由踢出 ISR。
长期预防
- 全站
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: curl 显示 x-vercel-cache: STALE,我的 ISR 是不是坏了?
没坏 —— STALE 是健康状态。它表示缓存副本过期了,Vercel 已经发起了一次后台重生成。隔一两秒 refetch 一两次,就该拿到新 HTML,之后响应头变成 HIT。只有这两种情况才是真出问题:STALE 一直变不新(后台渲染在失败 —— 查日志),或者 HIT 且 age 远超你的 revalidate(边缘 TTL 太长)。
Q: 我的页面 revalidate: 60,但日志里完全看不到后台 revalidate?
ISR 后台 revalidate 只在窗口过后下一次请求来时触发。从第一分钟起就没流量的页面会一直缓存。关键低流量页面建议定时发合成 ping(用 cron 去打那个 URL),让窗口里有请求来触发。
Q: 怎么一次性 invalidate 整站?
revalidatePath('/', 'layout') 会 invalidate 根 layout 实际上等于所有子页面。慎用,会触发一波集中重生成。
Q: 部署完了页面还是旧的,新构建也没用。
新构建会重置 prerender manifest,但 CDN 仍可能按 s-maxage 持有旧响应。等一个 s-maxage 窗口或者用 Vercel REST API 清。
Q: 要不要干脆切到全动态?
只有当 revalidate 模型从根本上不能覆盖你的需求(按用户、按区域)时才切。全动态在计算量和 TTFB 上是 10-100 倍代价。先把 ISR 修通。