发布日期

第 13 讲|Caching 全景:4 层缓存的真相

请求记忆 / 数据缓存 / 完整路由缓存 / 路由缓存:4 层缓存的真相与失效机制

阶段二:App Router 核心机制 · 第 13 / 40 讲 难度:⭐⭐⭐⭐⭐ · 预计耗时:3.5 小时 配套 fixture:fixtures/lecture-13/

学习目标

  • 把"4 层缓存"画在一张图里:Request Memoization / Data Cache / Full Route Cache / Router Cache。
  • 看懂每层的物理位置(进程内 / 磁盘 / 浏览器)、生命周期、命中/失效条件。
  • 理解一次请求里 4 层缓存的查询顺序,画出"miss 全部"的最长路径。
  • 区分各种 revalidate API 的影响范围:哪些 invalidate 哪一层。
  • 在 fixture 中观察"同一份数据"4 次刷新分别命中哪一层,并能解释。

1. 4 层缓存全景图

App Router 在不同位置放了 4 层独立的缓存。先认识它们:

┌────────────────────────────────────────────────────────────────────┐
│                          浏览器                                    │
│  ┌──────────────────────────────────────────────────────────────┐ │
│  │  ④ Router Cache(segment cache)                              │ │
│  │     - 客户端内存                                              │ │
│  │     - 生命周期:单个浏览器会话                                │ │
│  │     - key:URLCacheNode                                    │ │
│  │     -9 讲                                                  │ │
│  └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
                               navigate / prefetch
┌────────────────────────────────────────────────────────────────────┐
│                          服务端进程                                │
│  ┌──────────────────────────────────────────────────────────────┐ │
│  │  ③ Full Route Cache                                          │ │
│  │     - .next/server/app/{path}.html / .meta / .rsc            │ │
│  │     - 生命周期:build / ISR 周期                             │ │
│  │     - key:URL → 完整 HTML + RSC payload                     │ │
│  └──────────────────────────────────────────────────────────────┘ │
│  ┌──────────────────────────────────────────────────────────────┐ │
│  │  ② Data Cache(fetch / unstable_cache / 'use cache')       │ │
│  │     - .next/cache/{...} 或自定义 CacheHandler                │ │
│  │     - 生命周期:跨请求、跨部署可选                          │ │
│  │     - key:fetch URL+body+tags / unstable_cache key 等       │ │
│  │     -11 讲                                                │ │
│  └──────────────────────────────────────────────────────────────┘ │
│  ┌──────────────────────────────────────────────────────────────┐ │
│  │  ① Request Memoization(React.cache 套 fetch)              │ │
│  │     - 内存(AsyncLocalStorage scope)                        │ │
│  │     - 生命周期:单次请求                                     │ │
│  │     - key:fetch URL + init                                   │ │
│  └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘

记忆口诀:

  • 解决"同一请求多次 fetch 同一 URL" → 单请求内 dedupe。
  • 解决"跨请求重复获取同样数据" → 跨请求复用。
  • 解决"同一 URL 重复渲染整页" → 跨请求复用渲染产物。
  • 解决"同一会话切换 URL 重复发请求" → 浏览器侧零网络。

速记:①②③④ 由内向外、由短到长、由数据到产物

2. ① Request Memoization

最简单也最容易被忽视的一层。

2.1 它是什么

React 的 cache(fn) API 包装一个函数,在单次请求生命周期内对相同参数 dedupe。Next.js 在 server 端把 fetch 自动套上一层 cache(fn)

// 概念伪代码(实际在 patchFetch 里)
const memoizedFetch = React.cache((url, init) => originalFetch(url, init));
globalThis.fetch = memoizedFetch;

2.2 业务效果

// app/products/[id]/layout.tsx
async function getProduct(id: string) {
  const res = await fetch(`/api/products/${id}`, { next: { revalidate: 60 } });
  return res.json();
}

export default async function Layout({ params, children }) {
  const product = await getProduct(params.id); // 第一次 fetch
  return (
    <>
      <Breadcrumb name={product.name} />
      {children}
    </>
  );
}

// app/products/[id]/page.tsx
export default async function Page({ params }) {
  const product = await getProduct(params.id); // 第二次:命中 ① memoization,零 HTTP
  return <ProductDetail product={product} />;
}

Layout 与 Page 都调用 getProduct(id)实际只发一次 HTTP。这层 dedupe 完全无感、零配置。

2.3 失效

请求结束即失效。下个请求重新计算。

2.4 命中条件

  • 同一次请求内。
  • 相同 URL + 相同 init(method / headers / body)。
  • 没有显式 cache: 'no-store'(这种情况下不进入 memoization)。

3. ② Data Cache

第 11 讲深入讲过。这里复习要点。

3.1 它是什么

跨请求的"数据层缓存"。三种 API 都写到这里:

  • fetch(url, { next: { revalidate, tags } })
  • unstable_cache(fn, keyParts, options)
  • 'use cache' 函数

3.2 物理位置

packages/next/src/server/lib/incremental-cache/file-system-cache.ts

  • 默认:.next/cache/fetch-cache/(fetch 数据)+ .next/cache/{hash}.html 等。
  • 自定义:experimental.cacheHandler 接 Redis / KV / 自研。

3.3 IncrementalCache 类

106:packages/next/src/server/lib/incremental-cache/index.ts
export class IncrementalCache implements IncrementalCacheType {
  readonly dev?: boolean
  readonly disableForTestmode?: boolean
  readonly cacheHandler?: CacheHandler
  readonly hasCustomCacheHandler: boolean
  readonly prerenderManifest: DeepReadonly<PrerenderManifest>
  readonly requestHeaders: Record<string, undefined | string | string[]>
  readonly allowedRevalidateHeaderKeys?: string[]
  readonly minimalMode?: boolean
  readonly fetchCacheKeyPrefix?: string
  readonly isOnDemandRevalidate?: boolean
  readonly revalidatedTags?: readonly string[]

  private static readonly debug: boolean =
    !!process.env.NEXT_PRIVATE_DEBUG_CACHE

注意环境变量 NEXT_PRIVATE_DEBUG_CACHE=1 —— 打开后服务端会打印每次 cache get/set 详情。生产排查必备

3.4 失效条件

  • 时间过期(revalidate 秒数)。
  • 显式失效:revalidatePath / revalidateTag
  • 重新 build(默认 build 后清空,除非配置 cacheHandler 持久化到外部)。
  • 写入失败(如磁盘满、网络抖动)→ 这条 entry 进 stale,下次请求触发 revalidate。

4. ③ Full Route Cache

最重的一层——它缓存的是"整页 HTML + RSC payload"。

4.1 它是什么

build 时(或 ISR 触发时)把整页渲染好,存到磁盘:

.next/server/app/products/123.html        # SSR HTML
.next/server/app/products/123.rsc         # RSC payload
.next/server/app/products/123.meta        # 元数据(status, headers)

下次请求来 → 直接从磁盘读 → 零渲染、零数据库查询。

4.2 五种 CachedRouteKind

55:packages/next/src/server/response-cache/types.ts
export const enum CachedRouteKind {
  APP_PAGE = 'APP_PAGE',
  APP_ROUTE = 'APP_ROUTE',
  PAGES = 'PAGES',
  FETCH = 'FETCH',
  REDIRECT = 'REDIRECT',
  IMAGE = 'IMAGE',
}

每种 kind 的 entry 形态都不同:

100:packages/next/src/server/response-cache/types.ts
export interface CachedAppPageValue {
  kind: CachedRouteKind.APP_PAGE
  // this needs to be a RenderResult so since renderResponse
  // expects that type instead of a string
  html: RenderResult
  rscData: Buffer | undefined
  status: number | undefined
  postponed: string | undefined
  headers: OutgoingHttpHeaders | undefined
  segmentData: Map<string, Buffer> | undefined
}

export interface CachedPageValue {
  kind: CachedRouteKind.PAGES
  // this needs to be a RenderResult so since renderResponse
  // expects that type instead of a string
  html: RenderResult
  pageData: Object
  status: number | undefined
  headers: OutgoingHttpHeaders | undefined
}

export interface CachedRouteValue {
  kind: CachedRouteKind.APP_ROUTE
  // this needs to be a RenderResult so since renderResponse
  // expects that type instead of a string
  body: Buffer
  status: number
  headers: OutgoingHttpHeaders
}

观察:

  • APP_PAGEPAGES 多一个 rscDatasegmentData,因为 RSC 需要分段流式。
  • postponed 字段是 PPR 的"恢复点"——预渲染暂停时的状态序列化(第 19 讲细讲)。
  • APP_ROUTE 没 html,只有原始 body——route handler 可以返回任意类型(JSON / blob)。

4.3 命中条件

满足以下全部条件:

  • 路由是静态的(页面没用 dynamic = 'force-dynamic'、没用动态 API 如 cookies()headers() 不带请求 scope)。
  • 已经 build 过(或 ISR 触发过)。
  • Full Route Cache entry 没被 revalidatePath / 时间过期。

4.4 失效条件

  • revalidatePath('/products/[id]'):精确路径失效。
  • revalidateTag('xxx') 间接失效:因为 entry 里的 fetch 用了同 tag。
  • ISR 时间窗口到期。
  • 重新 build。

4.5 与 Data Cache 的协作

用户访问 /products/123 第一次:

  1. 查 ③ Full Route Cache → miss。
  2. 渲染 page → page 内 fetch → 查 ② Data Cache → miss → 真实拉数据。
  3. 渲染完成 → 存 ② + 存 ③。

第二次访问(短时间内):

  1. 查 ③ Full Route Cache → hit → 直接返回 HTML/RSC,不进入 React 渲染

第二次访问(③ 过期但 ② 没过期):

  1. 查 ③ → miss。
  2. 渲染 page → fetch → 查 ② → hit → 命中 fetch cache 拿数据。
  3. 重新渲染 → 存 ③。

生产排查提示:用户反馈"页面静态化但接口数据老旧"——这是 ③ miss 但 ② hit 的典型表现。两层独立 revalidate 设置导致的"假命中"。统一 revalidate 策略避免。

5. ④ Router Cache(segment cache)

第 9 讲讲过。这里只列要点:

  • 客户端内存(页面刷新即丢)。
  • 按 segment 切片缓存。
  • prefetch 与 navigate 都写入。
  • STATIC_STALETIME_MS 默认 5 分钟,DYNAMIC_STALETIME_MS 默认 0 秒。
  • revalidate* 不直接 invalidate ④ ——但下次 navigate 拉取 RSC 时会发现 ②③ 失效,间接更新 ④。

5.1 客户端 invalidate ④ 的方式

  • router.refresh():硬刷整个客户端 cache,再向 server 拉。
  • revalidatePath/revalidateTag + 服务端响应头 x-next-cache-tags:客户端基于 tag 失效对应 segment。

6. 一次完整请求里的查询顺序

假设全部 miss(最长路径),用户访问 /products/123

浏览器输入 URL
HTTP GET /products/123 → server
Full Route Cache.next/server/app/products/123.html → miss
进入 React renderToReadableStream,开始装配组件树
Page 组件中 await fetch('/api/p/123', { next: { tags: [...] } })
Request Memoization 查内存 → miss(首次)
Data Cache.next/cache/fetch-cache/{key} → miss
真实 HTTP 请求 /api/p/123
存 ② Data Cache(异步),存Request Memoization
继续渲染,可能再用 unstable_cache 读 ② → 同样 miss → 写
渲染完成,存 ③ Full Route Cache
HTML 流式响应到浏览器
浏览器初始化客户端 router,把 SeedData 写入 ④ Router Cache

第二次同 URL 访问:

HTTP GET /products/123
Full Route Cache hit → 直接返回 HTML + RSC
(不经过任何 React 渲染、fetch、② ① 都不查)
浏览器继续命中 ④ 不发 RSC 请求

7. 业务案例:博客文章页

// app/posts/[slug]/page.tsx
export const revalidate = 600  // 10 分钟 ISR

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 60, tags: [`post:${slug}`] },
  })
  return res.json()
}

export default async function PostPage({ params }) {
  const { slug } = await params
  const post = await getPost(slug)
  return <Article post={post} />
}

这一页里 4 层缓存的窗口:

失效时间
① Request Memoization单请求
② Data Cache(fetch)60s
③ Full Route Cache(ISR)600s
④ Router Cache5min(默认 STATIC_STALETIME_MS)

问:作者编辑文章后,怎么让所有缓存立刻刷新?

// 编辑接口完成后
revalidateTag(`post:${slug}`); // 失效 ②
revalidatePath(`/posts/${slug}`); // 失效 ③

服务端响应客户端时会设置 x-next-cache-tags: post:${slug},客户端 ④ Router Cache 基于此失效该 segment。

注意router.refresh() 不会失效 ②③ ——它只让客户端重发 RSC 请求,再读 ②③;如果 ②③ 没被 revalidate* 失效,仍然命中旧数据。

8. 4 层各自的"命中表"

下面这张表是排查时的速查工具:

现象① Memoization② Data Cache③ Full Route④ Router
F5 刷新数据没变hithit
F5 多次后突然变hit→misshit→miss
客户端 link 切换数据没变hit
router.refresh() 后变invalidate
revalidateTag 后变invalidateinvalidateinvalidate(间接)
revalidatePath 后变invalidate(同 tag)invalidateinvalidate(间接)
Layout 与 Page 同时 await 同 URLdedupe
调用 cookies() 后无 cachebypassbypassbypassn/a
cache: 'no-store'bypassbypass
dynamic = 'force-dynamic'bypass

生产排查提示:定位 cache 问题时,按从 ④ 到 ① 顺序排除(先看客户端是否命中,再排查 server)。NEXT_PRIVATE_DEBUG_CACHE=1 打开看 ② ③ 的 hit/miss,结合浏览器 console 的 window.nd.tree 看 ④。

9. 重难点

9.1 ② 与 ③ 是"独立失效"

很多人误以为 revalidatePath 会一并失效 ② 与 ③。其实它们各自有 entry,各自维护时间戳。当 ③ 失效但 ② 仍在窗口期,重渲染会读到旧数据并写回新 HTML——结果是"页面更新了但数据没变"。

解决:统一在 server action 里成对调用 revalidatePath + revalidateTag,让两层一起失效。

9.2 ① 与 ② 的边界

React.cache(fetch) 的 dedupe 与 next.revalidate 是两件事:

  • cache: 'no-store' 不写 ② Data Cache,但仍然走 ① memoization(同请求内 dedupe 还在)。
  • revalidate: 0 等价 no-store
  • revalidate: 60 写 ②,也走 ①。

不要把 no-store 当作"完全不缓存"——它不缓存的是跨请求那一层。

9.3 ④ 与 ② 的"二次命中假象"

用户报:'use cache' 失效了,但客户端仍看到老数据。

排查链:

  1. 服务端 ② 已失效(NEXT_PRIVATE_DEBUG_CACHE 看到 miss)。
  2. 服务端重新渲染并写 ③。
  3. 但客户端 ④ Router Cache 没收到失效信号 —— revalidateTag 服务端调用了,但客户端 navigate 还在 stale 窗口,下次 prefetch 才能更新。

解决:服务端 revalidate* 后用 redirect 触发硬导航,或在客户端调用 router.refresh()

9.4 dev 与 prod 的 ③ 差异

  • dev:③ 默认禁用——每次请求重新渲染(保证修改即刻可见)。
  • prod:③ 默认启用——build 时预渲染所有静态路由。

所以"dev 慢、prod 飞快"的体感差异主要来自 ③。

9.5 minimalMode 与外部 cacheHandler

Vercel 部署使用 minimalMode: true,所有 cache 写到外部 KV/blob 而非本地磁盘。自建部署+多副本时必须配置外部 cacheHandler——否则各副本各写各的磁盘,多机不一致。

10. 配套 fixture:观察 4 层缓存

fixtures/lecture-13/ 含 3 个示例:

  1. app/example-1-memoization/ — Layout + Page 同时 fetch 同 URL,演示 ① 命中。
  2. app/example-2-data-vs-route/ — ② 与 ③ 不同 revalidate 周期,演示"假命中"。
  3. app/example-3-revalidate/ — server action 触发 revalidatePath + revalidateTag,观察各层失效。

10.1 推荐实验

cd learning/nextjs-40-lectures/fixtures/lecture-13
NEXT_PRIVATE_DEBUG_CACHE=1 pnpm dev
# 浏览器打开 http://localhost:3013/

实验 A:① Request Memoization

  • /example-1,dev server 终端日志里 getTime 只打印一次(虽然 layout 与 page 都调用)。
  • fetch 改成 cache: 'no-store',仍然只一次(因为 ① 仍生效)。

实验 B:② vs ③ 失效不一致

  • /example-2,第一次访问看到时间 T0。
  • 设置 page 的 revalidate = 5,fetch 的 revalidate = 60
  • 5 秒后刷新——③ 重渲染,但 ② 还在 60s 内,所以页面"新"但数据"老"。
  • dev 终端日志会显示 fetch hit 但 page 重 render。

实验 C:revalidate 联动

  • /example-3 含一个表单,提交后服务端调 revalidatePath('/example-2') + revalidateTag('time')
  • 提交后导航到 /example-2,看到时间立即更新(②③ 同时失效)。

11. 检验问题

  1. App Router 4 层缓存分别叫什么?物理位置在哪?生命周期多长?
  2. ① Request Memoization 是 React 提供的还是 Next.js 实现的?
  3. ② Data Cache 的 entry 是按 URL 还是按 cacheKey 索引?为什么不同?
  4. ③ Full Route Cache 的 5 种 CachedRouteKind 各对应什么?
  5. APP_PAGE 与 PAGES 的 entry 多了什么字段?为什么?
  6. revalidatePath 直接失效哪几层?间接失效哪一层?
  7. cache: 'no-store'dynamic = 'force-dynamic' 各自 bypass 哪几层?
  8. 客户端 router.refresh() 失效 ④ 的同时会失效 ② ③ 吗?
  9. dev / prod 模式下 ③ Full Route Cache 行为有什么差别?
  10. fixture 实验 B 中"页面新但数据老"的根本原因是什么?怎么修?

12. 延伸阅读

  • 源码:packages/next/src/server/lib/incremental-cache/index.ts
  • 源码:packages/next/src/server/response-cache/types.ts(5 种 CachedRouteKind 类型)
  • 源码:packages/next/src/server/response-cache/index.ts
  • 文档:docs/01-app/02-guides/caching.mdx
  • 配套 fixture:fixtures/lecture-13/
  • 工具:环境变量 NEXT_PRIVATE_DEBUG_CACHE=1 打开 IncrementalCache 详细日志。

下一讲预告

第 14 讲|元数据、OG image、字体、静态资源:本阶段最后一讲。我们把"页面外围"——SEO 元数据 / Open Graph 图像 / next/font 自托管字体 / 静态资源放置策略——一并讲清。这是面试与生产里都很常考的"小知识点"。完成之后我们就会进入阶段三:渲染管线(RSC + SSR + PPR + Streaming)。