目次を表示する

DB 設計の軸 2026 ─ ドメイン駆動と特性駆動の二つの流派を行き来する 19 章

Cache 設計の意思決定 ─ Cache-aside・TTL・Stampede・Key 設計

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)
ランキング、leaderboard1 - 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 のフォーマットを変えるとき、v1v2 に切り替えるだけで旧キャッシュは自然に 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 年にツイートした”あの発言”も伏線として置いておく。