キャッシュ・スタンピード(Dogpile)を古い値の即返し + 非同期更新で吸収する実運用テク。


TL;DR

  • 二段階フェッチ = ①キャッシュ命中なら即返却 → ②ミス時は直近の古い値(stale)を即返却 → ③裏で非同期更新(revalidate)
  • ユーザー体験は“即レス”。バックエンドは突発QPSスパイクを回避でき、安定性が上がる。
  • Redis/Memcached/各種Online Feature Storeに応用可。Feast + Redis、DynamoDB(+DAX)、Bigtable/Cassandraコールドストア連携など。

背景

  • 特徴量や設定値をRedis/Memcachedに置く構成で、TTL一斉切れの瞬間にキャッシュミスが集中→ DB/コールドストアへリクエストが殺到。
  • スケールアウトしても**「キャッシュが効かない瞬間」**には追いつかず、レイテンシ急増→障害に。
[ Client ]  --->  [ API ]  --->  [ Redis ] ----X (miss storm) ----> [ DB/ColdStore ]
                         |  ↑                                     ↘  backlogs
                         |  └────── TTL expiry swarm

解決策:二段階フェッチ(SWR)

  1. まずキャッシュを見る(hitなら返す)。
  2. missなら、あらかじめ保持しておいたstale(影)値即返却
  3. 同時にバックグラウンドで最新化(DB/Feature Storeからフェッチ→キャッシュ再書き込み)。
[ Client ] -> 即レス (stale OK)
              │
[ API ]  -----┘   + 背後で refresh()

ポイント

  • ユーザーは少なくとも直近の値を即時に受け取れる。
  • バックエンドは同期I/Oを外し、突発スパイクを吸収。

データ設計パターン

  • feat:{id}新鮮値(TTL短め)
  • feat:{id}:shadow直近の古い値(TTL長め/または無期限 + 世代タグ)
  • feat:{id}:lock単一更新ロック(SET NX + 期限)で同時更新の雪崩を防ぐ
  • feat:{id}:ver … 任意:世代/ETag的バージョンで整合性チェック

実装例(Python + Redis:改良版)

シンプル実装 + 同時更新抑制(singleflight) + TTLジッター

import json, time, random, threading
import redis

r = redis.Redis()

FRESH_TTL = 300            # 新鮮値のTTL(秒)
SHADOW_TTL = 3600 * 24     # 影値のTTL(長め)
LOCK_TTL = 10              # ロック有効期限(秒)


def _fetch_from_cold_store(entity_id: str):
    # 実装者メモ:DB/Feature Storeから取得。失敗時は例外を投げる。
    # ここではダミー値
    return {"score": 42, "entity": entity_id, "ts": int(time.time())}


def _jitter(ttl: int, ratio: float = 0.2) -> int:
    # 一斉失効を避けるためTTLにジッターを入れる(±20%)
    delta = int(ttl * ratio)
    return max(1, ttl + random.randint(-delta, delta))


def _background_refresh(entity_id: str, key: str):
    lock_key = f"{key}:lock"
    # SET NX でロック(同一キーの同時更新を1本化)
    if not r.set(lock_key, "1", nx=True, ex=LOCK_TTL):
        return  # 他スレッドが更新中
    try:
        fresh = _fetch_from_cold_store(entity_id)
        payload = json.dumps(fresh)
        r.setex(key, _jitter(FRESH_TTL), payload)
        r.setex(f"{key}:shadow", _jitter(SHADOW_TTL), payload)
    finally:
        # TTL付きロックは自然解放。安全側でDELしても良い。
        r.delete(lock_key)


def get_feature(entity_id: str):
    key = f"feat:{entity_id}"
    val = r.get(key)
    if val:
        return json.loads(val)

    # miss → stale を即返し、裏で最新化
    stale = r.get(f"{key}:shadow")
    if stale:
        threading.Thread(target=_background_refresh, args=(entity_id, key), daemon=True).start()
        return json.loads(stale)

    # stale も無い(初回/長期欠損)→ 最低1回同期取得
    try:
        fresh = _fetch_from_cold_store(entity_id)
    except Exception:
        # ここで空返しはUX劣化。できればデフォルト値や前段のMLロジックで代替
        return None

    payload = json.dumps(fresh)
    r.setex(key, _jitter(FRESH_TTL), payload)
    r.setex(f"{key}:shadow", _jitter(SHADOW_TTL), payload)
    return fresh

Redis Lua での原子的な影値更新(任意)

SETEX×2やロック周りをLua化して一括原子実行

-- KEYS[1]=fresh_key, KEYS[2]=shadow_key
-- ARGV[1]=payload,  ARGV[2]=fresh_ttl, ARGV[3]=shadow_ttl
redis.call('SETEX', KEYS[1], tonumber(ARGV[2]), ARGV[1])
redis.call('SETEX', KEYS[2], tonumber(ARGV[3]), ARGV[1])
return 1

運用の勘所(SRE/プロダクション視点)

KPI/メトリクス

  • cache_hit_ratio_fresh(新鮮値の命中率)
  • cache_hit_ratio_stale(影値の参照率)
  • refresh_latency_p95/p99(非同期更新の完了時間)
  • stampede_blocked_total(ロックで抑止できた同時更新数)
  • cold_store_qpserror_rate(スパイク時の守備ライン)

アラート例

  • stale比率が急上昇 && cold_store_qps上昇 → TTL設定やmaterialization遅延を疑う
  • refresh_latency_p99 > 目標値 → バックエンド飽和/ネットワーク/スロークエリ

パラメータ最適化

  • FRESH_TTL 短め + SHADOW_TTL 長めが基本。ドメインに応じて許容古さを決める。
  • TTLジッターは必須。バッチ/同時デプロイと重なる一斉失効を避ける。
  • singleflight(キー単位ロック)で同時更新を1本化。

よくある落とし穴(アンチパターン)

  • ミス時に毎回DB直行 → 一瞬でQPS雪崩。
  • staleを持たない → レイテンシの“空白”が増え、UXが悪化。
  • 全て同期更新 → p99が跳ね、スループットも低下。
  • ロック無しで並列更新 → 同一キーにN本のリフレッシュが競合、コスト爆増。
  • TTL一律・ノージッター → デプロイ/バッチと重なり易く、同時多発ミス。

関連技術への当てはめ

  • Feast (OnlineStore=Redis): materialization jobで直近スナップショットを影値として残す設計が相性◎。
  • DynamoDB (+ DAX): DAXを“L1”、アプリ内/Redisを“L2”とする二層キャッシュでSWRを実装。
  • Cassandra/Bigtable: コールドストアとして非同期フェッチ先に。書き込みはまとめてバルク化。

さらに堅牢にするオプション

  • 確率的早期更新(Probabilistic Early Refresh): TTL残が小さい時に一定確率で先行更新。
  • バージョン/ETag: 影値と新鮮値の世代差を監視、陳腐化しすぎを検知。
  • バックオフ&再試行: コールド側が落ちている時の指数バックオフ/サーキットブレーカ。
  • 部分的デフォルト: 特徴量の一部欠損に耐えるフォールバック(前回値/モデル内デフォルト)。

用語ミニメモ

  • SWR (Stale-While-Revalidate): 使いながら裏で更新する戦略。
  • Dogpile/Stampede: TTL切れ等で同時にミス→一斉に下流へ雪崩。
  • Singleflight: 同一キーの更新を1本にまとめる手法。

参考:シンプル版(質問にあった原型)

原理理解用の最小コード(学習用)

import redis, json, threading
r = redis.Redis()

def refresh_feature(entity_id, key):
    fresh_val = {"score": 42}
    r.setex(key, 600, json.dumps(fresh_val))

def get_feature(entity_id):
    key = f"feat:{entity_id}"
    val = r.get(key)
    if val:
        return json.loads(val)

    stale_val = r.get(f"{key}:shadow")
    if stale_val:
        threading.Thread(target=refresh_feature, args=(entity_id, key)).start()
        return json.loads(stale_val)

    refresh_feature(entity_id, key)
    return json.loads(r.get(key))

まとめ

  • 二段階フェッチ(stale + 非同期更新)キャッシュミス集中を平準化。
  • UXは即時応答バックエンドは安全に最新化。Redis以外でも再現可能な普遍的スケーリング戦略

投稿者 kojiro777

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です