LESSON 40分

「キャッシュは銀の弾丸に見えるが、最も難しい問題の一つでもある」佐藤CTOは慎重な表情で語った。「“There are only two hard things in Computer Science: cache invalidation and naming things.” — Phil Karlton。キャッシュを正しく使えるかどうかが、シニアエンジニアの試金石だ。」

1. キャッシュパターン一覧

Cache-Aside(Lazy Loading)

最も一般的なパターン。アプリケーションがキャッシュを直接管理する。

class CacheAsideRepository<T> {
  constructor(
    private cache: CacheClient,
    private db: DatabaseClient,
    private ttlSeconds: number = 300
  ) {}

  async findById(id: string): Promise<T | null> {
    // 1. キャッシュを確認
    const cached = await this.cache.get(`entity:${id}`);
    if (cached) {
      return JSON.parse(cached) as T;
    }

    // 2. キャッシュミス → DBから取得
    const entity = await this.db.findById(id);
    if (!entity) return null;

    // 3. キャッシュに保存
    await this.cache.set(
      `entity:${id}`,
      JSON.stringify(entity),
      'EX',
      this.ttlSeconds
    );

    return entity;
  }

  async update(id: string, data: Partial<T>): Promise<T> {
    // DBを更新
    const updated = await this.db.update(id, data);

    // キャッシュを無効化(更新ではなく削除)
    await this.cache.del(`entity:${id}`);

    return updated;
  }
}

Read-Through

キャッシュ層がDB読み取りを代行する。

class ReadThroughCache<T> {
  constructor(
    private cache: CacheClient,
    private loader: (key: string) => Promise<T | null>,
    private ttlSeconds: number = 300
  ) {}

  async get(key: string): Promise<T | null> {
    // キャッシュがloaderを自動的に呼び出す
    const cached = await this.cache.get(key);
    if (cached) return JSON.parse(cached) as T;

    // キャッシュ層がDBロードを実行
    const value = await this.loader(key);
    if (value) {
      await this.cache.set(key, JSON.stringify(value), 'EX', this.ttlSeconds);
    }
    return value;
  }
}

// 使用例: アプリケーションはキャッシュ層のみとやり取り
const userCache = new ReadThroughCache<User>(
  redisClient,
  async (key) => {
    const userId = key.replace('user:', '');
    return db.users.findUnique({ where: { id: userId } });
  },
  600
);

Write-Through

書き込み時にキャッシュとDBを同時に更新する。

class WriteThroughCache<T extends { id: string }> {
  constructor(
    private cache: CacheClient,
    private db: DatabaseClient,
    private ttlSeconds: number = 300
  ) {}

  async write(entity: T): Promise<T> {
    // DB とキャッシュを同期的に更新
    const saved = await this.db.save(entity);
    await this.cache.set(
      `entity:${saved.id}`,
      JSON.stringify(saved),
      'EX',
      this.ttlSeconds
    );
    return saved;
  }

  async read(id: string): Promise<T | null> {
    const cached = await this.cache.get(`entity:${id}`);
    if (cached) return JSON.parse(cached) as T;

    // キャッシュミス時はDBから読み取り
    const entity = await this.db.findById(id);
    if (entity) {
      await this.cache.set(
        `entity:${id}`,
        JSON.stringify(entity),
        'EX',
        this.ttlSeconds
      );
    }
    return entity;
  }
}

Write-Behind (Write-Back)

書き込みをキャッシュに即時反映し、DBへの永続化は非同期で行う。

class WriteBehindCache<T extends { id: string }> {
  private writeQueue: Array<{ key: string; value: T }> = [];
  private flushInterval: NodeJS.Timeout;

  constructor(
    private cache: CacheClient,
    private db: DatabaseClient,
    private flushIntervalMs: number = 1000,
    private maxBatchSize: number = 100
  ) {
    this.flushInterval = setInterval(() => this.flush(), flushIntervalMs);
  }

  async write(entity: T): Promise<void> {
    // キャッシュに即時書き込み
    await this.cache.set(
      `entity:${entity.id}`,
      JSON.stringify(entity)
    );

    // 書き込みキューに追加
    this.writeQueue.push({ key: entity.id, value: entity });

    // バッチサイズに達したら即時フラッシュ
    if (this.writeQueue.length >= this.maxBatchSize) {
      await this.flush();
    }
  }

  private async flush(): Promise<void> {
    if (this.writeQueue.length === 0) return;

    const batch = this.writeQueue.splice(0, this.maxBatchSize);

    try {
      // バルクでDBに書き込み
      await this.db.bulkUpsert(batch.map(item => item.value));
    } catch (error) {
      // 失敗した場合、キューに戻す(リトライ)
      this.writeQueue.unshift(...batch);
      console.error('Write-behind flush failed, will retry:', error);
    }
  }

  async destroy(): Promise<void> {
    clearInterval(this.flushInterval);
    await this.flush(); // 残りをフラッシュ
  }
}

2. パターン比較

パターン読取性能書込性能データ整合性複雑さ適用場面
Cache-Aside高い(ヒット時)中程度中程度低い汎用的、読み取り多
Read-Through高い-中程度中程度読み取り多、透過的
Write-Through高い低い(同期)高い中程度整合性重視
Write-Behind高い高い(非同期)低い高い書き込み多、整合性低くてもOK

3. キャッシュ無効化戦略

// キャッシュ無効化パターン
class CacheInvalidator {
  constructor(private cache: CacheClient) {}

  // パターン1: TTL ベース(時間ベースの自動失効)
  async setWithTTL(key: string, value: string, ttlSeconds: number): Promise<void> {
    await this.cache.set(key, value, 'EX', ttlSeconds);
  }

  // パターン2: イベントベースの無効化
  async onEntityUpdated(entityType: string, entityId: string): Promise<void> {
    // 直接キーの削除
    await this.cache.del(`${entityType}:${entityId}`);

    // 関連するリストキャッシュも無効化
    const pattern = `${entityType}:list:*`;
    const keys = await this.cache.keys(pattern);
    if (keys.length > 0) {
      await this.cache.del(...keys);
    }
  }

  // パターン3: バージョニング
  async setVersioned(key: string, value: string, version: number): Promise<void> {
    const versionedKey = `${key}:v${version}`;
    await this.cache.set(versionedKey, value, 'EX', 86400);
    await this.cache.set(`${key}:current_version`, String(version));
  }

  async getVersioned(key: string): Promise<string | null> {
    const version = await this.cache.get(`${key}:current_version`);
    if (!version) return null;
    return this.cache.get(`${key}:v${version}`);
  }

  // パターン4: タグベースの無効化
  async setWithTags(key: string, value: string, tags: string[]): Promise<void> {
    await this.cache.set(key, value);
    for (const tag of tags) {
      await this.cache.sadd(`tag:${tag}`, key);
    }
  }

  async invalidateByTag(tag: string): Promise<number> {
    const keys = await this.cache.smembers(`tag:${tag}`);
    if (keys.length === 0) return 0;

    await this.cache.del(...keys);
    await this.cache.del(`tag:${tag}`);
    return keys.length;
  }
}

4. TTL 設計のベストプラクティス

データ種別推奨 TTL理由
静的マスタデータ24時間変更頻度が低い
ユーザープロフィール5〜15分適度に新鮮なデータ
セッション情報30分〜2時間セキュリティ要件に依存
検索結果1〜5分新鮮さとのトレードオフ
ランキング/集計5〜30分計算コストが高い
設定値1〜5分即時反映が望ましい
// TTL にジッター(ランダムな揺らぎ)を加える
function ttlWithJitter(baseTtlSeconds: number, jitterPercent: number = 10): number {
  const jitter = baseTtlSeconds * (jitterPercent / 100);
  const randomJitter = Math.random() * jitter * 2 - jitter;
  return Math.max(1, Math.round(baseTtlSeconds + randomJitter));
}

// 例: 300秒 ± 10% → 270〜330秒のランダムなTTL
// これにより Cache Stampede(同時期の大量キャッシュ失効)を防止
コラム: キャッシュの一貫性問題

分散システムでのキャッシュ一貫性には根本的な難しさがある。

問題: レースコンディション

Thread A: DB更新 → (ここでThread Bが割り込み) → キャッシュ削除
Thread B: DB読み取り(古い値) → キャッシュ書き込み(古い値)
結果: キャッシュに古い値が残る

対策:

  1. ダブルデリート: 更新時にキャッシュ削除 → 一定時間後に再度削除
  2. 遅延削除: 更新後に少し待ってからキャッシュ削除
  3. リース(Lease)方式: キャッシュミス時にリースを取得し、他のスレッドの書き込みを防止
  4. 最終的な整合性を受け入れる: TTL で自然に収束させる

まとめ

トピック要点
Cache-Asideアプリが直接管理、最も一般的
Read/Write-Throughキャッシュ層が透過的に仲介
Write-Behind非同期書き込みで高スループット、整合性は弱い
無効化戦略TTL/イベント/バージョニング/タグベース
TTL 設計データ特性に応じた設定、ジッターで同時失効を防止

チェックリスト

  • 4つのキャッシュパターンの違いを説明できる
  • ユースケースに応じたパターン選択ができる
  • キャッシュ無効化の4つの戦略を理解した
  • TTL 設計のベストプラクティスを知っている
  • キャッシュの一貫性問題を認識している

次のステップへ

キャッシュの基礎パターンを学んだ。次は 分散キャッシュ(Redis) の具体的なアーキテクチャと運用を深掘りしよう。

推定読了時間: 40分