Read OSS

深入缓存机制: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 运行时。

这份代码库值得细细研读。文件篇幅虽长,但这恰恰是因为相关逻辑被集中放在了一起。沿着一次请求在系统中的完整路径追踪下去,架构的全貌便会自然浮现。