Advanced (更新: 2026/6/1)

Claude CodeでRedisキャッシュを設計する実践ガイド:TTL・無効化・stampede対策

Claude CodeでRedisキャッシュを安全に設計・実装する実践ガイド。TTL、キー設計、無効化、stampede対策まで解説。

Claude CodeでRedisキャッシュを設計する実践ガイド:TTL・無効化・stampede対策

Redis(メモリ上にデータを置く高速なキーバリューストア)は、DB集計、外部APIレスポンス、ランキング、セッション、レート制限などを高速に扱うための定番ツールです。ただし、Claude Codeに「Redisキャッシュを入れて」とだけ頼むと、速くなる代わりに古い価格、消えない在庫、別ユーザーの情報漏れ、更新後に戻らない画面を作ってしまうことがあります。

この記事では、Claude Codeに渡せるRedisキャッシュの設計手順を、Masaが小さなNode.jsデモで検証した形に落とし込みます。中心は「何を何秒古くしてよいか」「どのキーをいつ消すか」「同時アクセスでDBに雪崩れ込まないか」です。広いキャッシュ戦略はClaude Codeで実アプリ向けキャッシュ戦略を設計する方法、Redisをキュー用途にも使う場合はClaude Codeでジョブキュー・非同期処理を実装するも合わせて読むと設計の境界が明確になります。

flowchart LR
  Request["HTTP request"] --> Cache["Redis cache"]
  Cache -->|hit| Response["Fast response"]
  Cache -->|miss| Lock["Short lock"]
  Lock --> Loader["DB or external API"]
  Loader --> Cache
  Loader --> Response
  Admin["Update event"] --> Invalidate["Delete known keys"]
  Invalidate --> Cache

先に決めるキャッシュ方針

Redisを導入する前に、CLAUDE.mdや実装依頼の冒頭へ次の表を置きます。Claude Codeは既存コードを読むのが得意ですが、キャッシュの「古さの許容値」はコードだけでは推測できません。

対象データキー例TTL目安無効化タイミング注意点
公開商品一覧claudecodelab:v1:products:list:ja5分商品更新後に一覧キーを削除価格変更はTTL任せにしない
記事詳細claudecodelab:v1:posts:item:{slug}10分公開・更新・非公開化の直後プレビューや下書きは保存しない
管理画面のPV集計claudecodelab:v1:analytics:daily:{date}30秒基本はTTLのみ厳密な会計値には使わない
外部API結果claudecodelab:v1:exchange-rate:usd-jpy1〜15分手動更新またはTTL利用規約で保存可否を確認
ログインユーザー情報原則キャッシュしない0秒なし共有キャッシュに載せない

ポイントは、TTLを「なんとなく300秒」にしないことです。読者が見る公開一覧なら数分でも許容されますが、購入価格や権限は数秒でも危険です。Claude Codeへは「公開一覧はRedis可、ログイン後の個人情報はRedisに保存しない」のように明示します。

Claude Codeへの実装依頼テンプレート

キャッシュ追加は小さな実装に見えて、キー設計、DB更新、テスト、レビュー観点が同時に絡みます。最初の依頼は次のように制約まで含めると、無関係なリファクタリングを避けやすくなります。

このNode.jsアプリにRedisのcache-aside層を追加してください。

要件:
1. Redisクライアントは公式のnode-redisパッケージ(redis)を使う
2. キーは claudecodelab:v1:{domain}:{resource}:{id} の形式にする
3. TTLは対象ごとのポリシー表から選び、10%以内のjitterを足す
4. 更新処理ではDB write成功後に既知の関連キーだけを削除する
5. KEYSコマンドは使わない。必要ならSCANか関連キーSetを使う
6. 人気キーの同時missに備えて短いlockを入れる
7. node:testでキー生成、TTL範囲、getOrSet、stampede対策をテストする

出力:
- 変更ファイル一覧
- 実行したテスト
- キャッシュ対象外にしたデータと理由

このテンプレートはClaude Codeコードレビュー・チェックリストにも流用できます。レビュー時に「速くなったか」だけを見るのではなく、「消えるべき時に消えるか」を確認するためです。

Node.jsで動く最小実装

公式のNode.jsクライアントはredisパッケージです。詳細はRedis公式のnode-redis guideを確認してください。ローカルで試す場合は、次のセットアップから始めます。

mkdir redis-cache-demo
cd redis-cache-demo
npm init -y
npm install redis
docker run --name redis-cache-demo -p 6379:6379 -d redis:7-alpine

まず、キーとTTLを1か所に集めます。ここを分離しておくと、Claude Codeが新しい画面へキャッシュを足す時もルールがぶれません。

// cache-policy.js
const CACHE_PREFIX = "claudecodelab";
const CACHE_VERSION = "v1";

const CACHE_POLICY = {
  productList: { ttl: 300, jitter: 30 },
  productItem: { ttl: 600, jitter: 60 },
  dailyStats: { ttl: 30, jitter: 5 },
};

function normalizePart(value) {
  const part = String(value).trim().toLowerCase();
  if (part.length === 0) {
    throw new Error("cache key part must not be empty");
  }
  return encodeURIComponent(part);
}

function cacheKey(parts) {
  if (!Array.isArray(parts) || parts.length === 0) {
    throw new Error("cacheKey requires a non-empty parts array");
  }
  return [CACHE_PREFIX, CACHE_VERSION, ...parts.map(normalizePart)].join(":");
}

function ttlWithJitter(baseSeconds, maxJitterSeconds = 30) {
  if (!Number.isInteger(baseSeconds) || baseSeconds <= 0) {
    throw new Error("base TTL must be a positive integer");
  }
  const jitter = Math.max(0, Math.floor(maxJitterSeconds));
  return baseSeconds + Math.floor(Math.random() * (jitter + 1));
}

module.exports = { CACHE_POLICY, cacheKey, ttlWithJitter };

次にRedis接続です。connect()をリクエストごとに呼ぶと接続競合が起きるので、共有クライアントを遅延初期化します。

// redis-client.js
const { createClient } = require("redis");

const redis = createClient({
  url: process.env.REDIS_URL || "redis://localhost:6379",
});

redis.on("error", (error) => {
  console.error("Redis Client Error", error);
});

let connecting;

async function getRedis() {
  if (redis.isOpen) return redis;
  if (!connecting) {
    connecting = redis.connect();
  }
  await connecting;
  return redis;
}

async function closeRedis() {
  if (redis.isOpen) {
    await redis.quit();
  }
  connecting = undefined;
}

module.exports = { getRedis, closeRedis };

本体はcache-asideです。Redisにあれば返す、なければloaderでDBや外部APIから取り、TTL付きで保存します。人気キーの期限切れで大量のリクエストが同時にDBへ流れる現象をcache stampedeと呼びます。ここではSET NX PXで短いロックを取り、他のリクエストは少し待って再読込します。

// redis-cache.js
const { randomUUID } = require("node:crypto");

const UNLOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
end
return 0
`;

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

class RedisJsonCache {
  constructor(redis, options = {}) {
    this.redis = redis;
    this.defaultTtl = options.defaultTtl || 300;
    this.lockMs = options.lockMs || 5000;
    this.waitMs = options.waitMs || 50;
    this.waitRetries = options.waitRetries || 10;
  }

  async get(key) {
    const raw = await this.redis.get(key);
    if (raw === null) return null;

    try {
      return JSON.parse(raw);
    } catch {
      await this.redis.del(key);
      return null;
    }
  }

  async set(key, value, ttlSeconds = this.defaultTtl) {
    if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
      throw new Error("ttlSeconds must be a positive integer");
    }
    await this.redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
  }

  async invalidate(keys) {
    const list = Array.isArray(keys) ? keys : [keys];
    if (list.length === 0) return 0;
    return this.redis.del(list);
  }

  async getOrSet(key, ttlSeconds, loader) {
    const cached = await this.get(key);
    if (cached !== null) {
      return { value: cached, cacheStatus: "hit" };
    }

    const lockKey = `${key}:lock`;
    const token = randomUUID();
    const acquired = await this.redis.set(lockKey, token, {
      NX: true,
      PX: this.lockMs,
    });

    if (acquired === "OK") {
      try {
        const fresh = await loader();
        await this.set(key, fresh, ttlSeconds);
        return { value: fresh, cacheStatus: "miss" };
      } finally {
        await this.redis.eval(UNLOCK_SCRIPT, {
          keys: [lockKey],
          arguments: [token],
        });
      }
    }

    for (let attempt = 0; attempt < this.waitRetries; attempt += 1) {
      await sleep(this.waitMs);
      const afterWait = await this.get(key);
      if (afterWait !== null) {
        return { value: afterWait, cacheStatus: "hit-after-wait" };
      }
    }

    const fallback = await loader();
    await this.set(key, fallback, Math.max(5, Math.floor(ttlSeconds / 3)));
    return { value: fallback, cacheStatus: "miss-after-timeout" };
  }
}

module.exports = { RedisJsonCache };

動作確認用のデモです。実アプリではloadProductsFromDb()をPrisma、Supabase、REST APIなどへ差し替えます。DB設計が絡む場合はClaude CodeでPrisma ORMを使うClaude CodeでSupabase連携を実装するも参照してください。

// demo-products.js
const { CACHE_POLICY, cacheKey, ttlWithJitter } = require("./cache-policy");
const { getRedis, closeRedis } = require("./redis-client");
const { RedisJsonCache } = require("./redis-cache");

const db = {
  products: [
    { id: "p1", locale: "ja", name: "CLAUDE.mdテンプレート", price: 980, published: true },
    { id: "p2", locale: "ja", name: "Claude Code研修", price: 30000, published: true },
    { id: "p3", locale: "en", name: "Review Prompt Pack", price: 19, published: true },
  ],
};

async function loadProductsFromDb(locale) {
  await new Promise((resolve) => setTimeout(resolve, 80));
  return db.products.filter((product) => product.locale === locale && product.published);
}

async function listPublishedProducts(cache, locale) {
  const key = cacheKey(["products", "list", locale]);
  const ttl = ttlWithJitter(CACHE_POLICY.productList.ttl, CACHE_POLICY.productList.jitter);
  return cache.getOrSet(key, ttl, () => loadProductsFromDb(locale));
}

async function main() {
  const redis = await getRedis();
  const cache = new RedisJsonCache(redis);

  const first = await listPublishedProducts(cache, "ja");
  const second = await listPublishedProducts(cache, "ja");

  console.log({
    firstStatus: first.cacheStatus,
    secondStatus: second.cacheStatus,
    products: second.value,
  });

  await cache.invalidate([cacheKey(["products", "list", "ja"])]);
  await closeRedis();
}

main().catch(async (error) => {
  console.error(error);
  await closeRedis();
  process.exitCode = 1;
});

実行すると、1回目はmiss、2回目はhitになります。

node demo-products.js

テストで確認すること

キャッシュは壊れてもUI上は「速い」ままなので、テストが重要です。最低限、キー生成、TTLの範囲、2回目がloaderを呼ばないこと、同時missでloaderが1回に抑えられることを確認します。

// redis-cache.test.js
const test = require("node:test");
const assert = require("node:assert/strict");
const { cacheKey, ttlWithJitter } = require("./cache-policy");
const { RedisJsonCache } = require("./redis-cache");

class FakeRedis {
  constructor() {
    this.store = new Map();
  }

  async get(key) {
    const entry = this.store.get(key);
    if (!entry) return null;
    if (entry.expiresAt && entry.expiresAt <= Date.now()) {
      this.store.delete(key);
      return null;
    }
    return entry.value;
  }

  async set(key, value, options = {}) {
    if (options.NX && (await this.get(key)) !== null) {
      return null;
    }
    const ttlMs = options.PX || (options.EX ? options.EX * 1000 : 0);
    this.store.set(key, {
      value,
      expiresAt: ttlMs ? Date.now() + ttlMs : 0,
    });
    return "OK";
  }

  async del(keys) {
    const list = Array.isArray(keys) ? keys : [keys];
    let deleted = 0;
    for (const key of list) {
      if (this.store.delete(key)) deleted += 1;
    }
    return deleted;
  }

  async eval(_script, options) {
    const [key] = options.keys;
    const [token] = options.arguments;
    if ((await this.get(key)) === token) {
      return this.del(key);
    }
    return 0;
  }
}

test("cacheKey encodes dynamic parts", () => {
  assert.equal(
    cacheKey(["Products", "List", "ja/Japan"]),
    "claudecodelab:v1:products:list:ja%2Fjapan"
  );
});

test("ttlWithJitter stays inside the expected range", () => {
  for (let i = 0; i < 50; i += 1) {
    const ttl = ttlWithJitter(300, 30);
    assert.ok(ttl >= 300);
    assert.ok(ttl <= 330);
  }
});

test("getOrSet caches the first loader result", async () => {
  const cache = new RedisJsonCache(new FakeRedis());
  let loads = 0;

  const first = await cache.getOrSet("products:list", 60, async () => {
    loads += 1;
    return [{ id: "p1" }];
  });
  const second = await cache.getOrSet("products:list", 60, async () => {
    loads += 1;
    return [{ id: "p2" }];
  });

  assert.equal(first.cacheStatus, "miss");
  assert.equal(second.cacheStatus, "hit");
  assert.equal(loads, 1);
  assert.deepEqual(second.value, [{ id: "p1" }]);
});

test("getOrSet waits instead of running duplicate loaders", async () => {
  const cache = new RedisJsonCache(new FakeRedis(), { waitMs: 5, waitRetries: 20 });
  let loads = 0;

  const loader = async () => {
    loads += 1;
    await new Promise((resolve) => setTimeout(resolve, 20));
    return { total: 42 };
  };

  const results = await Promise.all([
    cache.getOrSet("analytics:daily", 30, loader),
    cache.getOrSet("analytics:daily", 30, loader),
  ]);

  assert.equal(loads, 1);
  assert.deepEqual(results[0].value, { total: 42 });
  assert.deepEqual(results[1].value, { total: 42 });
});
node --test redis-cache.test.js

このテストはRedisサーバーなしで動きます。CIに入れるなら、実Redisを使う統合テストは別ジョブにし、ユニットテストではキー設計とstampede対策の分岐を速く回すのが現実的です。

3つ以上の実用ユースケース

1つ目は、公開商品一覧や記事一覧です。読者全員に同じ内容を返せるのでRedisに向いています。更新後は一覧キーと詳細キーを明示的に消し、CDNを使っているならURL単位でpurgeします。

2つ目は、管理画面の集計値です。PV、CVR、売上の速報値は30秒から5分程度古くても意思決定に使えることがあります。ただし、請求金額、返金可否、権限判定はキャッシュ対象外です。

3つ目は、外部APIの結果です。為替、天気、SaaSプラン、GitHubの公開メタデータなどは、Redisで短く保存するとレート制限と待ち時間を抑えられます。保存が禁止されていないか、公式規約をClaude Codeに確認させる指示も必要です。

4つ目は、レート制限や短命セッションです。RedisのINCREXPIREでIP単位の回数制限を作れますが、ログイン情報そのものを長期保存する場合は暗号化、TTL、失効処理、監査ログまで設計します。認証まわりはClaude Codeで認証実装を安全に進めるのような別観点も合わせて見ます。

具体的な落とし穴

一番多い失敗は、キーに条件を入れ忘れることです。products:listだけでは、日本語・英語、公開・非公開、価格表示通貨の違いを区別できません。URLやSQLのwhere条件と同じ情報をキーに含めます。

次に危険なのは、DB更新前にキャッシュを消すことです。DB writeが失敗したのにキャッシュだけ消えると、次のリクエストが古いDBから再生成してしまいます。基本は「DB成功、関連キー削除、必要ならCDN purge」の順です。

KEYS user:*のような全件探索も避けます。開発環境では便利ですが、本番の大きなkeyspaceではRedisを止める原因になります。関連キーをSetに登録する、既知キーだけ消す、メンテナンスコマンドでSCANを使う、といった方針にします。

null値の扱いにも注意します。上の実装はnullをmiss扱いにするので、「存在しない商品」を保存したい場合は{ found: false }のようにオブジェクトで包みます。これを決めないと、存在しないIDへのアクセスが毎回DBへ飛びます。

最後に、Redisを永続DBの代わりにしないことです。Redisは高速ですが、キャッシュ用途では消える前提の層です。失われて困る注文、契約、監査ログはDBに保存し、Redisには再生成できる値だけを置きます。

レビュー・チェックリスト

  • キャッシュ対象外にした個人情報、権限、請求データが明記されているか
  • キーにlocale、tenant、role、query、versionなど必要条件が入っているか
  • TTLが業務上の鮮度要件から説明できるか
  • 更新処理がDB成功後に関連キーを削除しているか
  • KEYSを本番コードで使っていないか
  • cache stampede対策としてlock、jitter、短いfallback TTLのいずれかがあるか
  • node --test、Redis hit率ログ、手動の更新確認が実施されているか
  • 障害時にRedisを迂回できるか、または短時間の劣化で済むか

Claude Codeには、このチェックリストを「完了条件」として渡します。レビューの流れをチームで整えるならClaude Codeレビュー・ワークフローチェックリストに組み込むのが実務的です。

公式リンクと次の一歩

Redisの全体像はRedis公式ドキュメント、Node.js接続はnode-redis guide、ロックやデータ構造の考え方はRedis patterns、運用時の性能観点はRedis optimizationを確認してください。Claude Codeに実装させる時も、これらのリンクをタスク本文に含めると古いAPI名や疑似コードを避けやすくなります。

自分のプロジェクト用にCLAUDE.md、キャッシュ方針、レビュー観点まで整えたい場合はClaudeCodeLabの教材・テンプレート一覧が使えます。既存の本番アプリにRedis、CDN、DB更新、監視をどうつなぐかを一緒に整理したい場合はClaude Code研修・相談も検討してください。

この記事で紹介した内容を実際に試した結果、Masaの検証用Node.jsデモでは1回目の一覧取得だけがloaderを呼び、2回目以降はRedis hitになりました。同時missのテストでもloaderは1回に抑えられました。効果が大きかったのはRedisそのものより、キー、TTL、無効化、レビュー条件を先に書いたことです。Claude Codeに任せる範囲を明確にすると、キャッシュは「速いけれど怖い仕組み」から「古さを管理できる仕組み」に変わります。

#Claude Code #Redis #キャッシュ #Node.js #パフォーマンス
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。