Next.js 缓存/重验证与一致性:避免「线上怎么不更新」事故(App Router)
一句话结论:App Router 下的“数据是否更新”不是一个开关,而是多层缓存共同作用的结果:
- **fetch/Data Cache(跨请求)**决定“服务端这次渲染用的是什么数据”
- route 级别的静态化/重验证决定“这条路由的 RSC/HTML 会不会被复用”
- **Route Segment Cache(客户端路由缓存)**决定“用户在浏览器里导航时会不会继续看到旧的 RSC payload”
- **Server Actions(写入)**不会自动让读路径一致,必须显式 revalidate + refresh
所谓「线上怎么不更新」,通常是:写已经成功,但读还在命中旧缓存(而 dev 环境缓存弱/不同,导致本地复现困难)。
0. 你要先回答的 3 个问题(定位方向)
- 旧的是哪一层?
- 仅“站内跳转”旧,但硬刷新就新 → 多半是 Route Segment Cache(客户端)
- 硬刷新也旧,但等一会儿会变新 → 多半是 ISR/time-based revalidate
- 过多久都不变(直到手动清缓存/发版)→ 多半是 Data Cache / Full Route Cache / unstable_cache 没有失效
- 这是公共数据还是用户态数据?
- 公共数据(资讯、商品详情、榜单)适合 cache + revalidate/tag
- 用户态数据(购物车、权限、个性化 feed)通常应
no-store或强动态
- 读路径和写路径是否在同一个“缓存一致性协议”里?
- 写:Server Action / API Route / 后台任务
- 读:RSC
fetch/unstable_cache/ Client Query
如果读和写分别被不同体系缓存(Next fetch cache + 浏览器 route cache + React Query),一致性问题是必然的。
1. 心智模型:App Router 的“4 层缓存”
把一次页面展示拆成两段:
- 服务端渲染(RSC 生成):决定“这次渲染拿到的数据”
- 客户端导航(soft navigation):决定“用户在 SPA 式导航中是否复用旧的 RSC payload”
对应常见缓存层(命名按工程理解,不是官方唯一术语):
1.1 Request 级:React/Next 的请求内去重(不是罪魁祸首)
- 同一次 RSC 渲染里,同样的
fetch(url, options)会被去重/复用 - 作用范围通常是单次请求/单次渲染,不会导致“线上永远不更新”
排查时不要把“去重”当作跨请求的缓存。
1.2 Data Cache:Next 增强 fetch 的“跨请求缓存”(高频事故源)
fetch()在 App Router 中不是纯浏览器 fetch,而是 Next 包装过的版本- 当命中 Data Cache:
- 服务端生成 RSC 时会直接复用旧响应
- 你的代码看起来“又执行了 fetch”,但实际上返回的是缓存结果
关键 knobs(常用):
fetch(url, {
cache: 'force-cache' | 'no-store',
next: {
revalidate: 60, // 秒:time-based ISR for this request
tags: ['posts', 'post:123'], // 用于 on-demand revalidateTag
},
})
1.3 Full Route / Segment 静态化:路由级别是否可复用(宏观策略)
路由/segment 的配置会影响“这条路由到底是静态、ISR 还是动态”。常用:
// app/.../page.tsx or layout.tsx
export const revalidate = 60 // 路由级 ISR(time-based)
export const dynamic = 'force-dynamic' // 强制动态(不走静态缓存)
export const dynamic = 'force-static' // 强制静态(配合 generateStaticParams)
// Next 也提供 fetchCache 之类的配置(用于约束该段下 fetch 的默认行为)
// export const fetchCache = 'force-no-store' | 'force-cache' | ...
工程理解:
- 路由如果被判定为 static/ISR,即便你页面里没写
revalidate,也可能在生产环境看到“复用/延迟更新”的行为 - 路由被判定为 dynamic,很多 fetch 默认会变得更接近实时(但成本更高)
常见触发“动态化”的因素:读取
cookies()/headers()、使用某些用户态 API 等。
1.4 Route Segment Cache(客户端):soft navigation 为何还在看旧数据
App Router 在浏览器端会缓存一份“segment tree 对应的 RSC payload”。因此会出现:
- 数据其实在服务端已更新
- 但用户从 A → B 的站内跳转复用了旧的 RSC payload
- 只有硬刷新/
router.refresh()才会看到新数据
高频触发场景:
<Link />prefetch 把旧 RSC 提前拉到本地缓存- mutation 发生后,你只更新了 DB,但没有触发 refresh / revalidate
2. revalidate 的三种语义:time-based / on-demand / no-store
2.1 time-based:过期后再更新(“最终一致”)
revalidate: 60表示:在 60 秒窗口内可能仍返回旧内容- 适用于:可接受延迟的公共数据(资讯、运营位、列表)
常见误区:
- 以为
revalidate: 60是“60 秒后立刻变新”- 实际更像:60 秒后下一次请求才会触发再生成(且不同平台/CDN 行为会影响体感)
2.2 on-demand:写入后立刻让读缓存失效(“写后读一致”的工程解)
核心 API:
revalidateTag(tag):按“数据域”失效(推荐默认)revalidatePath(path):按“路径”失效(用于路由级输出)
经验:tag 更接近数据层一致性,path 更接近页面层一致性。
2.3 no-store:完全不缓存(“强一致但贵”)
fetch(url, { cache: 'no-store' })- 适用于:用户态/权限敏感/强一致数据
常见误区:
- 只给某个 fetch
no-store,但页面仍被 segment/route 静态化复用,导致“看起来还是旧” - 或者相反:页面是动态的,但你的数据函数用了
unstable_cache,照样永远旧
3. Server Actions:写入≠自动刷新读缓存
Server Action 更像一个“RPC 写接口”:
- 它会改变数据库/后端状态
- 但 不会自动通知 Next 的 Data Cache / 路由输出缓存 / 客户端 route cache
因此 mutation 的标准收尾动作通常是:
- 服务端失效缓存:
revalidateTag/revalidatePath - 客户端刷新当前路由树:
router.refresh()(尤其是你留在同一页时)
示例(推荐形态:tag 驱动):
// app/actions/updatePost.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updatePostAction(id: string, input: any) {
await db.post.update({ where: { id }, data: input })
// 失效“数据域”
revalidateTag(`post:${id}`)
revalidateTag('post:list')
}
读路径:
export async function getPost(id: string) {
const res = await fetch(`${API}/posts/${id}`, {
next: { tags: [`post:${id}`], revalidate: 300 },
})
return res.json()
}
留在同页的交互(客户端):
import { useRouter } from 'next/navigation'
const router = useRouter()
await updatePostAction(id, input)
router.refresh() // 刷新 segment tree(避免命中本地旧 RSC payload)
4. 常见坑位清单(“为什么线上不更新”)
4.1 本地 dev 没问题,线上才复现
next dev的缓存策略与 production 不同(更偏“开发体验”)- 线上才会出现:Full Route Cache / Data Cache / CDN 缓存 / prefetch
排查要求:用 next build && next start 复现,或在预发用同样部署形态。
4.2 以为 fetch 默认实时,实际被 Data Cache 复用
- 在 static/ISR route 下,
fetch很容易进入 cache - 你看日志会发现“函数没重新跑/接口没重新请求”
解决:
- 明确写出策略:
cache: 'no-store'或next: { revalidate, tags } - 把策略收敛到数据层函数(不要散落到页面里)
4.3 用了 revalidatePath,但数据被多个路由复用
- 你失效了
/posts/123,但列表页/posts仍复用旧缓存
解决:
- 用
revalidateTag('post:list')一次性失效所有依赖该数据域的路由
4.4 soft navigation 命中 Route Segment Cache:硬刷新才更新
表现:
- 用户从站内点链接看到旧内容
Cmd+R/强刷后变新
解决:
- mutation 后
router.refresh()(留在同页) - 或者 mutation 后
redirect()到目标页(导航会触发新的 RSC 请求,但仍建议配合 revalidate) - 对“强一致页面”,谨慎 prefetch(或评估是否需要)
4.5 unstable_cache/自定义缓存:失效体系缺失
- DB 直连、SDK 调用不是 fetch(不会自动进入 Data Cache),你可能用了
unstable_cache包了一层 - 一旦没有 tags / revalidate,就会永久旧
解决:
- 给
unstable_cache明确 tags - 或者对强一致域禁止
unstable_cache
4.6 双缓存真相:Next 缓存 + Client Query 缓存
- RSC 用 Next cache,Client 组件又用 TanStack Query 管同一份数据
- 你 invalidate 了 A,却忘了 invalidate B
解决:
- “同一数据域只选一套真相”:
- 页面级/首屏:RSC + Next cache
- 强交互:Client Query(并用
router.refresh仅做壳更新)
5. Debugging Checklist(可直接拷贝到 issue)
5.1 复现条件
- 是否在 production build 复现?(
next build && next start) - 是否只在站内跳转复现?硬刷新是否能变新?
- 是否与特定用户/权限/cookie 有关?(不同用户是否不同)
5.2 判断是“读缓存旧”还是“写没成功”
- DB/后端是否确实已写入?(后台查库/查日志)
- 服务端直接请求同一数据接口是否已返回新数据?(curl/日志)
5.3 检查读路径(fetch / 数据函数)
- 关键
fetch是否显式声明了cache/next.revalidate/next.tags? - 是否用了
unstable_cache/ 自研缓存?是否有 tags/失效? - 是否因为
cookies()/headers()导致 segment dynamic 化(或反过来导致静态化)?
5.4 检查写路径(Server Action / API)
- mutation 后是否调用
revalidateTag/revalidatePath?失效粒度是否覆盖所有读页面? - 留在同页是否调用
router.refresh()? - 是否存在“写后立刻读”的流程(需要强一致)?如果是,是否仍在用 time-based revalidate?
5.5 检查客户端导航
-
<Link prefetch>是否提前拉了旧 payload? - 是否在同一个 tab 里长期停留导致一直复用旧 segment cache?
6. 推荐模式(团队级约束)
6.1 “缓存策略下沉到数据层”
不要在页面里零散写:
// ❌ 到处散落、难审计
await fetch(url, { next: { revalidate: 60 } })
建议统一成:
// ✅ 数据层收口
export const tags = {
post: (id: string) => `post:${id}`,
postList: () => 'post:list',
}
export async function getPost(id: string) {
return fetch(`${API}/posts/${id}`, {
next: { tags: [tags.post(id)], revalidate: 300 },
}).then(r => r.json())
}
收益:
- 策略可审计(容易 code review)
- 失效可统一(tag 命名规范化)
6.2 “tag 驱动失效”优先于“path 驱动失效”
- 数据域(posts/users/products)→ tag
- 页面输出(某个路径的组合渲染结果)→ path(作为补充)
6.3 mutation 的统一收尾协议
- Server Action / 写接口成功后:
revalidateTag(受影响的数据域)- 如需要:
revalidatePath(受影响的聚合页)
- UI 层:
- 留在当前路由:
router.refresh() - 跳转到新路由:
redirect()(必要时仍 refresh/失效)
- 留在当前路由:
6.4 对“强一致用户态”默认 no-store
- 用户资料/权限/购物车/通知数等:
- 默认
cache: 'no-store' - 并显式让相关 segment dynamic(避免被静态化策略误伤)
- 默认
7. 与 Suspense / 路由边界的协作(体验层)
缓存/刷新做对了还不够:你还需要避免 refresh 带来的“整页闪烁”。
- 用合理的
layout.tsx保持稳定壳 - 用
loading.tsx/<Suspense>把等待限制在局部边界
(这能把 router.refresh() 的体感从“整页重载”变成“局部更新”。)
关联阅读
- App Router 的 segment tree / layout / loading 与 prefetch:
- 路由与 URL state 心智模型:
- 数据层缓存与失效(客户端视角):
- 一致性与乐观更新:
- Suspense 与加载态边界:
- 渲染与交付总览: