キャッシュが一斉に失効したとき、大量のリクエストがDBに殺到する「キャッシュスタンピード」。本番環境でこれが起きると、DBが過負荷でダウンするケースがあります。今回はClaude Codeを使って、3つの対策パターンを実装します。
キャッシュスタンピードとは?
通常時:リクエスト → Redis HIT → 即返却
スタンピード時:
リクエスト1000件 → Redis MISS(TTL切れ)
→ 1000件全てDBへ → DBダウン
TTLを長くすれば発生確率は下がりますが、データ鮮度が犠牲になります。真の解決には専用のアルゴリズムが必要です。
パターン1:PER(確率的早期再計算)
GoogleのXFetch論文で提案されたアルゴリズム。TTL切れの直前に確率的に先回りして再計算します。
import { Redis } from 'ioredis';
const redis = new Redis();
function shouldRecompute(expiry: number, delta: number, beta: number = 1): boolean {
const now = Date.now() / 1000;
return now - beta * delta * Math.log(Math.random()) > expiry;
}
async function getWithPER<T>(key: string, fetcher: () => Promise<T>, ttl: number): Promise<T> {
const cached = await redis.get(key);
if (cached) {
const { value, expiry, delta } = JSON.parse(cached);
if (!shouldRecompute(expiry, delta)) return value;
}
const start = Date.now();
const value = await fetcher();
const delta = (Date.now() - start) / 1000;
const expiry = Date.now() / 1000 + ttl;
await redis.setex(key, ttl, JSON.stringify({ value, expiry, delta }));
return value;
}
PERの特徴:ロックなし・単一プロセスが自然に先回り再計算・シンプル。
パターン2:Mutex Lock(Redis NX SET)
1リクエストだけDBにアクセスさせ、他は待機させます。
async function getWithMutex<T>(key: string, fetcher: () => Promise<T>, ttl: number): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const lockValue = crypto.randomUUID();
const acquired = await redis.set(lockKey, lockValue, 'NX', 'EX', 10);
if (acquired === 'OK') {
try {
const value = await fetcher();
await redis.setex(key, ttl, JSON.stringify(value));
return value;
} finally {
// Luaで安全なロック解放(自分のロックのみ削除)
const script = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, lockValue);
}
} else {
await new Promise(resolve => setTimeout(resolve, 100));
return getWithMutex(key, fetcher, ttl);
}
}
Mutex Lockの特徴:確実に1リクエストのみDB接続・Luaスクリプトで安全な解放を保証。
パターン3:Stale-While-Revalidate(SWR)
古い値を即返却しつつ、バックグラウンドで更新します。
interface SWRCacheEntry<T> {
value: T;
freshUntil: number;
staleUntil: number;
}
async function getWithSWR<T>(
key: string,
fetcher: () => Promise<T>,
freshTTL: number,
staleTTL: number
): Promise<T> {
const cached = await redis.get(key);
const now = Date.now();
if (cached) {
const entry: SWRCacheEntry<T> = JSON.parse(cached);
if (now < entry.freshUntil) return entry.value;
if (now < entry.staleUntil) {
// 古い値を即返却 + バックグラウンドで更新
setImmediate(async () => {
const newValue = await fetcher();
const newEntry = {
value: newValue,
freshUntil: Date.now() + freshTTL * 1000,
staleUntil: Date.now() + staleTTL * 1000,
};
await redis.setex(key, staleTTL, JSON.stringify(newEntry));
});
return entry.value;
}
}
const value = await fetcher();
const entry = { value, freshUntil: now + freshTTL * 1000, staleUntil: now + staleTTL * 1000 };
await redis.setex(key, staleTTL, JSON.stringify(entry));
return value;
}
// 60秒新鮮、最大600秒古い値を許容
const result = await getWithSWR('dashboard_stats', () => db.stats.aggregate(), 60, 600);
SWRの特徴:古い値を即返却するためレイテンシが最小・バックグラウンド更新で常に新鮮な状態に近づく。
3パターンの比較
| PER | Mutex Lock | SWR | |
|---|---|---|---|
| 仕組み | 確率的先回り | 1台だけアクセス | 古い値を即返却 |
| レイテンシ | 低 | 中(待機あり) | 最低 |
| DB負荷 | 非常に低 | 最低(1リクエスト) | 低 |
| 実装複雑度 | 中 | 高(Lua必須) | 低 |
| 適用場面 | 汎用 | クリティカルなDB | UXを優先する場面 |
まとめ
- PER(確率的早期再計算) — TTL切れ直前に1リクエストが先回り再計算、スタンピードを自然回避
- Mutex Lock(Redis NX SET) — Luaスクリプトで安全なロック解放、確実に1台のみDB接続
- Stale-While-Revalidate — 古い値を即返却+でバックグラウンド更新
- 組み合わせが最強 — SWR + PERで「古い値即返却+TTL切れ前の先回り更新」を両立
Code Review Pack ¥980 — コードレビュー自動化スキルセット
キャッシュ設計・パフォーマンスボトルネック検出・DBクエリ最適化など、Claudeによるコードレビュー自動化スキルをまとめました。
Code Review Pack(¥980) は PromptWorks にて販売中です(product_id: 524)。
みょうが(ウーパールーパーVTuber)が実際のプロジェクトで使っているレビューパターンを凝縮しました。
Top comments (0)