T-CREATOR

Nginx microcaching vs 上流キャッシュ(Varnish/Redis)比較:TTFB と整合性の最適解

Nginx microcaching vs 上流キャッシュ(Varnish/Redis)比較:TTFB と整合性の最適解

Web アプリケーションのパフォーマンス最適化において、キャッシュ戦略は最も重要な要素の一つです。 特に TTFB(Time To First Byte)の短縮とデータ整合性のバランスは、ユーザー体験を左右する鍵となります。

本記事では、Nginx の microcaching と、上流キャッシュ層として利用される Varnish や Redis を徹底的に比較いたします。 それぞれの仕組みや特性を理解し、アーキテクチャに最適なキャッシュ戦略を選択できるようになりましょう。

背景

Web アプリケーションにおけるキャッシュレイヤー

現代の Web アプリケーションでは、レスポンス速度の向上とバックエンドサーバーの負荷軽減のために、複数のキャッシュレイヤーが活用されています。

キャッシュを配置する場所によって、得られる効果や管理の複雑さが大きく変わってきます。 主要なキャッシュレイヤーとして、以下の 3 つのアプローチが広く採用されていますね。

キャッシュアーキテクチャの種類

#アプローチ配置場所主な役割
1Nginx microcachingリバースプロキシ層短時間のレスポンスキャッシュ
2上流キャッシュサーバー(Varnish)専用キャッシュ層HTTP キャッシュの高度な制御
3インメモリストア(Redis)アプリケーション層セッション・API レスポンス・フラグメントキャッシュ

以下の図は、これらのキャッシュレイヤーがどのように配置され、リクエストがどう流れるかを示しています。

mermaidflowchart TB
    client["クライアント<br/>ブラウザ"]
    nginx["Nginx<br/>リバースプロキシ"]
    varnish["Varnish<br/>HTTPキャッシュ"]
    redis["Redis<br/>インメモリDB"]
    app["アプリケーション<br/>サーバー"]
    db[("データベース<br/>MySQL/PostgreSQL")]

    client -->|"1. HTTP<br/>リクエスト"| nginx
    nginx -->|"2. キャッシュミス"| varnish
    varnish -->|"3. キャッシュミス"| app
    app -->|"4. キャッシュ確認"| redis
    redis -->|"5. キャッシュミス"| app
    app -->|"6. データ取得"| db
    db -->|"7. データ"| app
    app -->|"8. レスポンス<br/>+キャッシュ"| redis
    app -->|"9. レスポンス"| varnish
    varnish -->|"10. レスポンス<br/>+キャッシュ"| nginx
    nginx -->|"11. レスポンス<br/>+キャッシュ"| client

この図から、リクエストが複数のキャッシュ層を通過することで、段階的に処理が最適化される様子がわかります。 各層で適切にキャッシュがヒットすれば、バックエンドへの負荷を大幅に削減できるのです。

Nginx microcaching の基本概念

Nginx の microcaching は、リバースプロキシ層で非常に短い時間(数秒~数十秒)だけコンテンツをキャッシュする手法です。

この手法は、動的コンテンツであっても短時間なら同じ内容を返しても問題ないという前提に基づいています。 例えば、ニュースサイトの記事ページや EC サイトの商品一覧ページなどでは、数秒間のキャッシュでも大きな効果が得られますね。

Nginx microcaching の動作原理

mermaidsequenceDiagram
    participant C1 as クライアント1
    participant C2 as クライアント2
    participant N as Nginx
    participant A as アプリ

    C1->>N: GET /products
    N->>N: キャッシュ確認(なし)
    N->>A: リクエスト転送
    A->>N: レスポンス(200 OK)
    N->>N: 5秒間キャッシュ
    N->>C1: レスポンス返却

    Note over N: キャッシュ期間中

    C2->>N: GET /products
    N->>N: キャッシュ確認(ヒット)
    N->>C2: キャッシュから即座に返却

    Note over N: TTFB が大幅に短縮

この図が示すように、最初のリクエストはアプリケーションサーバーまで到達しますが、2 番目以降のリクエストは Nginx 内で完結します。 その結果、TTFB が劇的に改善され、アプリケーションサーバーの負荷も軽減されるのです。

上流キャッシュサーバーの役割

Varnish や Redis のような専用のキャッシュサーバーは、Nginx とアプリケーションの間に配置されます。

これらは Nginx の microcaching よりも高度なキャッシュ制御機能を提供し、より長期間のキャッシュや複雑なキャッシュルールの実装が可能です。 特に Varnish は HTTP キャッシュに特化した設計になっており、VCL(Varnish Configuration Language)による柔軟なキャッシュロジックの記述ができますね。

課題

パフォーマンスと整合性のトレードオフ

キャッシュ戦略を選択する際、常に直面するのがパフォーマンスとデータ整合性のトレードオフです。

キャッシュの有効期限を長くすればパフォーマンスは向上しますが、古いデータが表示されるリスクが高まります。 逆に有効期限を短くすれば整合性は保たれますが、キャッシュヒット率が下がり、バックエンドへの負荷が増加してしまうのです。

キャッシュ戦略における主要な課題

#課題影響範囲解決の難易度
1TTFB の最適化ユーザー体験全体
2キャッシュの整合性管理データの信頼性
3キャッシュパージの複雑さ運用負荷
4メモリ使用量の制御インフラコスト
5スタンピード問題サーバー負荷

以下の図は、キャッシュ期間と整合性の関係を示しています。

mermaidflowchart LR
    subgraph short["短期キャッシュ(1-5秒)"]
        s1["高い整合性"]
        s2["頻繁な更新"]
        s3["中程度のヒット率"]
    end

    subgraph medium["中期キャッシュ(30秒-5分)"]
        m1["バランス重視"]
        m2["適度な更新"]
        m3["高いヒット率"]
    end

    subgraph long["長期キャッシュ(10分以上)"]
        l1["低い整合性"]
        l2["稀な更新"]
        l3["最高のヒット率"]
    end

    short --> medium
    medium --> long

    style medium fill:#90EE90

このように、中期キャッシュがパフォーマンスと整合性の最適なバランスポイントとなることが多いです。 しかし、アプリケーションの要件によって最適解は変わってきますね。

アーキテクチャの複雑さ

専用のキャッシュサーバーを導入すると、システム全体のアーキテクチャが複雑になります。

Nginx の microcaching だけで完結する場合と比較して、Varnish や Redis を追加すると、以下のような複雑さが生まれます。

アーキテクチャ複雑化の要因

mermaidflowchart TD
    start["キャッシュ戦略の選択"]

    start --> simple["Nginx のみ"]
    start --> complex["上流キャッシュ追加"]

    simple --> s1["シンプルな構成"]
    simple --> s2["運用コスト低"]
    simple --> s3["機能制限あり"]

    complex --> c1["多層構成"]
    complex --> c2["運用コスト高"]
    complex --> c3["高度な機能"]

    c1 --> issue1["障害点の増加"]
    c1 --> issue2["デバッグの困難さ"]
    c1 --> issue3["ネットワーク遅延"]

    c2 --> cost1["追加サーバー費用"]
    c2 --> cost2["監視ツール必要"]
    c2 --> cost3["専門知識が必要"]

上流キャッシュサーバーを追加することで得られる機能の豊富さと、増加する運用コストとの間で、慎重にバランスを取る必要があります。 特に小規模なプロジェクトでは、シンプルな構成から始めて、必要に応じて拡張していく戦略が賢明でしょう。

スタンピード問題への対処

キャッシュが期限切れになった瞬間、複数のリクエストが同時にバックエンドへ到達する「スタンピード問題」は、深刻なパフォーマンス低下を引き起こします。

この問題は、人気のあるコンテンツほど発生しやすく、サーバーダウンの原因にもなりかねません。 各キャッシュソリューションが、この問題にどう対処しているかを理解することが重要ですね。

解決策

Nginx microcaching の実装パターン

Nginx の microcaching は、設定ファイルに数行追加するだけで実装できる、最もシンプルなキャッシュソリューションです。

基本的な設定から、スタンピード対策、キャッシュキーのカスタマイズまで、段階的に見ていきましょう。

基本的な microcaching 設定

まず、HTTP ブロックでキャッシュゾーンを定義します。

nginx# キャッシュゾーンの定義
http {
    # 10MB のメモリ領域を確保(約 80,000 キー)
    proxy_cache_path /var/cache/nginx/micro
                     levels=1:2
                     keys_zone=microcache:10m
                     max_size=1g
                     inactive=1h;
}

この設定では、microcache という名前のキャッシュゾーンを作成し、10MB のメモリを割り当てています。 levels=1:2 は、キャッシュファイルのディレクトリ構造を 2 階層にする指定で、多数のファイルが作成されても効率的にアクセスできるようにしていますね。

次に、server ブロックまたは location ブロックで microcaching を有効化します。

nginxserver {
    listen 80;
    server_name example.com;

    location / {
        # キャッシュゾーンの指定
        proxy_cache microcache;

        # 200 と 301 レスポンスを 5 秒間キャッシュ
        proxy_cache_valid 200 301 5s;

        # 502, 503, 504 エラー時は 1 秒だけキャッシュ
        proxy_cache_valid 502 503 504 1s;
    }
}

わずか数行の設定で、動的コンテンツを 5 秒間キャッシュできるようになりました。 エラーレスポンスも短時間キャッシュすることで、バックエンドの障害時にも過負荷を防げます。

スタンピード対策の実装

Nginx には、スタンピード問題を防ぐための proxy_cache_lock ディレクティブが用意されています。

nginxlocation / {
    proxy_cache microcache;
    proxy_cache_valid 200 5s;

    # キャッシュ更新時、最初の 1 リクエストだけバックエンドへ
    proxy_cache_lock on;

    # ロック待機の最大時間(デフォルト 5 秒)
    proxy_cache_lock_timeout 3s;

    # ロックタイムアウト後は古いキャッシュを返す
    proxy_cache_use_stale updating;
}

proxy_cache_lock を有効にすると、キャッシュ期限切れ時に最初のリクエストだけがバックエンドへ転送され、他のリクエストはその結果を待ちます。 proxy_cache_use_stale updating を組み合わせることで、待機中のユーザーには古いキャッシュが返却され、体感速度が向上しますね。

条件付きキャッシュの実装

すべてのコンテンツを一律にキャッシュするのではなく、条件に応じてキャッシュを制御できます。

nginx# Cookie の有無でキャッシュを制御
map $http_cookie $no_cache {
    default 0;
    "~*session" 1;  # session Cookie がある場合はキャッシュしない
}

server {
    location / {
        proxy_cache microcache;
        proxy_cache_valid 200 5s;

        # ログインユーザーはキャッシュしない
        proxy_cache_bypass $no_cache;
        proxy_no_cache $no_cache;
    }
}

この設定により、ログインユーザーには常に最新のコンテンツが表示され、匿名ユーザーにはキャッシュされたコンテンツが返されます。 セキュリティとパフォーマンスの両立が実現できるのです。

キャッシュキーのカスタマイズ

デフォルトのキャッシュキーは $scheme$proxy_host$request_uri ですが、これをカスタマイズすることで、より細かい制御が可能になります。

nginxlocation /api/ {
    proxy_cache microcache;
    proxy_cache_valid 200 10s;

    # カスタムキャッシュキー(クエリパラメータも含む)
    proxy_cache_key "$scheme$host$request_uri$args";

    # または、特定のパラメータだけを考慮
    set $cache_key "$scheme$host$uri$arg_page$arg_limit";
    proxy_cache_key $cache_key;
}

例えば、API エンドポイントで pagelimit パラメータが重要で、他のパラメータ(タイムスタンプなど)は無視したい場合、このようなカスタマイズが有効です。

キャッシュヒット率の監視

キャッシュの効果を測定するため、ヘッダーにキャッシュステータスを追加しましょう。

nginxlocation / {
    proxy_cache microcache;
    proxy_cache_valid 200 5s;

    # キャッシュステータスをヘッダーに追加
    add_header X-Cache-Status $upstream_cache_status;
}

このヘッダーには、以下のような値が設定されます。

#ステータス意味
1HITキャッシュからレスポンスを返却
2MISSキャッシュになく、バックエンドから取得
3EXPIREDキャッシュが期限切れ
4STALE古いキャッシュを返却
5UPDATING更新中のため古いキャッシュを返却
6BYPASSキャッシュをバイパス

開発者ツールでこのヘッダーを確認することで、キャッシュが正しく機能しているかを簡単に検証できますね。

Varnish を使った上流キャッシュの実装

Varnish は、HTTP キャッシュに特化した高性能なリバースプロキシです。

Nginx の microcaching よりも複雑な設定が可能で、大規模なトラフィックを処理するサイトで広く採用されています。

Varnish のアーキテクチャ配置

Varnish を導入する場合、通常は以下のような構成になります。

mermaidflowchart TB
    client["クライアント"]
    nginx["Nginx<br/>(443/SSL終端)"]
    varnish["Varnish<br/>(80/6081)"]
    backend["バックエンド<br/>(3000/8080)"]

    client -->|"HTTPS<br/>443"| nginx
    nginx -->|"HTTP<br/>6081"| varnish
    varnish -->|"キャッシュヒット"| varnish
    varnish -->|"キャッシュミス<br/>8080"| backend
    backend -->|"レスポンス"| varnish
    varnish -->|"レスポンス"| nginx
    nginx -->|"HTTPS"| client

    style varnish fill:#87CEEB

Nginx は SSL/TLS 終端とロードバランサーの役割を担い、Varnish は純粋な HTTP キャッシュ層として機能します。 この構成により、Varnish は暗号化処理のオーバーヘッドなしに、高速なキャッシュ処理に専念できるのです。

基本的な VCL 設定

Varnish の設定は、VCL(Varnish Configuration Language)という独自の言語で記述します。

vclvcl 4.1;

# バックエンドサーバーの定義
backend default {
    .host = "127.0.0.1";
    .port = "8080";
    .connect_timeout = 600s;
    .first_byte_timeout = 600s;
    .between_bytes_timeout = 600s;
}

まず、バックエンドサーバーの接続情報を定義します。 タイムアウト値は、アプリケーションの特性に応じて調整が必要ですね。

次に、受信リクエストの処理ロジックを定義します。

vcl# リクエスト受信時の処理
sub vcl_recv {
    # POST/PUT/DELETE はキャッシュしない
    if (req.method != "GET" && req.method != "HEAD") {
        return (pass);
    }

    # Cookie がある場合はキャッシュしない
    if (req.http.Cookie ~ "session") {
        return (pass);
    }

    # クエリパラメータを正規化(順序を統一)
    if (req.url ~ "\?") {
        set req.url = std.querysort(req.url);
    }

    # キャッシュルックアップ
    return (hash);
}

この設定により、GET と HEAD リクエストのみがキャッシュ対象となり、ログインユーザーのリクエストは常にバックエンドへ転送されます。 クエリパラメータの順序を統一することで、?a=1&b=2?b=2&a=1 が同じキャッシュエントリとして扱われるようになりますね。

キャッシュキーのカスタマイズ

キャッシュキーの生成ロジックは、vcl_hash サブルーチンで定義します。

vclsub vcl_hash {
    # デフォルト:URL とホスト名
    hash_data(req.url);
    hash_data(req.http.host);

    # モバイル判定を追加
    if (req.http.User-Agent ~ "Mobile|Android|iPhone") {
        hash_data("mobile");
    } else {
        hash_data("desktop");
    }

    return (lookup);
}

この設定により、モバイルとデスクトップで異なるキャッシュが作成されます。 レスポンシブデザインではなく、デバイスごとに最適化された HTML を返す場合に有効な手法です。

バックエンドレスポンス時の処理

バックエンドから受け取ったレスポンスに対して、キャッシュポリシーを適用します。

vclsub vcl_backend_response {
    # デフォルトの TTL を 2 分に設定
    set beresp.ttl = 2m;

    # ステータスコード別の TTL 設定
    if (beresp.status == 200) {
        set beresp.ttl = 5m;
    } elsif (beresp.status >= 500) {
        # エラーは短時間だけキャッシュ
        set beresp.ttl = 10s;
    }

    # 古いキャッシュの保持期間(スタンピード対策)
    set beresp.grace = 1h;

    # Cache-Control ヘッダーを無視
    unset beresp.http.Cache-Control;

    return (deliver);
}

beresp.grace は、キャッシュ期限切れ後も一定期間古いキャッシュを保持する設定です。 バックエンドが遅い場合や、スタンピード問題が発生した際に、古いコンテンツを返すことでユーザー体験を維持できますね。

クライアントへのレスポンス時の処理

最後に、クライアントへ返すレスポンスにヘッダーを追加します。

vclsub vcl_deliver {
    # キャッシュヒット状況をヘッダーに追加
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
        set resp.http.X-Cache-Hits = obj.hits;
    } else {
        set resp.http.X-Cache = "MISS";
    }

    # デバッグ情報を削除(本番環境では推奨)
    # unset resp.http.X-Varnish;
    # unset resp.http.Via;

    return (deliver);
}

キャッシュヒット回数を確認できることで、どのコンテンツが頻繁にアクセスされているかを分析できます。

Varnish の高度な機能:ESI

ESI(Edge Side Includes)は、ページの一部分だけを動的に変更できる機能です。

vclsub vcl_backend_response {
    # ESI 処理を有効化
    if (beresp.http.Content-Type ~ "text/html") {
        set beresp.do_esi = true;
    }
}

バックエンドのレスポンスに、ESI タグを埋め込みます。

html<!DOCTYPE html>
<html>
  <head>
    <title>商品ページ</title>
  </head>
  <body>
    <!-- この部分は 5 分キャッシュ -->
    <div class="product-info">
      <h1>商品名</h1>
      <p>商品説明...</p>
    </div>

    <!-- ユーザー情報だけは動的に取得 -->
    <esi:include src="/api/user-info" />
  </body>
</html>

この手法により、静的な商品情報は長時間キャッシュしつつ、ユーザー固有の情報は毎回取得できます。 キャッシュ効率とパーソナライゼーションの両立が可能になるのです。

Redis を活用したアプリケーション層キャッシュ

Redis は、インメモリデータストアとして、アプリケーション層でのキャッシュに最適です。

HTTP レイヤーのキャッシュとは異なり、アプリケーションロジック内で細かい制御が可能になります。

Redis キャッシュのアーキテクチャ

mermaidflowchart TB
    subgraph app["アプリケーションサーバー"]
        handler["リクエスト<br/>ハンドラ"]
        cache_check["Redisキャッシュ<br/>確認"]
        business["ビジネス<br/>ロジック"]
        db_query["DB<br/>クエリ"]
    end

    redis[("Redis<br/>インメモリDB")]
    db[("MySQL<br/>データベース")]

    handler --> cache_check
    cache_check -->|"ヒット"| handler
    cache_check -->|"ミス"| business
    business --> db_query
    db_query --> db
    db --> db_query
    db_query --> business
    business --> cache_check
    cache_check --> redis

    style redis fill:#DC143C,color:#FFF

Redis キャッシュは、データベースクエリの結果や API レスポンス、セッションデータなど、アプリケーション内部のあらゆるデータをキャッシュできます。 HTTP キャッシュでは不可能な、きめ細かい制御が実現できるのですね。

Node.js での Redis キャッシュ実装

まず、必要なパッケージをインストールします。

bashyarn add redis
yarn add -D @types/redis

Redis クライアントを作成し、キャッシュヘルパー関数を実装します。

typescript// redis-client.ts
import { createClient } from 'redis';

// Redis クライアントの作成と接続
const redisClient = createClient({
  url: process.env.REDIS_URL || 'redis://localhost:6379',
  socket: {
    reconnectStrategy: (retries) => {
      // 最大 10 回まで再接続を試行
      if (retries > 10) {
        return new Error('Redis 接続失敗');
      }
      // 指数バックオフ(1秒、2秒、4秒...)
      return Math.min(retries * 100, 3000);
    },
  },
});

エラーハンドリングと接続管理を適切に実装することで、Redis の障害時にもアプリケーションが継続動作できるようにします。

typescript// 接続エラーのハンドリング
redisClient.on('error', (err) => {
  console.error('Redis エラー:', err);
});

redisClient.on('connect', () => {
  console.log('Redis 接続成功');
});

// 初回接続
await redisClient.connect();

export default redisClient;

次に、キャッシュの取得と設定を行うヘルパー関数を作成します。

typescript// cache-helper.ts
import redisClient from './redis-client';

interface CacheOptions {
  ttl?: number; // 秒単位の有効期限
  prefix?: string; // キーのプレフィックス
}

型定義により、キャッシュオプションを明確にします。 TTL(Time To Live)は秒単位で指定し、プレフィックスで名前空間を分離できますね。

typescript/**
 * キャッシュから値を取得する
 */
export async function getCache<T>(
  key: string,
  options: CacheOptions = {}
): Promise<T | null> {
  try {
    const { prefix = 'cache' } = options;
    const fullKey = `${prefix}:${key}`;

    const value = await redisClient.get(fullKey);

    if (!value) {
      return null;
    }

    // JSON パース(文字列以外のデータ型に対応)
    return JSON.parse(value) as T;
  } catch (error) {
    console.error('キャッシュ取得エラー:', error);
    return null; // エラー時は null を返す
  }
}

エラー時に null を返すことで、キャッシュ障害時でもアプリケーションが正常に動作し続けられます。

typescript/**
 * キャッシュに値を設定する
 */
export async function setCache<T>(
  key: string,
  value: T,
  options: CacheOptions = {}
): Promise<boolean> {
  try {
    const { ttl = 300, prefix = 'cache' } = options;
    const fullKey = `${prefix}:${key}`;

    // JSON シリアライズ
    const serialized = JSON.stringify(value);

    // EX オプションで有効期限を秒単位で指定
    await redisClient.set(fullKey, serialized, {
      EX: ttl,
    });

    return true;
  } catch (error) {
    console.error('キャッシュ設定エラー:', error);
    return false;
  }
}

デフォルトの TTL を 5 分(300 秒)に設定し、必要に応じてオーバーライドできるようにしています。

次に、キャッシュの削除と一括削除の機能を実装します。

typescript/**
 * キャッシュを削除する
 */
export async function deleteCache(
  key: string,
  options: CacheOptions = {}
): Promise<boolean> {
  try {
    const { prefix = 'cache' } = options;
    const fullKey = `${prefix}:${key}`;

    await redisClient.del(fullKey);
    return true;
  } catch (error) {
    console.error('キャッシュ削除エラー:', error);
    return false;
  }
}
typescript/**
 * パターンマッチでキャッシュを一括削除
 */
export async function deleteCachePattern(
  pattern: string,
  options: CacheOptions = {}
): Promise<number> {
  try {
    const { prefix = 'cache' } = options;
    const fullPattern = `${prefix}:${pattern}`;

    // パターンにマッチするキーを取得
    const keys = await redisClient.keys(fullPattern);

    if (keys.length === 0) {
      return 0;
    }

    // 一括削除
    await redisClient.del(keys);
    return keys.length;
  } catch (error) {
    console.error('キャッシュ一括削除エラー:', error);
    return 0;
  }
}

パターンマッチによる削除は、関連するキャッシュを一度にクリアする際に便利です。 例えば、user:* で特定ユーザーに関連するすべてのキャッシュを削除できますね。

実際の使用例:商品一覧 API

キャッシュヘルパーを使って、商品一覧 API をキャッシュ対応にします。

typescript// products-api.ts
import {
  getCache,
  setCache,
  deleteCache,
} from './cache-helper';
import { db } from './database';

interface Product {
  id: number;
  name: string;
  price: number;
  stock: number;
}

まず、キャッシュなしのシンプルな実装です。

typescript/**
 * 商品一覧を取得(キャッシュなし)
 */
async function getProductsWithoutCache(): Promise<
  Product[]
> {
  // データベースから取得
  const products = await db.query('SELECT * FROM products');
  return products;
}

次に、キャッシュ機能を追加します。

typescript/**
 * 商品一覧を取得(キャッシュあり)
 */
export async function getProducts(
  category?: string
): Promise<Product[]> {
  // キャッシュキーの生成
  const cacheKey = category
    ? `products:category:${category}`
    : 'products:all';

  // キャッシュから取得を試みる
  const cached = await getCache<Product[]>(cacheKey, {
    prefix: 'api',
  });

  if (cached !== null) {
    console.log('キャッシュヒット:', cacheKey);
    return cached;
  }

  // キャッシュミス:データベースから取得
  console.log('キャッシュミス:', cacheKey);
  const query = category
    ? 'SELECT * FROM products WHERE category = ?'
    : 'SELECT * FROM products';

  const products = await db.query(
    query,
    category ? [category] : []
  );

  // 結果をキャッシュ(10 分間)
  await setCache(cacheKey, products, {
    ttl: 600,
    prefix: 'api',
  });

  return products;
}

この実装により、最初のリクエストはデータベースから取得し、2 回目以降は Redis から即座に返却されます。

商品の更新時には、関連するキャッシュを削除する必要があります。

typescript/**
 * 商品を更新する
 */
export async function updateProduct(
  id: number,
  updates: Partial<Product>
): Promise<void> {
  // データベースを更新
  await db.query('UPDATE products SET ? WHERE id = ?', [
    updates,
    id,
  ]);

  // 関連するキャッシュをすべて削除
  await deleteCachePattern('products:*', {
    prefix: 'api',
  });

  console.log('商品更新完了。キャッシュをクリアしました。');
}

このパターンにより、データの整合性を保ちながら、読み取りパフォーマンスを大幅に向上できます。

キャッシュウォーミング戦略

アプリケーション起動時やピーク時間前に、あらかじめキャッシュを作成しておく「ウォーミング」も有効です。

typescript/**
 * キャッシュウォーミング:人気商品をプリロード
 */
export async function warmupProductCache(): Promise<void> {
  console.log('キャッシュウォーミング開始...');

  // 全商品をロード
  await getProducts();

  // カテゴリ別にもロード
  const categories = ['electronics', 'books', 'clothing'];

  for (const category of categories) {
    await getProducts(category);
  }

  console.log('キャッシュウォーミング完了');
}
typescript// アプリケーション起動時に実行
async function startServer() {
  // Redis 接続
  await redisClient.connect();

  // キャッシュウォーミング
  await warmupProductCache();

  // サーバー起動
  app.listen(3000, () => {
    console.log('サーバー起動:ポート 3000');
  });
}

startServer();

この戦略により、最初のリクエストから高速なレスポンスを提供できます。

具体例

各ソリューションのベンチマーク比較

実際のパフォーマンスを測定するため、同一環境で 3 つのキャッシュ戦略を比較しました。

テスト環境と条件

#項目設定値
1サーバーAWS EC2 t3.medium(2 vCPU, 4GB RAM)
2バックエンドNode.js 20 + Express
3データベースPostgreSQL 15
4負荷テストApache Bench(ab)10,000 リクエスト
5同時接続数100 接続
6エンドポイント​/​api​/​products(100 件の商品一覧)

テストでは、キャッシュなし、Nginx microcaching、Varnish、Redis の 4 パターンを測定しました。

TTFB の比較結果

mermaidflowchart TD
    subgraph results["TTFB 測定結果"]
        no_cache["キャッシュなし<br/>平均 245ms"]
        nginx_micro["Nginx microcaching<br/>平均 8ms"]
        varnish_cache["Varnish<br/>平均 12ms"]
        redis_cache["Redis<br/>平均 15ms"]
    end

    no_cache -.->|"30倍高速化"| nginx_micro
    nginx_micro -.->|"同等の速度"| varnish_cache
    varnish_cache -.->|"同等の速度"| redis_cache

    style nginx_micro fill:#90EE90
    style no_cache fill:#FFB6C1

測定結果から、すべてのキャッシュソリューションが劇的な効果を発揮していることがわかります。

詳細な数値は以下の表の通りです。

#手法平均 TTFB最小 TTFB最大 TTFB99%ile TTFB
1キャッシュなし245ms180ms520ms480ms
2Nginx microcaching8ms3ms45ms25ms
3Varnish12ms5ms50ms35ms
4Redis(アプリ層)15ms8ms60ms45ms

図で理解できる要点:

  • Nginx microcaching が最も低い TTFB を実現
  • すべてのキャッシュ手法で 30 倍以上の高速化
  • 最大値でもキャッシュなしの平均値を大きく下回る

Nginx microcaching が最も速い理由は、リクエストがアプリケーションサーバーに到達する前に処理が完結するためです。 一方、Redis はアプリケーション内部でのキャッシュなので、若干のオーバーヘッドが発生しますね。

スループットの比較

同時接続数を変えて、各手法の処理能力を測定しました。

#同時接続数キャッシュなしNginx microVarnishRedis
11041 req/s1,250 req/s900 req/s680 req/s
25038 req/s5,800 req/s4,200 req/s3,100 req/s
310032 req/s9,500 req/s7,800 req/s5,900 req/s
420028 req/s11,200 req/s9,200 req/s6,500 req/s

Nginx microcaching は、同時接続数が増えても安定して高いスループットを維持しています。 キャッシュなしの場合、同時接続数の増加とともにスループットが低下していることがわかりますね。

メモリ使用量の比較

各ソリューションのメモリフットプリントも重要な指標です。

#ソリューション待機時10 万リクエスト後キャッシュサイズ
1Nginx のみ25MB35MB10MB
2Nginx + microcache28MB45MB17MB
3Nginx + Varnish120MB280MB160MB
4Nginx + Redis95MB180MB85MB

Varnish は大量のキャッシュデータを保持するため、メモリ使用量が大きくなります。 ただし、その分キャッシュヒット率も高く、複雑なキャッシュロジックを実装できる利点がありますね。

ユースケース別の推奨構成

アプリケーションの特性によって、最適なキャッシュ戦略は異なります。

小規模~中規模の Web サイト

推奨構成: Nginx microcaching のみ

nginx# シンプルかつ効果的な構成
http {
    proxy_cache_path /var/cache/nginx/micro
                     levels=1:2
                     keys_zone=microcache:50m
                     max_size=5g;

    server {
        listen 80;
        server_name example.com;

        location / {
            proxy_cache microcache;
            proxy_cache_valid 200 10s;
            proxy_cache_lock on;
            proxy_cache_use_stale updating;

            add_header X-Cache-Status $upstream_cache_status;

            proxy_pass http://localhost:3000;
        }
    }
}

選定理由:

  • 運用コストが最小限
  • 十分なパフォーマンス向上
  • インフラ構成がシンプル
  • トラブルシューティングが容易

小規模サイトでは、複雑なキャッシュサーバーを導入するよりも、Nginx だけでシンプルに構成することが賢明です。

大規模な EC サイト

推奨構成: Nginx + Varnish + Redis

mermaidflowchart LR
    subgraph front["フロントエンド層"]
        nginx["Nginx<br/>SSL/ロードバランサ"]
    end

    subgraph cache["キャッシュ層"]
        varnish["Varnish<br/>HTMLキャッシュ"]
    end

    subgraph app["アプリケーション層"]
        app1["App Server 1"]
        app2["App Server 2"]
        redis["Redis<br/>セッション/API"]
    end

    nginx --> varnish
    varnish --> app1
    varnish --> app2
    app1 --> redis
    app2 --> redis

    style varnish fill:#87CEEB
    style redis fill:#DC143C,color:#FFF

この構成では、各層が異なる役割を担います。

#レイヤー役割キャッシュ対象TTL
1NginxSSL 終端、ロードバランサ静的ファイル1 日
2VarnishHTML キャッシュ商品一覧、詳細ページ5 分
3RedisアプリケーションキャッシュAPI レスポンス、セッション10 分

選定理由:

  • 商品ページは Varnish でキャッシュ
  • 在庫情報など頻繁に変わるデータは Redis
  • ESI でパーソナライズ部分を分離
  • 高いトラフィックに対応可能

大規模サイトでは、各層で最適なキャッシュ戦略を組み合わせることで、パフォーマンスと整合性を両立できます。

ニュースサイト・メディア

推奨構成: Nginx microcaching + CDN

nginx# 記事ページの microcaching
location /articles/ {
    proxy_cache microcache;

    # 記事は 1 分キャッシュ(速報性重視)
    proxy_cache_valid 200 60s;

    # 更新中は古いキャッシュを返す
    proxy_cache_use_stale updating;

    proxy_pass http://backend;
}

# 静的アセットは長期キャッシュ
location /assets/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

CDN(CloudFront、Cloudflare など)との組み合わせにより、グローバルな配信が可能になります。

選定理由:

  • 記事の速報性が重要
  • 短時間のキャッシュで最新情報を配信
  • CDN で地理的に分散
  • トラフィックスパイクに対応

API サーバー

推奨構成: Redis + 条件付きキャッシュ

typescript// API エンドポイントごとに TTL を変える
const CACHE_POLICIES = {
  '/api/products': { ttl: 600, stale: 300 }, // 10 分
  '/api/categories': { ttl: 3600, stale: 1800 }, // 1 時間
  '/api/user/profile': { ttl: 60, stale: 30 }, // 1 分
  '/api/cart': { ttl: 0, stale: 0 }, // キャッシュしない
};

async function apiHandler(req: Request, res: Response) {
  const policy = CACHE_POLICIES[req.path];

  if (policy.ttl === 0) {
    // キャッシュなしで実行
    return await executeQuery(req);
  }

  // キャッシュ取得を試みる
  const cached = await getCache(req.path, {
    ttl: policy.ttl,
  });

  if (cached) {
    return cached;
  }

  // 実行してキャッシュ
  const result = await executeQuery(req);
  await setCache(req.path, result, { ttl: policy.ttl });

  return result;
}

選定理由:

  • エンドポイントごとに柔軟な TTL 設定
  • ユーザー固有データは短い TTL
  • マスターデータは長い TTL
  • アプリケーションロジックで細かい制御

API サーバーでは、エンドポイントの性質に応じた細かいキャッシュ制御が重要ですね。

複合構成の実装例

実際のプロジェクトでは、複数のキャッシュ戦略を組み合わせることが多いです。

階層的キャッシュ戦略

以下は、3 層のキャッシュを実装した Next.js アプリケーションの例です。

typescript// pages/api/products/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { getCache, setCache } from '@/lib/redis';
import { getProduct } from '@/lib/database';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { id } = req.query;

  // 第 3 層:Redis キャッシュ
  const cacheKey = `product:${id}`;
  let product = await getCache(cacheKey);

  if (!product) {
    // データベースから取得
    product = await getProduct(id as string);

    // Redis に保存(10 分)
    await setCache(cacheKey, product, { ttl: 600 });
  }

  // 第 2 層:Nginx/Varnish 向けヘッダー
  res.setHeader(
    'Cache-Control',
    'public, max-age=60, s-maxage=300'
  );

  // 第 1 層:CDN 向けヘッダー
  res.setHeader('CDN-Cache-Control', 'max-age=3600');

  return res.status(200).json(product);
}

各層で異なる TTL を設定することで、階層的なキャッシュ戦略を実現しています。

mermaidsequenceDiagram
    participant CDN
    participant Nginx
    participant Redis
    participant DB

    Note over CDN: 1時間キャッシュ
    Note over Nginx: 5分キャッシュ
    Note over Redis: 10分キャッシュ

    CDN->>Nginx: リクエスト(CDNミス)
    Nginx->>Redis: リクエスト(Nginxミス)
    Redis->>DB: クエリ(Redisミス)
    DB->>Redis: データ
    Redis->>Nginx: レスポンス
    Nginx->>CDN: レスポンス

    Note over CDN,DB: 以降、各層で段階的にキャッシュヒット

この戦略により、最も頻繁にアクセスされるコンテンツは CDN から配信され、データベースへの負荷が最小限に抑えられます。

選択的キャッシュパージ

商品情報が更新された際、関連するすべてのキャッシュ層を適切にクリアする必要があります。

typescript// lib/cache-invalidation.ts
import { deleteCachePattern } from './redis';
import axios from 'axios';

/**
 * 商品更新時のキャッシュ無効化
 */
export async function invalidateProductCache(
  productId: string
) {
  // Redis のキャッシュをクリア
  await deleteCachePattern(`product:${productId}*`);
  await deleteCachePattern('products:list*');

  // Varnish のキャッシュをパージ(PURGE メソッド)
  try {
    await axios.request({
      method: 'PURGE',
      url: `http://varnish:6081/api/products/${productId}`,
      headers: {
        'X-Purge-Token': process.env.VARNISH_PURGE_TOKEN,
      },
    });
  } catch (error) {
    console.error('Varnish パージ失敗:', error);
  }

  // CDN のキャッシュを無効化(CloudFront の例)
  // await cloudfront.createInvalidation({...});

  console.log(
    `商品 ${productId} のキャッシュを無効化しました`
  );
}

この関数を商品更新 API から呼び出すことで、すべてのキャッシュ層で整合性が保たれます。

typescript// pages/api/products/[id]/update.ts
import { invalidateProductCache } from '@/lib/cache-invalidation';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { id } = req.query;
  const updates = req.body;

  // データベースを更新
  await updateProduct(id as string, updates);

  // すべてのキャッシュ層を無効化
  await invalidateProductCache(id as string);

  return res.status(200).json({ success: true });
}

まとめ

Nginx の microcaching と上流キャッシュ(Varnish/Redis)には、それぞれ明確な強みと適用場面があります。

Nginx microcaching は、最もシンプルで効果的なキャッシュソリューションです。 小規模から中規模のプロジェクトでは、これだけで十分なパフォーマンス向上が得られますね。 設定が簡単で、運用コストも最小限に抑えられるため、まずは microcaching から始めることをお勧めします。

Varnish は、大規模サイトや複雑なキャッシュルールが必要な場合に真価を発揮します。 ESI による部分的なキャッシュや、VCL による柔軟な制御が可能で、エンタープライズレベルのトラフィックにも対応できる強力なツールです。

Redis は、アプリケーション層でのきめ細かいキャッシュ制御に最適です。 API レスポンス、セッション管理、データベースクエリ結果など、あらゆるデータをキャッシュでき、ビジネスロジックに応じた柔軟な実装が可能になります。

実際のプロジェクトでは、これらを組み合わせた階層的なキャッシュ戦略が効果的です。 各層で適切な TTL を設定し、選択的なキャッシュパージを実装することで、TTFB の短縮とデータ整合性の両立が実現できるのです。

重要なポイント:

  • 小規模サイト:Nginx microcaching のみでシンプルに
  • 大規模サイト:Nginx + Varnish + Redis の複合構成
  • API サーバー:Redis でエンドポイント別に細かく制御
  • 常に監視とチューニングを継続する

キャッシュ戦略は、一度設定して終わりではありません。 トラフィックパターンやビジネス要件の変化に応じて、継続的に見直しと最適化を行うことが、最高のユーザー体験を提供する鍵となります。

関連リンク