Redisキャッシュスタンピード防止ガイド|Singleflight・Mutex・SET NX・確率的早期失効

Redis キャッシュ バックエンド ioredis パフォーマンス
結論
  • キャッシュスタンピード(Thundering Herd)は TTL 切れの瞬間にオリジンへ一斉アクセスが殺到する現象。
  • Redis MutexSET key token NX EX ttl)で再構築を 1 プロセスに直列化し、他クライアントはポーリングまたは stale キャッシュで待つ。
  • Node.js の in-flight マップ(Singleflight 相当)でプロセス内重複を抑え、確率的早期失効で期限切れの同時ミス確率を下げる。
  • HTTP キャッシュ層は API キャッシュ設計ガイド、オリジン保護の最後の砦として API レート制限設計 と併用する。

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

アプリケーションキャッシュ(Redis、Memcached、アプリ内メモリ)を導入すると、オリジン(RDBMS、外部 API、重い集計クエリ)への負荷は劇的に下がる。しかし TTL(Time To Live)が切れた瞬間 に、次の連鎖が起きることがある。

  1. キャッシュキー product:12345 の TTL が 0 になる
  2. 同時に 500 リクエストが GET product:12345 を実行する
  3. 全員がキャッシュミス(nil)を受け取る
  4. 500 リクエストがそれぞれ DB へ SELECT * FROM products WHERE id = 12345 を発行する
  5. DB 接続プールが枯渇し、レイテンシが秒単位に跳ね上がる

この「多数のクライアントが同時に同じキャッシュミスを検知し、オリジンへ殺到する」現象を キャッシュスタンピード(Cache Stampede)または Thundering Herd(雷鳴の群れ)と呼ぶ。

数式で見る負荷の増幅

キャッシュヒット率を h、同時リクエスト数を N、オリジン 1 件あたりの処理時間を T とすると、TTL 切れの瞬間にオリジンへ到達するリクエスト数は最大 N × (1 - h) だ。キャッシュミス時 h = 0 なら 全 N 件が DB へ 行く。

例: N = 1000、T = 50ms のクエリが 1000 並列 → DB 接続プール 100 だと 900 件が待ち行列に滞留し、p99 レイテンシは秒オーダーに達する。

なぜ「人気キー」ほど危険か

スタンピードは アクセスが集中するキー で顕在化する。

シナリオホットキー例リスク
EC セール開始sale:banner, product:人気SKU秒間数千ミス → DB ダウン
ニュース速報article:top, feed:homeCDN 通過後もオリジンが飽和
ランキング APIranking:daily重い集計クエリが N 並列実行
設定マスタconfig:global全インスタンスが同時に再取得

逆に、ロングテールの商品詳細は同時ミスが数件に収まり、スタンピードは起きにくい。対策の優先度はホットキーに絞るのが実務的だ。

単純なキャッシュの問題コード

ロックもスタンピード対策もない、最も素朴な ioredis 実装はこうなる。

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

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

async function getProduct(id: string): Promise<Product> {
  const cacheKey = `product:${id}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached) as Product;

  // ← ここで 500 リクエストが同時に到達すると全員が DB へ行く
  const product = await db.query<Product>(
    'SELECT id, name, price FROM products WHERE id = $1',
    [id],
  );
  await redis.setex(cacheKey, 300, JSON.stringify(product));
  return product;
}

TTL 300 秒は妥当でも、期限切れの瞬間 にこのコードはオリジンを直撃する。本記事では、この問題を段階的に解く。

HTTP キャッシュ・アプリキャッシュ・レート制限の三層

スタンピード対策は 複数の層 で組み合わせるのが現実的だ。

役割スタンピード対策の例
CDN / ブラウザエッジでレスポンスを保持stale-while-revalidate, ETag + 304
アプリケーション(Redis)オリジン直前の共有キャッシュMutex、Singleflight、確率的早期失効
オリジン保護殺到時の最後の砦接続プール、レート制限、リードレプリカ

API キャッシュ設計ガイド(ETag・Cache-Control・304・CDN) では、ブラウザ・CDN・オリジン間の HTTP キャッシュ を扱った。stale-while-revalidate304 Not Modified は、エッジやクライアント側でスタンピードを緩和する。

Redis 層は CDN の 内側(オリジン直前)に位置する。CDN が 90% を吸収しても、キャッシュパージや TTL 切れの瞬間にオリジンへ到達する 10% がスタンピードを起こしうる。

レート制限との併用

Mutex や Singleflight を入れても、設定ミス・ホットキー急増・DDoS 的トラフィックでオリジンは依然として危険だ。Redis キャッシュ層の外側API レート制限(Rate Limiting)実装ガイド で述べた Token Bucket や Sliding Window を置き、1 クライアントあたりの最大 QPS を cap する。

クライアント


CDN / HTTP キャッシュ(SWR, ETag)


レート制限(Redis Lua / Nginx limit_req)


アプリ + Redis キャッシュ(Mutex / Singleflight)


DB / 外部 API

スタンピード対策(Mutex)とレート制限は 競合しない。Mutex は「同一キーの再構築を 1 件に」、レート制限は「1 クライアントの総リクエスト数を」抑える。セール開始時は Mutex で DB クエリを直列化しつつ、異常クライアントには 429 を返す二段構えが堅牢だ。

Nginx の proxy_cache_lock

Nginx では proxy_cache_lock on により、同一キーのキャッシュミス時に 1 リクエストだけがオリジンへ 行き、他はロック解放まで待機する。アプリ層の Redis Mutex と同じ思想だが、リバースプロキシ内で完結する。自前アプリ + Redis の場合は本記事のパターンが必要になる。

対策パターン一覧

対策 特性・向き不向き
Redis Mutex(SET NX) クラスタ全体で再構築を 1 件に直列化。実装は中程度。ロック待ちレイテンシに注意
Singleflight / in-flight マップ 単一プロセス内の重複排除。軽量だがマルチインスタンスでは不十分
確率的早期失効(PEE) ロックなしでミス同時発生確率を分散。読み取り中心 API 向き。完全防止ではない
永続キャッシュ + 非同期更新 TTL で消さず、裏で更新。常に何かしら返せる。鮮度要件とのトレードオフ
リクエスト合流(coalescing) ミドルウェアで同一キーの in-flight を共有。GraphQL DataLoader と同思想
TTL にジッター キーごとに TTL をランダム化し、同時失効を避ける。補助的手段

以下、実務で最も使われる Redis MutexSingleflight確率的早期失効 を深掘りする。

Singleflight と Mutex の違い

用語が混在しやすいので整理する。

概念スコープ実装例解決する問題
Singleflight単一プロセス内Go singleflight.Group、Node in-flight Map同一プロセス内の重複 DB 呼び出し
Mutex(相互排除)プロセス内sync.Mutex、Java synchronized共有メモリへの同時書き込み
Redis 分散 Mutexクラスタ全体SET key token NX EX ttl複数インスタンス間の再構築直列化

Singleflight は「結果の共有」 が本質だ。最初の 1 リクエストが DB を叩き、同じキーを待っている他リクエストは 同じ Promise / 戻り値 を受け取る。Mutex は クリティカルセクションへの排他入場 が本質で、Redis 版はその分散実装と考えるとよい。

実務の定石は 二段構え:

リクエスト


[第1段] Singleflight / in-flight Map(プロセス内)


[第2段] Redis Mutex(クラスタ全体)


オリジン(DB)

第 1 段だけでは Pod が 10 台あれば最大 10 並列。第 2 段で 1 並列に抑える。

Redis 分散 Mutex の基礎(SET NX パターン)

Redis 分散 Mutexは、複数のアプリケーションインスタンスが同じ Redis を見ているとき、特定のキーに対する再構築を 1 プロセスだけ に限定する仕組みだ。

基本コマンド:SET key value NX EX seconds

Redis 2.6.12 以降、Mutex 取得は アトミックな 1 コマンド で行える。

SET lock:product:12345 <unique-token> NX EX 10
オプション意味
NXキーが 存在しないときだけ セット(Not eXists)
EX 1010 秒後にキーを自動削除(秒単位 TTL)
PX 10000ミリ秒単位の TTL(細かい制御向け)
  • 戻り値 OK → Mutex 取得成功。このプロセスが「リーダー」となりオリジンへ問い合わせる
  • 戻り値 (nil) → 他プロセスが既に Mutex 保持。フォロワーとして待機または stale を返す

ioredis での呼び出し:

const token = crypto.randomUUID();
const acquired = await redis.set(
  'lock:product:12345',
  token,
  'EX', 10,
  'NX',
);
// acquired === 'OK' → リーダー
// acquired === null  → フォロワー

なぜ TTL が必須か

Mutex 取得プロセスが クラッシュ した場合、DEL が呼ばれず Mutex が永続化すると、他の全リクエストが永久に待ち続ける(デッドロック)。EX で Mutex キー自体に TTL を付け、最大待ち時間を保証する。

Mutex TTL の目安:

  • オリジン取得の p99 レイテンシ × 3 以上
  • ただし長すぎるとクラッシュ後の回復が遅い
  • 典型値: 5〜30 秒(商品詳細なら 10 秒、重い集計なら 30 秒)

なぜ value にユニークトークンを入れるか

単純に SET lock:product:12345 1 NX EX 10 でも動くが、安全な Mutex 解放 にはトークンが必要だ。

プロセスA: SET lock:k tokenA NX EX 10  → OK(10秒ロック)
プロセスA: DB問い合わせに15秒かかる(タイムアウト設定ミス)
         → 10秒後 Mutex 自動解放
プロセスB: SET lock:k tokenB NX EX 10  → OK
プロセスA: やっと完了、DEL lock:k     → プロセスBの Mutex を誤って削除!

トークンを検証してから削除すれば、自分が取得した Mutex だけを解放できる。

安全な Mutex 解放(Lua スクリプト)

GET して一致確認してから DEL別コマンド で行うと、その間に Mutex が切れて他者が取得し、再び誤削除が起きうる。Lua でアトミックに実行する。

-- unlock.lua
-- KEYS[1] = lock:product:12345
-- ARGV[1] = unique-token(Mutex 取得時に生成した UUID)
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end

Redis への登録例:

redis-cli SCRIPT LOAD "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end"
# → ハッシュ値が返る。アプリから EVALSHA で呼ぶ

キャッシュ再構築フロー(Mutex あり)

典型的な読み取りフローを手順化する。

リクエスト到着


GET cache:key ──ヒット──► 返却

   ミス


SET lock:key token NX EX 10

    ├─ OK(リーダー)──────────────────┐
    │                                   ▼
    │                          オリジンへ問い合わせ
    │                                   │
    │                                   ▼
    │                    SETEX cache:key + unlock(Lua)
    │                                   │
    │                                   ▼
    └─────────────────────────────► 返却

   (nil)(フォロワー)


ポーリング GET cache:key(50ms間隔、最大2秒)

    ├─ ヒット ──► 返却

    └─ タイムアウト ──► stale あれば返却 / なければ 503
フォロワーのオリジン直叩き

Mutex 待ちのタイムアウト後に 全フォロワーがオリジンへ行く 実装は、スタンピードを再発させる。タイムアウト時は (1) 期限切れキャッシュ(stale)を返す、(2) 503 + Retry-After を返す、(3) ごく少数だけフォールバック取得、のいずれかを選ぶ。無制限フォールバックは避ける。429 の設計は API レート制限ガイド を参照。

キャッシュ書き込みと Mutex 解放を一体化する Lua

再構築完了時に キャッシュ SET + Mutex DEL を原子的に行いたい場合:

-- set_cache_and_release.lua
-- KEYS[1] = cache:product:12345
-- KEYS[2] = lock:product:12345
-- ARGV[1] = cached-json-string
-- ARGV[2] = cache-ttl-seconds (300)
-- ARGV[3] = lock-token
if redis.call('GET', KEYS[2]) ~= ARGV[3] then
  return 0  -- Mutex を保持していない(TTL切れ等)
end
redis.call('SETEX', KEYS[1], tonumber(ARGV[2]), ARGV[1])
redis.call('DEL', KEYS[2])
return 1

これにより「キャッシュは書けたが Mutex 解放前にクラッシュ」でフォロワーが永久待ち、という窓を小さくできる。

Node.js / ioredis 完全実装

本番でそのまま使える CacheService クラスの例。Singleflight(in-flight Map)+ Redis Mutex + stale フォールバックの三層を含む。

import { randomUUID } from 'node:crypto';
import Redis from 'ioredis';

const UNLOCK_LUA = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end
`;

const SET_CACHE_AND_RELEASE_LUA = `
if redis.call('GET', KEYS[2]) ~= ARGV[3] then
  return 0
end
redis.call('SETEX', KEYS[1], tonumber(ARGV[2]), ARGV[1])
redis.call('DEL', KEYS[2])
return 1
`;

export interface CacheOptions {
  cacheTtlSec?: number;
  lockTtlSec?: number;
  pollIntervalMs?: number;
  pollTimeoutMs?: number;
  staleMultiplier?: number;
}

const DEFAULTS: Required<CacheOptions> = {
  cacheTtlSec: 300,
  lockTtlSec: 10,
  pollIntervalMs: 50,
  pollTimeoutMs: 2000,
  staleMultiplier: 2,
};

export class StampedeSafeCache<T> {
  private readonly inflight = new Map<string, Promise<T>>();
  private unlockSha: string | null = null;
  private setCacheSha: string | null = null;

  constructor(
    private readonly redis: Redis,
    private readonly namespace: string,
    private readonly fetcher: (id: string) => Promise<T>,
    private readonly serialize: (value: T) => string = JSON.stringify,
    private readonly deserialize: (raw: string) => T = JSON.parse,
    private readonly opts: CacheOptions = {},
  ) {}

  private cfg(): Required<CacheOptions> {
    return { ...DEFAULTS, ...this.opts };
  }

  private cacheKey(id: string): string {
    return `${this.namespace}:${id}`;
  }

  private lockKey(id: string): string {
    return `lock:${this.namespace}:${id}`;
  }

  private staleKey(id: string): string {
    return `stale:${this.namespace}:${id}`;
  }

  async get(id: string): Promise<T> {
    const key = this.cacheKey(id);

    // 第1段: Singleflight(プロセス内 in-flight 合流)
    const existing = this.inflight.get(key);
    if (existing) return existing;

    const promise = this.getWithMutex(id).finally(() => {
      this.inflight.delete(key);
    });

    this.inflight.set(key, promise);
    return promise;
  }

  private async getWithMutex(id: string): Promise<T> {
    const { cacheTtlSec, lockTtlSec, pollIntervalMs, pollTimeoutMs, staleMultiplier } =
      this.cfg();

    const cacheKey = this.cacheKey(id);
    const lockKey = this.lockKey(id);
    const staleKey = this.staleKey(id);

    const cached = await this.redis.get(cacheKey);
    if (cached !== null) {
      return this.deserialize(cached);
    }

    const token = randomUUID();
    const acquired = await this.redis.set(lockKey, token, 'EX', lockTtlSec, 'NX');

    if (acquired === 'OK') {
      try {
        // double-check: Mutex 待ち中に他リーダーが書いた可能性
        const again = await this.redis.get(cacheKey);
        if (again !== null) {
          await this.evalUnlock(lockKey, token);
          return this.deserialize(again);
        }

        const value = await this.fetcher(id);
        const payload = this.serialize(value);

        await this.evalSetCacheAndRelease(
          cacheKey,
          lockKey,
          payload,
          cacheTtlSec,
          token,
        );

        await this.redis.setex(
          staleKey,
          cacheTtlSec * staleMultiplier,
          payload,
        );

        return value;
      } catch (err) {
        await this.evalUnlock(lockKey, token);
        throw err;
      }
    }

    // フォロワー: 指数バックオフ付きポーリング
    const deadline = Date.now() + pollTimeoutMs;
    let interval = pollIntervalMs;

    while (Date.now() < deadline) {
      await sleep(interval);
      interval = Math.min(interval * 1.5, 200);

      const hit = await this.redis.get(cacheKey);
      if (hit !== null) return this.deserialize(hit);
    }

    const stale = await this.redis.get(staleKey);
    if (stale !== null) return this.deserialize(stale);

    throw new Error(`Cache rebuild timeout: ${cacheKey}`);
  }

  async invalidate(id: string): Promise<void> {
    const cacheKey = this.cacheKey(id);
    this.inflight.delete(cacheKey);
    await this.redis.del(cacheKey);
  }

  private async ensureScripts(): Promise<void> {
    if (!this.unlockSha) {
      this.unlockSha = (await this.redis.script('LOAD', UNLOCK_LUA)) as string;
    }
    if (!this.setCacheSha) {
      this.setCacheSha = (await this.redis.script(
        'LOAD',
        SET_CACHE_AND_RELEASE_LUA,
      )) as string;
    }
  }

  private async evalUnlock(lockKey: string, token: string): Promise<void> {
    await this.ensureScripts();
    await this.redis.evalsha(this.unlockSha!, 1, lockKey, token);
  }

  private async evalSetCacheAndRelease(
    cacheKey: string,
    lockKey: string,
    payload: string,
    ttlSec: number,
    token: string,
  ): Promise<void> {
    await this.ensureScripts();
    await this.redis.evalsha(
      this.setCacheSha!,
      2,
      cacheKey,
      lockKey,
      payload,
      String(ttlSec),
      token,
    );
  }
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

利用例(Express ミドルウェア)

import express from 'express';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);
const app = express();

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

const productCache = new StampedeSafeCache<Product>(
  redis,
  'product',
  async (id) => {
    const row = await db.query<Product>(
      'SELECT id, name, price FROM products WHERE id = $1',
      [id],
    );
    if (!row) throw new Error('not found');
    return row;
  },
);

app.get('/api/products/:id', async (req, res) => {
  try {
    const product = await productCache.get(req.params.id);
    res.json(product);
  } catch (err) {
    if (err instanceof Error && err.message.includes('timeout')) {
      res.set('Retry-After', '2');
      return res.status(503).json({ error: 'cache_rebuild_timeout' });
    }
    throw err;
  }
});

ioredis クラスタ・接続設定の注意

import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: 6379,
  password: process.env.REDIS_PASSWORD,
  maxRetriesPerRequest: 3,
  enableReadyCheck: true,
  lazyConnect: false,
  // Mutex 取得中のコマンドタイムアウト(オリジンより短く)
  commandTimeout: 5000,
});

redis.on('error', (err) => {
  console.error('[redis] connection error', err);
});

Redis Cluster では Mutex キーとキャッシュキーを 同一ハッシュスロット に載せる必要がある。{product}:12345{product}:lock:12345 のように {} ハッシュタグを使う。

private cacheKey(id: string): string {
  return `{${this.namespace}}:${id}`;
}
private lockKey(id: string): string {
  return `{${this.namespace}}:lock:${id}`;
}

確率的早期失効(Probabilistic Early Expiration)

確率的早期失効(Probabilistic Early Expiration / XFetch)は、TTL 切れの 直前 にランダムで再構築を開始し、全リクエストが同時にミスになる確率を下げる手法だ。Facebook の論文「Scaling Memcache at Facebook」で広く知られる。

考え方

キャッシュキーに 論理 TTL物理 TTL を持たせる。

  • 物理 TTL: Redis の TTL(例: 300 秒)
  • 論理 TTL: アプリが「新鮮」とみなす期間(例: 270 秒)

残り TTL を delta、データ取得にかかる時間の推定を beta とすると、次の確率で 早期再構築 を開始する。

P_recompute = exp(-delta / (beta × ln(random)))

実装ではもっと単純な近似がよく使われる。

function shouldRecomputeEarly(remainingTtlSec: number, beta: number): boolean {
  if (remainingTtlSec <= 0) return true;
  // beta: 典型オリジン取得時間(秒)。人気キーは小さめに設定
  const probability = Math.exp(remainingTtlSec / beta);
  return Math.random() > probability;
}

async function getWithPEE(
  redis: Redis,
  cacheKey: string,
  beta: number,
  rebuild: (key: string) => Promise<void>,
): Promise<string> {
  const raw = await redis.get(cacheKey);
  if (raw === null) {
    await rebuild(cacheKey);
    const fresh = await redis.get(cacheKey);
    if (fresh === null) throw new Error('rebuild failed');
    return fresh;
  }

  const remaining = await redis.ttl(cacheKey);
  if (shouldRecomputeEarly(remaining, beta)) {
    // 非同期で再構築(リクエストは古いキャッシュを即返却)
    void rebuild(cacheKey).catch((err) => {
      console.error('[pee] background rebuild failed', err);
    });
  }
  return raw;
}

Mutex との併用

PEE 単体では低確率で複数プロセスが同時再構築を始めうる。PEE で早期再構築をトリガーし、実際の再構築は Redis Mutex で直列化 するのが堅牢だ。

キャッシュヒット


残り TTL を確認

    ├─ 十分残っている ──► 返却

    └─ 早期再構築判定(確率)

            ├─ 否 ──► 返却

            └─ 是 ──► 非同期で SET NX Mutex → 再構築

StampedeSafeCache に PEE を組み込む場合、キャッシュヒット時の分岐に shouldRecomputeEarly を追加し、背景再構築は getWithMutex と同じ Mutex パスを通す。

TTL ジッター:補助的だが効く一手

全キーが同じタイミングでデプロイされると、同一秒に TTL が切れる ことがある。キャッシュ書き込み時に TTL にランダムなジッターを足す。

function cacheTtlWithJitter(baseSec: number, jitterRatio = 0.1): number {
  const jitter = Math.floor(Math.random() * baseSec * jitterRatio);
  return baseSec + jitter;
}

// SETEX key 270〜330秒(base=300, jitterRatio=0.1)
await redis.setex(cacheKey, cacheTtlWithJitter(300), payload);

ジッターだけではスタンピードを完全には防げないが、定期バッチで一斉ウォームアップしたキー の同時失効を避けられる。Mutex や PEE の補助として入れておくとよい。

stale キャッシュと HTTP SWR の対応

アプリ層でも stale-while-revalidate 相当のパターンが使える。

キー内容TTL
product:123最新キャッシュ300 秒
stale:product:123フォールバック用600 秒
lock:product:123再構築 Mutex10 秒

再構築時は product:123 を更新し、stale:product:123 も同じペイロードで延長する。フォロワーが Mutex 待ちタイムアウトした場合、わずかに古いデータを返す ことで 503 を避けられる。

HTTP ヘッダーで Cache-Control: stale-while-revalidate=60 を返す設計と組み合わせる場合、API キャッシュ設計ガイド の SWR 節と整合させる。アプリ内 stale と CDN SWR は 鮮度要件の二重定義 にならないよう、どちらが「真の鮮度」を決めるかを文書化する。

Go 実装:singleflight と Redis Mutex の二段構え

Node 以外のスタック向け参考実装。Go 標準に近い golang.org/x/sync/singleflight は、同一プロセス内 で同じキーの同時リクエストを 1 つの実行にまとめる。

package cache

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
	"golang.org/x/sync/singleflight"
)

var (
	rdb *redis.Client
	sf  singleflight.Group
)

const (
	cacheTTL     = 300 * time.Second
	lockTTL      = 10 * time.Second
	pollInterval = 50 * time.Millisecond
	pollTimeout  = 2 * time.Second
)

var unlockScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end
`)

var setCacheAndReleaseScript = redis.NewScript(`
if redis.call('GET', KEYS[2]) ~= ARGV[3] then
  return 0
end
redis.call('SETEX', KEYS[1], tonumber(ARGV[2]), ARGV[1])
redis.call('DEL', KEYS[2])
return 1
`)

func randomToken() (string, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}

type Product struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

func GetProduct(ctx context.Context, id string) (*Product, error) {
	cacheKey := fmt.Sprintf("product:%s", id)
	v, err, _ := sf.Do(cacheKey, func() (interface{}, error) {
		return getProductWithRedisLock(ctx, cacheKey, id)
	})
	if err != nil {
		return nil, err
	}
	return v.(*Product), nil
}

func getProductWithRedisLock(ctx context.Context, cacheKey, id string) (*Product, error) {
	if raw, err := rdb.Get(ctx, cacheKey).Bytes(); err == nil {
		var p Product
		if json.Unmarshal(raw, &p) == nil {
			return &p, nil
		}
	}

	lockKey := "lock:" + cacheKey
	token, err := randomToken()
	if err != nil {
		return nil, err
	}

	acquired, err := rdb.SetNX(ctx, lockKey, token, lockTTL).Result()
	if err != nil {
		return nil, err
	}

	if acquired {
		if raw, err := rdb.Get(ctx, cacheKey).Bytes(); err == nil {
			unlockScript.Run(ctx, rdb, []string{lockKey}, token)
			var p Product
			json.Unmarshal(raw, &p)
			return &p, nil
		}

		product, err := fetchProductFromDB(ctx, id)
		if err != nil {
			unlockScript.Run(ctx, rdb, []string{lockKey}, token)
			return nil, err
		}

		payload, _ := json.Marshal(product)
		setCacheAndReleaseScript.Run(ctx, rdb,
			[]string{cacheKey, lockKey},
			string(payload), int(cacheTTL.Seconds()), token,
		)
		return product, nil
	}

	deadline := time.Now().Add(pollTimeout)
	for time.Now().Before(deadline) {
		time.Sleep(pollInterval)
		if raw, err := rdb.Get(ctx, cacheKey).Bytes(); err == nil {
			var p Product
			if json.Unmarshal(raw, &p) == nil {
				return &p, nil
			}
		}
	}

	if stale, err := rdb.Get(ctx, "stale:"+cacheKey).Bytes(); err == nil {
		var p Product
		if json.Unmarshal(stale, &p) == nil {
			return &p, nil
		}
	}

	return nil, fmt.Errorf("cache rebuild timeout for %s", cacheKey)
}

func fetchProductFromDB(ctx context.Context, id string) (*Product, error) {
	return &Product{ID: id, Name: "Example"}, nil
}

singleflight の注意点

  • sf.Do(key, fn)同じ key の fn が同時に 1 つ だけ実行される
  • fn の戻り値は 待機中の全 goroutine に共有 される(エラーも含む)
  • Forget(key) でキャッシュを無効化した直後に古い結果が返らないよう注意
  • マルチインスタンスでは singleflight だけでは不十分 → Redis Mutex が必須

Redlock は必要か

Redis 公式は単一インスタンスの SET NX + トークン検証を推奨している。Redis Cluster やマスター障害時のフェイルオーバー では、Mutex が二重取得される理論的可能性がある。

方式向き
単一 Redis マスター + SET NXキャッシュ再構築の大半。シンプルで十分
Redlock(複数独立 Redis)厳密な排他が必要な短期処理
etcd / Consul セッションインフラが既にあり長期 Mutex が必要

キャッシュスタンピード防止 の目的は「オリジン負荷削減」であり、Mutex 二重取得でキャッシュが二重書き込みされても致命的ではない場合が多い。在庫減算や決済とは要件が異なる。過剰な Redlock 導入は避け、キャッシュ用途では単一 Redis + Lua で始めるのが実務的だ。

監視とチューニング

対策を入れても、設定ミスやホットキー変更で再発する。以下をダッシュボード化する。

メトリクス異常の目安アクション
cache_miss_totalTTL 切れ直後にスパイクMutex 待ち・PEE パラメータ見直し
lock_acquire_total{result="wait"}急増Mutex TTL 不足 or オリジン遅延
lock_wait_duration_secondsp99 > 1sポーリング間隔・タイムアウト調整
origin_qpsキャッシュヒット率と逆相関でスパイクホットキー特定(Redis --hotkeys
stale_served_total常時高いTTL 短すぎ or 再構築遅い

Prometheus + ioredis のカスタムメトリクス例:

import { Counter, Histogram } from 'prom-client';

const cacheMissCounter = new Counter({
  name: 'cache_miss_total',
  help: 'Cache misses',
  labelNames: ['namespace'],
});

const lockWaitHistogram = new Histogram({
  name: 'lock_wait_duration_seconds',
  help: 'Mutex wait duration',
  labelNames: ['namespace'],
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5],
});

// フォロワー待機ループ内
const end = lockWaitHistogram.startTimer({ namespace: 'product' });
// ... polling ...
end();

Redis 7 以降では INFO STATSkeyspace_hits / keyspace_misses も参照する。オリジン QPS がレート制限上限に近づいたら API レート制限設計 の閾値見直しも検討する。

ホットキーの検出

# redis-cli --hotkeys(LFU 設定時)
redis-cli --hotkeys

# 特定パターンのサンプリング
redis-cli --scan --pattern 'product:*' | head -20

# リアルタイム MONITOR(本番では負荷注意)
redis-cli MONITOR | grep 'GET product:'

実装チェックリスト

1

ホットキー一覧を作り、スタンピード影響度(オリジンコスト × QPS)で優先順位を付ける

2

SET key token NX EX ttl で Mutex 取得し、トークン付き Lua でのみ解放する

3

リーダーは double-check 後にオリジン取得し、SETEX + unlock を Lua で原子的に実行する

4

フォロワーはポーリングし、タイムアウト時は stale または 503 を返す(無制限オリジン直叩き禁止)

5

Node in-flight マップ / Go singleflight でプロセス内重複を抑止する

6

必要に応じて確率的早期失効と TTL ジッターを追加する

7

cache_miss・lock_wait・origin_qps を監視し、TTL と Mutex TTL をデータに基づいて調整する

アンチパターン

アンチパターン問題
Mutex なし TTL のみ期限切れ瞬間にスタンピード
DEL lock をトークン検証なしで実行他プロセスの Mutex を誤削除
Mutex TTL なしクラッシュで永久デッドロック
フォロワー全員がタイムアウト後にオリジンへ二次スタンピード
Singleflight のみ(マルチインスタンス)インスタンス数分だけ並列再構築
PEE 確率 100% に近い設定常時再構築でキャッシュ意味が薄れる
Redis Cluster でハッシュタグ未使用Mutex とキャッシュが別スロットで Lua 失敗

関連記事

この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
Web APIのエラーハンドリング設計Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け
Web APIのべき等性(Idempotency)設計Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド
API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較
APIレート制限と429対応APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装
WebHook設計のベストプラクティスWebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する
ZodでAPI境界のランタイム検証を設計するZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026)
OpenAPI 3.1仕様のCIバリデーション完全ガイドOpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions
GraphQL N+1 問題の解決GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド
Circuit Breaker + Retry パターン実装ガイドCircuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter
Sagaパターン完全ガイドSagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js)
Event SourcingとCQRSの基礎Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド
PostgreSQL接続プール枯渇の対処法PostgreSQL接続プール枯渇の対処法|too many connections・PgBouncer・Node.js pg

まとめ

キャッシュスタンピードは TTL 切れと高 QPS が重なるときにオリジンを直撃する。Redis MutexSET NX + トークン + Lua 解放)でクラスタ内の再構築を 1 件に直列化し、Singleflight(in-flight マップ / Go singleflight)でプロセス内の重複を削る二段構えが実務の定石だ。

確率的早期失効TTL ジッターは、Mutex 待ちレイテンシや同時ミス確率をさらに下げる補助手段になる。フォロワーには stale キャッシュを返す設計がユーザー体験に優しい。

HTTP 層の stale-while-revalidate や ETag 設計は API キャッシュ設計ガイド で詳述している。オリジン保護の最後の砦として API レート制限設計 と組み合わせ、メトリクスで cache_missorigin_qps の相関を監視しながら TTL と Mutex パラメータを調整するのが、キャッシュスタンピード対策の継続的な運用だ。