Feature Store/特徴量キャッシュを Redis などの KV に載せるときの、**“バージョン付きキー + 一括TTL管理”**パターンの実務ガイド。Feast/Cassandra/Bigtable/DynamoDB にも応用できます。
TL;DR
- 課題:
entity_id
単体キーは TTL のばらつきで 新旧の値が混在。高頻度更新では 再計算と再ロードが衝突。スケールアウトすると どのPodがどのバージョンを読んでいるか不明瞭。 - 解: キーを
entity_id:version
とし、バージョン単位で一括ロード → TTLを揃える。クライアントは常にcurrent_version
ポインタを参照。 - 運用: ロールオーバーは 原子的なポインタ更新(Lua/トランザクション)。古いバージョンは グレース期間後にパージ。
- メリット: 一貫性↑ / スループット↑ / “キャッシュ空白”↓ / 可観測性↑。
背景(なぜ起きる?)
- 逐次 SETEX 更新だと、ロード順の時差で TTL がばらけ、同一瞬間に 新旧が混在。
- 単一キー上書きは高トラフィック時に レースが発生しやすい(特に水平スケール時)。
- スケールアウトで Pod 間の読みバージョンが揃わず、一貫性問題とデバッグ困難さを招く。
解決策のコア:バージョン付きキー + TTLの“一括”制御
設計原則
- ネームキー:
feat:{entity_id}:v{version}
- ポインタキー:
feat:current_version
(読み手はまずここを見る) - ロード:
version
を固定して バルク書き込み + 同一 TTL - 切替: すべての書込みが終わったら、ポインタを原子的に更新
- パージ: 旧
version
を グレース期間後に削除(“stale‑while‑revalidate”を許容)
[Materializer] --bulk load--> feat:U123:v1704770000 (TTL=600)
feat:U456:v1704770000 (TTL=600)
...
--atomic swap--> feat:current_version = 1704770000
[Reader] --get-> current_version -> read feat:{entity}:v{ver}
最小実装(Redis / Python)
import redis, json, time
r = redis.Redis()
BASE_TTL = 600 # 秒
def bulk_load(features_by_entity):
version = int(time.time()) # 適当な単調増加でもOK(後述)
pipe = r.pipeline(transaction=False)
for entity_id, feats in features_by_entity.items():
key = f"feat:{entity_id}:v{version}"
pipe.setex(key, BASE_TTL, json.dumps(feats))
# すべての key の書込み完了後に、current_version を更新。
# ※ 実運用は Lua で原子化するのが安全。
pipe.set("feat:current_version", version)
pipe.execute()
def get_feature(entity_id):
version = r.get("feat:current_version").decode()
key = f"feat:{entity_id}:v{version}"
val = r.get(key)
return json.loads(val) if val else None
原子的スワップ(Lua)
-- KEYS[1] = current_version key
-- ARGV[1] = expected_old_version (空文字可)
-- ARGV[2] = new_version
local cur = redis.call('GET', KEYS[1])
if ARGV[1] ~= '' and cur ~= ARGV[1] then
return {err = 'version_mismatch'}
end
redis.call('SET', KEYS[1], ARGV[2])
return 'OK'
- 狙い:
current_version
の 同時書込み競合を排除。 - 運用: Materialization ジョブは(必要なら)
expected_old_version
を指定。
バージョン設計の選択肢
- 時刻(epoch 秒): 実装が簡単、ソートも自然。同時更新の衝突に注意。
- 単調カウンタ:
INCR feat:version_seq
で採番。一意性が担保。 - ハッシュ(ETag 風): 入力データ/コードのハッシュ → 内容同一なら同一バージョン。
実務では INCR で採番→ メタに作成時刻/ジョブID/入力スナップショットを保存、がデバッグしやすい。
TTL 設計と“キャッシュ空白”対策
- 全体統一 TTL: バルクロードの粒度(例: 10分)でそろえる。
- グレース期間: 切替直後のキャッシュミスや遅延ノード向けに、旧バージョンを TTL+α 残す。
- stale‑while‑revalidate: 読みでミスったら 直前バージョンを返す → バックグラウンドで最新を温める。
gantt
dateFormat X
title TTL/Grace 運用イメージ
section v1
alive : 0, 600
grace (serve stale) : 600, 120
section v2
bulk load : 540, 60
atomic swap : 600, 1
監視と SLO(最低限)
- Consistency:
read_version_distribution{service=...}
(読まれた version の分布) - Hit Ratio:
redis_keyspace_hits / (hits+misses)
- Latency: p50/p95/p99、切替直後のスパイクを要監視
- Error:
version_mismatch
、nil read
の割合 - Cardinality: 同時保持バージョン数(メモリ圧と相談)
アンチパターン(なぜダメ?)
- 逐次 SETEX 更新: TTL がバラつき 混在しやすい。
- 古いキーを即削除: 切替瞬間に キャッシュ空白が発生。
- 単一キー上書き: 高トラフィックで レース/スロッシング。
スケール & 運用 Tips(Kubernetes 前提)
- Materializer は単一アクティブ:
leader‑election
で同時実行を防止。 - ノードローカルキャッシュ: Sidecar/Local Redis で ホットパス短縮(ただし整合性指標を可視化)。
- ローリング切替:
current_version
更新→ Read Pod 再起動 不要。ただし 接続プール/コネクションリーク は監視。 - Backfill Job: 旧バージョン参照が多い Entity を優先ヒート。
マルチテナント/ネームスペース
- キー:
feat:{tenant}:{entity_id}:v{version}
- ポインタ:
feat:{tenant}:current_version
- テナントごとに 独立ロールオーバー可能。メモリ/TTLを別勘定。
Feast / 他ストアへの応用
- Feast: materialization job で
entity_id + version
をキー化。current_version
は Redis / DynamoDB 側に置くと柔軟。 - Cassandra/Bigtable:
PK = entity_id
,CK = version
。WHERE version = :current
またはORDER BY version DESC LIMIT 1
。 - DynamoDB:
PK = entity_id
,SK = version
。current_version
は別テーブル or アイテムで管理。
段階的移行(ゼロダウンタイム)
- 新リーダー(バージョン付き)を 影で書き込み
- 並行で 両方のキーに書く(短期)
- Read を
current_version
参照へ切替 - 旧経路の読みをテレメトリで観測し、減少を確認
- グレース期間終了後に旧キーを削除
よくある質問(FAQ)
Q. バージョンが見つからない場合は? 直前バージョンを返し、メトリクスを上げて即ウォームアップ。
Q. バージョンをどれだけ保持する? 通常は 最新 + 直前(=2世代)。再計算コスト/メモリに応じて可変。
Q. 再計算ジョブが失敗したら? expected_old_version
でスワップを 中止し、次回リトライ。
Q. 書込み一部失敗時は? ロード検証で 総件数・サンプルチェックサム を確認。合格したらスワップ。
追加コード断片
stale‑while‑revalidate 読み
def get_feature_swrr(entity_id):
ver = r.get('feat:current_version').decode()
cur = r.get(f'feat:{entity_id}:v{ver}')
if cur:
return json.loads(cur)
# 直前バージョンへフォールバック
prev = str(int(ver) - 1)
old = r.get(f'feat:{entity_id}:v{prev}')
if old:
# 非同期でヒートアップ(擬似)
# thread_pool.submit(warm, entity_id, ver)
return json.loads(old)
return None
INCR 採番 + メタ
import time
new_ver = r.incr('feat:version_seq')
r.hset(f'feat:version_meta:{new_ver}', mapping={
'created_at': int(time.time()),
'job_id': 'materialize-20250908-0600',
'input_snapshot': 's3://bucket/path/...'
})
まとめ
- TTL は 逐次ではなく“バージョン単位”で揃える。
- バージョン付きキー + 原子ポインタで、一貫性/スケーラビリティ/運用のしやすさを同時に満たす。
- Redis 以外の KV/Feature Store でも 同じ設計原理が活きる。