Cache 設計の意思決定
Cache の本質を踏まえて、実際に Cache を設計する場面の 5 つの意思決定を扱う。
判断 1:書き込み戦略 ─ Cache-aside / Write-through / Write-behind
Cache とソース DB の間で「いつ書き、いつ読むか」を決めるパターン。
Cache-aside(最も一般的)
async function getUser(id: string): Promise<User> {
// 1. Cache に問い合わせ
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
// 2. miss なら DB から取得
const user = await db.findUser(id);
// 3. Cache に保存
await redis.setex(`user:${id}`, 300, JSON.stringify(user));
return user;
}
async function updateUser(id: string, data: UserUpdate): Promise<void> {
// DB を更新
await db.updateUser(id, data);
// Cache を invalidate
await redis.del(`user:${id}`);
}
pros: シンプル、Cache を信頼しなくていい cons: 初回 miss、更新後の race condition
Write-through
async function updateUser(id: string, data: UserUpdate): Promise<void> {
// Cache と DB を両方書く
await db.updateUser(id, data);
await redis.setex(`user:${id}`, 300, JSON.stringify({ ...data, id }));
}
pros: Cache が常に新しい cons: Cache 書き込みが必須、両方失敗時の整合性
Write-behind(Write-back)
Cache だけ書き、DB へはバックグラウンドで非同期 flush。
pros: 最速の write cons: Cache 落ちでデータ消失リスク。ジョブキュー的用途のみ
判断軸
graph TB
Q{用途は?}
Q -->|読み中心、DB 信頼| CA[Cache-aside]
Q -->|常に新しい Cache が欲しい| WT[Write-through]
Q -->|超高速 write、ロス許容| WB[Write-behind]
style CA fill:#e1ffe1
デフォルトは Cache-aside。それで困ってから他を検討する。
判断 2:TTL の決め方
「とりあえず 60 秒」「3600 秒」と適当に決めがちだが、TTL は データの “古さ許容度” で決まる。
TTL の決め方フレーム
| データの性質 | TTL の目安 |
|---|---|
| 設定値、フィーチャーフラグ | 数秒 - 数十秒 |
| ユーザーセッション | 30 分 - 24 時間 |
| 商品マスタ | 数分 - 1 時間 |
| 認証トークン | 短い(更新時に refresh) |
| ランキング、leaderboard | 1 - 5 分 |
| 静的コンテンツ | 1 時間 - 1 日 |
| ユーザープロファイル | 数分(更新時に invalidate) |
動的 TTL ─ jitter を加える
全 cache key が同じ TTL だと、同時に expire する。これが次節の Stampede の元。
// ❌ 固定 TTL
await redis.setex(key, 300, value);
// ✅ jitter を加える
const ttl = 300 + Math.floor(Math.random() * 60); // 300-360 秒
await redis.setex(key, ttl, value);
これで expire のタイミングが分散し、同時 miss を避けられる。
判断 3:Cache Stampede 対策
Stampede(殺到):人気のキャッシュが expire した瞬間、複数のリクエストが同時に miss し、全員が DB に殺到する現象。
sequenceDiagram
participant U1 as Request 1
participant U2 as Request 2
participant U3 as Request 3
participant C as Cache
participant DB as DB
U1->>C: GET key
C-->>U1: miss (expired)
U2->>C: GET key
C-->>U2: miss
U3->>C: GET key
C-->>U3: miss
par 全員 DB に殺到
U1->>DB: query
U2->>DB: query
U3->>DB: query
end
Note over DB: 同じクエリが 3 倍走る → DB 過負荷
実運用では3 つどころでは済まない。秒間 1000 リクエストの hot key が 1 件 expire すると、1000 個のクエリが DB に殺到する。
対策 1:Lock + Wait
最初に miss したリクエストだけが DB を引き、他は待つ。
async function getWithLock(key: string): Promise<any> {
const cached = await redis.get(key);
if (cached) return cached;
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);
if (acquired) {
try {
const value = await loadFromDB(key);
await redis.setex(key, 300, value);
return value;
} finally {
await redis.del(lockKey);
}
} else {
// ロックが取れなかったら少し待ってから再試行
await sleep(100);
return getWithLock(key);
}
}
対策 2:Probabilistic Early Refresh
TTL の終盤に確率的に先に refresh する。
function shouldEarlyRefresh(ttlRemaining: number, ttlOrig: number): boolean {
// 残り 20% 以下になったら、確率的に refresh
if (ttlRemaining > ttlOrig * 0.2) return false;
return Math.random() < (1 - ttlRemaining / (ttlOrig * 0.2));
}
これで expire 前に少しずつリフレッシュされ、stampede が起きない。
対策 3:背景再計算(Stale-while-revalidate)
Cache が expire しても古い値を返しつつ、バックグラウンドで再計算する。HTTP の stale-while-revalidate と同じ発想。
判断 4:Key 命名と namespace
Cache key は「なんとなく」付けると後で爆発する。
❌ アンチパターン
user_profile_123_v2_temp_test
意味が混在、namespace なし、version 不明、TTL 戦略不明。
✅ 命名規則
<service>:<entity>:<id>[:<modifier>][:v<n>]
例:
billing:invoice:abc123 # 請求書本体
billing:invoice:abc123:items # 請求書の項目
billing:invoice:abc123:v2 # スキーマ v2
auth:session:tok_xyz # セッション
ratelimit:user:123:1715234400 # レートリミット
version を入れる のが特に重要。Cache のフォーマットを変えるとき、v1 → v2 に切り替えるだけで旧キャッシュは自然に expire していく。
判断 5:Redis Cluster での hash tag 設計
第 8 章で触れた hash tag を、設計判断として再考する。
Hash tag を使うとき
複数キーの atomic 操作(MGET / MULTI/EXEC / EVAL)が必要なとき:
// 同じユーザーのデータをまとめて操作
await redis.mset([
'user:{123}:profile', profileData,
'user:{123}:settings', settingsData,
]);
// MULTI/EXEC で atomic
await redis.multi()
.hincrby('user:{123}:counters', 'login', 1)
.expire('user:{123}:counters', 86400)
.exec();
Hash tag の罠
// ❌ 全部同じ tag → 単一ノードに集中
'user:{prod}:1', 'user:{prod}:2', 'user:{prod}:3', ...
// → 'prod' tag のスロットが Hot に
// ✅ ID 単位で tag を分割
'user:{1}:profile', 'user:{2}:profile', ...
// → ID で分散、ユーザーごとには atomic 操作可能
hash tag の粒度 = atomic 操作の境界。Aggregate のような感覚で設計する。
Cache 設計の意思決定マトリクス
graph TB
Q1[書き込み戦略] --> A1[Cache-aside がデフォルト]
Q2[TTL] --> A2[データ性質に応じて<br/>+ jitter]
Q3[Stampede 対策] --> A3[Lock or Probabilistic refresh]
Q4[Key 命名] --> A4[service:entity:id:modifier:vN]
Q5[Hash tag] --> A5[atomic 操作の境界に揃える]
style Q1 fill:#e1f5ff
style Q2 fill:#e1f5ff
style Q3 fill:#e1f5ff
style Q4 fill:#e1f5ff
style Q5 fill:#e1f5ff
ドメインから見た Cache
Cache はドメインの “詳細” に近い。Repository のような抽象を被せて、ドメイン層からは存在を隠すのが基本。
// ドメイン層
interface UserRepository {
findById(id: UserId): Promise<User | null>;
}
// インフラ層(Cache 込み)
class CachedUserRepository implements UserRepository {
constructor(
private readonly cache: Redis,
private readonly db: PostgresUserRepository,
) {}
async findById(id: UserId): Promise<User | null> {
// Cache-aside ロジック
}
}
これは Robert Martin が “Database is a Detail” と言った範囲そのもの。Cache レイヤーの存在で、ドメインモデルが汚染されるべきではない。
この章の要点
- 書き込み戦略は Cache-aside がデフォルト。Write-through / Write-behind は特殊用途
- TTL は データの古さ許容度 + jitter で決める
- Stampede 対策は Lock / Probabilistic Refresh / Stale-while-revalidate
- Key 命名は service:entity:id:modifier:vN で namespace と version を持つ
- Cluster の hash tag は atomic 操作の境界に揃える
- Cache はインフラの詳細。Repository で隠蔽してドメインを汚さない
次章への問いかけ
Cache は OLTP の hot 部分の投影だった。だが、最初から「OLTP 的な役割を NoSQL で担う」流派もある。
次章は KV / Document NoSQL の本質 ── DynamoDB の Partition と Eventual Consistency の世界。Houlihan が 2024 年にツイートした”あの発言”も伏線として置いておく。