LESSON 40分

「Redis への1回のラウンドトリップが1msだとしても、100回呼べば100msだ」佐藤CTOは指摘した。「インプロセスキャッシュを組み合わせることで、さらにレイテンシを削減できる。ただし、マルチインスタンス環境での整合性には要注意だ。」

1. インプロセスキャッシュ

LRU キャッシュの実装

// 高性能なLRUキャッシュの実装
class LRUCache<K, V> {
  private cache = new Map<K, V>();

  constructor(private maxSize: number) {}

  get(key: K): V | undefined {
    if (!this.cache.has(key)) return undefined;

    // アクセスしたキーを末尾に移動(LRU更新)
    const value = this.cache.get(key)!;
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }

  set(key: K, value: V): void {
    // 既存キーの場合は削除して再挿入(LRU更新)
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.maxSize) {
      // 最も古いエントリ(Map の最初の要素)を削除
      const oldestKey = this.cache.keys().next().value;
      if (oldestKey !== undefined) {
        this.cache.delete(oldestKey);
      }
    }

    this.cache.set(key, value);
  }

  has(key: K): boolean {
    return this.cache.has(key);
  }

  delete(key: K): boolean {
    return this.cache.delete(key);
  }

  clear(): void {
    this.cache.clear();
  }

  get size(): number {
    return this.cache.size;
  }
}

// TTL 付きのインプロセスキャッシュ
class TTLCache<K, V> {
  private cache = new Map<K, { value: V; expiresAt: number }>();
  private cleanupInterval: NodeJS.Timeout;

  constructor(
    private maxSize: number,
    private defaultTtlMs: number,
    cleanupIntervalMs: number = 60000
  ) {
    this.cleanupInterval = setInterval(() => this.cleanup(), cleanupIntervalMs);
  }

  get(key: K): V | undefined {
    const entry = this.cache.get(key);
    if (!entry) return undefined;

    if (Date.now() > entry.expiresAt) {
      this.cache.delete(key);
      return undefined;
    }

    return entry.value;
  }

  set(key: K, value: V, ttlMs?: number): void {
    if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
      // 最も古いエントリを削除
      const oldestKey = this.cache.keys().next().value;
      if (oldestKey !== undefined) {
        this.cache.delete(oldestKey);
      }
    }

    this.cache.set(key, {
      value,
      expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs),
    });
  }

  private cleanup(): void {
    const now = Date.now();
    for (const [key, entry] of this.cache) {
      if (now > entry.expiresAt) {
        this.cache.delete(key);
      }
    }
  }

  destroy(): void {
    clearInterval(this.cleanupInterval);
    this.cache.clear();
  }
}

2. メモ化(Memoization)

// 関数メモ化のユーティリティ
function memoize<TArgs extends unknown[], TResult>(
  fn: (...args: TArgs) => TResult,
  options: {
    maxSize?: number;
    ttlMs?: number;
    keyFn?: (...args: TArgs) => string;
  } = {}
): (...args: TArgs) => TResult {
  const {
    maxSize = 1000,
    ttlMs = 300000, // 5分
    keyFn = (...args: TArgs) => JSON.stringify(args),
  } = options;

  const cache = new TTLCache<string, TResult>(maxSize, ttlMs);

  return (...args: TArgs): TResult => {
    const key = keyFn(...args);
    const cached = cache.get(key);
    if (cached !== undefined) return cached;

    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

// 非同期メモ化(重複リクエストの抑制付き)
function memoizeAsync<TArgs extends unknown[], TResult>(
  fn: (...args: TArgs) => Promise<TResult>,
  options: {
    maxSize?: number;
    ttlMs?: number;
    keyFn?: (...args: TArgs) => string;
  } = {}
): (...args: TArgs) => Promise<TResult> {
  const {
    maxSize = 1000,
    ttlMs = 300000,
    keyFn = (...args: TArgs) => JSON.stringify(args),
  } = options;

  const cache = new TTLCache<string, TResult>(maxSize, ttlMs);
  const inflight = new Map<string, Promise<TResult>>();

  return async (...args: TArgs): Promise<TResult> => {
    const key = keyFn(...args);

    // キャッシュヒット
    const cached = cache.get(key);
    if (cached !== undefined) return cached;

    // 同じキーのリクエストが進行中なら合流(deduplication)
    if (inflight.has(key)) {
      return inflight.get(key)!;
    }

    // 新規リクエスト
    const promise = fn(...args).then(result => {
      cache.set(key, result);
      inflight.delete(key);
      return result;
    }).catch(error => {
      inflight.delete(key);
      throw error;
    });

    inflight.set(key, promise);
    return promise;
  };
}

// 使用例
const getProductPrice = memoizeAsync(
  async (productId: string) => {
    const product = await db.products.findById(productId);
    return product?.price ?? 0;
  },
  { ttlMs: 60000, keyFn: (id) => `price:${id}` }
);

3. HTTP レスポンスキャッシュ

// Express ミドルウェアによるレスポンスキャッシュ
import { Request, Response, NextFunction } from 'express';

interface ResponseCacheOptions {
  ttlSeconds: number;
  keyGenerator?: (req: Request) => string;
  condition?: (req: Request) => boolean;
  vary?: string[];
}

function responseCacheMiddleware(
  cache: CacheClient,
  options: ResponseCacheOptions
) {
  const {
    ttlSeconds,
    keyGenerator = (req) => `res:${req.method}:${req.originalUrl}`,
    condition = (req) => req.method === 'GET',
    vary = [],
  } = options;

  return async (req: Request, res: Response, next: NextFunction) => {
    if (!condition(req)) return next();

    // Vary ヘッダーを考慮したキャッシュキー生成
    let cacheKey = keyGenerator(req);
    for (const header of vary) {
      cacheKey += `:${req.get(header) || ''}`;
    }

    // キャッシュ確認
    const cached = await cache.get(cacheKey);
    if (cached) {
      const { statusCode, headers, body } = JSON.parse(cached);
      res.set('X-Cache', 'HIT');
      res.set(headers);
      return res.status(statusCode).send(body);
    }

    // レスポンスをキャプチャ
    const originalJson = res.json;
    res.json = function (this: Response, body: any) {
      const entry = {
        statusCode: this.statusCode,
        headers: {
          'Content-Type': this.get('Content-Type'),
        },
        body,
      };

      // 成功レスポンスのみキャッシュ
      if (this.statusCode >= 200 && this.statusCode < 300) {
        cache.set(cacheKey, JSON.stringify(entry), 'EX', ttlSeconds);
      }

      res.set('X-Cache', 'MISS');
      return originalJson.call(this, body);
    } as any;

    next();
  };
}

// 使用例
// app.get('/api/products', responseCacheMiddleware(redis, {
//   ttlSeconds: 60,
//   vary: ['Accept-Language'],
// }), productController.list);

4. Cache Stampede(サンダリングハード問題)の防止

// Cache Stampede: キャッシュ失効時に大量のリクエストが同時にDBに殺到する問題

// 対策1: ロックベース(Mutex)
class StampedeProtectedCache {
  constructor(
    private cache: CacheClient,
    private lockTtlMs: number = 5000
  ) {}

  async getOrSet<T>(
    key: string,
    loader: () => Promise<T>,
    ttlSeconds: number
  ): Promise<T> {
    // 1. キャッシュ確認
    const cached = await this.cache.get(key);
    if (cached) return JSON.parse(cached);

    // 2. ロックを取得して排他的にロード
    const lockKey = `lock:${key}`;
    const lockAcquired = await this.cache.set(
      lockKey, '1', 'PX', this.lockTtlMs, 'NX'
    );

    if (lockAcquired) {
      try {
        // ロック取得成功 → ロードして保存
        const value = await loader();
        await this.cache.set(key, JSON.stringify(value), 'EX', ttlSeconds);
        return value;
      } finally {
        await this.cache.del(lockKey);
      }
    } else {
      // ロック取得失敗 → 少し待ってリトライ
      await new Promise(resolve => setTimeout(resolve, 100));
      return this.getOrSet(key, loader, ttlSeconds);
    }
  }
}

// 対策2: 確率的早期再検証(PER: Probabilistic Early Recomputation)
class PERCache {
  constructor(private cache: CacheClient) {}

  async getOrSet<T>(
    key: string,
    loader: () => Promise<T>,
    ttlSeconds: number,
    beta: number = 1.0  // 再計算の積極性(大きいほど早期に再計算)
  ): Promise<T> {
    const entry = await this.cache.get(key);

    if (entry) {
      const parsed = JSON.parse(entry) as {
        value: T;
        computeTime: number;
        createdAt: number;
      };

      const ttlRemaining = ttlSeconds - (Date.now() - parsed.createdAt) / 1000;

      // 確率的に早期再計算を実行
      // TTL残りが少ないほど、computeTimeが長いほど再計算確率が上がる
      const shouldRecompute =
        ttlRemaining < beta * parsed.computeTime * Math.log(Math.random()) * -1;

      if (!shouldRecompute) {
        return parsed.value;
      }
    }

    // キャッシュミスまたは早期再計算
    const startTime = Date.now();
    const value = await loader();
    const computeTime = (Date.now() - startTime) / 1000;

    await this.cache.set(key, JSON.stringify({
      value,
      computeTime,
      createdAt: Date.now(),
    }), 'EX', ttlSeconds);

    return value;
  }
}

// 対策3: stale キャッシュ + バックグラウンド更新
class StaleBackgroundCache {
  private refreshing = new Set<string>();

  constructor(private cache: CacheClient) {}

  async getOrSet<T>(
    key: string,
    loader: () => Promise<T>,
    freshTtlSeconds: number,
    staleTtlSeconds: number
  ): Promise<T | null> {
    const entry = await this.cache.get(`data:${key}`);
    const isFresh = await this.cache.get(`fresh:${key}`);

    if (entry) {
      const value = JSON.parse(entry) as T;

      if (!isFresh && !this.refreshing.has(key)) {
        // stale データ → バックグラウンドで更新
        this.refreshInBackground(key, loader, freshTtlSeconds, staleTtlSeconds);
      }

      return value;
    }

    // 完全にキャッシュなし
    const value = await loader();
    await this.setWithStale(key, value, freshTtlSeconds, staleTtlSeconds);
    return value;
  }

  private async setWithStale<T>(
    key: string, value: T, freshTtl: number, staleTtl: number
  ): Promise<void> {
    await this.cache.set(`data:${key}`, JSON.stringify(value), 'EX', staleTtl);
    await this.cache.set(`fresh:${key}`, '1', 'EX', freshTtl);
  }

  private async refreshInBackground<T>(
    key: string, loader: () => Promise<T>,
    freshTtl: number, staleTtl: number
  ): Promise<void> {
    this.refreshing.add(key);
    try {
      const value = await loader();
      await this.setWithStale(key, value, freshTtl, staleTtl);
    } finally {
      this.refreshing.delete(key);
    }
  }
}

// 型定義
declare class CacheClient {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ...args: any[]): Promise<any>;
  del(...keys: string[]): Promise<number>;
  keys(pattern: string): Promise<string[]>;
  sadd(key: string, ...members: string[]): Promise<number>;
  smembers(key: string): Promise<string[]>;
}
declare class DatabaseClient {
  findById(id: string): Promise<any>;
  update(id: string, data: any): Promise<any>;
  save(entity: any): Promise<any>;
  bulkUpsert(entities: any[]): Promise<void>;
}
コラム: 多層キャッシュの設計原則
L1: インプロセスキャッシュ  → レイテンシ: ~0.01ms, サイズ: ~100MB
L2: Redis (ローカルリージョン) → レイテンシ: ~1ms, サイズ: ~10GB
L3: CDN エッジ              → レイテンシ: ~5ms, サイズ: ~100GB
L4: オリジンサーバー         → レイテンシ: ~50ms

設計原則:

  1. L1 は小さく短く: ホットデータのみ、TTL短め(秒〜分)
  2. L2 は共有: 全インスタンスで共有、整合性を担保
  3. L3 は公開データ: パーソナライズされていないコンテンツ
  4. 無効化は L1→L2→L3 の順: Redis Pub/Sub でL1を無効化通知

まとめ

トピック要点
インプロセスキャッシュLRU/TTL、レイテンシ最小、サイズに注意
メモ化関数レベルのキャッシュ、重複リクエスト抑制
HTTP レスポンスキャッシュVary ヘッダー考慮、ミドルウェアで実装
Cache Stampede 防止ロック/PER/Stale+背景更新の3パターン

チェックリスト

  • LRU キャッシュの仕組みを実装レベルで理解した
  • 非同期メモ化と重複リクエスト抑制を説明できる
  • Cache Stampede の3つの防止策を比較できる
  • 多層キャッシュの設計原則を理解した

次のステップへ

アプリケーションレベルのキャッシュ戦略を学んだ。次は演習で 多層キャッシュ戦略の設計 に挑戦しよう。

推定読了時間: 40分