Notes

Next.js 缓存/重验证与一致性:避免「线上怎么不更新」事故(App Router)

一句话结论:App Router 下的“数据是否更新”不是一个开关,而是多层缓存共同作用的结果

  • **fetch/Data Cache(跨请求)**决定“服务端这次渲染用的是什么数据”
  • route 级别的静态化/重验证决定“这条路由的 RSC/HTML 会不会被复用”
  • **Route Segment Cache(客户端路由缓存)**决定“用户在浏览器里导航时会不会继续看到旧的 RSC payload”
  • **Server Actions(写入)**不会自动让读路径一致,必须显式 revalidate + refresh

所谓「线上怎么不更新」,通常是:写已经成功,但读还在命中旧缓存(而 dev 环境缓存弱/不同,导致本地复现困难)。

0. 你要先回答的 3 个问题(定位方向)

  1. 旧的是哪一层?
  • 仅“站内跳转”旧,但硬刷新就新 → 多半是 Route Segment Cache(客户端)
  • 硬刷新也旧,但等一会儿会变新 → 多半是 ISR/time-based revalidate
  • 过多久都不变(直到手动清缓存/发版)→ 多半是 Data Cache / Full Route Cache / unstable_cache 没有失效
  1. 这是公共数据还是用户态数据?
  • 公共数据(资讯、商品详情、榜单)适合 cache + revalidate/tag
  • 用户态数据(购物车、权限、个性化 feed)通常应 no-store 或强动态
  1. 读路径和写路径是否在同一个“缓存一致性协议”里?
  • 写: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 的标准收尾动作通常是:

  1. 服务端失效缓存revalidateTag / revalidatePath
  2. 客户端刷新当前路由树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() 的体感从“整页重载”变成“局部更新”。)

关联阅读

cd ..