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の“一括”制御

設計原則

  1. ネームキー: feat:{entity_id}:v{version}
  2. ポインタキー: feat:current_version(読み手はまずここを見る)
  3. ロード: version を固定して バルク書き込み + 同一 TTL
  4. 切替: すべての書込みが終わったら、ポインタを原子的に更新
  5. パージ: 旧 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_mismatchnil 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 = versionWHERE version = :current または ORDER BY version DESC LIMIT 1
  • DynamoDB: PK = entity_id, SK = versioncurrent_version は別テーブル or アイテムで管理。

段階的移行(ゼロダウンタイム)

  1. 新リーダー(バージョン付き)を 影で書き込み
  2. 並行で 両方のキーに書く(短期)
  3. Read を current_version 参照へ切替
  4. 旧経路の読みをテレメトリで観測し、減少を確認
  5. グレース期間終了後に旧キーを削除

よくある質問(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 でも 同じ設計原理が活きる。

投稿者 kojiro777

コメントを残す

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