コンテンツへスキップ
·16分で読めます

プロダクションで本当に使えるRedisキャッシュ戦略

Cache-aside、Write-through、キャッシュスタンピード防止、TTL戦略、無効化パターン。プロダクションのNode.jsアプリで使ってきたRedisパターンを実コード付きで解説。

シェア:X / TwitterLinkedIn

APIが遅いとき、誰もが「Redisを入れればいい」と言います。しかし、6ヶ月後にキャッシュが古いデータを返し続け、無効化ロジックが40個のファイルに散らばり、デプロイがキャッシュスタンピードを引き起こしてデータベースをキャッシュなしのとき以上に落とす事態については、誰も教えてくれません。

私は何年もプロダクションでRedisを運用してきました。おもちゃとしてでも、チュートリアルとしてでもなく、キャッシュを間違えれば午前3時にアラートが鳴るような実トラフィックを処理するシステムで使ってきました。以下は、正しくキャッシュするために私が学んだすべてです。

なぜキャッシュするのか?#

明白なことから始めましょう。データベースはメモリに比べて遅いです。PostgreSQLのクエリが15msかかるとしても、それはデータベースの基準では高速です。しかし、そのクエリがすべてのAPIリクエストで実行され、毎秒1,000リクエストを処理しているなら、1秒あたり累計15,000msのデータベース処理時間が発生します。コネクションプールは枯渇し、p99レイテンシは天井知らずに上昇し、ユーザーはスピナーを見つめることになります。

Redisはほとんどの読み取りを1ms未満で処理します。同じデータをキャッシュすれば、15msの操作が0.3msに変わります。これはマイクロ最適化ではありません。データベースレプリカが4台必要かゼロで済むかの違いです。

しかし、キャッシュはタダではありません。複雑さを増し、一貫性の問題を生み、まったく新しい障害モードを生み出します。何かをキャッシュする前に、自分に問いかけてください。

キャッシュが有効な場合:

  • 書き込みよりも読み取りが圧倒的に多い(10:1以上の比率)
  • 元のクエリが高コスト(JOIN、集計、外部API呼び出し)
  • わずかな古さが許容される(商品カタログ、ユーザープロフィール、設定)
  • 予測可能なアクセスパターンがある(同じキーが繰り返しヒットする)

キャッシュが逆効果な場合:

  • データが常に変化し、鮮度が必須(リアルタイム株価、ライブスコア)
  • すべてのリクエストがユニーク(多数のパラメータを持つ検索クエリ)
  • データセットが小さい(すべてアプリのメモリに収まるならRedisは不要)
  • キャッシュの問題を監視・デバッグする運用成熟度がない

Phil Karltonは「コンピュータサイエンスで本当に難しいことは2つだけ:キャッシュの無効化と名前付け」と言いました。彼は両方とも正しかったですが、夜中に叩き起こされるのはキャッシュの無効化のほうです。

ioredisのセットアップ#

パターンに入る前に、接続を確立しましょう。私はどこでもioredisを使っています。Node.jsで最も成熟したRedisクライアントで、適切なTypeScriptサポート、クラスターモード、Sentinelサポート、Luaスクリプティングを備えています。

typescript
import Redis from "ioredis";
 
const redis = new Redis({
  host: process.env.REDIS_HOST || "127.0.0.1",
  port: Number(process.env.REDIS_PORT) || 6379,
  password: process.env.REDIS_PASSWORD || undefined,
  db: Number(process.env.REDIS_DB) || 0,
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    const delay = Math.min(times * 200, 5000);
    return delay;
  },
  lazyConnect: true,
  enableReadyCheck: true,
  connectTimeout: 10000,
});
 
redis.on("error", (err) => {
  console.error("[Redis] Connection error:", err.message);
});
 
redis.on("connect", () => {
  console.log("[Redis] Connected");
});
 
export default redis;

いくつか注目すべき点があります。lazyConnect: trueは実際にコマンドを実行するまで接続が確立されないことを意味し、テストや初期化時に便利です。retryStrategyは5秒で上限付きの指数バックオフを実装しています。これがないと、Redisの障害時にアプリが再接続の試行を大量に送信します。そしてmaxRetriesPerRequest: 3は個々のコマンドが永遠にハングするのではなく、素早く失敗することを保証します。

Cache-Asideパターン#

これが80%の場面で使うパターンです。「遅延読み込み」や「ルックアサイド」とも呼ばれます。フローはシンプルです:

  1. アプリケーションがリクエストを受信
  2. Redisでキャッシュ値を確認
  3. 見つかった場合(キャッシュヒット)、それを返す
  4. 見つからない場合(キャッシュミス)、データベースにクエリ
  5. 結果をRedisに保存
  6. 結果を返す

型付きの実装はこちらです:

typescript
import redis from "./redis";
 
interface CacheOptions {
  ttl?: number;       // 秒
  prefix?: string;
}
 
async function cacheAside<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: CacheOptions = {}
): Promise<T> {
  const { ttl = 3600, prefix = "cache" } = options;
  const cacheKey = `${prefix}:${key}`;
 
  // ステップ1: キャッシュからの読み取りを試行
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      return JSON.parse(cached) as T;
    } catch {
      // 破損したキャッシュエントリ、削除してフォールスルー
      await redis.del(cacheKey);
    }
  }
 
  // ステップ2: キャッシュミス — ソースからフェッチ
  const result = await fetcher();
 
  // ステップ3: キャッシュに保存(awaitしない — ファイア・アンド・フォーゲット)
  redis
    .set(cacheKey, JSON.stringify(result), "EX", ttl)
    .catch((err) => {
      console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
    });
 
  return result;
}

使い方はこうなります:

typescript
interface User {
  id: string;
  name: string;
  email: string;
  plan: "free" | "pro" | "enterprise";
}
 
async function getUser(userId: string): Promise<User | null> {
  return cacheAside<User | null>(
    `user:${userId}`,
    async () => {
      const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
      return row[0] ?? null;
    },
    { ttl: 1800 } // 30分
  );
}

redis.setの呼び出しをファイア・アンド・フォーゲットにしていることに注目してください。これは意図的です。Redisがダウンしているか遅い場合でも、リクエストは完了します。キャッシュは最適化であり、必須要件ではありません。キャッシュへの書き込みが失敗しても、次のリクエストは単にデータベースに再度アクセスするだけです。大した問題ではありません。

多くのcache-aside実装で見逃されている微妙なバグがあります:null値のキャッシュ。ユーザーが存在しない場合にその事実をキャッシュしなければ、そのユーザーへのすべてのリクエストがデータベースに到達します。攻撃者はランダムなユーザーIDをリクエストすることでこれを悪用し、キャッシュを無意味にします。否定的な結果も常にキャッシュしてください。ただし、より短いTTLで。

typescript
async function getUserSafe(userId: string): Promise<User | null> {
  return cacheAside<User | null>(
    `user:${userId}`,
    async () => {
      const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
      return row[0] ?? null;
    },
    {
      // null結果にはメモリ使用量を制限するため短いTTL
      // ただし繰り返しのミスを吸収するのに十分な長さ
      ttl: row ? 1800 : 300,
    }
  );
}

実は、動的TTLが正しく動作するように再構成しましょう:

typescript
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
  const cacheKey = `cache:user:${userId}`;
 
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as User | null;
  }
 
  const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
  const user: User | null = row[0] ?? null;
 
  // 存在する結果は30分、null結果は5分キャッシュ
  const ttl = user ? 1800 : 300;
  await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
 
  return user;
}

Write-ThroughとWrite-Behind#

Cache-asideは読み取り重視のワークロードには最適ですが、一貫性の問題があります。他のサービスやプロセスがデータベースを直接更新した場合、TTLが期限切れになるまでキャッシュは古いままです。ここでwrite-throughとwrite-behindパターンの出番です。

Write-Through#

write-throughでは、すべての書き込みがキャッシュ層を通過します。キャッシュが最初に更新され、次にデータベースが更新されます。これにより、キャッシュがデータベースと常に一貫していることが保証されます(書き込みが常にアプリケーションを通過する場合)。

typescript
async function updateUser(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  // ステップ1: データベースを更新
  const updated = await db.query(
    "UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
    [userId, updates.name, updates.email]
  );
  const user: User = updated[0];
 
  // ステップ2: キャッシュを即座に更新
  const cacheKey = `cache:user:${userId}`;
  await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
 
  return user;
}

cache-asideとの主要な違い:読み取り時だけでなく、すべての書き込み時にキャッシュに書き込みます。これにより、最近更新されたデータのキャッシュは常にウォームです。

トレードオフ:すべての書き込みがデータベースとRedisの両方に触れるため、書き込みレイテンシが増加します。Redisが遅ければ、書き込みも遅くなります。ほとんどのアプリケーションでは読み取りが書き込みをはるかに上回るため、このトレードオフは価値があります。

Write-Behind(Write-Back)#

Write-behindはスクリプトを反転させます:書き込みはまずRedisに行き、データベースは非同期で更新されます。これにより、Redisがデータの永続化前にダウンした場合のデータ損失リスクの代わりに、非常に高速な書き込みが得られます。

typescript
async function updateUserWriteBehind(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const cacheKey = `cache:user:${userId}`;
 
  // 現在の状態を読み取り
  const current = await redis.get(cacheKey);
  const user = current ? JSON.parse(current) as User : null;
  if (!user) throw new Error("User not in cache");
 
  // キャッシュを即座に更新
  const updated = { ...user, ...updates };
  await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
 
  // 非同期処理のためにデータベース書き込みをキューに入れる
  await redis.rpush(
    "write_behind:users",
    JSON.stringify({ userId, updates, timestamp: Date.now() })
  );
 
  return updated;
}

そのキューを消費する別のワーカーが必要です:

typescript
async function processWriteBehindQueue(): Promise<void> {
  while (true) {
    const item = await redis.blpop("write_behind:users", 5);
 
    if (item) {
      const { userId, updates } = JSON.parse(item[1]);
      try {
        await db.query(
          "UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
          [userId, updates.name, updates.email]
        );
      } catch (err) {
        // 失敗時にリトライカウント付きで再キュー
        console.error("[WriteBehind] Failed:", err);
        await redis.rpush("write_behind:users:dlq", item[1]);
      }
    }
  }
}

実際にはwrite-behindはめったに使いません。データ損失のリスクは現実的です。ワーカーがキューを処理する前にRedisがクラッシュした場合、それらの書き込みは消えます。閲覧数、アナリティクスイベント、重要でないユーザー設定など、結果整合性が本当に許容されるデータにのみこれを使用してください。

TTL戦略#

TTLを正しく設定するのは見た目以上に繊細です。すべてに固定1時間のTTLは実装は簡単ですが、ほぼ常に間違いです。

データの変動性ティア#

データを3つのティアに分類し、それに応じてTTLを割り当てています:

typescript
const TTL = {
  // ティア1: めったに変わらない、計算コストが高い
  // 例: 商品カタログ、サイト設定、フィーチャーフラグ
  STATIC: 86400,       // 24時間
 
  // ティア2: 時折変わる、中程度のコスト
  // 例: ユーザープロフィール、チーム設定、権限
  MODERATE: 1800,      // 30分
 
  // ティア3: 頻繁に変わる、計算は安いが頻繁に呼ばれる
  // 例: フィードデータ、通知カウント、セッション情報
  VOLATILE: 300,       // 5分
 
  // ティア4: 一時的、レート制限やロックに使用
  EPHEMERAL: 60,       // 1分
 
  // null結果: 常に短命
  NOT_FOUND: 120,      // 2分
} as const;

TTLジッター:サンダリングハードの防止#

私に実際に起きたシナリオです。アプリをデプロイし、キャッシュが空で、10,000のリクエストがすべて同じデータを1時間のTTLでキャッシュしました。1時間後、10,000のキーがすべて同時に期限切れになります。10,000のリクエストがすべて一斉にデータベースにアクセスします。データベースが窒息します。プロダクションのPostgresインスタンスがこれでダウンしたのを見たことがあります。

修正はジッターです。TTL値にランダム性を追加します:

typescript
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
  const jitter = baseTtl * jitterPercent;
  const offset = Math.random() * jitter * 2 - jitter;
  return Math.max(1, Math.round(baseTtl + offset));
}
 
// この代わりに: redis.set(key, value, "EX", 3600)
// これを使う:  redis.set(key, value, "EX", ttlWithJitter(3600))
 
// 3600 ± 10% = 3240から3960のランダムな値

これにより有効期限がウィンドウ全体に分散されるため、10,000のキーが同じ秒に期限切れになる代わりに、12分のウィンドウで期限切れになります。データベースは崖ではなく緩やかなトラフィック増加を見ます。

クリティカルパスでは、さらに進んで20%のジッターを使用します:

typescript
const ttl = ttlWithJitter(3600, 0.2); // 2880〜4320秒

スライディングエクスパイリー#

アクセスのたびにTTLがリセットされるべきセッションのようなデータには、GETEX(Redis 6.2以降)を使用します:

typescript
async function getWithSlidingExpiry<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  // GETEXはアトミックに値を取得しTTLをリセット
  const value = await redis.getex(key, "EX", ttl);
  if (value === null) return null;
  return JSON.parse(value) as T;
}

古いRedisバージョンの場合は、パイプラインを使用します:

typescript
async function getWithSlidingExpiryCompat<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  const pipeline = redis.pipeline();
  pipeline.get(key);
  pipeline.expire(key, ttl);
  const results = await pipeline.exec();
 
  if (!results || !results[0] || results[0][1] === null) return null;
  return JSON.parse(results[0][1] as string) as T;
}

キャッシュスタンピード(サンダリングハード)#

TTLジッターは大量期限切れには役立ちますが、単一キーのスタンピードは解決しません。人気のあるキーが期限切れになり、数百の同時リクエストがすべて同時にそれを再生成しようとする場合です。

ホームページのフィードを5分のTTLでキャッシュしているとします。期限切れになります。50の同時リクエストがキャッシュミスを確認します。50のすべてが同じ高コストなクエリでデータベースにアクセスします。事実上、自分自身にDDoSを仕掛けたことになります。

解決策1:ミューテックスロック#

1つのリクエストだけがキャッシュを再生成します。他のすべては待ちます。

typescript
async function cacheAsideWithMutex<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const lockKey = `lock:${key}`;
 
  // まずキャッシュを確認
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }
 
  // ロックの取得を試行(NX = 存在しない場合のみ、EX = 自動期限切れ)
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (acquired) {
    try {
      // ロックを取得 — フェッチしてキャッシュ
      const result = await fetcher();
      await redis.set(
        cacheKey,
        JSON.stringify(result),
        "EX",
        ttlWithJitter(ttl)
      );
      return result;
    } finally {
      // ロックを解放
      await redis.del(lockKey);
    }
  }
 
  // 別のリクエストがロックを保持 — 待ってリトライ
  await sleep(100);
 
  const retried = await redis.get(cacheKey);
  if (retried !== null) {
    return JSON.parse(retried) as T;
  }
 
  // まだキャッシュなし — データベースにフォールスルー
  // (ロック保持者が失敗した場合の処理)
  return fetcher();
}
 
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

上記のロック解放には微妙な競合条件があります。ロック保持者が10秒(ロックTTL)以上かかった場合、別のリクエストがロックを取得し、その後最初のリクエストが2番目のリクエストのロックを削除してしまいます。適切な修正はユニークトークンを使用することです:

typescript
import { randomUUID } from "crypto";
 
async function acquireLock(
  lockKey: string,
  ttl: number
): Promise<string | null> {
  const token = randomUUID();
  const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
  return acquired ? token : null;
}
 
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
  // Luaスクリプトでアトミックなチェック・アンド・デリートを保証
  const script = `
    if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del", KEYS[1])
    else
      return 0
    end
  `;
  const result = await redis.eval(script, 1, lockKey, token);
  return result === 1;
}

これは本質的に簡略化されたRedlockです。単一インスタンスのRedisには十分です。Redis ClusterやSentinelの設定では、完全なRedlockアルゴリズムを検討してください。しかし正直なところ、キャッシュスタンピード防止にはこのシンプルなバージョンで問題ありません。

解決策2:確率的早期期限切れ#

これが私のお気に入りのアプローチです。キーの期限切れを待つ代わりに、期限切れの少し前にランダムに再生成します。このアイデアはVattani、Chierichetti、Lowensteinの論文から来ています。

typescript
interface CachedValue<T> {
  data: T;
  cachedAt: number;
  ttl: number;
}
 
async function cacheWithEarlyExpiration<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T> {
  const cacheKey = `cache:${key}`;
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    const entry = JSON.parse(cached) as CachedValue<T>;
    const age = (Date.now() - entry.cachedAt) / 1000;
    const remaining = entry.ttl - age;
 
    // XFetchアルゴリズム: 期限切れが近づくにつれ確率的に再生成
    // beta * Math.log(Math.random())は負の数を生成し
    // 期限切れが近づくにつれ大きく(より負に)なる
    const beta = 1; // チューニングパラメータ、1が良い
    const shouldRegenerate =
      remaining - beta * Math.log(Math.random()) * -1 <= 0;
 
    if (!shouldRegenerate) {
      return entry.data;
    }
 
    // フォールスルーして再生成
    console.log(`[Cache] Early regeneration triggered for ${key}`);
  }
 
  const data = await fetcher();
  const entry: CachedValue<T> = {
    data,
    cachedAt: Date.now(),
    ttl,
  };
 
  // 再生成前にRedisが期限切れにしないように余裕を持って設定
  await redis.set(
    cacheKey,
    JSON.stringify(entry),
    "EX",
    Math.round(ttl * 1.1)
  );
 
  return data;
}

このアプローチの美しさ:キーの残りTTLが減少するにつれ、再生成の確率が増加します。1,000の同時リクエストがあっても、1つか2つだけが再生成をトリガーし、残りはキャッシュされたデータを提供し続けます。ロックなし、調整なし、待機なし。

解決策3:Stale-While-Revalidate#

バックグラウンドで再生成しながら古い値を返します。フェッチャーを待つリクエストがないため、最良のレイテンシが得られます。

typescript
async function staleWhileRevalidate<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    freshTtl: number;   // データが「新鮮」な期間
    staleTtl: number;   // 古いデータを提供できる期間
  }
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const metaKey = `meta:${key}`;
 
  const [cached, meta] = await redis.mget(cacheKey, metaKey);
 
  if (cached !== null) {
    const parsedMeta = meta ? JSON.parse(meta) : null;
    const isFresh =
      parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
 
    if (!isFresh) {
      // データが古い — 提供するがバックグラウンドリフレッシュをトリガー
      revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
    }
 
    return JSON.parse(cached) as T;
  }
 
  // 完全なキャッシュミス — 同期的にフェッチする必要がある
  return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
 
async function fetchAndCache<T>(
  key: string,
  cacheKey: string,
  metaKey: string,
  fetcher: () => Promise<T>,
  options: { freshTtl: number; staleTtl: number }
): Promise<T> {
  const data = await fetcher();
  const totalTtl = options.freshTtl + options.staleTtl;
 
  const pipeline = redis.pipeline();
  pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
  pipeline.set(
    metaKey,
    JSON.stringify({ cachedAt: Date.now() }),
    "EX",
    totalTtl
  );
  await pipeline.exec();
 
  return data;
}
 
function revalidateInBackground<T>(
  key: string,
  cacheKey: string,
  metaKey: string,
  fetcher: () => Promise<T>,
  options: { freshTtl: number; staleTtl: number }
): void {
  // 複数のバックグラウンドリフレッシュを防ぐためにロックを使用
  const lockKey = `revalidate_lock:${key}`;
 
  redis
    .set(lockKey, "1", "EX", 30, "NX")
    .then((acquired) => {
      if (!acquired) return;
 
      return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
        .finally(() => redis.del(lockKey));
    })
    .catch((err) => {
      console.error(`[SWR] Background revalidation failed for ${key}:`, err);
    });
}

使い方:

typescript
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
  freshTtl: 300,     // 5分新鮮
  staleTtl: 3600,    // 再検証中は最大1時間古いデータを提供
});

このパターンは、絶対的な鮮度よりもレイテンシが重要なユーザー向けのものに使っています。ダッシュボードデータ、プロフィールページ、商品リスト。すべて完璧な候補です。

キャッシュの無効化#

Phil Karltonは冗談ではありませんでした。無効化は、キャッシュが「簡単な最適化」から「分散システムの問題」に変わるところです。

シンプルなキーベースの無効化#

最も簡単なケース:ユーザーを更新したら、そのキャッシュキーを削除します。

typescript
async function updateUserAndInvalidate(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const user = await db.query(
    "UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
    [userId, updates.name]
  );
 
  // キャッシュを無効化
  await redis.del(`cache:user:${userId}`);
 
  return user[0];
}

これはユーザーデータが他のキャッシュ結果に表示されるまで機能します。チームメンバーリストに埋め込まれているかもしれません。検索結果に含まれているかもしれません。14の異なるキャッシュされたAPIレスポンスに含まれているかもしれません。どのキャッシュキーにどのエンティティが含まれているかを追跡する必要が出てきます。

タグベースの無効化#

キャッシュエントリに含まれるエンティティでタグ付けし、タグで無効化します。

typescript
async function setWithTags<T>(
  key: string,
  value: T,
  ttl: number,
  tags: string[]
): Promise<void> {
  const pipeline = redis.pipeline();
 
  // 値を保存
  pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
 
  // 各タグのセットにキーを追加
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, `cache:${key}`);
    pipeline.expire(`tag:${tag}`, ttl + 3600); // タグセットは値より長生き
  }
 
  await pipeline.exec();
}
 
async function invalidateByTag(tag: string): Promise<number> {
  const keys = await redis.smembers(`tag:${tag}`);
 
  if (keys.length === 0) return 0;
 
  const pipeline = redis.pipeline();
  for (const key of keys) {
    pipeline.del(key);
  }
  pipeline.del(`tag:${tag}`);
 
  await pipeline.exec();
  return keys.length;
}

使い方:

typescript
// チームデータをキャッシュする際、すべてのメンバーIDでタグ付け
const team = await fetchTeam(teamId);
await setWithTags(
  `team:${teamId}`,
  team,
  1800,
  [
    `entity:team:${teamId}`,
    ...team.members.map((m) => `entity:user:${m.id}`),
  ]
);
 
// ユーザー42がプロフィールを更新したら、それを含むすべてを無効化
await invalidateByTag("entity:user:42");

イベント駆動の無効化#

より大きなシステムでは、Redis Pub/Subを使って無効化イベントをブロードキャストします:

typescript
// パブリッシャー(APIサービス内)
async function publishInvalidation(
  entityType: string,
  entityId: string
): Promise<void> {
  await redis.publish(
    "cache:invalidate",
    JSON.stringify({ entityType, entityId, timestamp: Date.now() })
  );
}
 
// サブスクライバー(各アプリインスタンス内)
const subscriber = new Redis(/* 同じ設定 */);
 
subscriber.subscribe("cache:invalidate", (err) => {
  if (err) console.error("[PubSub] Subscribe error:", err);
});
 
subscriber.on("message", async (_channel, message) => {
  const { entityType, entityId } = JSON.parse(message);
  await invalidateByTag(`entity:${entityType}:${entityId}`);
  console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});

これはマルチインスタンスデプロイメントで重要です。ロードバランサーの背後に4つのアプリサーバーがある場合、サーバー1での無効化はすべてのサーバーに伝播する必要があります。Pub/Subがこれを自動的に処理します。

パターンベースの無効化(慎重に)#

パターンに一致するすべてのキーを無効化する必要がある場合があります。**プロダクションで絶対にKEYSを使わないでください。**キースペース全体をスキャンしている間、Redisサーバーをブロックします。数百万のキーがあると、数秒間ブロックされる可能性があります。Redisの用語では永遠に等しい時間です。

代わりにSCANを使用してください:

typescript
async function invalidateByPattern(pattern: string): Promise<number> {
  let cursor = "0";
  let deletedCount = 0;
 
  do {
    const [nextCursor, keys] = await redis.scan(
      cursor,
      "MATCH",
      pattern,
      "COUNT",
      100
    );
    cursor = nextCursor;
 
    if (keys.length > 0) {
      await redis.del(...keys);
      deletedCount += keys.length;
    }
  } while (cursor !== "0");
 
  return deletedCount;
}
 
// 特定チームのすべてのキャッシュデータを無効化
await invalidateByPattern("cache:team:42:*");

SCANはインクリメンタルに反復します。サーバーをブロックすることはありません。COUNTヒントは反復ごとに返すキー数を提案します(ヒントであり保証ではありません)。大きなキースペースの場合、これが唯一の安全なアプローチです。

とはいえ、パターンベースの無効化はコードスメルです。頻繁にスキャンしていることに気づいたら、キー構造を再設計するかタグを使用してください。SCANはキースペースに対してO(N)であり、ホットパスではなくメンテナンス操作向けです。

文字列を超えるデータ構造#

ほとんどの開発者はRedisをJSON文字列のキーバリューストアとして扱います。これはスイスアーミーナイフを買って栓抜きだけを使うようなものです。Redisにはリッチなデータ構造があり、適切なものを選べば複雑さの全カテゴリーを排除できます。

オブジェクト用のハッシュ#

オブジェクト全体をJSONとしてシリアライズする代わりに、Redisハッシュとして保存します。全体をデシリアライズせずに個別のフィールドを読み書きできます。

typescript
// ユーザーをハッシュとして保存
async function setUserHash(user: User): Promise<void> {
  const key = `user:${user.id}`;
  await redis.hset(key, {
    name: user.name,
    email: user.email,
    plan: user.plan,
    updatedAt: Date.now().toString(),
  });
  await redis.expire(key, 1800);
}
 
// 特定のフィールドを読み取り
async function getUserPlan(userId: string): Promise<string | null> {
  return redis.hget(`user:${userId}`, "plan");
}
 
// 単一フィールドの更新
async function upgradeUserPlan(
  userId: string,
  plan: string
): Promise<void> {
  await redis.hset(`user:${userId}`, "plan", plan);
}
 
// ハッシュ全体をオブジェクトとして読み取り
async function getUserHash(userId: string): Promise<User | null> {
  const data = await redis.hgetall(`user:${userId}`);
  if (!data || Object.keys(data).length === 0) return null;
 
  return {
    id: userId,
    name: data.name,
    email: data.email,
    plan: data.plan as User["plan"],
  };
}

ハッシュは小さなオブジェクトに対してメモリ効率が良く(Redisは内部でコンパクトなziplistエンコーディングを使用)、シリアライズ/デシリアライズのオーバーヘッドを避けます。トレードオフ:ネストされたオブジェクトをフラット化せずに保存する機能を失います。

リーダーボードとレート制限用のソート済みセット#

ソート済みセットはRedisで最も過小評価されたデータ構造です。すべてのメンバーにスコアがあり、セットは常にスコアでソートされています。これによりリーダーボード、ランキング、スライディングウィンドウレート制限に最適です。

typescript
// リーダーボード
async function addScore(
  leaderboard: string,
  userId: string,
  score: number
): Promise<void> {
  await redis.zadd(leaderboard, score, userId);
}
 
async function getTopPlayers(
  leaderboard: string,
  count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
  const results = await redis.zrevrange(
    leaderboard,
    0,
    count - 1,
    "WITHSCORES"
  );
 
  const players: Array<{ userId: string; score: number }> = [];
  for (let i = 0; i < results.length; i += 2) {
    players.push({
      userId: results[i],
      score: parseFloat(results[i + 1]),
    });
  }
  return players;
}
 
async function getUserRank(
  leaderboard: string,
  userId: string
): Promise<number | null> {
  const rank = await redis.zrevrank(leaderboard, userId);
  return rank !== null ? rank + 1 : null; // 0始まりを1始まりに
}

スライディングウィンドウレート制限の場合:

typescript
async function slidingWindowRateLimit(
  identifier: string,
  windowMs: number,
  maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
  const key = `ratelimit:${identifier}`;
  const now = Date.now();
  const windowStart = now - windowMs;
 
  const pipeline = redis.pipeline();
 
  // ウィンドウ外のエントリを削除
  pipeline.zremrangebyscore(key, 0, windowStart);
 
  // 現在のリクエストを追加
  pipeline.zadd(key, now, `${now}:${Math.random()}`);
 
  // ウィンドウ内のリクエスト数をカウント
  pipeline.zcard(key);
 
  // キー全体に有効期限を設定
  pipeline.expire(key, Math.ceil(windowMs / 1000));
 
  const results = await pipeline.exec();
  const count = results?.[2]?.[1] as number;
 
  return {
    allowed: count <= maxRequests,
    remaining: Math.max(0, maxRequests - count),
  };
}

これは固定ウィンドウカウンターアプローチよりも正確で、1つのウィンドウの終わりと次のウィンドウの始まりのバーストがレート制限を実質的に2倍にする境界問題に悩まされません。

キュー用のリスト#

RedisのLPUSH/BRPOP付きリストは優れた軽量ジョブキューになります:

typescript
interface Job {
  id: string;
  type: string;
  payload: Record<string, unknown>;
  createdAt: number;
}
 
// プロデューサー
async function enqueueJob(
  queue: string,
  type: string,
  payload: Record<string, unknown>
): Promise<string> {
  const job: Job = {
    id: randomUUID(),
    type,
    payload,
    createdAt: Date.now(),
  };
 
  await redis.lpush(`queue:${queue}`, JSON.stringify(job));
  return job.id;
}
 
// コンシューマー(ジョブが利用可能になるまでブロック)
async function dequeueJob(
  queue: string,
  timeout: number = 5
): Promise<Job | null> {
  const result = await redis.brpop(`queue:${queue}`, timeout);
  if (!result) return null;
 
  return JSON.parse(result[1]) as Job;
}

基本的なキューイング以上に複雑なもの(リトライ、デッドレターキュー、優先度、遅延ジョブ)には、Redisを基盤にしつつすべてのエッジケースを処理するBullMQを使用してください。

ユニーク追跡用のセット#

ユニーク訪問者の追跡、イベントの重複排除、メンバーシップのチェックが必要ですか?セットは追加、削除、メンバーシップチェックがO(1)です。

typescript
// 日ごとのユニーク訪問者を追跡
async function trackVisitor(
  page: string,
  visitorId: string
): Promise<boolean> {
  const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
  const isNew = await redis.sadd(key, visitorId);
 
  // 48時間後に自動期限切れ
  await redis.expire(key, 172800);
 
  return isNew === 1; // 1 = 新しいメンバー、0 = すでに存在
}
 
// ユニーク訪問者数を取得
async function getUniqueVisitors(page: string, date: string): Promise<number> {
  return redis.scard(`visitors:${page}:${date}`);
}
 
// ユーザーがすでにアクションを実行したか確認
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
  return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}

非常に大きなセット(数百万メンバー)には、代わりにHyperLogLogを検討してください。カーディナリティに関係なく12KBのメモリしか使用しませんが、約0.81%の標準誤差があります:

typescript
// 概算ユニークカウント用のHyperLogLog
async function trackVisitorApprox(
  page: string,
  visitorId: string
): Promise<void> {
  const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
  await redis.pfadd(key, visitorId);
  await redis.expire(key, 172800);
}
 
async function getApproxUniqueVisitors(
  page: string,
  date: string
): Promise<number> {
  return redis.pfcount(`hll:visitors:${page}:${date}`);
}

シリアライゼーション:JSON vs MessagePack#

JSONはRedisシリアライゼーションのデフォルト選択です。読みやすく、普遍的で、ほとんどの場合で十分です。しかし、高スループットシステムでは、シリアライズ/デシリアライズのオーバーヘッドが積み重なります。

JSONの問題#

typescript
const user = {
  id: "usr_abc123",
  name: "Ahmet Kousa",
  email: "ahmet@example.com",
  plan: "pro",
  preferences: {
    theme: "dark",
    language: "tr",
    notifications: true,
  },
};
 
// JSON: 189バイト
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
 
// ホットパスでのJSON.parse: 呼び出しあたり約0.02ms
// 毎秒10,000リクエストの場合: 1秒あたり合計200msのCPU時間

MessagePackの代替#

MessagePackはJSONより小さく高速なバイナリシリアライゼーション形式です:

bash
npm install msgpackr
typescript
import { pack, unpack } from "msgpackr";
 
// MessagePack: 約140バイト(25%小さい)
const packed = pack(user);
console.log(packed.length); // 約140
 
// Bufferとして保存
await redis.set("user:123", packed);
 
// Bufferとして読み取り
const raw = await redis.getBuffer("user:123");
if (raw) {
  const data = unpack(raw);
}

getではなくgetBufferを使用していることに注目してください。これは重要です。getは文字列を返し、バイナリデータを破損させます。

大きな値の圧縮#

大きなキャッシュ値(数百のアイテムを含むAPIレスポンス、レンダリングされたHTML)には、圧縮を追加します:

typescript
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
 
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
 
async function setCompressed<T>(
  key: string,
  value: T,
  ttl: number
): Promise<void> {
  const json = JSON.stringify(value);
 
  // 1KB以上の場合のみ圧縮(小さな値には圧縮オーバーヘッドは価値がない)
  if (Buffer.byteLength(json) > 1024) {
    const compressed = await gzipAsync(json);
    await redis.set(key, compressed, "EX", ttl);
  } else {
    await redis.set(key, json, "EX", ttl);
  }
}
 
async function getCompressed<T>(key: string): Promise<T | null> {
  const raw = await redis.getBuffer(key);
  if (!raw) return null;
 
  try {
    // まず解凍を試みる
    const decompressed = await gunzipAsync(raw);
    return JSON.parse(decompressed.toString()) as T;
  } catch {
    // 圧縮されていない、通常のJSONとしてパース
    return JSON.parse(raw.toString()) as T;
  }
}

私のテストでは、gzip圧縮は通常JSONペイロードサイズを70-85%削減します。50KBのAPIレスポンスが8KBになります。Redisメモリに対して支払っている場合、これは重要です。キーあたりのメモリが少なければ、同じインスタンスでより多くのキーを保持できます。

トレードオフ:圧縮は操作あたり1-3msのCPU時間を追加します。ほとんどのアプリケーションではこれは無視できます。超低レイテンシパスではスキップしてください。

私の推奨#

プロファイリングでボトルネックであることが示されない限りJSONを使用してください。RedisでのJSONの可読性とデバッグ可能性(redis-cli GET keyで実際に値を読める)は、95%のアプリケーションでMessagePackのパフォーマンス向上を上回ります。1KBを超える値にのみ圧縮を追加してください。

Next.jsでのRedis#

Next.jsには独自のキャッシュストーリー(Data Cache、Full Route Cacheなど)がありますが、Redisは組み込みキャッシュが処理できないギャップを埋めます。特に複数インスタンス間でキャッシュを共有する必要がある場合や、デプロイ間でキャッシュを永続化する必要がある場合に重要です。

APIルートレスポンスのキャッシュ#

typescript
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
 
export async function GET(request: Request) {
  const url = new URL(request.url);
  const category = url.searchParams.get("category") || "all";
  const cacheKey = `api:products:${category}`;
 
  // キャッシュを確認
  const cached = await redis.get(cacheKey);
  if (cached) {
    return NextResponse.json(JSON.parse(cached), {
      headers: {
        "X-Cache": "HIT",
        "Cache-Control": "public, s-maxage=60",
      },
    });
  }
 
  // データベースからフェッチ
  const products = await db.products.findMany({
    where: category !== "all" ? { category } : undefined,
    orderBy: { createdAt: "desc" },
    take: 50,
  });
 
  // ジッター付きで5分キャッシュ
  await redis.set(
    cacheKey,
    JSON.stringify(products),
    "EX",
    ttlWithJitter(300)
  );
 
  return NextResponse.json(products, {
    headers: {
      "X-Cache": "MISS",
      "Cache-Control": "public, s-maxage=60",
    },
  });
}

X-Cacheヘッダーはデバッグに不可欠です。レイテンシが急上昇したとき、簡単なcurl -Iでキャッシュが機能しているかどうかがわかります。

セッションストレージ#

ステートフルアプリケーションでは、RedisとNext.jsの組み合わせがJWTに勝ります:

typescript
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
 
interface Session {
  userId: string;
  role: string;
  createdAt: number;
  data: Record<string, unknown>;
}
 
const SESSION_TTL = 86400; // 24時間
const SESSION_PREFIX = "session:";
 
export async function createSession(
  userId: string,
  role: string
): Promise<string> {
  const sessionId = randomUUID();
  const session: Session = {
    userId,
    role,
    createdAt: Date.now(),
    data: {},
  };
 
  await redis.set(
    `${SESSION_PREFIX}${sessionId}`,
    JSON.stringify(session),
    "EX",
    SESSION_TTL
  );
 
  return sessionId;
}
 
export async function getSession(
  sessionId: string
): Promise<Session | null> {
  const key = `${SESSION_PREFIX}${sessionId}`;
 
  // GETEXを使ってアクセスのたびにTTLをリフレッシュ(スライディングエクスパイリー)
  const raw = await redis.getex(key, "EX", SESSION_TTL);
  if (!raw) return null;
 
  return JSON.parse(raw) as Session;
}
 
export async function destroySession(sessionId: string): Promise<void> {
  await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
 
// ユーザーのすべてのセッションを破棄(「すべてからログアウト」に便利)
export async function destroyAllUserSessions(
  userId: string
): Promise<void> {
  // これにはuser->sessionsインデックスのメンテナンスが必要
  const sessionIds = await redis.smembers(`user_sessions:${userId}`);
 
  if (sessionIds.length > 0) {
    const pipeline = redis.pipeline();
    for (const sid of sessionIds) {
      pipeline.del(`${SESSION_PREFIX}${sid}`);
    }
    pipeline.del(`user_sessions:${userId}`);
    await pipeline.exec();
  }
}

レート制限ミドルウェア#

typescript
// middleware.ts(またはミドルウェアが使用するヘルパー)
import redis from "@/lib/redis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
export async function rateLimit(
  identifier: string,
  limit: number = 60,
  windowSeconds: number = 60
): Promise<RateLimitResult> {
  const key = `rate:${identifier}`;
  const now = Math.floor(Date.now() / 1000);
  const windowStart = now - windowSeconds;
 
  // アトミックなレート制限用Luaスクリプト
  const script = `
    redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
    redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
    local count = redis.call('ZCARD', KEYS[1])
    redis.call('EXPIRE', KEYS[1], ARGV[4])
    return count
  `;
 
  const count = (await redis.eval(
    script,
    1,
    key,
    windowStart,
    now,
    `${now}:${Math.random()}`,
    windowSeconds
  )) as number;
 
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
    resetAt: now + windowSeconds,
  };
}

ここでのLuaスクリプトは重要です。これがなければ、ZREMRANGEBYSCORE + ZADD + ZCARDのシーケンスはアトミックではなく、高い並行性の下でカウントが不正確になる可能性があります。LuaスクリプトはRedis内でアトミックに実行されます。他のコマンドとインターリーブされることはありません。

Next.js用の分散ロック#

複数のNext.jsインスタンスがあり、1つだけがタスクを処理する必要がある場合(スケジュールされたメール送信やクリーンアップジョブの実行など):

typescript
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
 
export async function withLock<T>(
  lockName: string,
  fn: () => Promise<T>,
  options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
  const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
  const token = randomUUID();
  const lockKey = `dlock:${lockName}`;
 
  // ロックの取得を試行
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
 
    if (acquired) {
      try {
        // 長時間実行タスクのためにロックを自動延長
        const extender = setInterval(async () => {
          const script = `
            if redis.call("get", KEYS[1]) == ARGV[1] then
              return redis.call("expire", KEYS[1], ARGV[2])
            else
              return 0
            end
          `;
          await redis.eval(script, 1, lockKey, token, ttl);
        }, (ttl * 1000) / 3);
 
        const result = await fn();
        clearInterval(extender);
        return result;
      } finally {
        // まだ所有している場合のみロックを解放
        const releaseScript = `
          if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
          else
            return 0
          end
        `;
        await redis.eval(releaseScript, 1, lockKey, token);
      }
    }
 
    // リトライ前に待機
    await new Promise((r) => setTimeout(r, retryDelay));
  }
 
  // すべてのリトライ後もロックを取得できなかった
  return null;
}

使い方:

typescript
// cronトリガーのAPIルート内
export async function POST() {
  const result = await withLock("daily-report", async () => {
    // 1つのインスタンスだけがこれを実行
    const report = await generateDailyReport();
    await sendReportEmail(report);
    return report;
  });
 
  if (result === null) {
    return NextResponse.json(
      { message: "Another instance is already processing" },
      { status: 409 }
    );
  }
 
  return NextResponse.json({ success: true });
}

ttl/3でのロック延長間隔は重要です。これがなければ、タスクがロックTTLより長くかかった場合、ロックが期限切れになり別のインスタンスがそれを取得します。延長機能がタスクの実行中ロックを維持します。

監視とデバッグ#

Redisはそうでなくなるまで高速です。問題が発生したら、可視性が必要です。

キャッシュヒット率#

最も重要な単一メトリクス。アプリケーション内で追跡してください:

typescript
// lib/cache-metrics.ts
import redis from "./redis";
 
const METRICS_KEY = "metrics:cache";
 
export async function recordCacheHit(): Promise<void> {
  await redis.hincrby(METRICS_KEY, "hits", 1);
}
 
export async function recordCacheMiss(): Promise<void> {
  await redis.hincrby(METRICS_KEY, "misses", 1);
}
 
export async function getCacheStats(): Promise<{
  hits: number;
  misses: number;
  hitRate: number;
}> {
  const stats = await redis.hgetall(METRICS_KEY);
  const hits = parseInt(stats.hits || "0", 10);
  const misses = parseInt(stats.misses || "0", 10);
  const total = hits + misses;
 
  return {
    hits,
    misses,
    hitRate: total > 0 ? hits / total : 0,
  };
}
 
// メトリクスを毎日リセット
export async function resetCacheStats(): Promise<void> {
  await redis.del(METRICS_KEY);
}

健全なキャッシュヒット率は90%以上です。80%を下回っている場合、TTLが短すぎるか、キャッシュキーが具体的すぎるか、アクセスパターンが思っていたよりランダムです。

INFOコマンド#

INFOコマンドはRedis組み込みのヘルスダッシュボードです:

bash
redis-cli INFO memory
# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23

監視する主要メトリクス:

  • used_memory vs maxmemory:上限に近づいていないか?
  • mem_fragmentation_ratio:1.5以上はRedisが論理メモリより大幅に多くのRSSを使用していることを意味します。再起動を検討してください。
  • evicted_keys:0でなく、エビクションを意図していなければ、メモリが不足しています。
bash
redis-cli INFO stats

注視すべきもの:

  • keyspace_hits / keyspace_misses:サーバーレベルのヒット率
  • total_commands_processed:スループット
  • instantaneous_ops_per_sec:現在のスループット

MONITOR(極めて慎重に使用)#

MONITORはRedisサーバーで実行されるすべてのコマンドをリアルタイムでストリームします。デバッグには非常に便利で、プロダクションでは非常に危険です。

bash
# プロダクションでは絶対に実行したままにしない
# 大きなオーバーヘッドが発生し、機密データをログに記録する可能性がある
redis-cli MONITOR
1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"

MONITORはまさに2つのことにのみ使用します:開発中のキー命名の問題のデバッグと、特定のコードパスが期待通りにRedisにアクセスしていることの確認。30秒以上は絶対に使いません。他のデバッグオプションをすべて使い果たさない限り、プロダクションでは使いません。

キースペース通知#

キーの期限切れや削除を知りたいですか?Redisはイベントをパブリッシュできます:

bash
# 期限切れとエビクションイベントのキースペース通知を有効化
redis-cli CONFIG SET notify-keyspace-events Ex
typescript
const subscriber = new Redis(/* 設定 */);
 
// キー期限切れイベントをリッスン
subscriber.subscribe("__keyevent@0__:expired", (err) => {
  if (err) console.error("Subscribe error:", err);
});
 
subscriber.on("message", (_channel, expiredKey) => {
  console.log(`Key expired: ${expiredKey}`);
 
  // 重要なキーをプロアクティブに再生成
  if (expiredKey.startsWith("cache:homepage")) {
    regenerateHomepageCache().catch(console.error);
  }
});

これはプロアクティブなキャッシュウォーミングに便利です。ユーザーがキャッシュミスをトリガーするのを待つ代わりに、期限切れの瞬間に重要なエントリを再生成します。

メモリ分析#

Redisメモリが予期せず増加した場合、最も多くを消費しているキーを見つける必要があります:

bash
# 最大の10キーをサンプリング
redis-cli --bigkeys
# キースペース全体をスキャンして最大のキーを検索
[00.00%] Biggest string found so far '"cache:search:electronics"' with 524288 bytes
[25.00%] Biggest zset found so far '"leaderboard:global"' with 150000 members
[50.00%] Biggest hash found so far '"session:abc123"' with 45 fields

より詳細な分析のために:

bash
# 特定キーのメモリ使用量(バイト単位)
redis-cli MEMORY USAGE "cache:search:electronics"
typescript
// プログラムによるメモリ分析
async function analyzeMemory(pattern: string): Promise<void> {
  let cursor = "0";
  const stats: Array<{ key: string; bytes: number }> = [];
 
  do {
    const [nextCursor, keys] = await redis.scan(
      cursor,
      "MATCH",
      pattern,
      "COUNT",
      100
    );
    cursor = nextCursor;
 
    for (const key of keys) {
      const bytes = await redis.memory("USAGE", key);
      if (bytes) {
        stats.push({ key, bytes: bytes as number });
      }
    }
  } while (cursor !== "0");
 
  // サイズの降順でソート
  stats.sort((a, b) => b.bytes - a.bytes);
 
  console.log("メモリ使用量上位20キー:");
  for (const { key, bytes } of stats.slice(0, 20)) {
    const mb = (bytes / 1024 / 1024).toFixed(2);
    console.log(`  ${key}: ${mb} MB`);
  }
}

エビクションポリシー#

Redisインスタンスにmaxmemory制限がある場合(あるべきです)、エビクションポリシーを設定してください:

bash
# redis.confまたはCONFIG SETで
maxmemory 512mb
maxmemory-policy allkeys-lru

利用可能なポリシー:

  • noeviction:メモリがいっぱいのときエラーを返す(デフォルト、キャッシュには最悪)
  • allkeys-lru:最も最近使われていないキーをエビクト(キャッシュに最適な汎用選択)
  • allkeys-lfu:最も頻度の低いキーをエビクト(一部のキーがバースト的にアクセスされる場合に優れる)
  • volatile-lru:TTLが設定されたキーのみエビクト(キャッシュと永続データを混在させる場合に便利)
  • allkeys-random:ランダムエビクション(驚くほどまとも、オーバーヘッドなし)

純粋なキャッシュワークロードには、allkeys-lfuが通常最良の選択です。最近アクセスされていなくても、頻繁にアクセスされるキーをメモリに保持します。

すべてを統合する:プロダクション用キャッシュモジュール#

これまで議論したすべてを組み合わせた、プロダクションで使用している完全なキャッシュモジュールです:

typescript
// lib/cache.ts
import Redis from "ioredis";
 
const redis = new Redis({
  host: process.env.REDIS_HOST || "127.0.0.1",
  port: Number(process.env.REDIS_PORT) || 6379,
  password: process.env.REDIS_PASSWORD || undefined,
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    return Math.min(times * 200, 5000);
  },
});
 
// TTLティア
const TTL = {
  STATIC: 86400,
  MODERATE: 1800,
  VOLATILE: 300,
  EPHEMERAL: 60,
  NOT_FOUND: 120,
} as const;
 
type TTLTier = keyof typeof TTL;
 
function ttlWithJitter(base: number, jitter = 0.1): number {
  const offset = base * jitter * (Math.random() * 2 - 1);
  return Math.max(1, Math.round(base + offset));
}
 
// スタンピード防止付きコアcache-aside
async function get<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    tier?: TTLTier;
    ttl?: number;
    tags?: string[];
    swr?: { freshTtl: number; staleTtl: number };
  } = {}
): Promise<T> {
  const { tier = "MODERATE", tags } = options;
  const baseTtl = options.ttl ?? TTL[tier];
  const cacheKey = `c:${key}`;
 
  // キャッシュを確認
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      const parsed = JSON.parse(cached);
      recordHit();
      return parsed as T;
    } catch {
      await redis.del(cacheKey);
    }
  }
 
  recordMiss();
 
  // スタンピード防止のためにロックを取得
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (!acquired) {
    // 別のプロセスがフェッチ中 — 少し待ってキャッシュをリトライ
    await new Promise((r) => setTimeout(r, 150));
    const retried = await redis.get(cacheKey);
    if (retried) return JSON.parse(retried) as T;
  }
 
  try {
    const result = await fetcher();
    const ttl = ttlWithJitter(baseTtl);
 
    const pipeline = redis.pipeline();
    pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
 
    // タグの関連付けを保存
    if (tags) {
      for (const tag of tags) {
        pipeline.sadd(`tag:${tag}`, cacheKey);
        pipeline.expire(`tag:${tag}`, ttl + 3600);
      }
    }
 
    await pipeline.exec();
    return result;
  } finally {
    await redis.del(lockKey);
  }
}
 
// 無効化
async function invalidate(...keys: string[]): Promise<void> {
  if (keys.length === 0) return;
  await redis.del(...keys.map((k) => `c:${k}`));
}
 
async function invalidateByTag(tag: string): Promise<number> {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length === 0) return 0;
 
  const pipeline = redis.pipeline();
  for (const key of keys) {
    pipeline.del(key);
  }
  pipeline.del(`tag:${tag}`);
  await pipeline.exec();
  return keys.length;
}
 
// メトリクス
function recordHit(): void {
  redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
 
function recordMiss(): void {
  redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
 
async function stats(): Promise<{
  hits: number;
  misses: number;
  hitRate: string;
}> {
  const raw = await redis.hgetall("metrics:cache");
  const hits = parseInt(raw.hits || "0", 10);
  const misses = parseInt(raw.misses || "0", 10);
  const total = hits + misses;
 
  return {
    hits,
    misses,
    hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
  };
}
 
export const cache = {
  get,
  invalidate,
  invalidateByTag,
  stats,
  redis,
  TTL,
};

アプリケーション全体での使い方:

typescript
import { cache } from "@/lib/cache";
 
// シンプルなcache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
  tier: "VOLATILE",
  tags: ["entity:products"],
});
 
// カスタムTTL付き
const config = await cache.get("app:config", fetchAppConfig, {
  ttl: 43200, // 12時間
});
 
// 商品を更新した後
await cache.invalidateByTag("entity:products");
 
// ヘルスチェック
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);

私がした(あなたがしなくて済むように)よくある間違い#

1. maxmemoryを設定しない。 Redisは利用可能なすべてのメモリをOSがプロセスを強制終了するまで喜んで使います。常に制限を設定してください。

2. プロダクションでKEYSを使用する。 サーバーをブロックします。SCANを使ってください。モニタリングスクリプトからのKEYS *呼び出しが3秒のダウンタイムを引き起こしたときにこれを学びました。

3. 過度にキャッシュする。 すべてをキャッシュする必要はありません。データベースクエリが2msで1分あたり10回呼ばれるなら、キャッシュは無視できる利益のために複雑さを追加するだけです。

4. シリアライゼーションコストを無視する。 かつて2MBのJSONブロブをキャッシュし、なぜキャッシュ読み取りが遅いのか困惑しました。シリアライゼーションのオーバーヘッドが、それが節約するはずだったデータベースクエリより大きかったのです。

5. グレースフルデグラデーションなし。 Redisがダウンしても、アプリはまだ動作するべきです。ただ遅くなるだけで。すべてのキャッシュ呼び出しをデータベースにフォールバックするtry/catchで囲んでください。キャッシュの障害がユーザー向けのエラーになることは絶対に許さないでください。

typescript
async function resilientGet<T>(
  key: string,
  fetcher: () => Promise<T>
): Promise<T> {
  try {
    return await cache.get(key, fetcher);
  } catch (err) {
    console.error(`[Cache] Degraded mode for ${key}:`, err);
    return fetcher(); // キャッシュを完全にバイパス
  }
}

6. エビクションを監視しない。 Redisがキーをエビクトしている場合、プロビジョニング不足かキャッシュしすぎのどちらかです。いずれにしても、知る必要があります。

7. キャッシュと永続データでRedisインスタンスを共有する。 別々のインスタンス(少なくとも別々のデータベース)を使用してください。ジョブキューのエントリを削除するキャッシュエビクションポリシーは、全員にとって悪い日です。

まとめ#

Redisキャッシュは難しくありませんが、間違えるのは簡単です。cache-asideから始め、初日からTTLジッターを追加し、ヒット率を監視し、すべてをキャッシュしたい衝動に抵抗してください。

最良のキャッシュ戦略は、何かが壊れたときの午前3時に理解できるものです。シンプルに保ち、観測可能に保ち、すべてのキャッシュ値はデータの状態についてユーザーについた嘘であることを覚えておいてください。あなたの仕事は、その嘘をできるだけ小さく短命に保つことです。

関連記事