深入缓存机制:ISR、响应缓存、缓存处理器与 `use cache` 指令
前置知识
- ›第 1-3 篇:架构、服务器生命周期与渲染引擎
- ›增量静态再生(ISR)概念——stale-while-revalidate 模式
- ›HTTP 缓存基础——Cache-Control 响应头与重新验证
- ›第 3 篇中关于 PPR 的概念理解
深入缓存机制:ISR、响应缓存、缓存处理器与 use cache 指令
Next.js 的缓存并非一套单一的系统,而是五个相互关联、分布在不同层次的系统协同工作的结果。要排查缓存问题、构建正确的应用,就必须清楚每一层缓存各司其职,以及它们之间如何配合。本文将完整梳理整个缓存架构——从防止惊群效应的内存响应缓存,到为 PPR 即时导航提供支撑的客户端分段缓存。
缓存架构总览
在深入细节之前,先建立一个整体认知:
flowchart TD
Request["HTTP Request"] --> ResponseCache["Response Cache\n(in-memory, per-server)"]
ResponseCache -->|Miss| IncrementalCache["Incremental Cache\n(filesystem/custom)"]
IncrementalCache -->|Miss| Render["Render Pipeline"]
Render --> UseCacheHandler["CacheHandler\n('use cache' directive)"]
UseCacheHandler --> DefaultHandler["Default Handler\n(in-memory LRU)"]
UseCacheHandler --> RemoteHandler["Remote Handler\n(custom provider)"]
Render --> FlightData["RSC Flight Data"]
FlightData --> SegmentCache["Segment Cache\n(client-side, per-segment)"]
Render --> ResumeDataCache["Resume Data Cache\n(PPR postponed state)"]
subgraph "Server-Side Caches"
ResponseCache
IncrementalCache
UseCacheHandler
DefaultHandler
RemoteHandler
ResumeDataCache
end
subgraph "Client-Side Caches"
SegmentCache
end
每一层缓存都有其独特的职责:
| 缓存层 | 作用范围 | 用途 |
|---|---|---|
| 响应缓存 | 单服务器内存 | 对并发请求去重 |
| 增量缓存 | 持久化(文件系统) | ISR 页面跨重启持久化 |
| CacheHandler | 可插拔(默认:LRU) | use cache 指令的存储后端 |
| 恢复数据缓存 | 单次渲染 | PPR 延迟状态,用于恢复渲染 |
| 分段缓存 | 客户端,按分段 | PPR 预取数据,用于导航 |
响应缓存:内存去重
response-cache/index.ts 实现了缓存的第一层。它最核心的作用是防止惊群效应——当 100 个并发请求同时访问同一页面时,只应该触发一次实际渲染。
响应缓存结合了 Batcher 工具(一种合并机制)与 LRU 缓存:
sequenceDiagram
participant R1 as Request 1
participant R2 as Request 2
participant R3 as Request 3
participant RC as Response Cache
participant Render as Render Pipeline
R1->>RC: GET /products
RC->>Render: Render /products (cache miss)
R2->>RC: GET /products (concurrent)
RC-->>R2: Coalesced (waiting for R1's render)
R3->>RC: GET /products (concurrent)
RC-->>R3: Coalesced (waiting for R1's render)
Render-->>RC: RenderResult
RC-->>R1: Response
RC-->>R2: Response (same result)
RC-->>R3: Response (same result)
缓存键的生成策略包含路径名,以及在 minimal 模式(部署环境)下的调用 ID——这样可以防止不同调用之间出现缓存污染。默认最大缓存条目数为 150,可通过 NEXT_PRIVATE_RESPONSE_CACHE_MAX_SIZE 配置,TTL 为 10 秒(NEXT_PRIVATE_RESPONSE_CACHE_TTL)。
响应缓存的设计本就是短暂的,它不会在服务器重启后持久化——那是增量缓存的职责。它的价值在于高并发场景下的请求去重。
增量缓存:ISR 持久化
增量缓存位于 server/lib/incremental-cache/,是增量静态再生(ISR)的核心支柱。它将已渲染的页面持久化到文件系统,使页面在服务器重启后依然可用,无需重新渲染。
每条缓存记录包含:
- 渲染后的 HTML
- RSC Flight 数据
- 路由元数据(响应头、状态码)
- 缓存控制信息(重新验证时机、过期时间)
缓存控制系统定义在 cache-control.ts:
export interface CacheControl {
revalidate: Revalidate // number | false
expire: number | undefined
}
export function getCacheControlHeader({ revalidate, expire }: CacheControl): string {
const swrHeader =
typeof revalidate === 'number' && expire !== undefined && revalidate < expire
? `, stale-while-revalidate=${expire - revalidate}`
: ''
if (revalidate === 0) {
return 'private, no-cache, no-store, max-age=0, must-revalidate'
} else if (typeof revalidate === 'number') {
return `s-maxage=${revalidate}${swrHeader}`
}
return `s-maxage=${CACHE_ONE_YEAR_SECONDS}${swrHeader}`
}
flowchart TD
Request["Request for /blog/post-1"] --> ISRCheck{"Cache entry\nexists?"}
ISRCheck -->|No| Render["Full Render"]
ISRCheck -->|Yes| FreshCheck{"Entry fresh?\n(within revalidate period)"}
FreshCheck -->|Yes| ServeCache["Serve from cache"]
FreshCheck -->|No| StaleCheck{"Entry stale?\n(within expire period)"}
StaleCheck -->|Yes| SWR["Serve stale +\nBackground revalidate"]
StaleCheck -->|No| Render
Render --> Store["Store in cache"]
SWR --> BGRender["Background Render"]
BGRender --> Store
revalidate 字段控制 stale-while-revalidate 窗口。设为 60 时,页面在 60 秒内为新鲜状态,之后进入过时状态(在后台重新验证的同时继续提供服务)。expire 字段控制最大缓存年龄——超过此时间后,过时条目将不再被提供,必须触发完整渲染。
通过 revalidatePath() 和 revalidateTag() 触发的按需重新验证,会立即将缓存条目标记为过时,忽略其基于时间的过期设置。
CacheHandler 接口与 use cache 指令
CacheHandler 接口是驱动 use cache 指令的抽象层,契约清晰简洁:
export interface CacheHandler {
get(cacheKey: string, softTags: string[]): Promise<undefined | CacheEntry>
set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void>
refreshTags(): Promise<void>
getExpiration(tags: string[]): Promise<Timestamp>
updateTags(tags: string[], durations?: { expire?: number }): Promise<void>
}
classDiagram
class CacheHandler {
<<interface>>
+get(cacheKey, softTags) Promise~CacheEntry~
+set(cacheKey, pendingEntry) Promise~void~
+refreshTags() Promise~void~
+getExpiration(tags) Promise~Timestamp~
+updateTags(tags, durations) Promise~void~
}
class DefaultCacheHandler {
-memoryCache: LRUCache
-pendingSets: Map
+get(cacheKey, softTags)
+set(cacheKey, pendingEntry)
}
class CustomHandler {
+get(cacheKey, softTags)
+set(cacheKey, pendingEntry)
+refreshTags()
}
CacheHandler <|.. DefaultCacheHandler
CacheHandler <|.. CustomHandler
CacheEntry 类型专为流式传输设计——其 value 字段是 ReadableStream<Uint8Array>,而非字符串或 buffer。这意味着缓存条目可以在计算完成之前就开始写入。set() 方法接收的是 Promise<CacheEntry>,处理器必须等待 Promise resolve 后条目才算完整。如果在 set() 完成前就有对同一键的 get() 请求,处理器应等待 set() 完成,而不是直接返回 undefined。
默认处理器使用内存 LRU 缓存。文件顶部的注释颇具启发性:"内存缓存是脆弱的,不应使用 stale-while-revalidate 语义……因为条目很可能在被用到之前就已被驱逐,预热它并不划算。"
缓存处理器的注册在 use-cache/handlers.ts 中完成。处理器通过 Symbol 挂载在 globalThis 上,以确保跨模块边界时能正常工作。initializeCacheHandlers() 会通过 @next/cache-handlers Symbol 查找用户自定义处理器,若未找到则回退到默认的 LRU 处理器。
提示: 如需实现自定义缓存处理器(例如基于 Redis),只需实现
CacheHandler接口,并在服务器启动前通过全局 Symbol 注册即可。仓库中的examples/cache-handler-redis示例展示了这一模式的完整用法。
基于标签的重新验证
标签是按语义类别(而非精确 URL)使缓存条目失效的机制。标签分为两类:隐式标签(从路由自动派生)和显式标签(通过 cacheTag() 手动添加)。
implicit-tags.ts 模块负责从路由路径派生标签:
const getDerivedTags = (pathname: string): string[] => {
const derivedTags: string[] = [`/layout`]
if (pathname.startsWith('/')) {
const pathnameParts = pathname.split('/')
for (let i = 1; i < pathnameParts.length + 1; i++) {
let curPathname = pathnameParts.slice(0, i).join('/')
if (curPathname) {
if (!curPathname.endsWith('/page') && !curPathname.endsWith('/route')) {
curPathname = `${curPathname}${
!curPathname.endsWith('/') ? '/' : ''
}layout`
}
derivedTags.push(curPathname)
}
}
}
return derivedTags
}
对于路径为 /dashboard/analytics/page 的页面,派生出的标签如下:
/layout/dashboard/layout/dashboard/analytics/page
flowchart TD
Revalidate["revalidatePath('/dashboard')"] --> FindTag["Match tag:\n/dashboard/layout"]
FindTag --> InvalidateAll["Invalidate all entries\ntagged with /dashboard/layout"]
InvalidateAll --> Entry1["/dashboard/analytics\n(has tag /dashboard/layout)"]
InvalidateAll --> Entry2["/dashboard/settings\n(has tag /dashboard/layout)"]
InvalidateAll --> Entry3["/dashboard\n(has tag /dashboard/layout)"]
这意味着调用 revalidatePath('/dashboard') 不只会使 dashboard 页面失效,还会使 /dashboard/* 下的所有页面失效——因为它们都共享 /dashboard/layout 这个隐式标签。这正是布局级重新验证背后的工作机制。
ImplicitTags 接口对过期时间采用惰性求值——只有当缓存条目被实际读取时,才会计算标签的过时状态。这一优化避免了对未命中缓存的请求做不必要的计算。
PPR 缓存:恢复数据缓存与分段缓存
PPR 引入了两种在传统渲染流水线中不存在的专用缓存。
恢复数据缓存(服务端)
resume-data-cache/ 存储的是静态预渲染阶段捕获的状态,这些状态在请求时用于完成延迟渲染。如第 3 篇所述,当 Server Component 在预渲染期间调用 headers() 时,React 会推迟该 Suspense 边界的渲染。恢复数据缓存存储以下内容:
- 已为静态外壳渲染完成的 RSC payload 片段
- Fetch 缓存条目(预渲染期间
fetch()调用的响应结果) - 序列化的延迟状态(哪些边界被推迟,以及推迟的原因)
当 PPR 页面收到请求时,服务器从增量缓存中加载静态外壳,从恢复数据缓存中加载恢复数据,然后调用 React 的 resume() 完成渲染。
分段缓存(客户端)
如第 4 篇所述,分段缓存在客户端存储已预取的 PPR 路由静态外壳分段。当指向 PPR 页面的 <Link> 进入视口时,路由器会预取静态外壳分段并缓存起来。导航时,这些缓存的分段能立即呈现视觉内容,同时在后台获取动态数据填充空缺。
flowchart TD
subgraph "Build Time"
Prerender["Prerender PPR page"] --> StaticShell["Static Shell\n(HTML + RSC)"]
Prerender --> ResumeData["Resume Data\n(postponed state)"]
StaticShell --> IncrCache["Incremental Cache"]
ResumeData --> ResumeCache["Resume Data Cache"]
end
subgraph "Client Prefetch"
LinkVisible["<Link> visible"] --> PrefetchReq["Prefetch request"]
PrefetchReq --> Server["Server serves\nstatic shell"]
Server --> SegCache["Segment Cache\n(client-side)"]
end
subgraph "Request Time"
Navigate["User navigates"] --> ServeShell["Serve static shell\n(from segment cache)"]
ServeShell --> DynamicReq["Fetch dynamic data"]
DynamicReq --> ServerResume["Server resumes\npostponed render"]
ServerResume --> DynamicData["Dynamic content\nstreamed to client"]
DynamicData --> Merge["Merge into page"]
end
开发环境与生产环境的缓存差异
不同环境下的缓存行为存在显著差异:
| 行为 | 开发环境 | 生产环境 |
|---|---|---|
| 响应缓存 | 禁用(每次请求都渲染) | 启用,带 LRU + TTL |
| 增量缓存 | 启用,但频繁失效 | 完整 ISR,支持 SWR |
use cache 处理器 |
默认 LRU(容量小) | 可配置(LRU 或自定义) |
| 静态预渲染 | 按需(首次请求时) | 构建时生成 |
| 分段缓存 | 启用(用于 PPR 测试) | 启用 |
| 恢复数据缓存 | 在开发 PPR 模式下使用 | 用于已部署的 PPR |
在开发环境中,大多数缓存会被有意弱化,确保开发者始终看到最新内容。响应缓存实际上处于禁用状态,每次请求都会触发全新渲染。增量缓存仍然运作(可以在开发环境中测试 ISR),但文件监听器会在源码变更时触发失效。
提示: 如需在生产环境调试缓存问题,可以将
NEXT_PRIVATE_DEBUG_CACHE=1设置为环境变量。默认缓存处理器和use-cache处理器都会检测这个变量,并向控制台输出详细的缓存命中/未命中信息。
系列总结
在这六篇文章中,我们完整地梳理了 Next.js 的整体架构——从 monorepo 中的 19 个包与 Rust crate(第 1 篇),到分层的服务器启动序列(第 2 篇),再到支持流式传输与 PPR 的 RSC 渲染引擎(第 3 篇),转向客户端路由器的状态机(第 4 篇),穿过三编译器构建流水线(第 5 篇),最终落到这套多层缓存系统(第 6 篇)。
贯穿所有系统的核心主题是分层抽象与统一契约。路由模块系统对渲染策略做了抽象,manifest 格式对 bundler 做了抽象,缓存处理器接口对存储后端做了抽象,AsyncLocalStorage 模式对 prop 传递做了抽象。每一层都带来了复杂性,但同时也带来了灵活性——正是这种灵活性,让 Next.js 得以在同一个框架内同时支持 Pages Router 与 App Router、webpack 与 Turbopack 和 Rspack、Node.js 与 Edge 运行时。
这份代码库值得细细研读。文件篇幅虽长,但这恰恰是因为相关逻辑被集中放在了一起。沿着一次请求在系统中的完整路径追踪下去,架构的全貌便会自然浮现。