EXERCISE 90分

ストーリー

高橋アーキテクトが設計書を広げた。

高橋アーキテクト
ECサイト”SpeedShop”のパフォーマンスを改善する。月間100万PV、商品数5万件、ピーク時3000 RPSのサイトだ。キャッシュ戦略をゼロから設計してもらう
あなた
実際のアーキテクチャ設計ですね…
高橋アーキテクト
そうだ。理論を知っているだけでは意味がない。手を動かして、トレードオフを肌で感じろ

ミッション概要

ECサイト “SpeedShop” のキャッシュアーキテクチャを設計する6つのミッションに挑戦します。

ミッションテーマ難易度
Mission 1キャッシュ対象の選定初級
Mission 2Cache-Aside の実装初級
Mission 3多層キャッシュの設計中級
Mission 4キャッシュ無効化戦略中級
Mission 5Thundering Herd 対策上級
Mission 6総合設計ドキュメント上級

Mission 1: キャッシュ対象の選定(10分)

以下のSpeedShopのデータについて、キャッシュすべきかどうか、すべき場合のTTLを決定してください。

データアクセス頻度更新頻度リアルタイム性
商品一覧(カテゴリ別)非常に高い1日数回
商品詳細高い数日に1回
在庫数高いリアルタイム
カート内容随時
ユーザーセッション高い随時
注文履歴低いほぼなし
ランキング非常に高い1時間ごと
解答例
データキャッシュTTL戦略理由
商品一覧する5分Cache-Aside + イベント無効化高アクセス、変更時に即座に反映
商品詳細する30分Cache-Aside + イベント無効化高アクセス、変更頻度低い
在庫数する10秒Write-Throughリアルタイム性が必要だが短TTL
カート内容する30分Write-Throughセッション紐付き、整合性重要
ユーザーセッションする30分Write-Through認証に必要、整合性重要
注文履歴する1時間Cache-Aside低アクセスだが変更もない
ランキングする10分Cache-Aside高アクセス、1時間更新なら10分TTLで十分

Mission 2: Cache-Aside の実装(15分)

商品詳細のキャッシュをCache-Asideパターンで実装してください。

要件

  • Redisをキャッシュストアとして使用
  • TTLは30分
  • キャッシュミス時はDBから取得
  • エラーハンドリング(Redis障害時はDBフォールバック)
// この関数を完成させてください
class ProductCacheRepository {
  constructor(
    private redis: RedisClient,
    private db: Database,
  ) {}

  async getProduct(id: string): Promise<Product> {
    // TODO: Cache-Aside パターンを実装
  }
}
解答例
class ProductCacheRepository {
  private readonly TTL_SECONDS = 1800; // 30分

  constructor(
    private redis: RedisClient,
    private db: Database,
  ) {}

  async getProduct(id: string): Promise<Product> {
    const cacheKey = `product:${id}`;

    // 1. キャッシュから取得を試みる
    try {
      const cached = await this.redis.get(cacheKey);
      if (cached) {
        return JSON.parse(cached) as Product;
      }
    } catch (error) {
      // Redis障害時はログを出してDBフォールバック
      console.warn(`Cache read failed for ${cacheKey}:`, error);
    }

    // 2. DBから取得
    const product = await this.db.findProduct(id);
    if (!product) {
      throw new NotFoundError(`Product ${id} not found`);
    }

    // 3. キャッシュに保存(失敗してもDBからの結果は返す)
    try {
      await this.redis.setex(
        cacheKey,
        this.TTL_SECONDS,
        JSON.stringify(product)
      );
    } catch (error) {
      console.warn(`Cache write failed for ${cacheKey}:`, error);
    }

    return product;
  }
}

ポイント:

  • Redis障害時にもサービスが継続できるようtry-catchでラップ
  • キャッシュはあくまで高速化の手段であり、障害時はDBにフォールバック

Mission 3: 多層キャッシュの設計(20分)

SpeedShopの商品ページに対して、4層のキャッシュ設計を行ってください。

要件

各層(ブラウザ、CDN、アプリケーション、DB)について以下を定義:

  1. 何をキャッシュするか
  2. TTLをいくらにするか
  3. Cache-Controlヘッダーの設定
解答例
// Layer 1: ブラウザキャッシュ設定
const browserCacheConfig = {
  // 静的アセット(CSS, JS, 画像)
  staticAssets: {
    cacheControl: 'public, max-age=31536000, immutable',
    // ファイル名にハッシュを含める: style.abc123.css
  },
  // 商品ページHTML
  productPage: {
    cacheControl: 'private, max-age=0, must-revalidate',
    etag: true, // ETagで変更確認
  },
  // API レスポンス(商品詳細)
  productApi: {
    cacheControl: 'private, max-age=60, stale-while-revalidate=300',
    // 1分キャッシュ、5分間は古いデータを返しつつ裏で更新
  },
};

// Layer 2: CDNキャッシュ設定
const cdnConfig = {
  staticAssets: {
    ttl: 86400 * 365, // 1年(キャッシュバスティングで管理)
    compress: true,
  },
  productImages: {
    ttl: 86400,       // 24時間
    compress: true,
    imageOptimization: true,
  },
  productPageHtml: {
    ttl: 60,          // 1分
    varyHeaders: ['Accept-Encoding'],
  },
};

// Layer 3: アプリケーションキャッシュ(Redis)
const appCacheConfig = {
  productDetail: { ttl: 1800 },    // 30分
  productList: { ttl: 300 },       // 5分
  ranking: { ttl: 600 },           // 10分
  userSession: { ttl: 1800 },      // 30分
};

// Layer 4: データベースキャッシュ
const dbConfig = {
  sharedBuffers: '4GB',
  effectiveCacheSize: '12GB',
  // 商品テーブルのインデックスがバッファに乗るように設計
};

Mission 4: キャッシュ無効化戦略(15分)

商品情報が更新された場合のキャッシュ無効化フローを設計してください。

シナリオ

管理者が商品の価格を変更した場合:

  1. どのキャッシュを無効化するか
  2. どの順序で無効化するか
  3. CDNキャッシュはどう扱うか
解答例
class ProductCacheInvalidator {
  async onProductUpdated(event: ProductUpdatedEvent): Promise<void> {
    const { productId, categoryId, oldPrice, newPrice } = event;

    // Step 1: アプリケーションキャッシュの無効化(即座に)
    await Promise.all([
      this.redis.del(`product:${productId}`),
      this.redis.del(`product-list:category:${categoryId}`),
      this.redis.del('ranking:sales'),
      this.redis.del('ranking:popular'),
    ]);

    // Step 2: CDNキャッシュの無効化
    await this.cdn.invalidate([
      `/products/${productId}`,
      `/api/products/${productId}`,
      `/categories/${categoryId}`,
    ]);

    // Step 3: ブラウザキャッシュは直接制御できない
    // → ETagの更新で次回アクセス時に検証
    // → WebSocket/SSEで接続中のユーザーに通知(オプション)

    // Step 4: 監査ログ
    await this.auditLog.record({
      action: 'cache_invalidation',
      trigger: 'product_price_change',
      productId,
      oldPrice,
      newPrice,
      timestamp: new Date(),
    });
  }
}

無効化の順序:

  1. アプリケーションキャッシュ(Redis) → 最も制御しやすく即座に反映
  2. CDNキャッシュ → API経由で無効化リクエスト(数秒かかる)
  3. ブラウザキャッシュ → 直接制御不可、ETag/Cache-Controlで間接制御

Mission 5: Thundering Herd 対策(15分)

セール開始時(0時0分)に予想される Thundering Herd に対する防御策を実装してください。

要件

  • ランキングページのキャッシュTTLが0時にちょうど切れる
  • ピーク時3000 RPSが予想される
  • DBへの同時クエリは最大10に制限したい
解答例
class SaleEventCacheProtection {
  private locks = new Map<string, Promise<string>>();

  // Singleflight パターン: 同じキーへの同時リクエストを1つにまとめる
  async getWithSingleflight(
    key: string,
    fetchFn: () => Promise<string>,
    ttlSeconds: number
  ): Promise<string> {
    // 1. キャッシュ確認
    const cached = await this.redis.get(key);
    if (cached) return cached;

    // 2. 既に同じキーのフェッチが進行中なら、その結果を待つ
    if (this.locks.has(key)) {
      return this.locks.get(key)!;
    }

    // 3. フェッチを開始し、他のリクエストと共有
    const fetchPromise = (async () => {
      try {
        const result = await fetchFn();
        await this.redis.setex(key, ttlSeconds, result);
        return result;
      } finally {
        this.locks.delete(key);
      }
    })();

    this.locks.set(key, fetchPromise);
    return fetchPromise;
  }

  // セール前のキャッシュウォームアップ
  async warmupBeforeSale(): Promise<void> {
    const popularKeys = [
      'ranking:sales',
      'ranking:popular',
      'sale-products:page1',
      'sale-products:page2',
      'sale-products:page3',
    ];

    for (const key of popularKeys) {
      const data = await this.fetchFromDB(key);
      // TTLをセール開始後も持続するよう長めに設定
      await this.redis.setex(key, 7200, data); // 2時間
    }

    console.log(`Warmed up ${popularKeys.length} cache keys before sale`);
  }
}

対策のまとめ:

  1. 事前ウォームアップ — セール開始前にキャッシュを温めておく
  2. Singleflight — 同じキーのリクエストを1つにまとめる
  3. TTLのジッター — 全キーが同時に切れないようランダムなオフセットを追加

Mission 6: 総合設計ドキュメント(15分)

これまでの設計をまとめた1ページの設計ドキュメントを書いてください。

記載項目

  1. アーキテクチャ図(テキストで可)
  2. 各層のキャッシュ設定一覧
  3. 無効化戦略
  4. 障害時のフォールバック方針
  5. モニタリング指標
解答例
# SpeedShop キャッシュ設計書

## 1. アーキテクチャ

ユーザー → [ブラウザキャッシュ] → [CDN (CloudFront)]
  → [ALB] → [App Server × 3] → [Redis Cluster]
  → [PostgreSQL (Primary + Read Replica)]

## 2. キャッシュ設定

| 層 | 対象 | TTL | 戦略 |
|----|------|-----|------|
| ブラウザ | 静的アセット | 1年(immutable) | キャッシュバスティング |
| ブラウザ | API | 1分 | stale-while-revalidate |
| CDN | 画像 | 24時間 | パス無効化 |
| CDN | HTML | 1分 | パス無効化 |
| Redis | 商品詳細 | 30分 | Cache-Aside + イベント無効化 |
| Redis | 一覧/ランキング | 5-10分 | Cache-Aside |
| Redis | セッション | 30分 | Write-Through |
| DB | クエリキャッシュ | 自動 | PostgreSQL設定 |

## 3. 無効化戦略
- 商品更新: イベントベース(Redis即削除 → CDN無効化)
- 在庫変更: Write-Through + 短TTL(10秒)
- ランキング: TTLベース(10分) + 定期バッチ更新

## 4. 障害時フォールバック
- Redis障害: DBフォールバック + アラート
- CDN障害: オリジン直接アクセス
- DB障害: キャッシュのみで読み取り継続(書き込み不可)

## 5. モニタリング指標
- キャッシュヒット率: 目標95%以上
- p99レイテンシ: 目標500ms以下
- Redis メモリ使用率: 閾値80%でアラート
- DB接続数: 閾値80%でアラート

達成度チェック

ミッションテーマ完了
Mission 1キャッシュ対象の選定[ ]
Mission 2Cache-Aside の実装[ ]
Mission 3多層キャッシュの設計[ ]
Mission 4キャッシュ無効化戦略[ ]
Mission 5Thundering Herd 対策[ ]
Mission 6総合設計ドキュメント[ ]

チェックリスト

  • データの特性に応じてキャッシュ戦略を選択できる
  • Cache-Asideパターンをエラーハンドリング付きで実装できる
  • 多層キャッシュの各層の設定を定義できる
  • 無効化フローを設計できる
  • Thundering Herd対策を実装できる

次のステップへ

お疲れさまでした。キャッシュ設計の実践力が身についたはずです。

次のセクションでは、Step 2の理解度チェックです。


推定所要時間: 90分