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:ja | 5分 | 商品更新後に一覧キーを削除 | 価格変更はTTL任せにしない |
| 記事詳細 | claudecodelab:v1:posts:item:{slug} | 10分 | 公開・更新・非公開化の直後 | プレビューや下書きは保存しない |
| 管理画面のPV集計 | claudecodelab:v1:analytics:daily:{date} | 30秒 | 基本はTTLのみ | 厳密な会計値には使わない |
| 外部API結果 | claudecodelab:v1:exchange-rate:usd-jpy | 1〜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のINCRとEXPIREで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に任せる範囲を明確にすると、キャッシュは「速いけれど怖い仕組み」から「古さを管理できる仕組み」に変わります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code Permission Receipt Pattern: 許可、証拠、ロールバックを残す運用
Claude Codeの権限運用を安全にする permission receipt。許可範囲、承認待ち、検証コマンド、CTA導線を記録します。
Claude CodeとCodex、結局どっち?事故らない“併用”の現実解
OpenAIのCodexとClaude Code、どっちが得意でどっちに任せる?両方を安全に併用する作業分担と権限・検証のワークフローを、僕の失敗談つきで解説します。
Claude Codeサブエージェント実装ガイド: 記事・コード作業を安全に並列委譲する方法
Claude Codeサブエージェントで記事・コード作業を安全に並列化する実装ガイド。委譲基準、プロンプト、失敗例を解説。