対象:Redis/Memcached + RDB/DWH(PostgreSQL, BigQuery, DynamoDB, Cassandra 等)でオンライン特徴量を提供しているチーム


TL;DR(まずは結論)

  • 推論時に**DBとキャッシュに同時書き込み(ライトスルー)**を行うと、キャッシュミス連発や「古い値が返る」事故を抑えられる。
  • 短めTTL + ジッター(ランダム化)で一斉失効スパイクを回避。
  • ミス時はDBフォールバック→即キャッシュ補填でE2Eレイテンシを安定化。
  • 「ライトバックのみ」「固定TTLのみ」「キャッシュ専用運用」はアンチパターン。

背景/よくある困りごと

オンライン特徴量をRedisやMemcachedのようなKVSに置くとき、

  • 推論要求のたびに都度更新(ライトバック)
  • 事前の一括投入(バルクロード) のどちらかに偏ると、一貫性の乱れスパイク時のDB過負荷につながる。特に高トラフィックでは、
  • 最新値が取得できずオフライン評価との差が出る
  • キャッシュミス多発 → レイテンシ悪化 → さらにミス増加 が連鎖的に起こる。

解決策:ライトスルー戦略(Write‑Through)

オンライン更新のたびにDB更新と同時にキャッシュへ書き込む。これで、

  • キャッシュミスを減らす
  • DBとキャッシュの一貫性を維持
  • バックエンドDBへの**輻輳(ふくそう)**を回避 が可能。

実装の要点

  • 同一トランザクション境界を意識(DBコミット成功後にキャッシュ書込)。
  • Idempotency(冪等性):同一リクエストの再送でも結果が変わらないようにする。
  • 短めTTL + ジッターttl = base + rand(±jitter) で一斉失効を防ぐ。
  • ホットキー対策:キー分散(suffixシャーディング)や局所LRU。

サンプル(Python + Redis + PostgreSQL)

import os, json, random
import redis, psycopg2

r = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0"))
pg = psycopg2.connect(os.getenv("PG_DSN", "dbname=features user=mlops"))

BASE_TTL = 600  # 10分
JITTER = 120    # ±2分

def _ttl():
    return BASE_TTL + random.randint(-JITTER, JITTER)

def update_feature(entity_id: str, features: dict):
    """ライトスルー:DB更新の直後にRedisへ反映(冪等)"""
    payload = json.dumps(features, separators=(",", ":"))
    with pg:
        with pg.cursor() as cur:
            # UPSERT(PostgreSQL)
            cur.execute(
                """
                INSERT INTO features(entity_id, payload, updated_at)
                VALUES (%s, %s, now())
                ON CONFLICT (entity_id)
                DO UPDATE SET payload = EXCLUDED.payload, updated_at = now()
                """,
                (entity_id, payload),
            )
    # DBコミット成功後にキャッシュへ(at-least-once)
    key = f"feat:{entity_id}"
    r.setex(key, _ttl(), payload)


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

    # ミス時:DBフォールバック → 即キャッシュ補填
    with pg.cursor() as cur:
        cur.execute("SELECT payload FROM features WHERE entity_id = %s", (entity_id,))
        row = cur.fetchone()
        if row and row[0]:
            r.setex(key, _ttl(), row[0])
            return json.loads(row[0])
    return None

メモ:RDB以外(DynamoDB/Cassandra 等)でも、永続層コミット → キャッシュ反映の順序は同じ。キュー/リトライで一時失敗にも耐性を持たせると良い。


ありがちなアンチパターン

  • ライトバックのみ:一時的なキャッシュ更新がDBに反映されず、古い特徴量が返る。
  • キャッシュ専用運用:Redis障害で全推論が落ちる(DBフォールバック経路が無い)。
  • 固定TTLだけ:同時刻に一斉失効 → ミス雪崩 → スパイク。

関連技術の組み合わせ

  • Feast:Redisをオンライン、BigQuery/Bigtable等をオフライン。materialize(バッチ)+ストリーミングを併用して鮮度と一貫性を両立。
  • DynamoDB:Streams + Lambda でコミットイベント駆動のライトスルーに近い設計が可能。
  • CassandraCONSISTENCY QUORUM を併用しつつ、書込後フックでRedis補填。

観測(Observability)設計のコア

最終的に守るべきはユーザーの体感。LagだけでなくE2E遅延を主指標にする。

  • メトリクス
    • feature_e2e_latency_ms(p50/p95/p99)
    • cache_hit_ratio(ヒット率)
    • db_fallback_rate(ミス→DB参照率)
    • redis_errors_total, retry_count
  • アラート例
    • p95 E2E > 200ms が 5分継続
    • cache_hit_ratio < 0.9 が 10分継続
    • db_fallback_rate スパイク(平常比 +300%)

ロールアウト戦略(安全に入れる)

  1. Shadow/Read‑only 段階:ライトスルーはONだが推論は従来経路を参照。
  2. Canary(5%→25%→50%→100%):メトリクスしきい値で自動ロールバック。
  3. 手戻り策:feature flag で瞬時に旧経路へ切替。

障害モードと対策

  • Redis障害:DBフォールバックが即座に機能する設計か? コネクションプール/サーキットブレーカ必須。
  • DB障害:一時的に**ライトバック(キャッシュ優先)**へ切替できる運用Runbookを用意。
  • ホットキー:キー分散、レプリカ読み、レイテンシベースのHPA、randomized TTL

1分でわかる比較表

パターン一貫性レイテンシ失敗時の挙動運用難易度
ライトスルー高い低いDB/Redis片系障害でも耐性(設計次第)
ライトバックのみ中〜低低い(短期)乖離・ロストで品質低下
キャッシュ専用キャッシュ障害=全停止
バルクロードのみ鮮度が落ちやすい

よくある質問(FAQ)

Q. TTLはどれくらいが目安?
A. 5〜15分を起点に、E2E遅延・ヒット率・DB負荷の3点を見て調整。常時更新が走る特徴量は短め、滅多に変わらないIDマッピング系は長め。

Q. 強整合性が絶対に必要な場合は?
A. 書き込み後に読取で必ずキャッシュを経由し、DB側とバージョン番号/更新時刻で突合。必要に応じてRead‑Your‑Writes保証をアプリで担保。

Q. マルチリージョンでは?
A. ソースオブトゥルースは1つに決め、クロスリージョンは非同期複製+バージョン優先ルールで解決。リージョン内は近接キャッシュ。


まとめ

  • ライトスルーは一貫性とスケーリングのバランスが良く、現実解になりやすい。
  • ただし“入れただけ”では不十分。ジッターTTL、DBフォールバック、観測/アラート、ホットキー対策、ロールバック手段まで揃えて初めて安定運用。

参考実装の拡張ポイント

  • 失敗時再試行(キュー化/DLQ)
  • 書込後イベントをPub/Sub配信し、他キャッシュ層も非同期更新
  • If-Match ヘッダやversionカラムで競合検出(楽観的ロック)

投稿者 kojiro777

コメントを残す

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