Redisキャッシュスタンピード防止ガイド|Singleflight・Mutex・SET NX・確率的早期失効
- キャッシュスタンピード(Thundering Herd)は TTL 切れの瞬間にオリジンへ一斉アクセスが殺到する現象。
- Redis Mutex(
SET key token NX EX ttl)で再構築を 1 プロセスに直列化し、他クライアントはポーリングまたは stale キャッシュで待つ。 - Node.js の in-flight マップ(Singleflight 相当)でプロセス内重複を抑え、確率的早期失効で期限切れの同時ミス確率を下げる。
- HTTP キャッシュ層は API キャッシュ設計ガイド、オリジン保護の最後の砦として API レート制限設計 と併用する。
キャッシュスタンピードとは何か
アプリケーションキャッシュ(Redis、Memcached、アプリ内メモリ)を導入すると、オリジン(RDBMS、外部 API、重い集計クエリ)への負荷は劇的に下がる。しかし TTL(Time To Live)が切れた瞬間 に、次の連鎖が起きることがある。
- キャッシュキー
product:12345の TTL が 0 になる - 同時に 500 リクエストが
GET product:12345を実行する - 全員がキャッシュミス(nil)を受け取る
- 500 リクエストがそれぞれ DB へ
SELECT * FROM products WHERE id = 12345を発行する - 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:home | CDN 通過後もオリジンが飽和 |
| ランキング API | ranking: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-revalidate や 304 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 on により、同一キーのキャッシュミス時に 1 リクエストだけがオリジンへ 行き、他はロック解放まで待機する。アプリ層の Redis Mutex と同じ思想だが、リバースプロキシ内で完結する。自前アプリ + Redis の場合は本記事のパターンが必要になる。
対策パターン一覧
| 対策 | 特性・向き不向き |
|---|---|
| Redis Mutex(SET NX) | クラスタ全体で再構築を 1 件に直列化。実装は中程度。ロック待ちレイテンシに注意 |
| Singleflight / in-flight マップ | 単一プロセス内の重複排除。軽量だがマルチインスタンスでは不十分 |
| 確率的早期失効(PEE) | ロックなしでミス同時発生確率を分散。読み取り中心 API 向き。完全防止ではない |
| 永続キャッシュ + 非同期更新 | TTL で消さず、裏で更新。常に何かしら返せる。鮮度要件とのトレードオフ |
| リクエスト合流(coalescing) | ミドルウェアで同一キーの in-flight を共有。GraphQL DataLoader と同思想 |
| TTL にジッター | キーごとに TTL をランダム化し、同時失効を避ける。補助的手段 |
以下、実務で最も使われる Redis Mutex、Singleflight、確率的早期失効 を深掘りする。
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 10 | 10 秒後にキーを自動削除(秒単位 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 | 再構築 Mutex | 10 秒 |
再構築時は 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_total | TTL 切れ直後にスパイク | Mutex 待ち・PEE パラメータ見直し |
lock_acquire_total{result="wait"} | 急増 | Mutex TTL 不足 or オリジン遅延 |
lock_wait_duration_seconds | p99 > 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 STATS の keyspace_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:'
実装チェックリスト
ホットキー一覧を作り、スタンピード影響度(オリジンコスト × QPS)で優先順位を付ける
SET key token NX EX ttl で Mutex 取得し、トークン付き Lua でのみ解放する
リーダーは double-check 後にオリジン取得し、SETEX + unlock を Lua で原子的に実行する
フォロワーはポーリングし、タイムアウト時は stale または 503 を返す(無制限オリジン直叩き禁止)
Node in-flight マップ / Go singleflight でプロセス内重複を抑止する
必要に応じて確率的早期失効と TTL ジッターを追加する
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 Mutex(SET NX + トークン + Lua 解放)でクラスタ内の再構築を 1 件に直列化し、Singleflight(in-flight マップ / Go singleflight)でプロセス内の重複を削る二段構えが実務の定石だ。
確率的早期失効と TTL ジッターは、Mutex 待ちレイテンシや同時ミス確率をさらに下げる補助手段になる。フォロワーには stale キャッシュを返す設計がユーザー体験に優しい。
HTTP 層の stale-while-revalidate や ETag 設計は API キャッシュ設計ガイド で詳述している。オリジン保護の最後の砦として API レート制限設計 と組み合わせ、メトリクスで cache_miss と origin_qps の相関を監視しながら TTL と Mutex パラメータを調整するのが、キャッシュスタンピード対策の継続的な運用だ。