「キャッシュは銀の弾丸に見えるが、最も難しい問題の一つでもある」佐藤CTOは慎重な表情で語った。「“There are only two hard things in Computer Science: cache invalidation and naming things.” — Phil Karlton。キャッシュを正しく使えるかどうかが、シニアエンジニアの試金石だ。」
1. キャッシュパターン一覧
Cache-Aside(Lazy Loading)
最も一般的なパターン。アプリケーションがキャッシュを直接管理する。
class CacheAsideRepository<T> {
constructor(
private cache: CacheClient,
private db: DatabaseClient,
private ttlSeconds: number = 300
) {}
async findById(id: string): Promise<T | null> {
// 1. キャッシュを確認
const cached = await this.cache.get(`entity:${id}`);
if (cached) {
return JSON.parse(cached) as T;
}
// 2. キャッシュミス → DBから取得
const entity = await this.db.findById(id);
if (!entity) return null;
// 3. キャッシュに保存
await this.cache.set(
`entity:${id}`,
JSON.stringify(entity),
'EX',
this.ttlSeconds
);
return entity;
}
async update(id: string, data: Partial<T>): Promise<T> {
// DBを更新
const updated = await this.db.update(id, data);
// キャッシュを無効化(更新ではなく削除)
await this.cache.del(`entity:${id}`);
return updated;
}
}
Read-Through
キャッシュ層がDB読み取りを代行する。
class ReadThroughCache<T> {
constructor(
private cache: CacheClient,
private loader: (key: string) => Promise<T | null>,
private ttlSeconds: number = 300
) {}
async get(key: string): Promise<T | null> {
// キャッシュがloaderを自動的に呼び出す
const cached = await this.cache.get(key);
if (cached) return JSON.parse(cached) as T;
// キャッシュ層がDBロードを実行
const value = await this.loader(key);
if (value) {
await this.cache.set(key, JSON.stringify(value), 'EX', this.ttlSeconds);
}
return value;
}
}
// 使用例: アプリケーションはキャッシュ層のみとやり取り
const userCache = new ReadThroughCache<User>(
redisClient,
async (key) => {
const userId = key.replace('user:', '');
return db.users.findUnique({ where: { id: userId } });
},
600
);
Write-Through
書き込み時にキャッシュとDBを同時に更新する。
class WriteThroughCache<T extends { id: string }> {
constructor(
private cache: CacheClient,
private db: DatabaseClient,
private ttlSeconds: number = 300
) {}
async write(entity: T): Promise<T> {
// DB とキャッシュを同期的に更新
const saved = await this.db.save(entity);
await this.cache.set(
`entity:${saved.id}`,
JSON.stringify(saved),
'EX',
this.ttlSeconds
);
return saved;
}
async read(id: string): Promise<T | null> {
const cached = await this.cache.get(`entity:${id}`);
if (cached) return JSON.parse(cached) as T;
// キャッシュミス時はDBから読み取り
const entity = await this.db.findById(id);
if (entity) {
await this.cache.set(
`entity:${id}`,
JSON.stringify(entity),
'EX',
this.ttlSeconds
);
}
return entity;
}
}
Write-Behind (Write-Back)
書き込みをキャッシュに即時反映し、DBへの永続化は非同期で行う。
class WriteBehindCache<T extends { id: string }> {
private writeQueue: Array<{ key: string; value: T }> = [];
private flushInterval: NodeJS.Timeout;
constructor(
private cache: CacheClient,
private db: DatabaseClient,
private flushIntervalMs: number = 1000,
private maxBatchSize: number = 100
) {
this.flushInterval = setInterval(() => this.flush(), flushIntervalMs);
}
async write(entity: T): Promise<void> {
// キャッシュに即時書き込み
await this.cache.set(
`entity:${entity.id}`,
JSON.stringify(entity)
);
// 書き込みキューに追加
this.writeQueue.push({ key: entity.id, value: entity });
// バッチサイズに達したら即時フラッシュ
if (this.writeQueue.length >= this.maxBatchSize) {
await this.flush();
}
}
private async flush(): Promise<void> {
if (this.writeQueue.length === 0) return;
const batch = this.writeQueue.splice(0, this.maxBatchSize);
try {
// バルクでDBに書き込み
await this.db.bulkUpsert(batch.map(item => item.value));
} catch (error) {
// 失敗した場合、キューに戻す(リトライ)
this.writeQueue.unshift(...batch);
console.error('Write-behind flush failed, will retry:', error);
}
}
async destroy(): Promise<void> {
clearInterval(this.flushInterval);
await this.flush(); // 残りをフラッシュ
}
}
2. パターン比較
| パターン | 読取性能 | 書込性能 | データ整合性 | 複雑さ | 適用場面 |
|---|---|---|---|---|---|
| Cache-Aside | 高い(ヒット時) | 中程度 | 中程度 | 低い | 汎用的、読み取り多 |
| Read-Through | 高い | - | 中程度 | 中程度 | 読み取り多、透過的 |
| Write-Through | 高い | 低い(同期) | 高い | 中程度 | 整合性重視 |
| Write-Behind | 高い | 高い(非同期) | 低い | 高い | 書き込み多、整合性低くてもOK |
3. キャッシュ無効化戦略
// キャッシュ無効化パターン
class CacheInvalidator {
constructor(private cache: CacheClient) {}
// パターン1: TTL ベース(時間ベースの自動失効)
async setWithTTL(key: string, value: string, ttlSeconds: number): Promise<void> {
await this.cache.set(key, value, 'EX', ttlSeconds);
}
// パターン2: イベントベースの無効化
async onEntityUpdated(entityType: string, entityId: string): Promise<void> {
// 直接キーの削除
await this.cache.del(`${entityType}:${entityId}`);
// 関連するリストキャッシュも無効化
const pattern = `${entityType}:list:*`;
const keys = await this.cache.keys(pattern);
if (keys.length > 0) {
await this.cache.del(...keys);
}
}
// パターン3: バージョニング
async setVersioned(key: string, value: string, version: number): Promise<void> {
const versionedKey = `${key}:v${version}`;
await this.cache.set(versionedKey, value, 'EX', 86400);
await this.cache.set(`${key}:current_version`, String(version));
}
async getVersioned(key: string): Promise<string | null> {
const version = await this.cache.get(`${key}:current_version`);
if (!version) return null;
return this.cache.get(`${key}:v${version}`);
}
// パターン4: タグベースの無効化
async setWithTags(key: string, value: string, tags: string[]): Promise<void> {
await this.cache.set(key, value);
for (const tag of tags) {
await this.cache.sadd(`tag:${tag}`, key);
}
}
async invalidateByTag(tag: string): Promise<number> {
const keys = await this.cache.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
await this.cache.del(...keys);
await this.cache.del(`tag:${tag}`);
return keys.length;
}
}
4. TTL 設計のベストプラクティス
| データ種別 | 推奨 TTL | 理由 |
|---|---|---|
| 静的マスタデータ | 24時間 | 変更頻度が低い |
| ユーザープロフィール | 5〜15分 | 適度に新鮮なデータ |
| セッション情報 | 30分〜2時間 | セキュリティ要件に依存 |
| 検索結果 | 1〜5分 | 新鮮さとのトレードオフ |
| ランキング/集計 | 5〜30分 | 計算コストが高い |
| 設定値 | 1〜5分 | 即時反映が望ましい |
// TTL にジッター(ランダムな揺らぎ)を加える
function ttlWithJitter(baseTtlSeconds: number, jitterPercent: number = 10): number {
const jitter = baseTtlSeconds * (jitterPercent / 100);
const randomJitter = Math.random() * jitter * 2 - jitter;
return Math.max(1, Math.round(baseTtlSeconds + randomJitter));
}
// 例: 300秒 ± 10% → 270〜330秒のランダムなTTL
// これにより Cache Stampede(同時期の大量キャッシュ失効)を防止
コラム: キャッシュの一貫性問題
分散システムでのキャッシュ一貫性には根本的な難しさがある。
問題: レースコンディション
Thread A: DB更新 → (ここでThread Bが割り込み) → キャッシュ削除
Thread B: DB読み取り(古い値) → キャッシュ書き込み(古い値)
結果: キャッシュに古い値が残る
対策:
- ダブルデリート: 更新時にキャッシュ削除 → 一定時間後に再度削除
- 遅延削除: 更新後に少し待ってからキャッシュ削除
- リース(Lease)方式: キャッシュミス時にリースを取得し、他のスレッドの書き込みを防止
- 最終的な整合性を受け入れる: TTL で自然に収束させる
まとめ
| トピック | 要点 |
|---|---|
| Cache-Aside | アプリが直接管理、最も一般的 |
| Read/Write-Through | キャッシュ層が透過的に仲介 |
| Write-Behind | 非同期書き込みで高スループット、整合性は弱い |
| 無効化戦略 | TTL/イベント/バージョニング/タグベース |
| TTL 設計 | データ特性に応じた設定、ジッターで同時失効を防止 |
チェックリスト
- 4つのキャッシュパターンの違いを説明できる
- ユースケースに応じたパターン選択ができる
- キャッシュ無効化の4つの戦略を理解した
- TTL 設計のベストプラクティスを知っている
- キャッシュの一貫性問題を認識している
次のステップへ
キャッシュの基礎パターンを学んだ。次は 分散キャッシュ(Redis) の具体的なアーキテクチャと運用を深掘りしよう。
推定読了時間: 40分