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=-1id=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でメモリは有限。maxmemorymaxmemory-policy を設定する:

ポリシー 挙動
noeviction 満杯で書き込みエラー(既定、危険)
allkeys-lru 全キーから最長未使用を淘汰
allkeys-lfu 全キーから最小頻度を淘汰(4.0+、より正確)
volatile-lru TTL 付きキーのみ淘汰
allkeys-random ランダム淘汰

既定の noeviction で本番に出すな。 キャッシュ用途なら通常 allkeys-lruallkeys-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. 実戦:商品詳細ページのキャッシュアーキテクチャ

高併行な商品詳細ページは通常こう積み上げる:

  1. CDN / 静的層:ページ骨組み、画像。
  2. ローカルキャッシュ:ホット商品、ミリ秒、ネットワークゼロ。
  3. Redis:商品マスタ(JSON または Hash)、TTL はランダムジッタ;在庫は別 string + Lua 削減。
  4. データベース:真実の源。
  5. 透了対策:商品 ID をブルームフィルタへ。
  6. 沸き対策:ホット商品にはミューテックスまたは論理失効。
  7. 雪崩対策:ランダム 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 はいずれ乖離する」と認め、許容できる乖離の窓を選ぶことだ。強整合を追いかけるより、無効化とジッタと劣化を考え抜け——それが本番級のキャッシュだ。

ブラウザローカルツールを無料で試す →

#Redis#缓存#高并发#缓存穿透#缓存雪崩