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-Aside | DBのみ + キャッシュ削除 | キャッシュ→DB→キャッシュ更新 | 動く(遅い) |
| Read-Through | DBのみ + キャッシュ削除 | キャッシュ層が自動で DB から補充 | 動かない |
| Write-Through | キャッシュ→DB(同期) | キャッシュ層 | 動かない |
| Write-Behind | キャッシュ→DB(非同期) | キャッシュ層 | 動かない / データロスあり |
Web サービスで一番採用されているのは Cache-Aside です。Read-Through はキャッシュ層が DB アクセスを担うので構成が綺麗な反面、キャッシュが死ぬとサービスも止まります。
redis-py 7.4で最小のキャッシュ層を書く
SET と EXPIRE を別呼び出しにすると、間にプロセスが落ちたとき 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を付けて関数情報を残す。

