キャッシュ・スタンピード(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)
- まずキャッシュを見る(hitなら返す)。
- missなら、あらかじめ保持しておいたstale(影)値を即返却。
- 同時にバックグラウンドで最新化(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_qps
とerror_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以外でも再現可能な普遍的スケーリング戦略。