Read OSS

キャッシュの深層:ISR、レスポンスキャッシュ、キャッシュハンドラー、そして `use cache` ディレクティブ

上級

前提知識

  • 記事 1〜3:アーキテクチャ、サーバーライフサイクル、レンダリングエンジン
  • Incremental Static Regeneration(ISR)の概念 — stale-while-revalidate パターン
  • HTTP キャッシュの基礎 — Cache-Control ヘッダー、再検証
  • 記事 3 で解説した PPR の理解

キャッシュの深層:ISR、レスポンスキャッシュ、キャッシュハンドラー、そして use cache ディレクティブ

Next.js のキャッシュは単一のシステムではありません。スタックの異なるレイヤーで連携して動く、5 つの相互関連するシステムの集合体です。どのキャッシュが何をするのか、それぞれがどう連携するのかを理解することは、キャッシュの挙動をデバッグするうえでも、正しいアプリケーションを構築するうえでも欠かせません。この記事では、サンダリングハード (thundering herd) を防ぐインメモリのレスポンスキャッシュから、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

各レイヤーはそれぞれ明確な役割を担っています。

キャッシュ スコープ 目的
Response Cache サーバーごと、インメモリ 同時リクエストの重複排除
Incremental Cache 永続(ファイルシステム) 再起動をまたぐ ISR ページの永続化
CacheHandler プラグイン可能(デフォルト:LRU) use cache ディレクティブのストレージ
Resume Data Cache レンダリングごと 再開に必要な PPR の postponed 状態
Segment Cache クライアントサイド、セグメントごと ナビゲーション用 PPR プリフェッチデータ

レスポンスキャッシュ:インメモリによる重複排除

response-cache/index.ts がキャッシュの第一層を担っています。その主な役割は サンダリングハード を防ぐことです。同じページに 100 件のリクエストが同時に来ても、実際にレンダリングするのは 1 件だけにする、という仕組みです。

レスポンスキャッシュは、コアレッシング (coalescing) 機構である 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 モード(デプロイ環境)ではさらに invocation ID を加えます。これにより、異なる invocation 間でキャッシュエントリが混入するのを防ぎます。デフォルトの最大キャッシュサイズは 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> になっています。つまり、キャッシュエントリは計算が完了する前から書き込みを開始できます。set() メソッドは Promise<CacheEntry> を受け取り、ハンドラーはそのエントリが完成するまで Promise の解決を待つ必要があります。set() の完了前に同じキーで get() が呼ばれた場合は、undefined を返すのではなく set() の完了を待つべきです。

デフォルトハンドラーはインメモリの LRU キャッシュを使用します。ファイル冒頭のコメントが示唆に富んでいます。「インメモリキャッシュは壊れやすく、stale-while-revalidate のセマンティクスを使うべきではない…エビクト (evict) される前に使われることのないエントリをウォームアップする意味がないからだ」とあります。

キャッシュハンドラーの登録は use-cache/handlers.ts で行われます。ハンドラーはモジュール境界をまたいで動作するよう、Symbol を使って globalThis に格納されます。initializeCacheHandlers() 関数は @next/cache-handlers Symbol 経由でユーザー定義のハンドラーを探し、なければデフォルトの LRU ハンドラーにフォールバックします。

ヒント: Redis バックエンドなどのカスタムキャッシュハンドラーを実装するには、CacheHandler インターフェースを実装し、サーバー起動前にグローバル Symbol で登録します。リポジトリの examples/cache-handler-redis がこのパターンの参考例になります。

タグベースの再検証

タグは、キャッシュエントリを完全な URL ではなく意味的なカテゴリで無効化するための仕組みです。暗黙タグ(ルートから自動導出)と明示タグcacheTag() で追加)の 2 種類があります。

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/layout という暗黙タグを共有しているからです。これがレイアウトレベルの再検証を支える仕組みです。

ImplicitTags インターフェースは有効期限の評価を遅延させます。タグの鮮度はキャッシュエントリが実際に読み込まれるときにのみ計算されるため、どのキャッシュにもヒットしないリクエストで不要な処理が走ることはありません。

PPR キャッシュ:Resume Data キャッシュとセグメントキャッシュ

PPR は、従来のレンダリングパイプラインには存在しない 2 つの専用キャッシュを導入します。

Resume Data キャッシュ(サーバーサイド)

resume-data-cache/ は、静的プリレンダリング中にキャプチャされた状態を保存します。この状態は、リクエスト時に postponed なレンダリングを完了するために必要です。記事 3 で見たように、Server Component がプリレンダリング中に headers() を呼び出すと、React はその Suspense バウンダリを postpone します。Resume Data キャッシュには以下が格納されます。

  • 静的シェルとしてすでにレンダリングされた RSC ペイロードのフラグメント
  • フェッチキャッシュエントリ(プリレンダリング中に実行した fetch() のレスポンス)
  • シリアライズされた postpone 状態(どのバウンダリが、なぜ postpone されたか)

PPR ページへのリクエストが来ると、サーバーはインクリメンタルキャッシュから静的シェルを、Resume Data キャッシュから resume データを読み込み、React の resume() を呼び出してレンダリングを完了させます。

セグメントキャッシュ(クライアントサイド)

記事 4 で詳しく解説したように、セグメントキャッシュは PPR が有効なルートの静的シェルをプリフェッチしてクライアント上に保存します。PPR ページへの <Link> が画面に表示されると、router は静的シェルのセグメントをプリフェッチしてキャッシュします。ナビゲーション時には、キャッシュされたセグメントが即座に視覚的なフィードバックを提供し、その間にダイナミックな部分が取得されます。

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 で有効
インクリメンタルキャッシュ 有効だが頻繁に無効化 SWR を含む完全な ISR
use cache ハンドラー デフォルト LRU(小サイズ) 設定可能(LRU またはカスタム)
静的プリレンダリング オンデマンド(初回リクエスト時) ビルド時
セグメントキャッシュ 有効(PPR のテスト用) 有効
Resume Data キャッシュ 開発の PPR モードで使用 デプロイ済み PPR で使用

開発環境では、開発者が常に最新のコンテンツを確認できるよう、ほとんどのキャッシュが意図的に弱められています。レスポンスキャッシュは事実上無効で、各リクエストが毎回レンダリングをトリガーします。インクリメンタルキャッシュは動作しており(開発環境でも ISR をテスト可能)、ソースの変更があればファイルウォッチャーが無効化をトリガーします。

ヒント: 本番環境でキャッシュの問題をデバッグする際は、環境変数として NEXT_PRIVATE_DEBUG_CACHE=1 を設定してみましょう。デフォルトのキャッシュハンドラーと use-cache ハンドラーはどちらもこの変数を参照し、キャッシュのヒット/ミスに関する詳細なログをコンソールに出力します。

シリーズのまとめ

この 6 本の記事を通じて、Next.js のアーキテクチャ全体を追いかけてきました。19 のパッケージと Rust クレートからなるモノレポ(記事 1)から始まり、多層構造のサーバー起動シーケンス(記事 2)、ストリーミングと PPR を備えた RSC レンダリングエンジン(記事 3)、クライアントサイド router のステートマシン(記事 4)、3 つのコンパイラによるビルドパイプライン(記事 5)、そして今回の多層キャッシュシステム(記事 6)まで、一通りの全体像を描きました。

これらすべてのシステムに共通するテーマは 共有された契約による抽象化の積み重ね です。route module システムはレンダリング戦略を抽象化し、manifest フォーマットはバンドラーを抽象化し、cache handler インターフェースはストレージバックエンドを抽象化し、AsyncLocalStorage パターンは props のバケツリレーを抽象化しています。各レイヤーは複雑さを加えますが、同時に柔軟性も生み出しています。そしてその柔軟性こそが、Pages Router / App Router、webpack / Turbopack / Rspack、Node.js / Edge といった多様な選択肢を、単一のフレームワーク内で支えることを可能にしています。

このコードベースは丁寧に読む価値があります。ファイルは大きいですが、それは関連するロジックをひとつの場所にまとめているからです。1 つのリクエストをシステム全体で追いかけてみてください。そうすれば、アーキテクチャの全貌が自然と見えてきます。