LESSON 30分

ストーリー

セール開始のカウントダウンが始まった。0時0分0秒、数万人のユーザーが一斉にトップページにアクセス。

高橋アーキテクト
キャッシュのTTLがちょうど切れたタイミングで全リクエストがDBに殺到した。Thundering Herdだ

高橋アーキテクトの声は冷静だった。

高橋アーキテクト
分散システムのキャッシュには、単体サーバーにはない特有の課題がある。今日はそれを学ぼう

Thundering Herd(サンダリングハード)

キャッシュの期限が切れた瞬間に大量のリクエストが同時にDBに問い合わせてしまう現象です。

// 問題: TTL切れ → 全リクエストがDBに殺到
// 1000 RPS の状態でキャッシュが切れると、1000リクエストが同時にDBへ

// 対策1: ロックを使って1つのリクエストだけがDBに問い合わせる
class ThunderingHerdProtection {
  constructor(
    private cache: CacheClient,
    private db: Database,
    private lock: DistributedLock,
  ) {}

  async getWithLock(key: string, fetchFn: () => Promise<string>): Promise<string> {
    // 1. キャッシュ確認
    const cached = await this.cache.get(key);
    if (cached) return cached;

    // 2. ロック取得を試みる
    const lockKey = `lock:${key}`;
    const acquired = await this.lock.acquire(lockKey, 5000); // 5秒タイムアウト

    if (acquired) {
      try {
        // 3. ダブルチェック(ロック取得中に別リクエストがキャッシュを更新した可能性)
        const rechecked = await this.cache.get(key);
        if (rechecked) return rechecked;

        // 4. DBから取得してキャッシュに保存
        const result = await fetchFn();
        await this.cache.setex(key, 3600, result);
        return result;
      } finally {
        await this.lock.release(lockKey);
      }
    } else {
      // 5. ロック取得失敗 → 少し待ってリトライ
      await sleep(100);
      return this.getWithLock(key, fetchFn);
    }
  }
}

対策2: 確率的早期更新(Probabilistic Early Recomputation)

// TTL切れ前に確率的にキャッシュを更新する
class ProbabilisticCache {
  async getWithEarlyRecompute(
    key: string,
    fetchFn: () => Promise<string>,
    ttlSeconds: number,
    beta: number = 1.0 // 更新の早さを制御するパラメータ
  ): Promise<string> {
    const cached = await this.cache.getWithTTL(key);

    if (cached) {
      const { value, remainingTTL } = cached;

      // 残りTTLが少ないほど、再計算する確率が上がる
      const shouldRecompute =
        remainingTTL - beta * Math.log(Math.random()) * ttlSeconds <= 0;

      if (!shouldRecompute) {
        return value; // まだ十分なTTLがある
      }

      // バックグラウンドで再計算(現在のキャッシュはそのまま返す)
      this.recomputeInBackground(key, fetchFn, ttlSeconds);
      return value;
    }

    // キャッシュミス → 通常取得
    const result = await fetchFn();
    await this.cache.setex(key, ttlSeconds, result);
    return result;
  }
}

Cache Stampede(キャッシュスタンピード)

人気のあるキーのキャッシュが失われたときに、同時に大量のリクエストがバックエンドに押し寄せる現象です。Thundering Herdと似ていますが、こちらはキャッシュの削除やサーバー障害が原因です。

// 対策: Stale-While-Revalidate パターン
class StaleWhileRevalidateCache {
  async get(
    key: string,
    fetchFn: () => Promise<string>,
    ttlSeconds: number,
    staleTTLSeconds: number = 300 // 古いデータを返せる猶予期間
  ): Promise<string> {
    const entry = await this.cache.getEntry(key);

    if (entry) {
      if (!entry.isExpired) {
        return entry.value; // 新鮮なキャッシュ
      }

      if (!entry.isStale) {
        // 期限切れだがまだ stale 期間内 → 古いデータを返しつつ裏で更新
        this.revalidateInBackground(key, fetchFn, ttlSeconds, staleTTLSeconds);
        return entry.value;
      }
    }

    // 完全にキャッシュがない → 通常取得
    const result = await fetchFn();
    await this.setWithStaleTTL(key, result, ttlSeconds, staleTTLSeconds);
    return result;
  }
}

ホットキー問題

特定のキーに大量のアクセスが集中し、キャッシュサーバーの1ノードに負荷が偏る問題です。

// 対策: キーのレプリケーション
class HotKeyProtection {
  private readonly REPLICA_COUNT = 5;

  async get(key: string): Promise<string | null> {
    if (await this.isHotKey(key)) {
      // ホットキーはランダムなレプリカから読む
      const replicaIndex = Math.floor(Math.random() * this.REPLICA_COUNT);
      return this.cache.get(`${key}:replica:${replicaIndex}`);
    }
    return this.cache.get(key);
  }

  async set(key: string, value: string, ttl: number): Promise<void> {
    await this.cache.setex(key, ttl, value);

    if (await this.isHotKey(key)) {
      // ホットキーは複数レプリカに書き込む
      const promises = Array.from({ length: this.REPLICA_COUNT }, (_, i) =>
        this.cache.setex(`${key}:replica:${i}`, ttl, value)
      );
      await Promise.all(promises);
    }
  }

  private async isHotKey(key: string): Promise<boolean> {
    // アクセス頻度を監視して判定
    const count = await this.accessCounter.increment(key);
    return count > 10000; // 閾値を超えたらホットキー
  }
}

キャッシュの一貫性

分散環境では、複数のキャッシュノード間でデータの一貫性を保つことが課題になります。

一貫性モデル説明ユースケース
強い一貫性常に最新データを返す決済、在庫管理
結果整合性時間差で最終的に一致商品情報、プロフィール
読み取り一貫性自分の書き込みは即座に見えるユーザー設定
// Read-Your-Writes 一貫性の実装例
class ReadYourWritesCache {
  async update(userId: string, key: string, value: string): Promise<void> {
    await this.db.update(key, value);
    await this.cache.set(key, value, 3600);

    // このユーザーのローカルキャッシュも更新
    await this.localCache.set(`${userId}:${key}`, value, 60);
  }

  async get(userId: string, key: string): Promise<string> {
    // まず自分のローカルキャッシュを確認
    const local = await this.localCache.get(`${userId}:${key}`);
    if (local) return local; // 自分の書き込みが反映されている

    // 通常のキャッシュから取得
    return this.cache.get(key) || this.db.get(key);
  }
}

まとめ

ポイント内容
Thundering HerdTTL切れに大量リクエストがDB殺到 → ロック or 確率的早期更新
Cache Stampedeキャッシュ消失で負荷集中 → Stale-While-Revalidate
ホットキー特定キーにアクセス集中 → レプリケーション
一貫性分散環境でのデータ整合性 → 要件に応じた一貫性モデル選択

チェックリスト

  • Thundering Herdの原因と対策を説明できる
  • Cache Stampedeの原因と対策を説明できる
  • ホットキー問題の対策を理解した
  • 一貫性モデルの選択基準を把握した

次のステップへ

次は演習です。ECサイトのキャッシュ戦略を設計し、ここまで学んだ知識を実践に活かしましょう。


推定読了時間: 30分