- 发布日期
第 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 全部"的最长路径。
- 区分各种
revalidateAPI 的影响范围:哪些 invalidate 哪一层。 - 在 fixture 中观察"同一份数据"4 次刷新分别命中哪一层,并能解释。
1. 4 层缓存全景图
App Router 在不同位置放了 4 层独立的缓存。先认识它们:
┌────────────────────────────────────────────────────────────────────┐
│ 浏览器 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ ④ Router Cache(segment cache) │ │
│ │ - 客户端内存 │ │
│ │ - 生命周期:单个浏览器会话 │ │
│ │ - key:URL → CacheNode │ │
│ │ - 第 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 类
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
export const enum CachedRouteKind {
APP_PAGE = 'APP_PAGE',
APP_ROUTE = 'APP_ROUTE',
PAGES = 'PAGES',
FETCH = 'FETCH',
REDIRECT = 'REDIRECT',
IMAGE = 'IMAGE',
}
每种 kind 的 entry 形态都不同:
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_PAGE比PAGES多一个rscData与segmentData,因为 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 Cacheentry 没被revalidatePath/ 时间过期。
4.4 失效条件
revalidatePath('/products/[id]'):精确路径失效。revalidateTag('xxx')间接失效:因为 entry 里的 fetch 用了同 tag。- ISR 时间窗口到期。
- 重新 build。
4.5 与 Data Cache 的协作
用户访问 /products/123 第一次:
- 查 ③ Full Route Cache → miss。
- 渲染 page → page 内 fetch → 查 ② Data Cache → miss → 真实拉数据。
- 渲染完成 → 存 ② + 存 ③。
第二次访问(短时间内):
- 查 ③ Full Route Cache → hit → 直接返回 HTML/RSC,不进入 React 渲染。
第二次访问(③ 过期但 ② 没过期):
- 查 ③ → miss。
- 渲染 page → fetch → 查 ② → hit → 命中 fetch cache 拿数据。
- 重新渲染 → 存 ③。
生产排查提示:用户反馈"页面静态化但接口数据老旧"——这是 ③ 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 Cache | 5min(默认 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 刷新数据没变 | — | hit | hit | — |
| F5 多次后突然变 | — | hit→miss | hit→miss | — |
| 客户端 link 切换数据没变 | — | — | — | hit |
router.refresh() 后变 | — | — | — | invalidate |
revalidateTag 后变 | — | invalidate | invalidate | invalidate(间接) |
revalidatePath 后变 | — | invalidate(同 tag) | invalidate | invalidate(间接) |
| Layout 与 Page 同时 await 同 URL | dedupe | — | — | — |
调用 cookies() 后无 cache | bypass | bypass | bypass | n/a |
cache: 'no-store' | bypass | bypass | — | — |
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' 失效了,但客户端仍看到老数据。
排查链:
- 服务端 ② 已失效(
NEXT_PRIVATE_DEBUG_CACHE看到 miss)。 - 服务端重新渲染并写 ③。
- 但客户端 ④ 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 个示例:
app/example-1-memoization/— Layout + Page 同时 fetch 同 URL,演示 ① 命中。app/example-2-data-vs-route/— ② 与 ③ 不同 revalidate 周期,演示"假命中"。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. 检验问题
- App Router 4 层缓存分别叫什么?物理位置在哪?生命周期多长?
- ① Request Memoization 是 React 提供的还是 Next.js 实现的?
- ② Data Cache 的 entry 是按 URL 还是按 cacheKey 索引?为什么不同?
- ③ Full Route Cache 的 5 种
CachedRouteKind各对应什么? - APP_PAGE 与 PAGES 的 entry 多了什么字段?为什么?
revalidatePath直接失效哪几层?间接失效哪一层?cache: 'no-store'与dynamic = 'force-dynamic'各自 bypass 哪几层?- 客户端
router.refresh()失效 ④ 的同时会失效 ② ③ 吗? - dev / prod 模式下 ③ Full Route Cache 行为有什么差别?
- 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)。