Advanced (更新: 2026/6/6)

Redisキャッシュで古い価格が消えない事故を防ぐ:TTL・無効化・stampede対策

Claude CodeにRedisキャッシュを任せると古い価格や情報漏れが起きがち。TTL設計・キー無効化・stampede対策を、僕の事故込みで具体的に解説します。

Redisキャッシュで古い価格が消えない事故を防ぐ:TTL・無効化・stampede対策

セールを始めた朝、商品ページの価格が980円のまま動かない。DBはちゃんと500円に下がっているのに、画面だけ前日の値段を出し続けている。

原因はRedisでした。前夜に僕が「一覧が遅いからキャッシュ入れといて」とClaude Codeに丸投げして、TTLだけ300秒で貼ったやつ。値段を変えてもキャッシュを消す処理がなかったので、期限が切れるまで古い価格を配り続けていたんです。

速くはなった。でも、古い価格でカートに入れたお客さんに正しい金額を請求できるのか、という別の問題が生まれていました。これがRedisの怖いところで、間違っていてもUIは「速い」ままなので、誰も気づかない

この記事の要点

  • Redisキャッシュの事故はモデルの賢さでは防げない。「何を何秒古くしてよいか」を先に決めるかどうかで決まる
  • 価格・在庫・権限はTTL任せにしない。更新したらその場で関連キーを消す(無効化を後回しにしない)
  • 人気キーが同時に切れるとDBへ雪崩れ込む(cache stampede)。短いロックとjitterで防ぐ
  • ログイン後の個人情報は共有Redisに載せない。漏れたら一発アウト
  • Claude Codeには「速くして」ではなく「消えるべき時に消える」を完了条件として渡す

下に貼るNode.jsコードはローカルのRedisで実際に動かして確認したものです。コピペすればmisshitの動きと、同時アクセスでloaderが1回に抑えられるところまで再現できます。

なぜ「キャッシュ入れといて」が事故るのか

Redisは、メモリ上にデータを置く高速なキーバリューストアです。DBの集計結果、外部APIのレスポンス、ランキング、レート制限のカウンタ——再計算が重いものを一時的に覚えておく場所、と思えばだいたい合っています。

問題は、Claude Codeに「Redisキャッシュを入れて」とだけ頼むと、たいてい読み込みだけ速くして、消す話を忘れることです。これはAIがサボっているわけじゃなくて、コードを読んでも「この価格は何秒古くてよいか」は推測できないからです。古さの許容値は業務知識であって、ソースコードのどこにも書いていない。

だから設計の中心は3つだけに絞れます。

  1. 何を何秒古くしてよいか(TTL)
  2. どのキーをいつ消すか(無効化)
  3. 同時アクセスでDBに雪崩れ込まないか(stampede対策)

この3つを言語化せずにキャッシュを足すと、僕の980円事故みたいなことが起きます。全体のキャッシュ戦略をどこに何を置くかから考えたい人はClaude Codeで実アプリ向けキャッシュ戦略を設計する方法、Redisをキュー用途にも使う場合はClaude Codeでジョブキュー・非同期処理を実装するも読むと、Redisで「やること/やらないこと」の境界がはっきりします。

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

先に決める「古さの許容値」を表にする

実装より前に、この表をCLAUDE.mdや依頼文の冒頭に置きます。これがないと、Claude Codeは全部まとめて「なんとなく300秒」にしがちで、それがそのまま事故になります。

対象データキー例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秒なし共有キャッシュに載せない

線引きの感覚は単純です。読者全員に同じものを返す公開一覧なら、数分古くても誰も困らない。でも、購入価格や権限は数秒古いだけで事故ります。僕の980円事件は、価格を「公開一覧と同じ5分でいいや」と雑に扱ったのが敗因でした。価格は一覧に混ざっていても、更新と同時に消す対象として別枠で考えるべきだったんです。

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が「個人情報はキャッシュしませんでした」と自分で線引きを言語化してくれて、レビューが一気に楽になります。このテンプレートは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);
}

// 例: cacheKey(["products","list","ja"]) -> claudecodelab:v1:products:list:ja
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(":");
}

// TTLにjitterを足し、人気キーの同時失効をずらす(stampede予防の第一歩)
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;

// 接続は使い回す。最初の呼び出しだけ実際に connect する
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(あれば返す、なければ取りに行って保存する)です。人気キーの期限切れで大量のリクエストが同時にDBへ流れる現象をcache stampede(キャッシュ雪崩)と呼びます。ここではSET NX PXで短いロックを1つだけ取らせ、他のリクエストは少し待ってから読み直します。

// 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 {
      // 壊れたJSONはキャッシュごと捨てる(古いゴミを返さない)
      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" };
    }

    // ロックを取れた1人だけがloaderを実行する
    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" };
      }
    }

    // それでもダメなら短いTTLで自分も取りに行く(待ち続けて固まらない保険)
    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 },
  ],
};

// 本物のDBの代わり。80msかけて公開商品だけ返す(遅いクエリの再現)
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,   // miss
    secondStatus: second.cacheStatus, // hit
    products: second.value,
  });

  // 商品を更新したら、その場で一覧キーを消す(ここを忘れると980円事故)
  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サーバーなしで動きます。

// 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");

// Redisの代わりに使うインメモリの偽物。NX/PX/EX/evalだけ最低限まねる
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で毎回回すユニットテストはこの偽物で速く済ませる、という分け方が現実的です。キー設計とstampedeの分岐さえ高速に守れれば十分です。

実際に効いた4つの使いどころ

僕の運用で「Redisにして正解だった」と思えたのは、だいたいこの4パターンに収まります。

1. 公開商品一覧・記事一覧。 読者全員に同じ内容を返せるので、Redisにいちばん向いています。ただし更新後は一覧キーと詳細キーを明示的に消す。CDNを併用しているならURL単位でpurgeまでやって、はじめて「消えた」と言えます。

2. 管理画面の集計値。 PV、CVR、売上の速報は30秒〜5分古くても意思決定に使えます。逆に請求金額・返金可否・権限判定はキャッシュ対象外。ここを混ぜると、速報のつもりが会計事故になります。

3. 外部APIの結果。 為替、天気、SaaSプラン、GitHubの公開メタデータなどは、Redisで短く保存するとレート制限と待ち時間を両方抑えられます。保存が規約違反でないかは、Claude Codeに公式規約を確認させる一文を足しておくと安全です。

4. レート制限・短命セッション。 RedisのINCREXPIREでIP単位の回数制限が作れます。ただしログイン情報そのものを長く持つなら、暗号化・TTL・失効処理・監査ログまで設計が要ります。認証まわりはClaude Codeで認証実装を安全に進めるの観点も合わせて見てください。

僕がやらかした落とし穴4つ

正直に書きます。Redis導入で踏んだ地雷はだいたいこの4つでした。

ひとつ目は、冒頭の価格の無効化漏れproducts:listにTTLだけ貼って、価格更新時に消す処理を忘れた。基本は必ず「DB write成功 → 関連キー削除 →(必要ならCDN purge)」の順です。DB更新の前にキャッシュを消すと、write失敗時に古いDBから再生成してしまうので、順番も大事。

ふたつ目は、キーに条件を入れ忘れたことproducts:listだけでは、日本語と英語、公開と非公開、表示通貨の違いを区別できません。URLやSQLのwhere条件と同じ情報をキーに入れる、と覚えておくと事故りません。

みっつ目は、KEYS user:*で全件探索したこと。開発では便利でしたが、本番の大きなkeyspaceでこれをやるとRedisが固まります。関連キーをSetに登録して既知キーだけ消すか、メンテ用にSCANを使う。KEYSは本番コードから追放しました。

よっつ目は、nullの扱いを決めなかったこと。上の実装はnullをmiss扱いにするので、「存在しない商品」を保存できません。結果、存在しないIDへのアクセスが毎回DBに飛ぶ。{ found: false }のようにオブジェクトで包めば、不在もキャッシュできます。

そして大前提として、Redisを永続DBの代わりにしない。Redisは消える前提の層です。失われて困る注文・契約・監査ログはDBに置き、Redisには再生成できる値だけを載せます。

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

Claude Codeにレビューを頼むときは、これを「完了条件」として渡します。「速くなったか」だけ見て合格にしないためです。

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

レビューの流れをチームで整えるならClaude Codeレビュー・ワークフローチェックリストに組み込むと回しやすくなります。

よくある質問

Q. TTLは結局、何秒にすればいいですか? A. 秒数そのものより「何秒古くても困らないか」を業務側で決めるのが先です。公開一覧は5分、管理画面の速報は30秒、価格や権限は実質キャッシュしない(更新で即消す)が出発点。迷ったら短めにして、hit率を見ながら伸ばします。

Q. キャッシュの無効化とTTL、どちらを優先すべき? A. 両方使いますが、役割が違います。TTLは「最悪これ以上は古くしない」という保険。無効化は「変わった瞬間に消す」という主役。価格・在庫のように間違うと痛いデータは、無効化を主役にしてTTLは保険に回します。

Q. cache stampedeはどんな時に起きますか? A. アクセスの多いキーが一斉に期限切れし、その瞬間に来た全リクエストがDBへ取りに行く時です。本記事のように短いロックで取得を1つに絞り、TTLにjitterを足して失効時刻をばらけさせると防げます。

Q. ログインユーザーの情報はRedisに入れて大丈夫? A. 共有Redisに生の個人情報を載せるのはおすすめしません。誤ったキー設計で別ユーザーに混ざると情報漏れになります。どうしても必要ならユーザーIDをキーに含め、暗号化・短いTTL・失効処理・監査ログまでセットで設計します。

Q. Claude Codeに丸投げしても安全にできますか? A. 「Redis入れて」だけだと事故ります。本記事の方針表と依頼テンプレートを渡し、「キャッシュ対象外にしたデータと理由」を必ず出力させてください。線引きを言語化させるだけで、危険なキャッシュはかなり防げます。

この記事で紹介した内容を実際に試した結果

検証用のNode.jsデモでは、1回目の一覧取得だけがloaderを呼び、2回目以降はRedis hitになりました。同時missのテストでもloaderは1回に抑えられています。

ただ、いちばん効いたのはRedisそのものではありませんでした。キー・TTL・無効化・レビュー条件を先に紙に書いてからClaude Codeに渡したことです。これをやってから、僕の手元で価格や在庫の「消し忘れ事故」は出ていません。キャッシュは「速いけど怖い仕組み」ではなく、「古さを自分で管理できる仕組み」に変わりました。

自分のプロジェクト用にCLAUDE.md・キャッシュ方針・レビュー観点まで一式そろえたい人はClaudeCodeLabの教材・テンプレート一覧が早いです。Redis・CDN・DB更新・監視を既存の本番アプリにどうつなぐかを一緒に整理したい場合はClaude Code研修・相談もどうぞ。まずは上の30秒で動くデモを動かして、自分のアプリで「一番消し忘れたら困るキー」を1つ決めるところから始めてみてください。

#Claude Code #Redis #キャッシュ #TTL #Node.js
無料

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

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

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

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

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

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

PR

関連書籍・参考図書

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

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