目次を表示する

システム設計とCS概念

分散キャッシュ(Redis)── キャッシュ戦略と整合性のトレードオフ

分散キャッシュ(Redis)── キャッシュ戦略と整合性のトレードオフ

扱うCS概念:キャッシュ置換アルゴリズム(LRU/LFU)、キャッシュ無効化、シャーディング、冪等性


分散キャッシュ戦略 — Cache-Aside / Write-Through / Write-Back

この章で何ができるようになるか:キャッシュの各戦略(Cache-Aside, Write-Through, Write-Back)をユースケースに応じて選択できるようになる。キャッシュが引き起こす整合性問題とその対処法を説明できるようになる。


問題設定

「DB が遅い」を解決する最も手軽な手段がキャッシュだ。しかしキャッシュを導入した途端、整合性の問題が生まれる。

「キャッシュのデータ」と「DBのデータ」が食い違う
→ ユーザーAが更新した内容が、ユーザーBには古い値が見える
→ 在庫が0なのにキャッシュには1が残っている
→ 決済済みなのにキャッシュにはカート情報が残っている

さらに、大規模になると:

Thundering Herd(サンダリングハード):
  人気記事のキャッシュが切れた瞬間、数千リクエストが一斉にDBへ流れ込む

Cache Stampede(キャッシュスタンピード):
  同じキーへの並行リクエストが全てキャッシュミスし、同じDBクエリを並行実行する

Cold Start:
  サーバー再起動後、キャッシュが空の状態でリクエストが殺到する

これらを理解するには、キャッシュの基本戦略を整理することが必要だ。


3つのキャッシュ書き込み戦略

1. Cache-Aside(Lazy Loading)

最も一般的なパターン。アプリケーションがキャッシュとDBの両方を管理する。

def get_user(user_id: int) -> dict:
    # 1. キャッシュを確認
    cached = redis.get(f"user:{user_id}")
    if cached:
        return json.loads(cached)  # キャッシュヒット

    # 2. キャッシュミス → DB から取得
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)

    # 3. キャッシュに書き込み(TTL=3600秒)
    redis.setex(f"user:{user_id}", 3600, json.dumps(user))

    return user

def update_user(user_id: int, data: dict):
    # 4. DB を更新
    db.execute("UPDATE users SET ... WHERE id = %s", user_id)

    # 5. キャッシュを削除(次回読み込み時に再生成させる)
    redis.delete(f"user:{user_id}")
sequenceDiagram
    participant App
    participant Cache as Redis
    participant DB

    App->>Cache: GET user:123
    Cache-->>App: nil(ミス)
    App->>DB: SELECT * FROM users WHERE id=123
    DB-->>App: {id:123, name:"Alice"}
    App->>Cache: SET user:123 "{...}" EX 3600
    App->>App: データを返す

    Note over App,DB: 更新時
    App->>DB: UPDATE users SET name="Bob"
    App->>Cache: DEL user:123

✅ 長所:読み込みが多いユースケースに最適。キャッシュが落ちてもDBに直接アクセスできるため障害耐性が高い。

❌ 短所:初回アクセス(コールドスタート)は必ずキャッシュミスする。書き込みとキャッシュ削除の間にわずかな不整合窓がある。


2. Write-Through

書き込み時にキャッシュとDBの両方を同時に更新する。

def update_user(user_id: int, data: dict):
    # DB と キャッシュを同時更新
    db.execute("UPDATE users SET ... WHERE id = %s", user_id)
    redis.setex(f"user:{user_id}", 3600, json.dumps(data))
    # キャッシュは常に最新

✅ 長所:キャッシュが常に最新。読み込みは必ずキャッシュヒット。

❌ 短所:書き込みレイテンシが増加(DB + キャッシュの2回)。書き込まれたデータが二度と読まれない場合も必ずキャッシュに書く(無駄なメモリ消費)。


3. Write-Back(Write-Behind)

書き込みはキャッシュのみ行い、DBへの書き込みは非同期にバッファリングする。

def update_user(user_id: int, data: dict):
    # キャッシュのみ即時更新(高速)
    redis.setex(f"user:{user_id}", 3600, json.dumps(data))
    
    # DB への書き込みはキューに入れる(非同期)
    message_queue.publish("db_write", {
        "table": "users",
        "id": user_id,
        "data": data
    })

# バックグラウンドワーカーが DB に書き込む
def db_writer():
    while True:
        msg = message_queue.consume("db_write")
        db.execute("UPDATE users SET ... WHERE id = %s", msg["id"])

✅ 長所:書き込み速度が極めて高速。DBへの書き込みをバッチ処理できる(I/O効率)。

❌ 短所:キャッシュが落ちるとデータロス。実装が複雑。強整合性が必要な場面(決済など)には使えない。


3つの比較

Cache-AsideWrite-ThroughWrite-Back
読み込み速度初回は遅い常に速い常に速い
書き込み速度速いやや遅い非常に速い
整合性弱(短い窓あり)最弱
データロスリスクなしなしあり
向いているユース読み多・書き少読み書きバランス書き込み集中

キャッシュ置換アルゴリズム

Redis は maxmemory-policy でエビクション(削除)戦略を設定できる。

# redis.conf
maxmemory 4gb
maxmemory-policy allkeys-lru

LRU(Least Recently Used)

直近で使われていないものを削除する。実装は「ハッシュマップ + 双方向リンクリスト」の組み合わせが O(1) で実現できる。

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = OrderedDict()  # 挿入順を保持

    def get(self, key: str):
        if key not in self.cache:
            return None
        # アクセスされたら末尾(最新)に移動
        self.cache.move_to_end(key)
        return self.cache[key]

    def put(self, key: str, value):
        if key in self.cache:
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.capacity:
            # 先頭(最も古い)を削除
            self.cache.popitem(last=False)

LFU(Least Frequently Used)

最もアクセス頻度が低いものを削除する。

import heapq
from collections import defaultdict

class LFUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache: dict = {}          # key → value
        self.freq: dict = defaultdict(int)  # key → 頻度
        self.freq_map: dict = defaultdict(OrderedDict)  # 頻度 → {key: ...}
        self.min_freq = 0

    def _update(self, key: str):
        f = self.freq[key]
        del self.freq_map[f][key]
        if not self.freq_map[f]:
            del self.freq_map[f]
            if self.min_freq == f:
                self.min_freq += 1
        self.freq[key] += 1
        self.freq_map[f + 1][key] = True

    def get(self, key: str):
        if key not in self.cache:
            return None
        self._update(key)
        return self.cache[key]

    def put(self, key: str, value):
        if key in self.cache:
            self.cache[key] = value
            self._update(key)
        else:
            if len(self.cache) >= self.capacity:
                # 最小頻度の中で最も古いものを削除
                evict_key, _ = self.freq_map[self.min_freq].popitem(last=False)
                del self.cache[evict_key]
                del self.freq[evict_key]
            self.cache[key] = value
            self.freq[key] = 1
            self.freq_map[1][key] = True
            self.min_freq = 1

LRU vs LFU の選択

シナリオ向いているアルゴリズム
SNSのバズり投稿(一時的に爆発的アクセス)LRU(バズが終わったら自然と削除)
商品の定番人気ページ(長期的に人気)LFU(人気のあるページを保持)
動画・ニュースの時系列コンテンツLRU(新しいものが次々と参照される)

Thundering Herd 対策

人気コンテンツのキャッシュが期限切れした瞬間、多数のリクエストがDBに殺到する問題だ。

対策1:Mutex(分散ロック)

最初の1リクエストだけDBアクセスを許可し、残りはロックが解放されるまで待つ。

def get_popular_content(content_id: int):
    cached = redis.get(f"content:{content_id}")
    if cached:
        return json.loads(cached)

    # 分散ロックを取得(他のリクエストはここで待機)
    lock_key = f"lock:content:{content_id}"
    acquired = redis.set(lock_key, "1", nx=True, ex=10)  # NX=Only if Not exists

    if acquired:
        try:
            content = db.get_content(content_id)
            redis.setex(f"content:{content_id}", 3600, json.dumps(content))
            return content
        finally:
            redis.delete(lock_key)
    else:
        # ロック取得できなかった → 少し待ってリトライ
        time.sleep(0.1)
        return get_popular_content(content_id)  # 再帰的にリトライ

対策2:Probabilistic Early Expiration(確率的早期失効)

TTL が切れる「少し前」から、確率的にキャッシュを再生成し始める。急なキャッシュ切れを防ぐ。

import math
import time

def get_with_pee(key: str, ttl: int, fetch_fn, beta: float = 1.0):
    """
    PEE (Probabilistic Early Expiration)
    残り時間が短くなるほど、再取得する確率が高くなる
    """
    data_bytes = redis.get(key)
    
    if data_bytes:
        data = json.loads(data_bytes)
        remaining = redis.ttl(key)
        # delta:前回の取得にかかった時間(より長い処理ほど早めに再生成)
        delta = data.get("_fetch_time", 0.1)
        
        # 確率的に早期再生成するかどうか判定
        should_refresh = (-delta * beta * math.log(random.random())) >= remaining
        
        if not should_refresh:
            return data["value"]

    # 再生成
    start = time.time()
    value = fetch_fn()
    fetch_time = time.time() - start
    
    payload = json.dumps({"value": value, "_fetch_time": fetch_time})
    redis.setex(key, ttl, payload)
    return value

対策3:背景再生成(Stale-While-Revalidate)

HTTP キャッシュ制御ヘッダー stale-while-revalidate と同じ発想をアプリ層で実装する。

def get_with_background_refresh(key: str):
    cached = redis.get(f"data:{key}")
    
    if cached:
        data = json.loads(cached)
        remaining_ttl = redis.ttl(f"data:{key}")
        
        # TTL が 20% 以下になったらバックグラウンドで更新
        if remaining_ttl < data.get("original_ttl", 3600) * 0.2:
            executor.submit(refresh_cache, key)  # 非同期
        
        return data["value"]  # 古いデータを即座に返す
    
    # キャッシュなし → 同期的に取得
    return sync_fetch_and_cache(key)

Redis Cluster:分散ハッシュスロット

Redis を複数ノードにスケールアウトする場合、Redis Cluster を使う。

Redis Cluster は16384 個のハッシュスロットを複数ノードで分担する。

ノード1:スロット 0 〜 5460
ノード2:スロット 5461 〜 10922
ノード3:スロット 10923 〜 16383

キーのルーティング:

def get_slot(key: str) -> int:
    # { } で囲まれた部分だけをハッシュ(ハッシュタグ)
    # 例: "user:{123}:profile" → "123" だけをハッシュ
    match = re.search(r'\{(.+?)\}', key)
    hash_key = match.group(1) if match else key
    return crc16(hash_key) % 16384

ハッシュタグは同一ユーザーの複数キーを同じスロット(同じノード)に配置するための仕組みだ。

# ハッシュタグなし → 異なるノードに分散
redis.set("user:123:profile", ...)   # スロット 8976 → ノード2
redis.set("user:123:settings", ...)  # スロット 3421 → ノード1
# MULTI/EXEC(トランザクション)が使えない!

# ハッシュタグあり → 同じノードに配置
redis.set("user:{123}:profile", ...)   # ハッシュキー "123" → 同じスロット
redis.set("user:{123}:settings", ...)  # ハッシュキー "123" → 同じスロット
# MULTI/EXEC が使える ✅

まとめ

課題解決策トレードオフ
読み込み速度Cache-Aside初回ミス・弱い整合性
書き込みの一貫性Write-Through書き込みレイテンシ増
書き込みスループットWrite-Backデータロスリスク
メモリ効率LRU / LFU(用途次第)アクセスパターン依存
Thundering Herd分散ロック / PEE / 背景更新複雑さとのトレードオフ
水平スケールRedis Cluster(16384スロット)クロスノードトランザクション不可

キャッシュは「銀の弾丸」ではない。整合性要件・障害耐性・書き込みパターンを整理してから戦略を選ぶことが重要だ。

次章では、Kafka がどうやって「絶対に落とさない」メッセージキューを実現しているかを見ていく。