EXERCISE 60分

Mission 1: キャッシュパターンの選択

以下の4つのシナリオに対して、最適なキャッシュパターン(Cache-Aside / Read-Through / Write-Through / Write-Behind)を選択し、理由を説明せよ。

  1. 商品カタログ API: 読み取り 95%、書き込み 5%、強い整合性不要
  2. ユーザーセッション管理: 読み書き同頻度、強い整合性必要
  3. IoT センサーデータ集約: 秒間10万件の書き込み、リアルタイム集計
  4. ニュースフィード生成: 読み取り多、計算コスト高、数分の遅延許容
解答例
// 1. 商品カタログ API → Cache-Aside
// 理由: 読み取り主体で最もシンプル。TTLベースの失効で十分。
// Write-Through の同期書き込みオーバーヘッドが不要。
class ProductCatalogCache {
  async getProduct(id: string): Promise<Product | null> {
    const cached = await redis.get(`product:${id}`);
    if (cached) return JSON.parse(cached);

    const product = await db.products.findById(id);
    if (product) {
      await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600);
    }
    return product;
  }
}

// 2. セッション管理 → Write-Through
// 理由: 読み書き同頻度で整合性重要。キャッシュとDBを同期更新。
// セッション更新が即座にキャッシュに反映される必要がある。
class SessionStore {
  async updateSession(sessionId: string, data: SessionData): Promise<void> {
    await db.sessions.upsert(sessionId, data);
    await redis.set(`session:${sessionId}`, JSON.stringify(data), 'EX', 7200);
  }
}

// 3. IoT データ集約 → Write-Behind
// 理由: 秒間10万件をDBに直接書くのは不可能。
// キャッシュにバッファし、バッチでDBに書き込む。
class IoTDataBuffer {
  private buffer: SensorReading[] = [];
  // 1秒ごとにバッチ書き込み
  async flush(): Promise<void> {
    const batch = this.buffer.splice(0, 10000);
    await db.sensorReadings.bulkInsert(batch);
  }
}

// 4. ニュースフィード → Read-Through + PER
// 理由: 計算コストが高く、数分の遅延が許容できる。
// PER で確率的に早期再計算し、Cache Stampede を防止。
class NewsFeedCache {
  async getFeed(userId: string): Promise<FeedItem[]> {
    return perCache.getOrSet(
      `feed:${userId}`,
      () => this.generateFeed(userId),
      300, // 5分 TTL
      1.5  // beta(やや積極的に再計算)
    );
  }
}

Mission 2: Redis キャッシュ設計

EC サイトの商品検索機能に対して、Redis を使ったキャッシュ設計を行え。

要件:
- 商品数: 100万件
- 検索 QPS: 5,000
- 平均レスポンスサイズ: 2KB
- 検索条件: カテゴリ、価格帯、キーワード、ソート順
- 商品情報の更新頻度: 平均1分に1件
- キャッシュ予算: Redis 8GB

設計すべき項目:

  1. キャッシュキーの設計
  2. TTL とエビクション戦略
  3. メモリ使用量の推定
  4. キャッシュヒット率の向上策
解答例
// 1. キャッシュキー設計
function buildSearchCacheKey(params: SearchParams): string {
  // 正規化してキーの一意性を保証
  const normalized = {
    cat: params.category || 'all',
    pmin: params.priceMin || 0,
    pmax: params.priceMax || 999999,
    q: (params.keyword || '').toLowerCase().trim(),
    sort: params.sortBy || 'relevance',
    page: params.page || 1,
    size: params.pageSize || 20,
  };

  // キーは短く、でも衝突しないように
  return `search:${JSON.stringify(normalized)}`;
  // もしくはハッシュ化: `search:${md5(JSON.stringify(normalized))}`
}

// 2. TTL とエビクション
// - 検索結果TTL: 60秒(1分に1件更新、許容範囲)
// - エビクション: allkeys-lfu(人気検索条件を優先保持)
// - 商品更新時はタグベースで関連キャッシュを無効化

// 3. メモリ使用量推定
// 検索結果キャッシュ:
//   - ユニーク検索条件/分: 5000QPS × 60s = 300,000リクエスト
//   - ユニーク率 30% → 90,000 ユニークキー
//   - キーサイズ: ~100B、値サイズ: ~2KB
//   - 90,000 × (100 + 2048) = ~193MB
//   - Redis オーバーヘッド 2x → ~400MB
// → 8GBの5%、余裕あり

// 商品個別キャッシュ:
//   - 人気商品 10万件 × 1KB = 100MB
//   - Redis オーバーヘッド 2x → 200MB
// 合計: ~600MB / 8GB → 十分な余裕

// 4. キャッシュヒット率向上策
class SearchCacheOptimizer {
  // 4a. 検索条件の正規化
  normalizeQuery(raw: string): string {
    return raw.toLowerCase().trim()
      .replace(/\s+/g, ' ')          // 連続スペースを1つに
      .replace(/[^\w\s]/g, '');       // 特殊文字除去
  }

  // 4b. 価格帯のバケット化(連続値をグループ化)
  bucketizePrice(price: number): number {
    if (price <= 1000) return Math.ceil(price / 100) * 100;
    if (price <= 10000) return Math.ceil(price / 500) * 500;
    return Math.ceil(price / 1000) * 1000;
  }

  // 4c. 人気検索のプリウォーム
  async prewarmPopularSearches(): Promise<void> {
    const popularSearches = await db.searchLogs
      .groupBy('normalized_query')
      .orderBy('count', 'desc')
      .limit(1000);

    for (const search of popularSearches) {
      await this.executeAndCache(search.params);
    }
  }

  // 4d. 関連キーワードの統合
  // "iPhone 15" と "iphone15" を同じキャッシュに
}

Mission 3: Cache-Control ヘッダー設計

以下の API エンドポイントに対して、適切な Cache-Control ヘッダーを設計せよ。

エンドポイント特性
GET /api/products/:id商品情報、更新頻度低
GET /api/users/meログインユーザー情報
GET /api/feedパーソナライズフィード
GET /api/categoriesマスタデータ、ほぼ不変
GET /api/products/:id/reviewsユーザーレビュー、頻繁に追加
POST /api/orders注文作成
解答例
const cacheHeaders: Record<string, string> = {
  // 商品情報: CDNでキャッシュ、ブラウザはSWRで更新
  'GET /api/products/:id':
    'public, max-age=60, s-maxage=300, stale-while-revalidate=600, stale-if-error=86400',

  // ログインユーザー情報: プライベート、短めTTL
  'GET /api/users/me':
    'private, max-age=60, must-revalidate',

  // パーソナライズフィード: CDNキャッシュ不可、ブラウザのみ短期キャッシュ
  'GET /api/feed':
    'private, no-cache, max-age=0',
  // Vary: Authorization, Accept-Language

  // マスタデータ: 長期キャッシュ、SWRで更新
  'GET /api/categories':
    'public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400',

  // レビュー: CDNで短期キャッシュ、SWRで鮮度と速度のバランス
  'GET /api/products/:id/reviews':
    'public, max-age=0, s-maxage=30, stale-while-revalidate=120',

  // 注文作成: キャッシュ禁止
  'POST /api/orders':
    'no-store',
};

// Vary ヘッダーも重要
const varyHeaders: Record<string, string[]> = {
  'GET /api/products/:id': ['Accept', 'Accept-Encoding'],
  'GET /api/users/me': ['Authorization'],
  'GET /api/feed': ['Authorization', 'Accept-Language'],
  'GET /api/categories': ['Accept-Language', 'Accept-Encoding'],
  'GET /api/products/:id/reviews': ['Accept-Encoding'],
};

Mission 4: Cache Stampede 対策

以下のシナリオで Cache Stampede が発生する状況を分析し、最適な防止策を実装せよ。

シナリオ:
- 人気商品ページのアクセス: 1,000 req/s
- 商品情報の計算コスト: 500ms(DB + 外部API + 集計)
- キャッシュ TTL: 60秒
- Redis の SETNX レイテンシ: 1ms
- 問題: TTL 失効の瞬間に 1,000 リクエストが同時に DB に殺到
解答例
// 最適解: Stale + バックグラウンド更新 + ロック
// 理由:
// - 500ms の計算コストは長い → stale データを返すのが最善
// - 1000 req/s → ロックだけでは待ちが発生
// - PER は確率的なので、1000req/sでは複数が同時に再計算してしまう可能性

class HotProductCache {
  private refreshing = new Map<string, Promise<ProductDetail>>();

  constructor(
    private redis: CacheClient,
    private lockTtlMs: number = 10000
  ) {}

  async getProduct(productId: string): Promise<ProductDetail | null> {
    const key = `product_detail:${productId}`;

    // 1. データ取得(fresh でも stale でも返す)
    const cached = await this.redis.get(key);
    if (cached) {
      const entry = JSON.parse(cached) as {
        data: ProductDetail;
        expiresAt: number;
      };

      // fresh ならそのまま返す
      if (Date.now() < entry.expiresAt) {
        return entry.data;
      }

      // stale だがデータはある → バックグラウンドで更新
      this.triggerBackgroundRefresh(productId, key);
      return entry.data;  // stale データを即時返却
    }

    // 2. 完全にキャッシュなし → ロック付きで取得
    return this.loadWithLock(productId, key);
  }

  private async triggerBackgroundRefresh(
    productId: string, key: string
  ): Promise<void> {
    // 既に更新中なら何もしない
    if (this.refreshing.has(key)) return;

    // 分散ロックを取得(他のインスタンスの重複更新を防止)
    const lockKey = `lock:refresh:${key}`;
    const locked = await this.redis.set(lockKey, '1', 'PX', this.lockTtlMs, 'NX');
    if (!locked) return;

    const promise = this.computeProductDetail(productId)
      .then(async (data) => {
        await this.redis.set(key, JSON.stringify({
          data,
          expiresAt: Date.now() + 60000, // 60秒後にstaleに
        }), 'EX', 300); // 300秒後にデータ自体が消える
      })
      .catch(err => console.error('Background refresh failed:', err))
      .finally(() => {
        this.refreshing.delete(key);
        this.redis.del(lockKey);
      });

    this.refreshing.set(key, promise);
  }

  private async loadWithLock(
    productId: string, key: string
  ): Promise<ProductDetail | null> {
    const lockKey = `lock:load:${key}`;
    const locked = await this.redis.set(lockKey, '1', 'PX', this.lockTtlMs, 'NX');

    if (locked) {
      try {
        const data = await this.computeProductDetail(productId);
        await this.redis.set(key, JSON.stringify({
          data,
          expiresAt: Date.now() + 60000,
        }), 'EX', 300);
        return data;
      } finally {
        await this.redis.del(lockKey);
      }
    }

    // ロック取得失敗 → 待機してリトライ
    await new Promise(r => setTimeout(r, 100));
    const cached = await this.redis.get(key);
    if (cached) return JSON.parse(cached).data;
    return this.loadWithLock(productId, key); // リトライ
  }

  private async computeProductDetail(productId: string): Promise<ProductDetail> {
    // 500ms かかる重い処理
    const [product, reviews, inventory] = await Promise.all([
      db.products.findById(productId),
      reviewApi.getReviews(productId),
      inventoryService.getStock(productId),
    ]);
    return { ...product, reviews, inventory };
  }
}

interface ProductDetail {
  reviews: any;
  inventory: any;
  [key: string]: any;
}