caching-strategies について
アプリケーションのキャッシュ設計を支援するエンジニア向けスキルです。キャッシュ方式、鮮度、無効化、スタンピード対策を扱い、古いデータや無制限な蓄積を避ける設計を示します。
主な機能
- 01Cache-aside と write-through 方式
- 02鮮度要件に応じた TTL 設計
- 03Redis の無効化と Lua パターン
- 04HTTP キャッシュと CDN の指針
- 05スタンピードの防止策
活用シーン
- 01アプリケーションキャッシュ方式の選定
- 02変化するデータの TTL 設定
- 03多層キャッシュの無効化設計
- 04同時キャッシュミスによる過負荷の防止
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ curiositech/ some_claude_skills --skill caching-strategiesインストール後、Claude Code で/caching-strategiesを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
.claude-plugin/plugin.jsonreferences/http-caching.mdredis-patterns.md詳細
キャッシュ戦略
キャッシュは、パフォーマンス向上の手法の中で最も誤用されやすいものです。問題となるのは「キャッシュが少なすぎる」ことではなく、「無効化の戦略なしにキャッシュを導入し、6か月後、本番環境で古いデータについてユーザーから苦情が寄せられても、その原因を説明できない」ことです。
使用する場面
✅ 次の用途に使用します。
- ユースケースに適したキャッシュパターン(キャッシュアサイド、ライトスルー、ライトビハインド)の選択
- データの鮮度要件に応じた TTL 値の設計
- Redis のキャッシュパターン(ソート済みセット、Pub/Sub による無効化、Lua スクリプト)の実装
- Cache-Control ヘッダー、ETag、CDN の動作の設定
- ロック、確率的な早期有効期限切れ、バックグラウンド更新によるキャッシュスタンピードの防止
- コールドスタートのシナリオに向けたキャッシュウォーミング戦略
- 多層キャッシュ設計(インメモリ L1、Redis L2、CDN L3)
❌ 次の用途には使用しません。
- データベース内部のクエリプランキャッシュ(データベースが処理します)
- Python の
functools.lru_cache/JavaScript のメモ化ユーティリティ(純粋関数のメモ化) - CPU の分岐予測やハードウェアキャッシュのチューニング
- セッションストレージ(専用のセッションスキルを使用してください)
どのキャッシュパターンを選ぶべきか?
flowchart TD
Q1{Who writes to cache?} --> WA[Application writes]
Q1 --> WC[Cache writes automatically]
WA --> Q2{When does the cache get populated?}
Q2 -->|On read miss| CA[Cache-Aside\n'Lazy loading']
Q2 -->|On every write| WT[Write-Through\n'Eager write']
WC --> Q3{Sync or async write-back?}
Q3 -->|Sync — write completes when cache updates| WT
Q3 -->|Async — write returns fast, flush later| WB[Write-Behind\n'Write-back']
CA --> N1{Is stale data OK\nfor a short period?}
N1 -->|Yes| CA_USE[Use cache-aside\nwith TTL expiry]
N1 -->|No| INVAL[Add explicit invalidation\nor use write-through]
WT --> NOTE2[Good for read-heavy data\nthat changes infrequently]
WB --> NOTE3[Good for write-heavy workloads\nRisk: data loss on crash]
多層キャッシュアーキテクチャ
flowchart LR
USER[User Request] --> CDN{CDN / Edge Cache\nL3 — 100ms+ saved}
CDN -->|Cache hit| RESP[Response]
CDN -->|Cache miss| LB[Load Balancer]
LB --> APP[App Server]
APP --> L1{In-Process Cache\nL1 — ~0ms}
L1 -->|Hit| APP
L1 -->|Miss| REDIS{Redis\nL2 — 1-5ms}
REDIS -->|Hit| APP
REDIS -->|Miss| DB[(Database\n10-100ms)]
DB --> REDIS
REDIS --> APP
APP --> L1
APP --> CDN
APP --> RESP
| 層 | テクノロジー | レイテンシ | 容量 | 共有 |
|---|---|---|---|---|
| L1:プロセス内 | Node.js Map、Python dict、LRU-cache | 約 0ms | 小(MB) | いいえ — インスタンスごと |
| L2:分散型 | Redis、Memcached | 1~5ms | 大(GB) | はい — すべてのインスタンス |
| L3:エッジ/CDN | Cloudflare、Fastly、CloudFront | 10~100ms | 非常に大きい | はい — グローバル |
ルール:データはまず 1 か所で変更されます。無効化は外側に向かって伝播します:DB → Redis → CDN。無効化の際に層を飛ばしてはいけません。
キャッシュアサイドパターン(最も一般的)
アプリケーションがキャッシュを明示的に管理します。読み取り時は、キャッシュを確認し、キャッシュミスなら DB から取得してキャッシュに格納し、返します。書き込み時は、DB を更新してキャッシュエントリーを削除します。
class UserCache {
private redis: Redis;
private readonly TTL_SECONDS = 300; // 5 minutes
async getUser(userId: string): Promise<User> {
const key = `user:${userId}`;
// 1. Check cache
const cached = await this.redis.get(key);
if (cached) return JSON.parse(cached);
// 2. Cache miss — fetch from source
const user = await db.users.findById(userId);
if (!user) throw new NotFoundError('User', userId);
// 3. Populate cache
await this.redis.setex(key, this.TTL_SECONDS, JSON.stringify(user));
return user;
}
async updateUser(userId: string, data: Partial<User>): Promise<User> {
const user = await db.users.update(userId, data);
// 4. Invalidate — delete, don't update
// Updating in cache risks race conditions; let the next read repopulate
await this.redis.del(`user:${userId}`);
return user;
}
}
無効化時に削除するか上書きするか:ほとんどの場合、削除が正解です。書き込み後にキャッシュを上書きすると競合が発生する可能性があります。DB への書き込みからキャッシュへの書き込みまでの間に、別のリクエストが古い値を取得しているかもしれません。削除すれば、次の読み取りで必ず最新のデータが取得されます。
ライトスルーパターン
すべての書き込みは、キャッシュと DB の両方に同期的に行われます。キャッシュには常にデータが格納されます。一度だけ書き込まれ、その後何度も読み取られるデータに適しています。
async function createProduct(data: CreateProductInput): Promise<Product> {
// Write to DB first (source of truth)
const product = await db.products.create(data);
// Immediately populate cache — no future cache miss for this product
const key = `product:${product.id}`;
await redis.setex(key, 3600, JSON.stringify(product));
// Also invalidate list caches that include this product
await redis.del('products:list:*'); // pattern delete via SCAN, see redis-patterns.md
return product;
}
トレードオフ:書き込みレイテンシが増加します(1 回の操作で 2 回書き込みます)。作成後に再び読み取られないアイテムには、キャッシュ領域が無駄になります。読み取りと書き込みの比率が高いデータに最適です。
TTL の設計
TTL はキャッシュ無効化の戦略ではなく、許容できるデータの古さの上限です。データの変動性と許容できる古さに基づいて TTL を設計します。
| データの種類 | TTL | 理由 |
|---|---|---|
| ユーザーセッショントークン | セッションの有効期限に合わせる | セキュリティ要件 |
| ユーザープロフィール(名前、アバター) | 5~15 分 | 変更頻度は低く、応答性を保てる程度に短い |
| 商品カタログ | 1~4 時間 | 変更頻度は低く、遅延が許容される |
| 在庫数 | 30 秒 | 頻繁に変わるため短くするが、ゼロにはしない |
| 為替レート | 60 秒 | 規制上、古くなりすぎてはいけない |
| 静的設定/機能フラグ | 60 秒 + Pub/Sub による無効化 | 変更時にプッシュ型の無効化が必要 |
| 計算済み集計値(日次統計) | 次回の計算まで | 再計算時に明示的に無効化 |
TTL のジッター:多くのキーが同じ TTL を持つと、同時に期限切れになり、大量のリクエストが一斉に発生します。ランダムなジッターを加えます。
const jitter = Math.floor(Math.random() * 60); // 0-60 seconds
await redis.setex(key, baseTtl + jitter, value);
キャッシュスタンピードの防止
スタンピード(ドッグパイル、サンダリングハードとも呼ばれます)は、多数のリクエストが期限切れのキャッシュキーに同時にアクセスし、すべてがその値の計算や取得に殺到するときに発生します。
戦略 1:確率的な早期有効期限切れ(XFetch)
キーの有効期限が近づくほど高い確率で、有効期限が切れる前に再取得します。
async function getWithEarlyExpiry<T>(
key: string,
fetcher: () => Promise<T>,
ttlSeconds: number,
beta = 1.0
): Promise<T> {
const entry = await redis.get(key + ':meta');
if (entry) {
const { value, expiresAt, fetchDurationMs } = JSON.parse(entry);
const now = Date.now();
const ttlRemaining = expiresAt - now;
// Fetch early if within probabilistic window
const shouldRefetch = ttlRemaining < beta * fetchDurationMs * Math.log(Math.random());
if (!shouldRefetch) return value;
}
// Fetch and cache
const start = Date.now();
const value = await fetcher();
const fetchDurationMs = Date.now() - start;
const expiresAt = Date.now() + ttlSeconds * 1000;
await redis.setex(key + ':meta', ttlSeconds, JSON.stringify({ value, expiresAt, fetchDurationMs }));
return value;
}
戦略 2:ミス時のミューテックスロック
1 つのワーカーだけが値を再計算し、ほかのワーカーはロックを待つか、古いデータを返します:
async function getWithLock<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const lockAcquired = await redis.set(lockKey, '1', 'NX', 'PX', 5000); // 5s TTL
if (!lockAcquired) {
// Another worker is computing — poll briefly then return stale or throw
await sleep(100);
const retried = await redis.get(key);
if (retried) return JSON.parse(retried);
throw new Error('Cache unavailable');
}
try {
const value = await fetcher();
await redis.setex(key, ttl, JSON.stringify(value));
return value;
} finally {
await redis.del(lockKey);
}
}
このロックの Lua によるアトミックなバージョン(誤ったクライアントによるロック解放を防止)については、references/redis-patterns.md を参照してください。
アンチパターン
アンチパターン:すべてを永久にキャッシュする
初心者:「キャッシュすれば高速になります。キャッシュヒット率を最大化するため、TTL を 0(有効期限なし)または 1 年に設定します。」
エキスパート:上限のないキャッシュは、手順が増えただけのメモリリークです。また、古いデータが確実に残り、ユーザーは数か月前の価格、権限、コンテンツを目にすることになります。「なぜこのユーザーに古いプランの上限が表示されているのか」という本番環境のインシデントは、ほとんどの場合、キャッシュを永久に保持するバグが原因です。
// Wrong — no expiry means the cache grows forever
await redis.set(`user:${id}`, JSON.stringify(user)); // no TTL
// Right — every cache entry has a maximum lifetime
await redis.setex(`user:${id}`, 300, JSON.stringify(user)); // 5 minutes
Python での同等の例:
# Wrong
redis.set(f"user:{id}", json.dumps(user))
# Right
redis.setex(f"user:{id}", 300, json.dumps(user))
検出方法:EX/PX/EXAT オプションなしでの redis.set(key, value)。キャッシュキーに対する Redis の TTL key の戻り値が -1。時間の経過とともにメモリ使用量が増え続け、頭打ちにならないこと。
経緯:これは昔から誤りですが、Redis のデフォルトが有効期限なしのため、誤って設定しやすくなっています。Redis 7.0(2022 年)ではキーの退避ポリシーがデフォルトで導入され、深刻度は下がりましたが、それでも古いデータは残ります。
アンチパターン:無効化戦略がない
初心者:「TTL を短く設定すれば、古いデータの問題は自然に解決します。」
エキスパート:TTL のみで無効化すると、データに変更があるたびに、TTL と同じ長さの伝播遅延が発生します。一部のデータ(ユーザーのロール、権限、セール終了後の価格など)では、この遅延は許容できません。さらに悪いことに、これは文書化されない暗黙の契約を生み出します。その後、チームがパフォーマンス向上のために TTL を延長し、古いデータが残る期間を大幅に延ばしてしまったことに気づかない場合もあります。
// Problem: user loses admin role, but can still access admin routes for 5 minutes
await redis.setex(`user:permissions:${id}`, 300, JSON.stringify(permissions));
// Right: invalidate explicitly on change
async function revokeAdminRole(userId: string) {
await db.userRoles.delete(userId, 'admin');
await redis.del(`user:permissions:${userId}`); // immediate invalidation
// Also publish to notify other app instances to clear L1 caches
await redis.publish('permissions:invalidated', userId);
}
LLM の誤り:単純なキャッシュアサイドの例では見えにくいため、LLM はコード生成時に無効化ロジックを省略することがよくあります。どのチュートリアルにも「書き込み時に設定する」例はありますが、「更新時に削除する」例はほとんどありません。
検出方法:書き込みパスに対応する削除処理がないキャッシュ設定。ユーザーが制御するデータ(ロール、権限、設定)の退避手段が TTL のみ。コードベースの更新ハンドラーに DEL、UNLINK、または pub/sub イベントがないこと。
参考資料
references/redis-patterns.md— Redis 固有のパターン(リーダーボード用のソート済みセット、Lua のアトミック操作、pub/sub によるキャッシュ無効化、SCAN ベースのキー削除、パイプラインによるバッチ処理)については、こちらを参照してくださいreferences/http-caching.md— ブラウザーのキャッシュ(Cache-Control ディレクティブ、ETag、Vary ヘッダー、CDN の設定、サービスワーカーのキャッシュ戦略)については、こちらを参照してください