Redisキャッシュスタンピード:ロックとTTLジッターで防ぐ方法

期限切れのキャッシュキーへのリクエスト急増がデータベースに過負荷をかける仕組みと、Redisでミューテックスロック、確率的早期再計算、TTLジッターを使用してそれを防ぐ方法を学びます。

Redisキャッシュスタンピード:ロックとTTLジッターで防ぐ方法

はじめに

キャッシュスタンピード(別名:キャッシュの雪崩、Thundering Herd問題)は、人気のあるキャッシュキーが期限切れになった瞬間に、多数のリクエストが同時にデータベースに殺到する現象です。この記事では、Redisを使用してこの問題を防ぐ3つの実践的な方法を紹介します。

問題の理解

典型的なキャッシュフロー:

  1. アプリケーションがRedisからデータを取得しようとする
  2. キーが存在しないか期限切れの場合、データベースからデータを取得する
  3. 結果をRedisに保存し、クライアントに返す

問題は、キーが期限切れになった瞬間に何千ものリクエストが同時に到着すると、全員がデータベースに直接アクセスしようとすることです。これによりデータベースが過負荷になり、レイテンシが増加し、場合によってはダウンタイムが発生する可能性があります。

解決策1:ミューテックスロック

最も簡単なアプローチは、最初のリクエストだけがデータベースにアクセスできるようにし、他のリクエストは待機させることです。

import redis
import time

r = redis.Redis()

def get_data_with_lock(key, lock_timeout=5):
    # まずキャッシュを確認
    data = r.get(key)
    if data is not None:
        return data
    
    # ロックキーを作成
    lock_key = f"lock:{key}"
    
    # ロックの取得を試みる(SET NXを使用)
    if r.setnx(lock_key, "locked"):
        # ロックの有効期限を設定
        r.expire(lock_key, lock_timeout)
        
        # データベースからデータを取得
        data = query_database(key)
        
        # キャッシュに保存
        r.setex(key, 300, data)
        
        # ロックを解除
        r.delete(lock_key)
        
        return data
    else:
        # 他のリクエストは待機して再試行
        time.sleep(0.1)
        return get_data_with_lock(key)

注意点:

  • デッドロックを防ぐために、ロックには常にタイムアウトを設定する
  • ロックの保持時間は、データベースクエリの予想時間より長くする
  • 再帰呼び出しの深さを制限して、無限ループを防ぐ

解決策2:確率的早期再計算

このアプローチでは、キャッシュが期限切れになる前に、一部のリクエストがデータベースからデータを再取得する責任を負います。

import random

def get_data_probabilistic(key, ttl=300):
    data = r.get(key)
    
    if data is None:
        # キャッシュミス - データベースから取得
        data = query_database(key)
        r.setex(key, ttl, data)
        return data
    
    # 残りTTLを取得
    remaining_ttl = r.ttl(key)
    
    # 確率的な早期再計算
    # 残りTTLが短いほど、再計算確率が高くなる
    if remaining_ttl < ttl * 0.1:  # TTLの10%未満
        probability = 1 - (remaining_ttl / (ttl * 0.1))
        if random.random() < probability:
            # このリクエストが再計算を担当
            new_data = query_database(key)
            r.setex(key, ttl, new_data)
            return new_data
    
    return data

利点:

  • ロックが不要
  • 負荷が自然に分散される
  • 実装が比較的簡単

解決策3:TTLジッター

すべてのキャッシュキーに同じTTLを設定する代わりに、ランダムな変動を追加することで、キーの期限切れを分散させます。

def set_with_jitter(key, data, base_ttl=300, jitter_percent=0.1):
    # 基本TTLに±10%のランダムな変動を追加
    jitter = base_ttl * jitter_percent
    actual_ttl = base_ttl + random.uniform(-jitter, jitter)
    
    r.setex(key, int(actual_ttl), data)

使用例:

# ユーザープロフィールをキャッシュする場合
for user_id in user_ids:
    key = f"user:{user_id}:profile"
    data = get_user_profile(user_id)
    set_with_jitter(key, data, base_ttl=3600, jitter_percent=0.2)

ベストプラクティスの組み合わせ

実際の本番環境では、これらのテクニックを組み合わせることがよくあります:

def robust_cache_get(key, base_ttl=300):
    # 1. まずキャッシュを確認
    data = r.get(key)
    if data is not None:
        # 2. 確率的早期再計算をチェック
        remaining_ttl = r.ttl(key)
        if remaining_ttl < base_ttl * 0.2:
            if random.random() < 0.1:  # 10%の確率
                # バックグラウンドで非同期に再計算
                async_recompute(key, base_ttl)
        return data
    
    # 3. キャッシュミス - ロックを使用
    return get_with_lock(key, base_ttl)

パフォーマンスの比較

方法 複雑さ データベース保護 レイテンシへの影響
ミューテックスロック 優れている 待機時間が追加される
確率的早期再計算 良好 最小限
TTLジッター 中程度 なし

まとめ

キャッシュスタンピードは、高トラフィックシステムで見過ごされがちな問題です。Redisのロック、確率的早期再計算、TTLジッターを実装することで、データベースを突然の負荷スパイクから保護できます。最適なアプローチは、特定のユースケースとパフォーマンス要件によって異なります。

これらのテクニックは相互に排他的ではないことを覚えておいてください。多くの本番システムでは、包括的な保護のために複数の方法を組み合わせて使用しています。