Redisキャッシュスタンピード対策ガイド:高負荷時にDBを守る設計と実装

Redis を使っていても、ピークトラフィック時に DB が突然落ちることがあります。原因の多くはキャッシュスタンピードです。人気キーの TTL が同時に切れると、大量リクエストが一斉に DB へ流れ、接続プールが飽和します。

「Redis を入れたのに遅い」「ピーク時だけ 500 が増える」という現象は、このパターンで説明できることが非常に多いです。

本記事では、キャッシュスタンピードを実運用で防ぐために、設計原則・実装パターン・監視方法を順に解説します。

1. キャッシュスタンピードとは何か

典型シナリオ:

  1. 商品ランキング API が ranking:daily を Redis に 300 秒で保存
  2. 300 秒後、人気時間帯にキー期限切れ
  3. 同時に 1000 リクエストが miss
  4. 1000 回 DB 集計が走ってレイテンシ急増

このとき Redis 自体は正常でも、背後の DB が壊れます。つまり、問題はキャッシュ障害ではなく「再生成の同時実行制御」です。

2. 防御の基本は三層構え

スタンピード対策は単一施策では不十分です。次の三層を組み合わせると安定します。

  1. 同時再生成の抑制(singleflight / 分散ロック)
  2. 期限切れの分散(TTL ジッター)
  3. 期限切れ後の挙動制御(stale-while-revalidate)

3. パターン1: singleflight で同時再生成を止める

同一キーの miss が同時発生しても、1 リクエストだけ再生成し、他は待つ設計です。

TypeScript 例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const inflight = new Map<string, Promise<string>>();

async function getOrCompute(key: string, ttlSec: number, compute: () => Promise<string>) {
  const cached = await redis.get(key);
  if (cached) return cached;

  if (!inflight.has(key)) {
    const p = (async () => {
      try {
        const value = await compute();
        await redis.set(key, value, { EX: ttlSec });
        return value;
      } finally {
        inflight.delete(key);
      }
    })();
    inflight.set(key, p);
  }

  return await inflight.get(key)!;
}

単一プロセスではこれで十分ですが、複数インスタンス構成では分散ロックも必要です。

4. パターン2: 分散ロック(SET NX EX)

複数 Pod で同時 miss が起きる場合、Redis ロックで再生成担当を 1 つに制限します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const lockKey = `lock:${key}`;
const lock = await redis.set(lockKey, instanceId, { NX: true, EX: 10 });

if (lock) {
  // ロック獲得: 再生成担当
  const value = await compute();
  await redis.set(key, value, { EX: ttlSec });
  await redis.del(lockKey);
  return value;
}

// ロック未獲得: 少し待って再取得
await sleep(40);
return await redis.get(key);

注意点は、ロック TTL が短すぎると再生成中に失効し、二重計算になることです。処理時間の p99 を見て余裕を持って設定してください。

5. パターン3: TTL ジッターで期限切れを分散

同系統キーが同時に切れると負荷波形が尖ります。TTL にランダム幅を持たせるだけでかなり改善します。

1
2
3
4
5
function ttlWithJitter(baseSec: number, jitterSec: number) {
  return baseSec + Math.floor(Math.random() * jitterSec);
}

await redis.set(key, value, { EX: ttlWithJitter(300, 90) });

300 秒固定より、300〜390 秒の分布にすると、同時失効が目に見えて減ります。

6. パターン4: stale-while-revalidate

高可用性が重要な API では、期限切れ直後でも古い値を短時間返しつつ、裏で更新する手法が有効です。

データ構造例:

1
2
3
4
5
{
  "value": {"items": [...]},
  "hardExpireAt": 1700000000,
  "softExpireAt": 1699999700
}
  • now < softExpireAt: 新鮮データ
  • softExpireAt <= now < hardExpireAt: 古い値を返しつつ非同期更新
  • hardExpireAt <= now: 同期再生成

この方式はピーク帯の体感性能を維持しやすく、DB 保護にも強いです。

7. 失敗時フォールバックを設計する

キャッシュ再生成が失敗したときの動作を明確にしておくと、障害時の揺れが減ります。

推奨順序:

  1. stale データがあれば返す
  2. stale もなければ軽量な代替レスポンス
  3. 最後に明示エラー(再試行可能)

「毎回 DB にフォールバック」は危険です。障害時に DB をさらに追い込むため、回路遮断(circuit breaker)とセットで設計すべきです。

8. 監視で見るべき指標

対策の効果はメトリクスで評価します。

  • cache hit ratio
  • key 単位の miss burst(短時間 miss 件数)
  • 再生成処理の同時実行数
  • DB クエリ QPS と接続待ち時間

Prometheus を使う場合、以下のようなカウンタを実装すると分析しやすいです。

  • cache_requests_total{key_group, result="hit|miss|stale"}
  • cache_rebuild_total{key_group, result="ok|error"}
  • cache_lock_contention_total{key_group}

9. 導入手順(既存システム向け)

Step 1: 熱いキーを特定

アクセス上位 20 キーを抽出し、まずそこだけ対策します。

Step 2: singleflight + ジッター導入

実装コストが低く、効果が高い組み合わせです。

Step 3: 分散ロック導入

複数インスタンス環境で必須。ロック競合率も計測する。

Step 4: stale-while-revalidate 追加

高トラフィック API へ段階適用。UX と DB 安定性が両立しやすい。

10. よくある失敗例

失敗1: TTL を長くしてごまかす

データ鮮度要件を満たせず、別の問題が出ます。期限延長は応急処置に留める。

失敗2: 分散ロックだけで安心する

ロックが取れない側の待機戦略がないと、スパイクは残ります。待機・再取得・stale 応答まで設計が必要です。

失敗3: miss 率しか見ない

miss が少なくても、特定キーへの burst が強ければ障害は起きます。キーグループ単位で観測してください。

11. 実運用チェックリスト

  • 熱いキーを定義し、キーグループで計測している
  • singleflight もしくは同等の同時実行制御がある
  • TTL にジッターを導入している
  • 分散ロックの TTL が処理時間 p99 を上回る
  • stale-while-revalidate の返却条件が明確
  • 障害時フォールバック(回路遮断含む)がある

まとめ

Redis キャッシュスタンピード対策は、キャッシュを置くことではなく「miss 後の世界を設計する」ことです。

  • 同時再生成を止める
  • 失効タイミングを分散する
  • 期限切れ直後の応答を制御する

この3点を実装すれば、ピーク時の DB 崩壊リスクは大幅に下げられます。まずはアクセス上位キーから段階導入し、メトリクスで効果を確認してください。仕組みとして回り始めると、トラフィック増加に対する耐性が目に見えて改善します。

12. キャッシュキー設計の実務ポイント

スタンピード対策では、アルゴリズム以前にキー設計が重要です。user:123:timeline のような粒度が粗すぎるキーは、人気ユーザーにアクセスが集中したとき一気にホットスポットになります。可能なら pagesegment を分割し、巨大レスポンスを小さく分けると miss 時の再生成コストを抑えられます。

また、キーの命名規約をチームで統一しておくと観測しやすくなります。service:domain:resource:variant 形式で揃えれば、メトリクス集計時に key_group を自動分類しやすく、どの領域で burst が起きているかを短時間で判断できます。運用性を上げるキー設計は、性能改善と同じくらい価値があります。