Pythonでredis-pyを使うキャッシュ実装:Cache-AsideとTTL設計

Pythonでredis-pyを使うキャッシュ実装:Cache-AsideとTTL設計 | mohablog

APIレスポンスを毎回 PostgreSQL から取り直すと p99 が 800ms 前後で頭打ちになります。Redis を間に挟むだけで桁が変わりますが、TTL と無効化の設計を間違えると古い値を 30 分返し続けます。redis-py 7.4 で Cache-Aside パターンを書くときの実装と運用判断を整理します。

目次

Cache-Asideパターンがやっていること

Cache-Aside は「アプリがキャッシュと DB の両方を読み書きする」戦略。Redis 公式の cache-aside ガイドでは “Lazy loading” として紹介されており、必要になったタイミングでキャッシュを埋める動きを取ります。

読み取りの流れ

リクエストが届くとアプリは 最初に Redis を引きにいきます。ヒットすればそのまま返し、ミスなら DB から取得して Redis に書き込んでから返します。アプリのコードに読み書きが見えるのでデバッグしやすく、Redis を落としても Web サービスのレスポンスは返せます。

Write-Through・Read-Throughとの違い

用語が混乱しやすいので一度整理します。

パターン書き込み読み取りキャッシュ未参加時
Cache-AsideDBのみ + キャッシュ削除キャッシュ→DB→キャッシュ更新動く(遅い)
Read-ThroughDBのみ + キャッシュ削除キャッシュ層が自動で DB から補充動かない
Write-Throughキャッシュ→DB(同期)キャッシュ層動かない
Write-Behindキャッシュ→DB(非同期)キャッシュ層動かない / データロスあり

Web サービスで一番採用されているのは Cache-Aside です。Read-Through はキャッシュ層が DB アクセスを担うので構成が綺麗な反面、キャッシュが死ぬとサービスも止まります。

redis-py 7.4で最小のキャッシュ層を書く

SETEXPIRE を別呼び出しにすると、間にプロセスが落ちたとき TTL 無しのキーが残ります。setex は書き込みと TTL 設定をアトミックに実行するので、ここを基本形にします。

ConnectionPoolで接続を使い回す

FastAPI や Django の Web プロセスごとに redis.Redis() をリクエスト毎に生成すると、コネクション枯渇で ConnectionError が出ます。プールで使い回します。

import json
import redis

pool = redis.ConnectionPool(
    host="localhost",
    port=6379,
    db=0,
    max_connections=32,
    decode_responses=True,
)
r = redis.Redis(connection_pool=pool)
r.ping()

実行結果:

True

1 プロセスに 1 つのプールを持ち、リクエストごとに redis.Redis(connection_pool=pool) を作る使い方ならスレッドセーフで動きます。

setexでアトミックにTTLを設定する

get_user は Redis を引いて、ミスなら DB から取得して setex で 60 秒の TTL 付きで書き戻します。

CACHE_KEY = "user:{user_id}"
TTL_SECONDS = 60

def get_user(user_id: int) -> dict | None:
    key = CACHE_KEY.format(user_id=user_id)
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)

    record = fetch_user_from_db(user_id)
    if record is None:
        return None
    r.setex(key, TTL_SECONDS, json.dumps(record))
    return record

実行結果(キャッシュミス→ヒット):

$ python -c "
import time
from cache import get_user
for _ in range(2):
    t = time.perf_counter()
    print(get_user(42))
    print(f'{(time.perf_counter()-t)*1000:.1f}ms')
"
{'id': 42, 'name': 'sayed', 'email': 'sayed@example.com'}
14.8ms
{'id': 42, 'name': 'sayed', 'email': 'sayed@example.com'}
0.7ms

初回 14.8ms は DB アクセスを含むので想定通り。2 回目は Redis だけで 0.7ms。本番だと DB の方が 100ms 前後になることが多いので、桁で差がつきます。

TTL設計が古いデータをどこまで許すかを決める

The TTL is the upper bound on how long a stale value can be served.

redis.io の “Production usage” の中の “Choose a TTL that matches your staleness tolerance” にある一文です。TTL は「古い値を最大どれだけ返してよいか」の上限であって、性能チューニングの数字ではありません。

短いTTLと長いTTLのトレードオフ

短い TTL(数秒) は最新性が高い反面、ヒット率が下がって DB の負荷が増えます。長い TTL(数時間) はヒット率を稼げますが、ユーザー情報を更新しても古い名前が表示され続けます。「このデータが古かったら問い合わせが来るか」を基準に決めます。

データの種類TTL目安無効化
マスタ(国コード等)1〜24時間マスタ更新時にDEL
ユーザープロフィール60〜300秒更新APIでDEL
セッション付き設定10〜30秒TTL任せ
ランキング・カウント1〜5秒TTL任せ

ジッターを加えてキャッシュの同時失効を防ぐ

同じ TTL で何千件も書き込むと、ちょうど 60 秒後に一斉に失効して DB に集中アクセスが来ます。ランダムに数秒のばらつきを足すと分散します。

import random

def setex_with_jitter(client: redis.Redis, key: str, value: str,
                      base_ttl: int, jitter: int = 5) -> None:
    ttl = base_ttl + random.randint(0, jitter)
    client.setex(key, ttl, value)

実行結果:

>>> [r.ttl(f"user:{i}") for i in range(5)]
[63, 60, 64, 61, 62]

5 件のキーがそれぞれ 60〜65 秒のどこかで失効するようになりました。

書き込み時のキャッシュ無効化を漏らさない

Cache-Aside で最も事故が起きるのが「DB は更新したのにキャッシュを消し忘れた」ケース。古い値が TTL いっぱい返り続け、ユーザーから「変更が反映されない」問い合わせが来ます。

SET ではなく DEL を選ぶ理由

更新パスで Redis にも書き込むと、DB と Redis の書き込み順序によって不整合が起きます。よくあるアンチパターン:

def update_user(user_id: int, name: str) -> None:
    # NG: 先にキャッシュを書く
    r.set(CACHE_KEY.format(user_id=user_id), json.dumps({"name": name}))
    db.execute("UPDATE users SET name=%s WHERE id=%s", (name, user_id))

このコードは DB 更新が失敗しても Redis には新しい値が入るので、リトライ前のリクエストが「更新後」の値を見てから DB は古いままという奇妙な状態を生みます。逆順に書いても、別プロセスの Cache-Aside の読み取りが間に挟まると古い DB の値をキャッシュに書き戻して上書きされます。

削除に倒すと素直です:

def update_user(user_id: int, name: str) -> None:
    db.execute("UPDATE users SET name=%s WHERE id=%s", (name, user_id))
    r.delete(CACHE_KEY.format(user_id=user_id))

削除後の最初のリクエストはキャッシュミスになり、確実に最新の DB を引きにいきます。redis.io の “Invalidate, don’t try to keep the cache in sync” でも同じ方針が示されています。

部分更新にはHSETでフィールド単位

Hash 型でキャッシュしているなら、特定フィールドだけ更新する HSET も使えます。

r.hset("user:42", mapping={"name": "moha", "email": "moha@example.com"})
r.hset("user:42", "name", "moha-updated")

実行結果:

>>> r.hgetall("user:42")
{'name': 'moha-updated', 'email': 'moha@example.com'}

ただし「書き込みは DB を正とする」原則を破ると整合性の追跡が難しくなるので、Cache-Aside の枠を抜けて Write-Through に踏み込む場面だけで使います。

キャッシュ・スタンピードをsingle-flightロックで防ぐ

人気エンドポイントのキャッシュが TTL で消えた瞬間、数千リクエストが同時に DB を引きにいきます。これがキャッシュ・スタンピード(thundering herd)。redis.io の “Stampede protection with a Lua lock” では Lua スクリプトで単一フライトロックを実装する例が示されています。

SET NX EXでロックを取って一人だけDBを引く

キャッシュミスを検知したクライアントだけがロックを取り、DB アクセスを行います。他のクライアントは少し待って再度キャッシュを読みにいきます。

import time

LOCK_KEY = "lock:user:{user_id}"
LOCK_TTL = 5

def get_user_with_lock(user_id: int) -> dict | None:
    key = CACHE_KEY.format(user_id=user_id)
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)

    lock_key = LOCK_KEY.format(user_id=user_id)
    got = r.set(lock_key, "1", nx=True, ex=LOCK_TTL)
    if not got:
        for _ in range(20):
            time.sleep(0.05)
            cached = r.get(key)
            if cached is not None:
                return json.loads(cached)
        return fetch_user_from_db(user_id)

    try:
        record = fetch_user_from_db(user_id)
        if record is not None:
            r.setex(key, TTL_SECONDS, json.dumps(record))
        return record
    finally:
        r.delete(lock_key)

実行結果(同時 100 並列でアクセス、キャッシュ消去直後):

DB query count (no lock):   97
DB query count (with lock):  1

ロック無しだと 100 並列がほぼ全てキャッシュミスを引き、97 件 DB クエリが流れます。ロックを噛ませると 1 件で済みます。

ロックTTLは短く、必ず解放する

ロック保持中にプロセスが落ちると、ロックキーが残ってデッドロック相当の状態になります。LOCK_TTL を 5 秒前後に抑えておけば最悪でも 5 秒で解放されます。redis.io の “Tune the single-flight lock TTL” でも、ロック TTL は短く保ち、明示的な DEL と TTL の両方で守ると書かれています。

デコレータでキャッシュロジックを業務コードから剥がす

業務関数に Redis 操作を直書きすると、関数本体に DB クエリと Redis 呼び出しが混ざります。デコレータに寄せると関数本体には DB アクセスだけが残ります。

import functools
import hashlib

def cache_aside(ttl: int):
    def deco(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            payload = f"{func.__name__}:{args}:{sorted(kwargs.items())}"
            key = "cache:" + hashlib.sha1(payload.encode()).hexdigest()
            cached = r.get(key)
            if cached is not None:
                return json.loads(cached)
            value = func(*args, **kwargs)
            r.setex(key, ttl, json.dumps(value))
            return value
        return wrapper
    return deco

@cache_aside(ttl=60)
def get_top_articles(limit: int = 10) -> list[dict]:
    return db.fetch_all(
        "SELECT * FROM articles ORDER BY views DESC LIMIT %s", (limit,)
    )

実行結果:

>>> get_top_articles(limit=5)   # 初回(DB)
12.4ms
>>> get_top_articles(limit=5)   # 2回目(Redis)
0.6ms
>>> get_top_articles(limit=10)  # 引数が違うので別キー
13.1ms

functools.wraps を忘れると、デコレータをかけた関数の __name__wrapper になり、ログやスタックトレースから関数名が消えます。Pythonデコレータの仕組みと自作—functools.wrapsで関数情報を保つ で詳しく書いた話なので、デコレータを書くときは @functools.wraps(func) を必ず付けてください。

まとめ

  • Cache-Aside はアプリがキャッシュと DB の両方を読み書きする。Redis が落ちても DB さえ生きていれば動く。
  • TTL は性能チューニング値ではなく「古い値を最大何秒返してよいか」の上限。データの性質ごとに決める。
  • 書き込み時はキャッシュを SET ではなく DEL で消す。順序ミスや競合で古い値が書き戻されなくなる。
  • キャッシュ・スタンピード対策は SET NX EX の single-flight ロックで DB アクセスを 1 本に絞る。ロック TTL は 5 秒前後。
  • デコレータで剥がすと業務コードから Redis が消える。functools.wraps を付けて関数情報を残す。
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次