10日で覚えるSystem DesignDay 4: キャッシュとCDN

Day 4: キャッシュとCDN

今日学ぶこと

  • キャッシュがなぜ重要か
  • キャッシュ戦略(Cache-aside、Write-through、Write-behind、Read-through)
  • キャッシュ追い出しポリシー(LRU、LFU、TTL)
  • RedisとMemcachedの比較
  • CDN(Content Delivery Network)のアーキテクチャ
  • キャッシュ無効化の課題と対策

なぜキャッシュが重要か

キャッシュは、頻繁にアクセスされるデータを高速なストレージに保存することで、レスポンス時間を大幅に短縮します。

flowchart LR
    Client["クライアント"] --> Cache{"キャッシュ\n(Redis)"}
    Cache -->|"ヒット\n< 1ms"| Client
    Cache -->|"ミス"| DB["データベース\n10-100ms"]
    DB --> Cache
    style Cache fill:#22c55e,color:#fff
    style DB fill:#8b5cf6,color:#fff

アクセス速度の比較

レイヤー レイテンシ 容量
ブラウザキャッシュ < 1ms 数百MB
CDN 10-50ms 数TB
アプリケーションキャッシュ(Redis) 1-5ms 数百GB
データベース 10-100ms 数TB
ディスク 1-10ms 数PB

80/20の法則: 多くのシステムでは、20%のデータが80%のアクセスを受けます。この20%をキャッシュするだけで大幅な改善が見込めます。


キャッシュ戦略

Cache-Aside(Lazy Loading)

最も一般的な戦略です。アプリケーションがキャッシュとDBを直接管理します。

flowchart TB
    subgraph CacheAside["Cache-Aside パターン"]
        App["アプリケーション"]
        App -->|"1. キャッシュ確認"| Cache["キャッシュ"]
        Cache -->|"2a. ヒット → データ返却"| App
        App -->|"2b. ミス → DB読み取り"| DB["データベース"]
        DB -->|"3. データ返却"| App
        App -->|"4. キャッシュに書き込み"| Cache
    end
    style CacheAside fill:#3b82f6,color:#fff
    style Cache fill:#22c55e,color:#fff
    style DB fill:#8b5cf6,color:#fff
# Cache-aside pattern implementation
def get_user(user_id):
    # Step 1: Check cache
    user = cache.get(f"user:{user_id}")
    if user:
        return user  # Cache hit

    # Step 2: Cache miss - read from DB
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)

    # Step 3: Write to cache with TTL
    cache.set(f"user:{user_id}", user, ttl=3600)
    return user

Write-Through

データの書き込み時に、キャッシュとDBの両方に同時に書き込みます。

flowchart LR
    App["アプリケーション"] -->|"1. 書き込み"| Cache["キャッシュ"]
    Cache -->|"2. 同期書き込み"| DB["データベース"]
    Cache -->|"3. 応答"| App
    style Cache fill:#f59e0b,color:#fff
    style DB fill:#8b5cf6,color:#fff

Write-Behind(Write-Back)

キャッシュに書き込み後、非同期でDBに書き込みます。

flowchart LR
    App["アプリケーション"] -->|"1. 書き込み"| Cache["キャッシュ"]
    Cache -->|"2. 即座に応答"| App
    Cache -->|"3. 非同期で\nバッチ書き込み"| DB["データベース"]
    style Cache fill:#ef4444,color:#fff
    style DB fill:#8b5cf6,color:#fff

Read-Through

Cache-Asideと似ていますが、キャッシュ自体がDBへの読み取りを行います。

flowchart LR
    App["アプリケーション"] -->|"1. 読み取り"| Cache["キャッシュ\n(プロバイダー)"]
    Cache -->|"2. ミス時\n自動でDB参照"| DB["データベース"]
    Cache -->|"3. データ返却"| App
    style Cache fill:#22c55e,color:#fff
    style DB fill:#8b5cf6,color:#fff

戦略の比較

戦略 読み取り性能 書き込み性能 一貫性 適するケース
Cache-Aside 高い(ヒット時) DB直接書き込み 結果整合性 汎用的、読み取り多い
Write-Through 高い 低い(同期書き込み) 強い一貫性 データの整合性が重要
Write-Behind 高い 非常に高い 弱い一貫性 書き込みが多い
Read-Through 高い 結果整合性 シンプルな読み取りキャッシュ

キャッシュ追い出しポリシー

キャッシュの容量は有限です。容量がいっぱいになったとき、どのデータを追い出すかを決めるのが追い出しポリシーです。

ポリシー 仕組み メリット デメリット
LRU(Least Recently Used) 最も最近使われていないデータを追い出す 実装がシンプル、多くの場合に有効 スキャン耐性が低い
LFU(Least Frequently Used) 使用頻度が最も低いデータを追い出す 頻繁にアクセスされるデータを保持 新しいデータが追い出されやすい
TTL(Time To Live) 有効期限切れのデータを追い出す 鮮度を保証できる 期限設定が難しい
FIFO(First In First Out) 最初にキャッシュされたデータから追い出す 非常にシンプル アクセスパターンを考慮しない
flowchart TB
    subgraph LRU["LRU キャッシュの動作"]
        direction LR
        A["A(最新)"] --> B["B"] --> C["C"] --> D["D(最古)"]
    end
    subgraph After["Bにアクセス後"]
        direction LR
        B2["B(最新)"] --> A2["A"] --> C2["C"] --> D2["D(最古)"]
    end
    LRU -->|"Bにアクセス"| After
    style LRU fill:#3b82f6,color:#fff
    style After fill:#22c55e,color:#fff

面接でのポイント: ほとんどのケースでLRU + TTLの組み合わせを推奨しましょう。TTLでデータの鮮度を保ち、LRUで容量を管理します。


Redis vs Memcached

比較項目 Redis Memcached
データ構造 String, Hash, List, Set, Sorted Set String のみ
永続化 RDB / AOF なし
レプリケーション Leader-Follower なし
クラスタリング Redis Cluster クライアント側で実装
Pub/Sub 対応 非対応
Luaスクリプト 対応 非対応
メモリ効率 やや低い(機能が多いため) 高い(シンプル)
スレッドモデル シングルスレッド(I/Oスレッド対応) マルチスレッド
適するケース 汎用キャッシュ、セッション、ランキング シンプルなキャッシュ
flowchart TB
    subgraph Decision["Redis vs Memcached 選択"]
        Q1{"複雑なデータ構造\nが必要?"}
        Q1 -->|"はい"| Redis["Redis"]
        Q1 -->|"いいえ"| Q2{"永続化や\nレプリケーションが\n必要?"}
        Q2 -->|"はい"| Redis
        Q2 -->|"いいえ"| Q3{"シンプルな\nKey-Value\nキャッシュだけ?"}
        Q3 -->|"はい"| Memcached["Memcached"]
        Q3 -->|"いいえ"| Redis
    end
    style Decision fill:#3b82f6,color:#fff
    style Redis fill:#ef4444,color:#fff
    style Memcached fill:#22c55e,color:#fff

面接での現実: ほとんどの場面でRedisを選択して問題ありません。Memcachedを選ぶのは、純粋なKey-Valueキャッシュで最大限のメモリ効率が必要な場合のみです。


CDN(Content Delivery Network)

CDNは、静的コンテンツを世界中のエッジサーバーに配置して、ユーザーに最も近いサーバーからコンテンツを配信する仕組みです。

flowchart TB
    User1["ユーザー\n(東京)"] --> Edge1["エッジサーバー\n東京"]
    User2["ユーザー\n(NY)"] --> Edge2["エッジサーバー\nニューヨーク"]
    User3["ユーザー\n(ロンドン)"] --> Edge3["エッジサーバー\nロンドン"]
    Edge1 -->|"キャッシュミス時"| Origin["オリジンサーバー"]
    Edge2 -->|"キャッシュミス時"| Origin
    Edge3 -->|"キャッシュミス時"| Origin
    style Origin fill:#ef4444,color:#fff
    style Edge1 fill:#22c55e,color:#fff
    style Edge2 fill:#22c55e,color:#fff
    style Edge3 fill:#22c55e,color:#fff

Pull型 vs Push型

方式 仕組み メリット デメリット
Pull型 リクエスト時にオリジンから取得 設定がシンプル 初回アクセスが遅い
Push型 あらかじめコンテンツをエッジに配置 初回アクセスも高速 ストレージコストが高い

CDNに適するコンテンツ

コンテンツタイプ CDN適性 理由
画像・動画 非常に高い 大容量、変更頻度低い
CSS・JavaScript 高い 静的ファイル
APIレスポンス 場合による 動的だがキャッシュ可能な場合あり
ユーザー固有データ 低い キャッシュが難しい

キャッシュ無効化の課題

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton

flowchart TB
    subgraph Problems["キャッシュの課題"]
        P1["Thundering Herd\n大量のキャッシュミスが\n同時にDBへ"]
        P2["Cache Penetration\n存在しないキーへの\n大量リクエスト"]
        P3["Cache Avalanche\n大量のキャッシュが\n同時に期限切れ"]
        P4["Stale Data\n古いデータが\n残り続ける"]
    end
    subgraph Solutions["対策"]
        S1["ロック/セマフォで\nDB問い合わせを制限"]
        S2["Bloomフィルタで\n存在しないキーを除外"]
        S3["TTLにランダムな\nジッターを追加"]
        S4["イベント駆動で\n能動的に無効化"]
    end
    P1 --> S1
    P2 --> S2
    P3 --> S3
    P4 --> S4
    style Problems fill:#ef4444,color:#fff
    style Solutions fill:#22c55e,color:#fff
課題 説明 対策
Thundering Herd キャッシュ失効時に大量のリクエストがDBに殺到 ロック機構で1リクエストのみDB参照
Cache Penetration 存在しないデータへの繰り返しリクエスト Bloomフィルタ、null値のキャッシュ
Cache Avalanche 大量のキャッシュが同時に期限切れ TTLにランダムなジッターを追加
Stale Data データ更新後もキャッシュに古いデータ イベント駆動の無効化、短いTTL

キャッシュ無効化パターン

# Pattern 1: TTL-based (simplest)
cache.set("product:123", product_data, ttl=300)  # 5 minutes

# Pattern 2: Event-driven invalidation
def update_product(product_id, data):
    db.update(product_id, data)
    cache.delete(f"product:{product_id}")  # Invalidate cache

# Pattern 3: Versioned keys
version = db.get_version("product:123")
cache_key = f"product:123:v{version}"

まとめ

今日のポイント

トピック キーポイント
キャッシュの重要性 80/20の法則、レイテンシを100倍改善
キャッシュ戦略 Cache-Asideが最も汎用的、ユースケースに応じて選択
追い出しポリシー LRU + TTLの組み合わせが一般的
Redis vs Memcached ほとんどの場合Redisで良い
CDN 静的コンテンツ配信に不可欠、Pull型が一般的
キャッシュ無効化 Thundering Herd、Penetration、Avalancheに注意

キャッシュ設計チェックリスト

1. キャッシュすべきデータを特定(読み取り頻度 > 書き込み頻度)
2. キャッシュ戦略を選択(通常はCache-Aside)
3. 追い出しポリシーを決定(LRU + TTL)
4. キャッシュの容量を見積もり
5. キャッシュ無効化戦略を設計
6. Thundering Herd等の障害対策
7. CDNで静的コンテンツを配信

練習問題

基礎レベル

問題: 以下の4つのキャッシュ戦略(Cache-Aside、Write-Through、Write-Behind、Read-Through)のそれぞれについて、メリットとデメリットを1つずつ挙げてください。

中級レベル

問題: DAU 500万人のニュースサイトのキャッシュ戦略を設計してください。以下の要件を満たすこと。

  • トップページは1分以内の鮮度を保つ
  • 記事ページは5分以内の鮮度で許容
  • 画像は変更されることがほぼない
  • ピーク時(朝8時)にトラフィックが通常の5倍
  • Thundering Herdへの対策を含めること

チャレンジレベル

問題: グローバルECサイト(Amazon規模)の多層キャッシュアーキテクチャを設計してください。

  • ブラウザキャッシュ、CDN、アプリケーションキャッシュ、DBキャッシュの各層の役割
  • 商品価格が更新された場合のキャッシュ無効化フロー
  • フラッシュセール(タイムセール)時のキャッシュ戦略
  • 在庫数のようなリアルタイム性が必要なデータの扱い

参考リンク


次回予告

Day 5: メッセージキューと非同期処理 では、同期処理の限界を突破する非同期アーキテクチャを学びます。Kafka、RabbitMQ、SQSの比較や、イベント駆動設計、メッセージ配信の保証レベルなど、マイクロサービス時代の必須知識を身につけましょう。