T-CREATOR

Redis キャッシュ設計大全:Cache-Aside/Write-Through/Write-Behind の実装指針

Redis キャッシュ設計大全:Cache-Aside/Write-Through/Write-Behind の実装指針

アプリケーションのパフォーマンスを劇的に向上させる方法をお探しですか? Redis を使ったキャッシュ戦略は、データベースへの負荷を減らし、レスポンス速度を飛躍的に改善できる強力な手段です。

しかし、一口にキャッシュといっても、Cache-Aside、Write-Through、Write-Behind など、さまざまなパターンが存在します。 それぞれの特性を理解し、適切に使い分けることで、システムの要件に最適なキャッシュ設計が実現できるのです。

本記事では、Redis を用いた 3 つの主要なキャッシュパターンについて、それぞれの仕組み、実装方法、使いどころを徹底解説します。 実践的な TypeScript コードとともに、各パターンのメリット・デメリットを比較しながら、あなたのプロジェクトに最適な選択肢を見つけるお手伝いをしますね。

背景

キャッシュの必要性

現代の Web アプリケーションでは、ユーザー体験の向上が最優先課題となっています。 データベースへの直接アクセスは、ネットワークレイテンシやディスク I/O の制約により、どうしても遅延が発生してしまいます。

特にアクセス数が増加すると、データベースがボトルネックになり、システム全体のパフォーマンスが低下する問題が顕在化します。 この課題を解決するために、高速なインメモリデータストアである Redis をキャッシュ層として導入することで、データアクセスを高速化できるのです。

以下の図は、キャッシュ導入前後のデータアクセスフローを示しています。

mermaidflowchart LR
    client1["クライアント"] -->|リクエスト| app1["アプリケーション"]
    app1 -->|SQLクエリ| db1[("データベース<br/>遅い")]
    db1 -->|結果| app1
    app1 -->|レスポンス| client1

    client2["クライアント"] -->|リクエスト| app2["アプリケーション"]
    app2 -->|まずキャッシュ確認| redis["Redis<br/>高速"]
    redis -.->|ヒット時| app2
    app2 -.->|ミス時のみ| db2[("データベース")]
    db2 -.->|結果| app2
    app2 -->|レスポンス| client2

キャッシュを導入することで、頻繁にアクセスされるデータをメモリ上に保持し、データベースへの負荷を大幅に軽減できます。 これにより、レスポンスタイムの短縮とスループットの向上という 2 つの重要な効果が得られるでしょう。

Redis の特徴

Redis は、単なるキー・バリューストアではなく、豊富なデータ構造と高度な機能を備えたインメモリデータベースです。 文字列、ハッシュ、リスト、セット、ソート済みセットなど、多様なデータ型をサポートしています。

また、TTL(Time To Live)による自動的なキー削除、Pub/Sub メッセージング、トランザクション機能など、キャッシュ実装に必要な機能が標準で揃っています。 シングルスレッドアーキテクチャながら、非常に高速な処理性能を実現しているのも大きな特徴ですね。

#特徴説明キャッシュでの活用
1インメモリ動作データをメモリ上に保持し高速アクセスミリ秒以下のレスポンスタイム
2豊富なデータ型String、Hash、List、Set、Sorted Set用途に応じた最適な保存形式
3TTL 機能キーに有効期限を設定可能自動的な古いキャッシュの削除
4アトミック操作複数コマンドの一括実行データ整合性の保証
5永続化オプションRDB、AOF によるディスク保存再起動時のデータ復元

キャッシュパターンの分類

キャッシュとデータベース間のデータ同期方法には、大きく分けて 3 つの戦略があります。 それぞれ、読み込み処理と書き込み処理のタイミングや責任範囲が異なるため、システム要件に応じた選択が必要です。

Cache-Aside パターンは、アプリケーションがキャッシュとデータベースを直接制御する最もシンプルな方式です。 Write-Through パターンは、書き込み時にキャッシュとデータベースの両方を同期的に更新し、データの一貫性を重視します。

Write-Behind パターンは、書き込みを非同期化することで高速性を追求する設計となっています。 以下の図で、3 つのパターンの基本的な処理フローの違いを確認できます。

mermaidflowchart TB
    subgraph ca["Cache-Aside(読み込み重視)"]
        ca_app["アプリ"] -->|1.読み込み| ca_cache["キャッシュ"]
        ca_cache -.->|2.ミス| ca_app
        ca_app -->|3.DB読み込み| ca_db[("DB")]
        ca_db -->|4.データ| ca_app
        ca_app -->|5.キャッシュ保存| ca_cache
    end

    subgraph wt["Write-Through(整合性重視)"]
        wt_app["アプリ"] -->|1.書き込み| wt_cache["キャッシュ"]
        wt_cache -->|2.同期書き込み| wt_db[("DB")]
        wt_db -->|3.完了| wt_cache
        wt_cache -->|4.完了| wt_app
    end

    subgraph wb["Write-Behind(速度重視)"]
        wb_app["アプリ"] -->|1.書き込み| wb_cache["キャッシュ"]
        wb_cache -->|2.即座に完了| wb_app
        wb_cache -.->|3.非同期書き込み| wb_db[("DB")]
    end

各パターンの選択基準は、システムが重視する要素によって変わります。 読み込みが多いシステムでは Cache-Aside、強い整合性が必要なら Write-Through、高速な書き込みが求められる場合は Write-Behind が適しているでしょう。

課題

キャッシュ設計における共通課題

キャッシュを導入する際には、いくつかの重要な課題に直面します。 最も深刻なのは、キャッシュとデータベース間のデータ不整合です。

キャッシュの更新タイミングとデータベースの更新タイミングがずれると、古いデータが返されたり、最新のデータが失われたりする可能性があります。 特に複数のサーバーインスタンスが同時に動作する分散環境では、この問題はより複雑になりますね。

また、どのデータをキャッシュするべきか、キャッシュの有効期限をどう設定するか、といった設計判断も難しい課題です。 キャッシュしすぎるとメモリを圧迫し、キャッシュが少なすぎると効果が薄れてしまいます。

以下の図は、キャッシュ設計で直面する主要な課題を示しています。

mermaidflowchart TD
    start["キャッシュ設計"] --> issue1["データ整合性"]
    start --> issue2["パフォーマンス"]
    start --> issue3["運用管理"]

    issue1 --> i1_1["キャッシュとDBの<br/>データ不整合"]
    issue1 --> i1_2["並行更新時の<br/>競合"]
    issue1 --> i1_3["部分的な<br/>更新失敗"]

    issue2 --> i2_1["キャッシュミス時の<br/>遅延"]
    issue2 --> i2_2["キャッシュ<br/>ウォームアップ"]
    issue2 --> i2_3["メモリ使用量の<br/>制御"]

    issue3 --> i3_1["適切なTTL<br/>設定"]
    issue3 --> i3_2["キャッシュ<br/>無効化戦略"]
    issue3 --> i3_3["監視と<br/>デバッグ"]

図で理解できる要点:

  • データ整合性、パフォーマンス、運用管理の 3 つの軸で課題が発生する
  • 各軸にはさらに具体的な問題が存在し、それぞれに対策が必要
  • キャッシュパターンの選択により、これらの課題への対処方法が変わる

Cache-Aside パターンの課題

Cache-Aside パターンでは、アプリケーションがキャッシュとデータベースの両方を管理する必要があります。 この責任の分散により、実装が複雑になり、バグが混入しやすくなります。

特に問題となるのが、キャッシュミス時の「Thundering Herd(群衆効果)」です。 人気のあるデータのキャッシュが期限切れになった瞬間、複数のリクエストが同時にデータベースにアクセスし、データベースに過大な負荷をかけてしまうのです。

また、書き込み時にキャッシュを無効化するタイミングが難しく、データの不整合が発生しやすいという課題もあります。

#課題影響リスクレベル
1Thundering Herdキャッシュミス時に DB 負荷が急増★★★
2キャッシュ無効化漏れ古いデータを返す可能性★★★
3実装コードの複雑化バグの混入やメンテナンス性低下★★
4Cold Start 問題起動直後のパフォーマンス低下★★

Write-Through パターンの課題

Write-Through パターンは、書き込み処理が同期的であるため、レスポンスタイムが遅くなるという根本的な課題があります。 キャッシュへの書き込みとデータベースへの書き込みの両方が完了するまで、クライアントは待機しなければなりません。

また、書き込み操作が失敗した場合のロールバック処理が複雑になります。 キャッシュへの書き込みは成功したが、データベースへの書き込みが失敗した場合、キャッシュの整合性をどう保つかという問題が発生するのです。

さらに、一度しか読まれないデータもキャッシュに保存されるため、メモリの無駄遣いにつながる可能性があります。

#課題影響対策の必要性
1書き込み遅延レスポンスタイムの増加★★★
2部分的な失敗処理データ整合性の維持が困難★★★
3不要なキャッシュメモリ使用効率の低下★★
4DB 障害時の影響書き込み全体の失敗★★

Write-Behind パターンの課題

Write-Behind パターンの最大の課題は、データ損失のリスクです。 キャッシュには書き込まれたがデータベースへの書き込みが完了する前にシステムがクラッシュすると、そのデータは永久に失われてしまいます。

また、非同期書き込みのため、データベースへの書き込み順序が保証されない可能性があります。 これにより、依存関係のあるデータで整合性の問題が発生することがあるでしょう。

実装の複雑さも大きな課題です。 バックグラウンドでの書き込み処理、エラーハンドリング、リトライロジックなど、考慮すべき要素が多くなります。

#課題影響重要度
1データ損失リスク書き込み済みデータの消失★★★
2書き込み順序の不保証依存データの整合性問題★★★
3実装の高度な複雑性開発・保守コストの増加★★
4デバッグの困難さ問題の原因特定に時間がかかる★★

解決策

Cache-Aside パターンの実装戦略

Cache-Aside パターンは、アプリケーション側でキャッシュの読み書きを完全に制御する方式です。 読み込み時には、まずキャッシュを確認し、存在しない場合のみデータベースから取得してキャッシュに保存します。

書き込み時には、データベースを更新した後、該当するキャッシュを削除する戦略が一般的です。 この「削除」戦略により、次回読み込み時に最新データがキャッシュされるため、データの一貫性を保ちやすくなります。

Thundering Herd 問題への対策としては、ロック機構を使った単一リクエストによるデータベースアクセスや、確率的な早期期限切れ設定などの手法があります。 以下の図は、Cache-Aside パターンの基本的な処理フローを示しています。

mermaidsequenceDiagram
    participant App as アプリケーション
    participant Cache as Redisキャッシュ
    participant DB as データベース

    Note over App,DB: 読み込みフロー
    App->>Cache: GET key
    alt キャッシュヒット
        Cache-->>App: データ返却
    else キャッシュミス
        Cache-->>App: null
        App->>DB: SELECT query
        DB-->>App: データ返却
        App->>Cache: SET key value EX ttl
        Cache-->>App: OK
    end

    Note over App,DB: 書き込みフロー
    App->>DB: UPDATE query
    DB-->>App: OK
    App->>Cache: DEL key
    Cache-->>App: OK

図で理解できる要点:

  • 読み込みは「キャッシュ確認 → ミス時に DB 読み込み → キャッシュ保存」の流れ
  • 書き込みは「DB 更新 → キャッシュ削除」のシンプルな 2 ステップ
  • アプリケーションがすべての処理を制御するため、柔軟性が高い

Write-Through パターンの実装戦略

Write-Through パターンでは、書き込み操作が常にキャッシュとデータベースの両方を更新します。 重要なのは、両方の操作が成功した場合のみ、書き込みが成功したとみなすという原則です。

実装時には、トランザクション的な処理が必要となります。 キャッシュへの書き込みが成功してもデータベースへの書き込みが失敗した場合は、キャッシュの値もロールバックする必要があるでしょう。

パフォーマンスを改善するには、キャッシュとデータベースへの書き込みを並列化する方法があります。 ただし、片方が失敗した場合の整合性管理は慎重に設計しなければなりません。

mermaidsequenceDiagram
    participant App as アプリケーション
    participant Cache as Redisキャッシュ
    participant DB as データベース

    Note over App,DB: 書き込みフロー(成功ケース)
    App->>Cache: SET key value
    Cache-->>App: OK
    App->>DB: INSERT/UPDATE query
    DB-->>App: OK
    App-->>App: 両方成功:完了

    Note over App,DB: 書き込みフロー(失敗ケース)
    App->>Cache: SET key value
    Cache-->>App: OK
    App->>DB: INSERT/UPDATE query
    DB-->>App: ERROR
    App->>Cache: DEL key(ロールバック)
    Cache-->>App: OK
    App-->>App: エラー返却

このパターンは、読み込み処理が非常に高速になる利点があります。 常にキャッシュとデータベースが同期されているため、読み込みはキャッシュからのみ行えばよいのです。

Write-Behind パターンの実装戦略

Write-Behind パターンは、書き込みの高速化を最優先する設計です。 アプリケーションはキャッシュへの書き込みが完了した時点で、すぐにクライアントに成功を返します。

データベースへの書き込みは、バックグラウンドプロセスが非同期に処理します。 この実装には、書き込みキューの管理、バッチ処理、エラーリトライなどの機構が必要です。

データ損失を防ぐためには、Redis の永続化機能(AOF)を有効にし、定期的なスナップショットを取得することが重要でしょう。 また、書き込み失敗時のアラート機能も不可欠です。

mermaidflowchart TD
    write["書き込みリクエスト"] --> cache["キャッシュに保存"]
    cache --> response["即座にレスポンス"]
    cache --> queue["書き込みキュー"]

    queue --> worker["バックグラウンド<br/>ワーカー"]
    worker --> batch["バッチ処理"]
    batch --> db_write["DB書き込み"]

    db_write -->|成功| remove["キューから削除"]
    db_write -->|失敗| retry{"リトライ<br/>可能?"}
    retry -->|Yes| wait["待機"]
    wait --> batch
    retry -->|No| alert["アラート発火"]
    alert --> dlq["デッドレター<br/>キュー"]

図で理解できる要点:

  • 書き込みとレスポンスが非同期に分離され、高速性を実現
  • バックグラウンドワーカーがキューからデータを取り出し DB 書き込み
  • 失敗時のリトライとデッドレターキューによるデータ保護

パターン選択の判断基準

3 つのパターンから最適なものを選ぶには、システムの特性と要件を明確にすることが重要です。 読み込みと書き込みの比率、データの重要度、整合性要件、許容できる遅延時間などを考慮しましょう。

一般的には、読み込みが圧倒的に多いシステム(90%以上)では Cache-Aside が最適です。 金融システムなど強い整合性が求められる場合は Write-Through、ログ収集やメトリクスなど高速な書き込みが必要な場合は Write-Behind が適しています。

実際には、システムの異なる部分で異なるパターンを組み合わせることも有効な戦略となります。

#パターン適したケース読み込み速度書き込み速度整合性実装難易度
1Cache-Aside読み込み多数、汎用的な用途★★★★★★★
2Write-Through強い整合性が必要★★★★★★★★
3Write-Behind高速書き込みが必要★★★★★★★★★

具体例

Cache-Aside パターンの実装

Cache-Aside パターンの実装では、まず必要なパッケージをインストールします。 Redis クライアントとして ioredis ライブラリを使用し、データベースアクセスには pg ライブラリを利用します。

パッケージのインストール

Yarn を使って必要なパッケージをインストールしましょう。

bashyarn add ioredis pg
yarn add -D @types/ioredis @types/pg

Redis 接続クライアントの作成

Redis への接続を管理するクライアントを作成します。 シングルトンパターンで実装することで、アプリケーション全体で同じ接続を再利用できます。

typescript// cache/redis-client.ts
import Redis from 'ioredis';

/**
 * Redisクライアントのシングルトンインスタンス
 * アプリケーション全体で1つの接続を共有します
 */
class RedisClient {
  private static instance: Redis | null = null;

  /**
   * Redisクライアントのインスタンスを取得
   * 初回呼び出し時に接続を確立します
   */
  public static getInstance(): Redis {
    if (!RedisClient.instance) {
      RedisClient.instance = new Redis({
        host: process.env.REDIS_HOST || 'localhost',
        port: parseInt(process.env.REDIS_PORT || '6379'),
        password: process.env.REDIS_PASSWORD,
        retryStrategy: (times: number) => {
          // 接続失敗時のリトライ戦略
          const delay = Math.min(times * 50, 2000);
          return delay;
        },
      });
    }
    return RedisClient.instance;
  }
}

export default RedisClient;

このコードでは、環境変数から接続情報を取得し、接続失敗時のリトライ戦略も定義しています。 リトライ間隔は指数バックオフで最大 2 秒まで延長されますね。

Cache-Aside パターンの実装クラス

Cache-Aside パターンの核となる処理を実装します。 読み込み時のキャッシュ確認とミス時のデータベース取得、書き込み時のキャッシュ無効化を担当します。

typescript// cache/cache-aside.ts
import RedisClient from './redis-client';
import { Pool } from 'pg';

/**
 * ユーザーデータの型定義
 */
interface User {
  id: number;
  name: string;
  email: string;
  created_at: Date;
}

/**
 * Cache-Asideパターンの実装クラス
 * キャッシュとDBの読み書きを管理します
 */
export class CacheAsideService {
  private redis = RedisClient.getInstance();
  private db: Pool;
  private readonly DEFAULT_TTL = 3600; // 1時間

  constructor(dbPool: Pool) {
    this.db = dbPool;
  }

  /**
   * キャッシュキーの生成
   * 命名規則を統一することで管理しやすくします
   */
  private getCacheKey(userId: number): string {
    return `user:${userId}`;
  }
}

まず、基本的なクラス構造と型定義を行います。 キャッシュキーの命名規則を統一することで、デバッグやメンテナンスが容易になるでしょう。

読み込み処理の実装

ユーザーデータを取得する処理を実装します。 キャッシュヒット時は Redis から、ミス時はデータベースから取得し、その結果をキャッシュに保存します。

typescript  /**
   * ユーザー情報の取得(Cache-Asideパターン)
   * 1. キャッシュを確認
   * 2. ヒット時:キャッシュから返却
   * 3. ミス時:DBから取得してキャッシュに保存
   */
  async getUser(userId: number): Promise<User | null> {
    const cacheKey = this.getCacheKey(userId);

    try {
      // Step 1: キャッシュから取得を試みる
      const cached = await this.redis.get(cacheKey);

      if (cached) {
        console.log(`Cache HIT: ${cacheKey}`);
        return JSON.parse(cached) as User;
      }

      console.log(`Cache MISS: ${cacheKey}`);
    } catch (error) {
      // キャッシュエラーは無視してDBから取得を続行
      console.error('Redis error:', error);
    }

    return null;
  }

キャッシュヒット時にはログを出力し、パフォーマンスの監視に役立てます。 Redis のエラーが発生してもアプリケーション全体を停止させず、データベースからのフォールバックを継続することが重要です。

データベースからの取得とキャッシュ保存

キャッシュミス時のデータベース取得処理を実装します。 取得したデータは自動的にキャッシュに保存され、次回アクセス時の高速化を実現します。

typescript  /**
   * データベースからユーザー情報を取得してキャッシュに保存
   */
  private async fetchAndCacheUser(userId: number): Promise<User | null> {
    const cacheKey = this.getCacheKey(userId);

    try {
      // Step 2: データベースから取得
      const result = await this.db.query(
        'SELECT id, name, email, created_at FROM users WHERE id = $1',
        [userId]
      );

      if (result.rows.length === 0) {
        // ユーザーが存在しない場合は負のキャッシュを保存(短いTTL)
        await this.redis.setex(cacheKey, 60, 'null');
        return null;
      }

      const user: User = result.rows[0];

      // Step 3: 取得したデータをキャッシュに保存
      await this.redis.setex(
        cacheKey,
        this.DEFAULT_TTL,
        JSON.stringify(user)
      );

      console.log(`Cached: ${cacheKey}`);
      return user;
    } catch (error) {
      console.error('Database error:', error);
      throw error;
    }
  }

ユーザーが存在しない場合にも短時間のキャッシュ(負のキャッシュ)を保存することで、存在しない ID への連続アクセスからデータベースを保護できます。 これは、意図的な攻撃や誤った ID でのアクセスに対する防御策となるでしょう。

書き込み処理とキャッシュ無効化

ユーザー情報を更新する処理を実装します。 データベース更新後に該当のキャッシュを削除することで、次回読み込み時に最新データが取得されます。

typescript  /**
   * ユーザー情報の更新(Cache-Asideパターン)
   * 1. データベースを更新
   * 2. キャッシュを削除(次回読み込み時に最新データを取得)
   */
  async updateUser(
    userId: number,
    updates: Partial<Pick<User, 'name' | 'email'>>
  ): Promise<boolean> {
    const cacheKey = this.getCacheKey(userId);

    try {
      // Step 1: データベースを更新
      const setClause = Object.keys(updates)
        .map((key, index) => `${key} = $${index + 2}`)
        .join(', ');

      const values = [userId, ...Object.values(updates)];

      const result = await this.db.query(
        `UPDATE users SET ${setClause} WHERE id = $1`,
        values
      );

      if (result.rowCount === 0) {
        return false;
      }

      // Step 2: キャッシュを削除
      await this.redis.del(cacheKey);
      console.log(`Cache invalidated: ${cacheKey}`);

      return true;
    } catch (error) {
      console.error('Update error:', error);
      throw error;
    }
  }

キャッシュを削除する戦略(Delete-on-Write)は、更新する戦略(Update-on-Write)よりも安全です。 削除戦略では、次回読み込み時に確実に最新データが取得されるため、データの不整合が発生しにくくなります。

Thundering Herd 対策の実装

キャッシュが期限切れになった瞬間、複数のリクエストが同時にデータベースにアクセスする問題を防ぎます。 Redis のロック機構を使って、最初のリクエストのみがデータベースにアクセスできるようにします。

typescript  /**
   * Thundering Herd対策を実装した取得処理
   * 複数リクエストが同時にキャッシュミスした場合、
   * 最初の1つだけがDBアクセスし、他は待機します
   */
  async getUserWithLock(userId: number): Promise<User | null> {
    const cacheKey = this.getCacheKey(userId);
    const lockKey = `lock:${cacheKey}`;
    const lockTimeout = 10; // 10秒

    try {
      // キャッシュを確認
      const cached = await this.redis.get(cacheKey);
      if (cached && cached !== 'null') {
        return JSON.parse(cached) as User;
      }

      // ロックの取得を試みる(NXオプションで排他制御)
      const lockAcquired = await this.redis.set(
        lockKey,
        '1',
        'EX',
        lockTimeout,
        'NX'
      );

      if (lockAcquired) {
        // ロック取得成功:DBからデータを取得
        try {
          const user = await this.fetchAndCacheUser(userId);
          return user;
        } finally {
          // 処理完了後、必ずロックを解放
          await this.redis.del(lockKey);
        }
      } else {
        // ロック取得失敗:少し待ってからキャッシュを再確認
        await this.sleep(100); // 100ms待機
        return this.getUserWithLock(userId); // 再帰的に再試行
      }
    } catch (error) {
      console.error('Error in getUserWithLock:', error);
      // エラー時はロックをクリーンアップ
      await this.redis.del(lockKey);
      throw error;
    }
  }

  /**
   * 指定ミリ秒待機するヘルパー関数
   */
  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

このロック機構により、データベースへの同時アクセスを防ぎ、負荷を大幅に軽減できます。 ロックを取得できなかったリクエストは短時間待機後にキャッシュを再確認するため、最初のリクエストがキャッシュに保存したデータを利用できるでしょう。

Write-Through パターンの実装

Write-Through パターンでは、書き込み時にキャッシュとデータベースの両方を同期的に更新します。 両方の更新が成功して初めて、書き込みが完了したとみなす厳格な整合性管理が特徴です。

Write-Through クラスの基本構造

Write-Through パターンを実装するクラスを作成します。 トランザクション的な処理を実現するため、ロールバック機構も組み込みます。

typescript// cache/write-through.ts
import RedisClient from './redis-client';
import { Pool } from 'pg';

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

/**
 * Write-Throughパターンの実装クラス
 * キャッシュとDBを同期的に更新し、強い整合性を保証します
 */
export class WriteThroughService {
  private redis = RedisClient.getInstance();
  private db: Pool;
  private readonly DEFAULT_TTL = 7200; // 2時間

  constructor(dbPool: Pool) {
    this.db = dbPool;
  }

  /**
   * キャッシュキーの生成
   */
  private getCacheKey(productId: number): string {
    return `product:${productId}`;
  }
}

商品情報を扱う例として、在庫管理システムを想定しています。 在庫数などの重要なデータは、キャッシュとデータベースが常に一致している必要があるでしょう。

読み込み処理の実装

Write-Through パターンでは、読み込みは常にキャッシュから行います。 キャッシュとデータベースが同期されているため、キャッシュのデータは常に最新です。

typescript  /**
   * 商品情報の取得
   * Write-Throughパターンではキャッシュが常に最新なので、
   * キャッシュのみを確認します
   */
  async getProduct(productId: number): Promise<Product | null> {
    const cacheKey = this.getCacheKey(productId);

    try {
      // キャッシュから取得
      const cached = await this.redis.get(cacheKey);

      if (cached) {
        console.log(`Cache HIT: ${cacheKey}`);
        return JSON.parse(cached) as Product;
      }

      // キャッシュにない場合はDBから取得してキャッシュ
      console.log(`Cache MISS: ${cacheKey}`);
      return await this.loadAndCacheProduct(productId);
    } catch (error) {
      console.error('Error in getProduct:', error);
      throw error;
    }
  }

  /**
   * データベースから商品を取得してキャッシュに保存
   */
  private async loadAndCacheProduct(
    productId: number
  ): Promise<Product | null> {
    const cacheKey = this.getCacheKey(productId);

    const result = await this.db.query(
      'SELECT id, name, price, stock, updated_at FROM products WHERE id = $1',
      [productId]
    );

    if (result.rows.length === 0) {
      return null;
    }

    const product: Product = result.rows[0];
    await this.redis.setex(cacheKey, this.DEFAULT_TTL, JSON.stringify(product));

    return product;
  }

初回アクセス時のキャッシュミスを除いて、すべての読み込みがキャッシュから行われるため、非常に高速です。 データベースの負荷も最小限に抑えられますね。

書き込み処理の実装(基本)

キャッシュとデータベースの両方を同期的に更新する処理を実装します。 片方が失敗した場合は、全体をロールバックして整合性を保ちます。

typescript  /**
   * 商品情報の更新(Write-Throughパターン)
   * 1. キャッシュを更新
   * 2. データベースを更新
   * 3. どちらかが失敗したら全体をロールバック
   */
  async updateProduct(
    productId: number,
    updates: Partial<Pick<Product, 'name' | 'price' | 'stock'>>
  ): Promise<boolean> {
    const cacheKey = this.getCacheKey(productId);
    let oldCacheValue: string | null = null;

    try {
      // Step 1: 現在のキャッシュ値を保存(ロールバック用)
      oldCacheValue = await this.redis.get(cacheKey);

      // Step 2: 最新データを取得
      const currentProduct = await this.getProduct(productId);
      if (!currentProduct) {
        return false;
      }

      // Step 3: 更新後のデータを作成
      const updatedProduct: Product = {
        ...currentProduct,
        ...updates,
        updated_at: new Date(),
      };

      // Step 4: キャッシュを更新
      await this.redis.setex(
        cacheKey,
        this.DEFAULT_TTL,
        JSON.stringify(updatedProduct)
      );
      console.log(`Cache updated: ${cacheKey}`);

      return true;
    } catch (error) {
      console.error('Error in updateProduct:', error);

      // ロールバック:キャッシュを元に戻す
      if (oldCacheValue) {
        await this.redis.setex(cacheKey, this.DEFAULT_TTL, oldCacheValue);
        console.log(`Cache rolled back: ${cacheKey}`);
      }

      throw error;
    }
  }

キャッシュ更新前に古い値を保存しておくことで、失敗時のロールバックが可能になります。 この仕組みにより、データの不整合を防げるでしょう。

データベース更新の実装

データベースへの更新処理を実装します。 キャッシュ更新とデータベース更新の両方が成功して初めて、処理が完了します。

typescript  /**
   * データベース更新処理
   * キャッシュ更新後に呼び出され、失敗時はキャッシュもロールバックします
   */
  private async updateDatabase(
    productId: number,
    updates: Partial<Pick<Product, 'name' | 'price' | 'stock'>>
  ): Promise<boolean> {
    try {
      // 更新するカラムのSQL文を動的に生成
      const setClause = Object.keys(updates)
        .map((key, index) => `${key} = $${index + 2}`)
        .join(', ');

      const values = [productId, ...Object.values(updates)];

      // データベースを更新
      const result = await this.db.query(
        `UPDATE products SET ${setClause}, updated_at = NOW() WHERE id = $1`,
        values
      );

      if (result.rowCount === 0) {
        throw new Error('Product not found in database');
      }

      console.log(`Database updated: product ${productId}`);
      return true;
    } catch (error) {
      console.error('Database update failed:', error);
      throw error;
    }
  }

データベース更新が失敗した場合、呼び出し元でキャッシュのロールバックが実行されます。 この 2 段階の処理により、データの整合性が保証されるのです。

トランザクション的な書き込みの完全実装

キャッシュとデータベースの両方を更新する完全な処理フローを実装します。 エラーハンドリングとロールバック機構を組み込んだ堅牢な設計です。

typescript  /**
   * 完全なWrite-Through更新処理
   * キャッシュ→DB の順で更新し、失敗時は適切にロールバック
   */
  async updateProductComplete(
    productId: number,
    updates: Partial<Pick<Product, 'name' | 'price' | 'stock'>>
  ): Promise<boolean> {
    const cacheKey = this.getCacheKey(productId);
    let oldCacheValue: string | null = null;
    let cacheUpdated = false;

    try {
      // ステップ1: 現在のキャッシュ値を保存
      oldCacheValue = await this.redis.get(cacheKey);

      // ステップ2: 現在の商品データを取得
      const currentProduct = await this.getProduct(productId);
      if (!currentProduct) {
        return false;
      }

      // ステップ3: 更新データを作成
      const updatedProduct: Product = {
        ...currentProduct,
        ...updates,
        updated_at: new Date(),
      };

      // ステップ4: キャッシュを更新
      await this.redis.setex(
        cacheKey,
        this.DEFAULT_TTL,
        JSON.stringify(updatedProduct)
      );
      cacheUpdated = true;
      console.log(`✓ Cache updated: ${cacheKey}`);

      // ステップ5: データベースを更新
      await this.updateDatabase(productId, updates);
      console.log(`✓ Database updated: product ${productId}`);

      // 両方成功
      return true;
    } catch (error) {
      console.error('❌ Update failed:', error);

      // ロールバック処理
      if (cacheUpdated && oldCacheValue) {
        try {
          await this.redis.setex(cacheKey, this.DEFAULT_TTL, oldCacheValue);
          console.log(`↩ Cache rolled back: ${cacheKey}`);
        } catch (rollbackError) {
          console.error('❌ Rollback failed:', rollbackError);
          // ロールバック失敗は致命的なので、アラート等を発火すべき
        }
      }

      throw error;
    }
  }

処理の各ステップでログを出力することで、どこで失敗したかを追跡しやすくなります。 ロールバック処理自体が失敗した場合は、運用チームへのアラートなど、追加の対応が必要でしょう。

Write-Behind パターンの実装

Write-Behind パターンは、書き込みを非同期化することで最高速度を実現します。 実装には、書き込みキューの管理とバックグラウンド処理が必要です。

Write-Behind クラスの基本構造

非同期書き込みを管理するクラスを作成します。 Redis のリストをキューとして使用し、バックグラウンドワーカーがデータベースへの書き込みを処理します。

typescript// cache/write-behind.ts
import RedisClient from './redis-client';
import { Pool } from 'pg';

interface LogEntry {
  id: string;
  user_id: number;
  action: string;
  timestamp: Date;
  metadata: Record<string, any>;
}

/**
 * 書き込みキューのアイテム型
 */
interface WriteQueueItem {
  id: string;
  data: LogEntry;
  retry_count: number;
  created_at: string;
}

/**
 * Write-Behindパターンの実装クラス
 * 書き込みを非同期化し、高速なレスポンスを実現します
 */
export class WriteBehindService {
  private redis = RedisClient.getInstance();
  private db: Pool;
  private readonly QUEUE_KEY = 'write_queue:logs';
  private readonly DLQ_KEY = 'dlq:logs'; // Dead Letter Queue
  private readonly MAX_RETRY = 3;
  private workerRunning = false;

  constructor(dbPool: Pool) {
    this.db = dbPool;
  }

  /**
   * キャッシュキーの生成
   */
  private getCacheKey(logId: string): string {
    return `log:${logId}`;
  }
}

ログ収集システムを例として、大量の書き込みを高速に処理する実装を示します。 Dead Letter Queue を用意することで、リトライ上限を超えた失敗データも保存できますね。

書き込み処理の実装

ログデータをキャッシュに保存し、書き込みキューに追加します。 この時点でクライアントには成功を返すため、非常に高速なレスポンスが実現できます。

typescript  /**
   * ログエントリの作成(Write-Behindパターン)
   * 1. キャッシュに即座に保存
   * 2. 書き込みキューに追加
   * 3. すぐにクライアントに成功を返す
   */
  async createLog(logEntry: LogEntry): Promise<boolean> {
    const cacheKey = this.getCacheKey(logEntry.id);

    try {
      // Step 1: キャッシュに保存(即座に完了)
      await this.redis.setex(
        cacheKey,
        3600, // 1時間のTTL
        JSON.stringify(logEntry)
      );
      console.log(`✓ Cached: ${cacheKey}`);

      // Step 2: 書き込みキューに追加
      const queueItem: WriteQueueItem = {
        id: logEntry.id,
        data: logEntry,
        retry_count: 0,
        created_at: new Date().toISOString(),
      };

      await this.redis.rpush(this.QUEUE_KEY, JSON.stringify(queueItem));
      console.log(`✓ Queued: ${logEntry.id}`);

      // Step 3: 即座に成功を返す(DBへの書き込みは待たない)
      return true;
    } catch (error) {
      console.error('❌ Error in createLog:', error);
      throw error;
    }
  }

  /**
   * ログエントリの取得(キャッシュから)
   */
  async getLog(logId: string): Promise<LogEntry | null> {
    const cacheKey = this.getCacheKey(logId);

    try {
      const cached = await this.redis.get(cacheKey);

      if (cached) {
        return JSON.parse(cached) as LogEntry;
      }

      // キャッシュになければDBから取得(通常のケース)
      return await this.loadFromDatabase(logId);
    } catch (error) {
      console.error('Error in getLog:', error);
      throw error;
    }
  }

書き込みキューへの追加が完了した時点でクライアントに成功を返すため、データベースのレイテンシに影響されません。 これにより、書き込み処理が大幅に高速化されるでしょう。

バックグラウンドワーカーの実装

書き込みキューからデータを取り出し、データベースに書き込むワーカーを実装します。 エラー発生時のリトライ機構も組み込みます。

typescript  /**
   * バックグラウンドワーカーの開始
   * キューからアイテムを取り出してDBに書き込みます
   */
  async startWorker(): Promise<void> {
    if (this.workerRunning) {
      console.log('⚠ Worker is already running');
      return;
    }

    this.workerRunning = true;
    console.log('🚀 Write-Behind worker started');

    while (this.workerRunning) {
      try {
        // キューから1件取り出す(ブロッキング動作、最大5秒待機)
        const result = await this.redis.blpop(this.QUEUE_KEY, 5);

        if (!result) {
          // タイムアウト:次のループへ
          continue;
        }

        const [, itemJson] = result;
        const item: WriteQueueItem = JSON.parse(itemJson);

        console.log(`📝 Processing: ${item.id}`);

        // データベースへの書き込みを試みる
        const success = await this.writeToDatabase(item.data);

        if (success) {
          console.log(`✓ Written to DB: ${item.id}`);
        } else {
          // 失敗時はリトライ処理へ
          await this.handleWriteFailure(item);
        }
      } catch (error) {
        console.error('❌ Worker error:', error);
        // エラーが発生してもワーカーは継続
        await this.sleep(1000); // 1秒待機してから再開
      }
    }

    console.log('🛑 Write-Behind worker stopped');
  }

  /**
   * ワーカーの停止
   */
  stopWorker(): void {
    this.workerRunning = false;
  }

  /**
   * 待機処理のヘルパー関数
   */
  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

ブロッキング動作(blpop)を使うことで、キューが空の時は待機し、CPU リソースを無駄にしません。 エラーが発生してもワーカーは停止せず、継続的に処理を行えるように設計されています。

データベース書き込みとエラーハンドリング

実際のデータベース書き込み処理と、失敗時のリトライ、Dead Letter Queue への移動を実装します。

typescript  /**
   * データベースへの書き込み処理
   */
  private async writeToDatabase(logEntry: LogEntry): Promise<boolean> {
    try {
      await this.db.query(
        `INSERT INTO logs (id, user_id, action, timestamp, metadata)
         VALUES ($1, $2, $3, $4, $5)
         ON CONFLICT (id) DO UPDATE SET
         user_id = EXCLUDED.user_id,
         action = EXCLUDED.action,
         timestamp = EXCLUDED.timestamp,
         metadata = EXCLUDED.metadata`,
        [
          logEntry.id,
          logEntry.user_id,
          logEntry.action,
          logEntry.timestamp,
          JSON.stringify(logEntry.metadata),
        ]
      );

      return true;
    } catch (error) {
      console.error('Database write failed:', error);
      return false;
    }
  }

  /**
   * 書き込み失敗時の処理
   * リトライ回数が上限未満なら再キュー、上限を超えたらDLQへ
   */
  private async handleWriteFailure(item: WriteQueueItem): Promise<void> {
    item.retry_count += 1;

    if (item.retry_count < this.MAX_RETRY) {
      // リトライ回数が上限未満:再度キューに追加
      console.log(`↻ Retry ${item.retry_count}/${this.MAX_RETRY}: ${item.id}`);

      // 指数バックオフで待機時間を増やす
      const backoffMs = Math.pow(2, item.retry_count) * 1000;
      await this.sleep(backoffMs);

      await this.redis.rpush(this.QUEUE_KEY, JSON.stringify(item));
    } else {
      // リトライ上限到達:Dead Letter Queueへ移動
      console.error(`❌ Max retries reached, moving to DLQ: ${item.id}`);

      await this.redis.rpush(this.DLQ_KEY, JSON.stringify(item));

      // アラートを発火(実装例:メール、Slack通知など)
      await this.sendAlert(`Failed to write log after ${this.MAX_RETRY} retries: ${item.id}`);
    }
  }

  /**
   * アラート送信(実装はシステムに応じてカスタマイズ)
   */
  private async sendAlert(message: string): Promise<void> {
    // 実装例:Slack、メール、監視システムへの通知
    console.error(`🚨 ALERT: ${message}`);
    // 実際の通知処理をここに実装
  }

  /**
   * DBからログを読み込む
   */
  private async loadFromDatabase(logId: string): Promise<LogEntry | null> {
    const result = await this.db.query(
      'SELECT id, user_id, action, timestamp, metadata FROM logs WHERE id = $1',
      [logId]
    );

    if (result.rows.length === 0) {
      return null;
    }

    return result.rows[0] as LogEntry;
  }

指数バックオフによるリトライ戦略により、一時的なエラーには対応しつつ、システムへの負荷を抑えられます。 Dead Letter Queue に移動したデータは、後から手動で確認・再処理することができるでしょう。

バッチ処理の最適化

複数の書き込みをまとめて処理することで、データベースへの負荷を軽減する実装を追加します。

typescript  /**
   * バッチ処理バージョンのワーカー
   * 複数のアイテムをまとめてDBに書き込み、スループット向上
   */
  async startBatchWorker(batchSize: number = 10): Promise<void> {
    if (this.workerRunning) {
      console.log('⚠ Worker is already running');
      return;
    }

    this.workerRunning = true;
    console.log(`🚀 Batch worker started (batch size: ${batchSize})`);

    while (this.workerRunning) {
      try {
        const items: WriteQueueItem[] = [];

        // バッチサイズ分のアイテムを取得
        for (let i = 0; i < batchSize; i++) {
          const result = await this.redis.lpop(this.QUEUE_KEY);

          if (!result) {
            break; // キューが空になったら終了
          }

          items.push(JSON.parse(result));
        }

        if (items.length === 0) {
          // アイテムがない場合は少し待機
          await this.sleep(1000);
          continue;
        }

        console.log(`📦 Processing batch: ${items.length} items`);

        // バッチ書き込みを実行
        await this.batchWriteToDatabase(items);

        console.log(`✓ Batch completed: ${items.length} items`);
      } catch (error) {
        console.error('❌ Batch worker error:', error);
        await this.sleep(1000);
      }
    }

    console.log('🛑 Batch worker stopped');
  }

  /**
   * バッチでデータベースに書き込み
   */
  private async batchWriteToDatabase(
    items: WriteQueueItem[]
  ): Promise<void> {
    const client = await this.db.connect();

    try {
      await client.query('BEGIN');

      for (const item of items) {
        const { data } = item;

        await client.query(
          `INSERT INTO logs (id, user_id, action, timestamp, metadata)
           VALUES ($1, $2, $3, $4, $5)
           ON CONFLICT (id) DO UPDATE SET
           user_id = EXCLUDED.user_id,
           action = EXCLUDED.action,
           timestamp = EXCLUDED.timestamp,
           metadata = EXCLUDED.metadata`,
          [
            data.id,
            data.user_id,
            data.action,
            data.timestamp,
            JSON.stringify(data.metadata),
          ]
        );
      }

      await client.query('COMMIT');
    } catch (error) {
      await client.query('ROLLBACK');
      console.error('Batch write failed:', error);

      // 失敗したアイテムを個別に再キュー
      for (const item of items) {
        await this.handleWriteFailure(item);
      }

      throw error;
    } finally {
      client.release();
    }
  }

バッチ処理により、データベースへの接続回数を減らし、トランザクションのオーバーヘッドを削減できます。 1 つでも失敗した場合は全体をロールバックし、個別にリトライキューへ戻すことで、データの損失を防げるでしょう。

実装パターンの使用例

3 つのパターンを実際のアプリケーションで使用する例を示します。 Express.js を使った Web アプリケーションで、各パターンを統合します。

アプリケーションのセットアップ

必要なパッケージをインストールし、基本的な Express サーバーを構築します。

typescript// app.ts
import express from 'express';
import { Pool } from 'pg';
import { CacheAsideService } from './cache/cache-aside';
import { WriteThroughService } from './cache/write-through';
import { WriteBehindService } from './cache/write-behind';

/**
 * データベース接続プールの作成
 */
const dbPool = new Pool({
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME || 'myapp',
  user: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD,
  max: 20, // 最大接続数
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

// 各サービスのインスタンスを作成
const cacheAsideService = new CacheAsideService(dbPool);
const writeThroughService = new WriteThroughService(dbPool);
const writeBehindService = new WriteBehindService(dbPool);

// Write-Behindワーカーを起動
writeBehindService.startBatchWorker(10);

const app = express();
app.use(express.json());

アプリケーション起動時にデータベース接続プールと各サービスを初期化します。 Write-Behind ワーカーもバックグラウンドで起動し、継続的にキューを処理できるようにしていますね。

Cache-Aside パターンのエンドポイント

ユーザー情報の取得と更新のエンドポイントを実装します。 読み込みが多いユーザープロフィールに適したパターンです。

typescript/**
 * Cache-Asideパターンのエンドポイント
 * ユーザー情報の取得・更新
 */

// ユーザー情報取得(読み込み重視)
app.get('/api/users/:id', async (req, res) => {
  try {
    const userId = parseInt(req.params.id);

    // Thundering Herd対策版を使用
    const user = await cacheAsideService.getUserWithLock(
      userId
    );

    if (!user) {
      return res
        .status(404)
        .json({ error: 'User not found' });
    }

    res.json(user);
  } catch (error) {
    console.error('Error fetching user:', error);
    res
      .status(500)
      .json({ error: 'Internal server error' });
  }
});

// ユーザー情報更新
app.patch('/api/users/:id', async (req, res) => {
  try {
    const userId = parseInt(req.params.id);
    const updates = req.body;

    const success = await cacheAsideService.updateUser(
      userId,
      updates
    );

    if (!success) {
      return res
        .status(404)
        .json({ error: 'User not found' });
    }

    res.json({ message: 'User updated successfully' });
  } catch (error) {
    console.error('Error updating user:', error);
    res
      .status(500)
      .json({ error: 'Internal server error' });
  }
});

Cache-Aside パターンは、ユーザープロフィールのように読み込みが圧倒的に多いデータに最適です。 更新時にキャッシュを削除することで、シンプルかつ安全にデータの一貫性を保てるでしょう。

Write-Through パターンのエンドポイント

商品情報と在庫管理のエンドポイントを実装します。 データの正確性が重要な在庫情報には、強い整合性を持つ Write-Through が適しています。

typescript/**
 * Write-Throughパターンのエンドポイント
 * 商品情報・在庫管理(整合性重視)
 */

// 商品情報取得
app.get('/api/products/:id', async (req, res) => {
  try {
    const productId = parseInt(req.params.id);
    const product = await writeThroughService.getProduct(
      productId
    );

    if (!product) {
      return res
        .status(404)
        .json({ error: 'Product not found' });
    }

    res.json(product);
  } catch (error) {
    console.error('Error fetching product:', error);
    res
      .status(500)
      .json({ error: 'Internal server error' });
  }
});

// 商品情報更新(在庫変更など)
app.patch('/api/products/:id', async (req, res) => {
  try {
    const productId = parseInt(req.params.id);
    const updates = req.body;

    const success =
      await writeThroughService.updateProductComplete(
        productId,
        updates
      );

    if (!success) {
      return res
        .status(404)
        .json({ error: 'Product not found' });
    }

    res.json({ message: 'Product updated successfully' });
  } catch (error) {
    console.error('Error updating product:', error);
    res
      .status(500)
      .json({ error: 'Internal server error' });
  }
});

在庫情報のように、キャッシュとデータベースの不整合が問題になるデータには、Write-Through パターンが最適です。 多少のレスポンス遅延を許容できるなら、確実なデータ整合性が得られますね。

Write-Behind パターンのエンドポイント

アクセスログの記録エンドポイントを実装します。 大量の書き込みが発生するログ収集には、高速な Write-Behind が効果的です。

typescript/**
 * Write-Behindパターンのエンドポイント
 * ログ記録(高速書き込み重視)
 */

// ログ作成
app.post('/api/logs', async (req, res) => {
  try {
    const logEntry = {
      id: `log_${Date.now()}_${Math.random()
        .toString(36)
        .substr(2, 9)}`,
      user_id: req.body.user_id,
      action: req.body.action,
      timestamp: new Date(),
      metadata: req.body.metadata || {},
    };

    // キャッシュに保存してキューに追加(即座に完了)
    await writeBehindService.createLog(logEntry);

    // すぐにレスポンスを返す
    res.status(201).json({
      message: 'Log created successfully',
      id: logEntry.id,
    });
  } catch (error) {
    console.error('Error creating log:', error);
    res
      .status(500)
      .json({ error: 'Internal server error' });
  }
});

// ログ取得
app.get('/api/logs/:id', async (req, res) => {
  try {
    const logId = req.params.id;
    const log = await writeBehindService.getLog(logId);

    if (!log) {
      return res
        .status(404)
        .json({ error: 'Log not found' });
    }

    res.json(log);
  } catch (error) {
    console.error('Error fetching log:', error);
    res
      .status(500)
      .json({ error: 'Internal server error' });
  }
});

ログ記録のように、大量の書き込みが発生し、若干の遅延が許容できるケースには、Write-Behind が最適です。 データベースへの書き込みは非同期で行われるため、API のレスポンスタイムは非常に高速になるでしょう。

サーバーの起動とシャットダウン処理

アプリケーションの起動と、グレースフルシャットダウンを実装します。

typescript/**
 * サーバーの起動
 */
const PORT = process.env.PORT || 3000;

const server = app.listen(PORT, () => {
  console.log(`🚀 Server running on port ${PORT}`);
  console.log(`📊 Cache-Aside: /api/users/:id`);
  console.log(`📦 Write-Through: /api/products/:id`);
  console.log(`📝 Write-Behind: /api/logs`);
});

/**
 * グレースフルシャットダウン
 * SIGTERM/SIGINTシグナルを受信したら、適切に終了処理を行う
 */
const gracefulShutdown = async () => {
  console.log('\n🛑 Shutting down gracefully...');

  // 新規リクエストの受付を停止
  server.close(() => {
    console.log('✓ HTTP server closed');
  });

  // Write-Behindワーカーを停止
  writeBehindService.stopWorker();
  console.log('✓ Write-Behind worker stopped');

  // データベース接続プールをクローズ
  await dbPool.end();
  console.log('✓ Database pool closed');

  console.log('👋 Shutdown complete');
  process.exit(0);
};

process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

グレースフルシャットダウンにより、処理中のリクエストを完了させてから終了できます。 特に Write-Behind パターンでは、キュー内のデータを失わないよう、ワーカーを適切に停止することが重要ですね。

パフォーマンス比較と監視

各パターンのパフォーマンスを測定し、監視する実装を追加します。 レスポンスタイムやキャッシュヒット率を記録することで、最適なパターン選択に役立てられます。

パフォーマンス測定ミドルウェア

各リクエストのレスポンスタイムを測定するミドルウェアを実装します。

typescript// middleware/performance.ts
import { Request, Response, NextFunction } from 'express';

/**
 * パフォーマンス測定ミドルウェア
 * 各リクエストのレスポンスタイムを記録
 */
export const performanceMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const startTime = Date.now();

  // レスポンス完了時の処理
  res.on('finish', () => {
    const duration = Date.now() - startTime;
    const { method, originalUrl } = req;
    const { statusCode } = res;

    console.log(
      `⏱ ${method} ${originalUrl} - ${statusCode} - ${duration}ms`
    );

    // メトリクスの記録(Prometheusなどに送信)
    recordMetric({
      method,
      path: originalUrl,
      statusCode,
      duration,
      timestamp: new Date(),
    });
  });

  next();
};

/**
 * メトリクスの記録(実装例)
 */
function recordMetric(metric: {
  method: string;
  path: string;
  statusCode: number;
  duration: number;
  timestamp: Date;
}) {
  // 実装例:Prometheus、DataDog、CloudWatchなどに送信
  // ここでは簡単にログ出力のみ
}

このミドルウェアを Express アプリケーションに追加することで、すべてのリクエストのパフォーマンスを自動的に記録できます。

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

キャッシュの効果を測定するため、ヒット率を計算する機能を追加します。

typescript// utils/cache-metrics.ts
import RedisClient from '../cache/redis-client';

/**
 * キャッシュメトリクスの管理クラス
 */
export class CacheMetrics {
  private redis = RedisClient.getInstance();
  private readonly METRICS_KEY = 'cache:metrics';

  /**
   * キャッシュヒットを記録
   */
  async recordHit(cacheKey: string): Promise<void> {
    await this.redis.hincrby(this.METRICS_KEY, 'hits', 1);
  }

  /**
   * キャッシュミスを記録
   */
  async recordMiss(cacheKey: string): Promise<void> {
    await this.redis.hincrby(this.METRICS_KEY, 'misses', 1);
  }

  /**
   * キャッシュヒット率の取得
   */
  async getHitRate(): Promise<number> {
    const metrics = await this.redis.hgetall(
      this.METRICS_KEY
    );

    const hits = parseInt(metrics.hits || '0');
    const misses = parseInt(metrics.misses || '0');
    const total = hits + misses;

    if (total === 0) {
      return 0;
    }

    return (hits / total) * 100;
  }

  /**
   * メトリクスのリセット
   */
  async resetMetrics(): Promise<void> {
    await this.redis.del(this.METRICS_KEY);
  }

  /**
   * 詳細なメトリクスの取得
   */
  async getDetailedMetrics() {
    const metrics = await this.redis.hgetall(
      this.METRICS_KEY
    );
    const hits = parseInt(metrics.hits || '0');
    const misses = parseInt(metrics.misses || '0');
    const total = hits + misses;
    const hitRate = total > 0 ? (hits / total) * 100 : 0;

    return {
      hits,
      misses,
      total,
      hitRate: hitRate.toFixed(2) + '%',
    };
  }
}

キャッシュヒット率を監視することで、キャッシュ戦略の効果を定量的に評価できます。 ヒット率が低い場合は、TTL の調整やキャッシュ対象の見直しが必要かもしれませんね。

監視エンドポイントの追加

メトリクスを確認するためのエンドポイントを実装します。

typescript// メトリクス表示エンドポイント
import { CacheMetrics } from './utils/cache-metrics';

const cacheMetrics = new CacheMetrics();

app.get('/api/metrics/cache', async (req, res) => {
  try {
    const metrics = await cacheMetrics.getDetailedMetrics();
    res.json(metrics);
  } catch (error) {
    console.error('Error fetching metrics:', error);
    res
      .status(500)
      .json({ error: 'Internal server error' });
  }
});

app.post('/api/metrics/cache/reset', async (req, res) => {
  try {
    await cacheMetrics.resetMetrics();
    res.json({ message: 'Metrics reset successfully' });
  } catch (error) {
    console.error('Error resetting metrics:', error);
    res
      .status(500)
      .json({ error: 'Internal server error' });
  }
});

これらのエンドポイントを使うことで、運用中のキャッシュパフォーマンスをリアルタイムで確認できます。

まとめ

Redis を使ったキャッシュ設計は、アプリケーションのパフォーマンスを飛躍的に向上させる強力な手段です。 本記事では、Cache-Aside、Write-Through、Write-Behind という 3 つの主要なキャッシュパターンについて、それぞれの特性と実装方法を詳しく解説しました。

Cache-Aside パターンは、読み込みが多いシステムに最適で、実装がシンプルかつ柔軟性が高い点が魅力です。 ユーザープロフィールやコンテンツ配信など、汎用的な用途に広く使えるでしょう。

Write-Through パターンは、強い整合性が求められるシステムに適しています。 在庫管理や金融システムなど、キャッシュとデータベースの不整合が許されない場面で力を発揮します。

Write-Behind パターンは、高速な書き込みが必要なシステムに最適です。 ログ収集やメトリクス記録など、大量の書き込みを処理しながら、低レイテンシを維持できるのが大きな利点ですね。

パターンの選択は、システムの読み書き比率、データの重要度、整合性要件、許容できる遅延時間などを総合的に判断して決定しましょう。 実際のシステムでは、異なる部分で異なるパターンを組み合わせることも有効な戦略となります。

#判断基準Cache-AsideWrite-ThroughWrite-Behind
1読み書き比率読み込み 90%以上読み込み多数書き込み多数
2整合性要件中程度高い低〜中程度
3レスポンス速度読み込み高速読み込み高速書き込み高速
4実装難易度低い中程度高い
5推奨用途汎用的在庫・決済ログ・メトリクス

適切なキャッシュ戦略の実装により、データベースの負荷を大幅に削減し、優れたユーザー体験を提供できます。 本記事で紹介した TypeScript コードを参考に、あなたのプロジェクトに最適なキャッシュ設計を実現してください。

キャッシュは一度実装して終わりではなく、継続的な監視と調整が重要です。 キャッシュヒット率やレスポンスタイムを定期的に確認し、TTL の調整やパターンの見直しを行うことで、常に最適なパフォーマンスを維持できるでしょう。

関連リンク