Redis キャッシュ設計パターン:透了・沸き・雪崩と本番実戦
キャッシュは「入れれば速くなる」ものではない
「API が遅い?Redis キャッシュを入れよう」——そんな会話を何度見たか。キャッシュを入れても直らず、むしろ奇妙なバグが増える。データを更新しても反映されない、真夜中にキャッシュが一斉失効して DB が落ちる、存在しない ID で DB を道連れにする、といった具合だ。
キャッシュはアクセラレータであり魔法ではない。整合性、無効化、雪崩という新たな問題を連れてくる。本番で本当に痛いパターンを、そのまま使えるコードとともに解説する。
1. 三つの読み書き戦略
Cache Aside — 最も一般的
読み:まずキャッシュを確認、ヒットなら即返却、ミスなら DB を引き、キャッシュへ書いて返す。 書き:まず DB を更新、次にキャッシュを削除(更新ではない)。
// 読み
async function getUser(id: string) {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.users.find(id);
if (user) await redis.set(`user:${id}`, JSON.stringify(user), "EX", 300);
return user;
}
// 書き:DB 更新 + キャッシュ削除
async function updateUser(id: string, data: Partial<User>) {
await db.users.update(id, data);
await redis.del(`user:${id}`); // set ではなく del
}
なぜ「更新」ではなく「削除」か?並行書き込み下では、キャッシュを先に更新してから DB を更新するとキャッシュに古い値が残ることがある。削除すれば次の読みがソースに戻り、自然に最新を得る。代償は短い空白だが、安全だ。
Read/Write Through
アプリはキャッシュとだけ話し、キャッシュが DB と同期する。読み書きはキャッシュ層を通り、呼び出し側には透過的。キャッシュを「主記憶」とする場面に向くが、実装は重い。
Write Behind
書き込みはキャッシュにだけ着地し、キャッシュが非同期で一括して DB にFlushする。最高性能だがデータ消失リスク最大——プロセスが落ちれば未Flushの書き込みは消える。整合性要件が極めて低くない限り避けるべきだ。
2. キャッシュ透了:存在しない key を問う
攻撃者や汚れたデータが id=-1、id=99999999 を繰り返し問う。キャッシュになく(DB にもない)、毎回 DB へ直撃する。量が増えれば DoS だ。
対策1:null をキャッシュする
DB ミス時にも、短 TTL の null マーカーをキャッシュに置く。
const user = await db.users.find(id);
if (!user) {
await redis.set(`user:${id}`, "NULL", "EX", 60); // null もしばらく_cache
return null;
}
null の TTL は短く(例 60s)保つ。本当にその行が挿入されても、古い「存在しない」に隠されないようにするためだ。
対策2:ブルームフィルタ
全正当 ID をブルームフィルタに入れ、問う前に「この ID は存在しうるか」を確認する。存在しないと分かれば即返却——キャッシュも DB も触らない。
// 擬似コード
if (!bloom.mightContain(id)) return null; // 確実に不在
const cached = await redis.get(`user:${id}`);
// ...
ブルームフィルタには若干の偽陽性(「存在しうる」と言ったのに実はない)があるが、存在するものを不在とは絶対に言わない。透了防御には十分だ。
3. キャッシュ沸き:一つのホットキーがちょうど失効
雪崩と違い、沸きは単一の極端にホットなキー(例:トップページの目玉商品)がまさにその瞬間に失効し、大量のリクエストが一斉に DB へ向かうことだ。
対策:ミューテックスロック(singleflight)
キャッシュ不在を最初に見つけたリクエストがロックを取り、DB を引いてキャッシュを埋める。他は待つか少し待ってリトライする。
async function getHotProduct(id: string) {
const cached = await redis.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const locked = await redis.set(`lock:${id}`, "1", "EX", 5, "NX");
if (!locked) {
await sleep(50); // ロック取れず、待ってリトライ
return getHotProduct(id);
}
try {
const product = await db.products.find(id);
await redis.set(`product:${id}`, JSON.stringify(product), "EX", 300);
return product;
} finally {
await redis.del(`lock:${id}`); // 解放
}
}
これが singleflight の考え方——同一キーにつき同時には一つのリクエストだけがソースへ向かう。Go の golang.org/x/sync/singleflight はまさにこれをする。
対策2:論理失効
キーに物理 TTL を付けず、value 内に expireAt を持つ。読み時に「論理失効」していれば、旧値を返しつつ非同期でリフレッシュを起動する。ユーザーがソースを待つことはなく体験最高だが、短く古いデータを読む。
4. キャッシュ雪崩:大量のキーが同時に失効
すべてのキャッシュ TTL が 300s で、同時に書き込まれていたら、一斉に失効する——瞬時に全リクエストが DB へ貫き、DB は飽和する。
対策1:TTL をランダマイズ
const ttl = 300 + Math.floor(Math.random() * 60); // ジッタ 300~360s
await redis.set(key, value, "EX", ttl);
一行で一斉失効を避けられる。
対策2:多段キャッシュ
ローカルキャッシュ(LRU Map、Caffeine)+ Redis + DB。Redis が死んでも一斉失効しても、ローカル層が大半のトラフィックを吸う。
function get(key: string) {
const local = localCache.get(key);
if (local) return local; // 層1
const redisVal = redis.get(key);
if (redisVal) { localCache.set(key, redisVal); return redisVal; } // 層2
return dbQueryAndFill(key); // 層3
}
対策3:HA + レート制限 + 劣化
Redis 自体を master/sentinel/cluster にして単一障害点をなくす。入口でレート制限(トークンバケツ)と劣化(既定値/静的ページを返す)を入れ、負荷時に DB を守る。
5. 整合性:キャッシュと DB が乖離する
Cache Aside 下には古典的な競合がある。リクエスト A がミス→DB 参照→キャッシュ書き込みへ;その間にリクエスト B が DB 更新とキャッシュ削除;そして A が古い値を書き戻す。キャッシュが汚れる。
緩和策:
- 遅延二重削除:DB 更新後、キャッシュを削除し、数百 ms 後に再度削除して「古い再充填」を消す。
- binlog 購読:Canal 等で DB の binlog を購読し、非同期でキャッシュを追い出す。最もきれい——無効化がビジネスから分離される。
- 結果整合を受け入れる:大半の業務はキャッシュ/DB の強整合を要しない。適切な TTL で自然に失効させれば良い。
6. メモリと淘汰ポリシー
Redis はメモリDBでメモリは有限。maxmemory と maxmemory-policy を設定する:
| ポリシー | 挙動 |
|---|---|
noeviction |
満杯で書き込みエラー(既定、危険) |
allkeys-lru |
全キーから最長未使用を淘汰 |
allkeys-lfu |
全キーから最小頻度を淘汰(4.0+、より正確) |
volatile-lru |
TTL 付きキーのみ淘汰 |
allkeys-random |
ランダム淘汰 |
既定の noeviction で本番に出すな。 キャッシュ用途なら通常 allkeys-lru か allkeys-lfu。絶対に失いたくないキー(ホット設定)は TTL なしにし、volatile-* ポリシーを併用する。
7. ホットキーとビッグキー
- ホットキー:単一キーの QPS が極端に高い(目玉商品等)。リスクは単ノード CPU の張り付き。対策:ローカルキャッシュ + キー分散(同一データを
product:{id}:1~:Nの複数レプリカに分割して流す)。 - ビッグキー:単一 value が巨大(商品リスト全体の巨大 JSON 等)。リスクはシリアライズ遅延、ブロック、大きなパケット。対策:分割、圧縮、または必要なフィールドのみをキャッシュ。
redis-cli --bigkeysで定期スキャン。
8. Pipeline、一括、Lua の原子操作
複数往復にはネットワークオーバヘッドがある。一括には Pipeline を:
const pipeline = redis.pipeline();
for (const id of ids) pipeline.get(`user:${id}`);
const results = await pipeline.exec();
「読み-変更-書き」の原子性(在庫削減等)には Lua スクリプトで競合を避ける:
-- 在庫削減:0 より大のときのみ
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock > 0 then
redis.call('DECR', KEYS[1])
return 1
end
return 0
9. 実戦:商品詳細ページのキャッシュアーキテクチャ
高併行な商品詳細ページは通常こう積み上げる:
- CDN / 静的層:ページ骨組み、画像。
- ローカルキャッシュ:ホット商品、ミリ秒、ネットワークゼロ。
- Redis:商品マスタ(JSON または Hash)、TTL はランダムジッタ;在庫は別 string + Lua 削減。
- データベース:真実の源。
- 透了対策:商品 ID をブルームフィルタへ。
- 沸き対策:ホット商品にはミューテックスまたは論理失効。
- 雪崩対策:ランダム TTL + ローカルキャッシュ兜底 + レート制限。
骨組み:
async function getProductDetail(id: string) {
if (!bloom.mightContain(id)) return null; // 透了対策
const local = localCache.get(id);
if (local) return local; // 層1
const redisVal = await redis.get(`product:${id}`);
if (redisVal) {
localCache.set(id, redisVal);
return JSON.parse(redisVal);
}
const product = await loadWithLock(id); // ミス → ミューテックス再充填(第3節)
return product;
}
よくある質問
Q1:Cache Aside はなぜキャッシュを「更新」せず「削除」する?
並行書き込み下では、キャッシュを先に更新してから DB を更新すると稀に古い値が残る。削除すれば次の読みが自然に最新を得る。短い空白はあるが安全。強整合が要るなら遅延二重削除や binlog 購読を重ねる。
Q2:null キャッシュは DB を圧迫しない?
null は「実際に DB を引いて不在」だったときのみ書き、TTL は短い。防ぐのは「同じ不存在 ID の反復問い合わせ」。攻撃者が異なる不正 ID を大量に使うなら、やはりブルームフィルタが必要。
Q3:TTL はどれくらいに?
変更頻度と業務許容度による。商品詳細は 5〜10 分、設定類は長く、フラッシュセール在庫は極短またはリアルタイム。核心原則:TTL にはランダムジッタが必須。
Q4:Redis が落ちたら?
多段キャッシュのローカル層が一部を吸う。同時にレート制限と劣化、DB は接続プールで保護。Redis 自体は sentinel/cluster で HA を。キャッシュミスを「必ず DB 直撃」と考えるな——やや古いローカル副本や既定ページを返してよい。
Q5:キャッシュと DB の二重書き、どうやって失くさない?
キャッシュは永続層ではない。失くさないと期待するな。重要な書き込みは DB へ。キャッシュは読みを速くするだけ。無効化は binlog 購読で非同期に——ビジネスコード内の同期二重書きより信頼できる。
おすすめツール
キャッシュのキーや JSON と付き合うとき、ToolsKu の以下のツールが手間を省く:
- ハッシュ — キャッシュキーの安定フィンガープリント生成、またはブルームフィルタ前のハッシュに
- JSON 最小化 — Redis に放り込む JSON の空白を削ってメモリ節約
- JSON フォーマッター — キャッシュ内の JSON が正しいか確認
キャッシュ設計の核心は、「キャッシュと DB はいずれ乖離する」と認め、許容できる乖離の窓を選ぶことだ。強整合を追いかけるより、無効化とジッタと劣化を考え抜け——それが本番級のキャッシュだ。
ブラウザローカルツールを無料で試す →