TTLを付けなかったキーが作るもの
一言でいうと
期限切れは、時刻になってもすぐには起きません。アクセスしたときに消す(遅延削除)か、バックグラウンドがサンプルを取って消します(能動的な期限切れ処理)。
なぜ必要なのか
Redisインスタンスのメモリが増え続けるのに、原因が見つからない状況はよくあります。たいてい答えは1つです。TTLを付けていないキーが混ざっています。
問題は、それが目立たないことです。キャッシュのコードはたいていSETEXを使っていますが、どこか1か所でSETだけを使っていたり、SET key valで更新しながら、もともとあったTTLを消してしまったりします。この2つ目が特に巧妙です。SETは既存のTTLを取り除きます。TTLを維持するには、KEEPTTLオプションが必要です。
どう動くのか
期限切れの仕組みを、まず正確に知る必要があります。Redisは2つの方式を併用します。遅延削除は、キーにアクセスした瞬間に期限切れを確認して消します。能動的な期限切れ処理は、バックグラウンドでTTLのあるキーをランダムにサンプリングして、期限切れのものを消します。そのため、期限切れの時刻が過ぎているのに誰もアクセスしないキーは、しばらくメモリに残っています。DBSIZEが予想より大きい理由が、これです。
maxmemoryに達すると、エビクションポリシーが働きます。選択肢は8種類ですが、実務で重要なのは3つです。
noevictionはデフォルト値で、上限に達すると書き込みを拒否します。キャッシュとして使うときにこの設定だと、ある日突然、すべての書き込みが失敗します。
allkeys-lruまたはallkeys-lfuは、すべてのキーをエビクションの対象と見ます。純粋なキャッシュ用途なら、こちらです。筆者の推奨はallkeys-lfuです。最近性よりも頻度のほうが、キャッシュのヒット率によく合うからです。
volatile-lruは、TTLが設定されたキーだけをエビクトします。キャッシュと永続データが1つのインスタンスに混ざっているときに使います。ただし、この設定でキャッシュキーにTTLを付け忘れると、そのキーは永遠にエビクトされないままメモリを食います。
キャッシュが崩れる3つのパターン
TTL設計は、この3つを防ぐためのものです。名前が似ていて紛らわしいですが、原因と対策が違います。
| 名前 | 何が起きるか | 防ぐ方法 |
|---|---|---|
| スタンピード(stampede) | 人気のキーが期限切れになる瞬間に、数千のリクエストが同時にDBへ | ジッター、ロック、早期再計算 |
| 貫通(penetration) | 存在しないキーを調べ続ける。キャッシュに何も残らず、毎回DBへ | 「ない」も短くキャッシュする、ブルームフィルター |
| 雪崩(avalanche) | 多くのキーが一度に期限切れになる、またはキャッシュサーバーがダウンする | ジッター、TTLの分散、多層キャッシュ |
スタンピードはロックで防ぎます。最初のリクエストだけがDBに行き、残りは待ちます。
def get_or_load(key, loader, ttl=300):
v = r.get(key)
if v is not None:
return v
# SET NX 로 잠금 하나만 얻는다. 얻지 못한 요청은 잠깐 기다렸다 다시 읽는다.
if r.set(f"lock:{key}", "1", nx=True, ex=10):
try:
v = loader()
r.set(key, v, ex=ttl + random.randint(-30, 30)) # 지터
return v
finally:
r.delete(f"lock:{key}")
time.sleep(0.05)
return r.get(key) or loader() # 그래도 없으면 어쩔 수 없이 직접
より良い方法は早期再計算です。TTLが終わる前に確率的に先に更新すれば、期限切れの瞬間そのものがなくなります。残りのTTLが短いほど更新確率を高める方式(probabilistic early expiration)が、広く使われています。
貫通は、「ない」もキャッシュします。ただし短く(30–60秒)設定する必要があり、そうすると本当のデータができたときに、すぐ反映されます。
キャッシュと原本がずれる瞬間
書き込みがあれば無効化が必要で、無効化には順序の問題があります。
❌ 캐시를 먼저 지우고 DB 를 쓴다
1. DEL cache 2. (다른 요청이 옛 값을 읽어 캐시에 다시 넣는다)
3. UPDATE db → 캐시에 옛 값이 영원히 남는다
✅ DB 를 먼저 쓰고 캐시를 지운다 (cache-aside)
1. UPDATE db 2. DEL cache
→ 사이에 읽은 요청은 옛 값을 보지만, 곧 사라진다
それでも、まれにずれます。完全に防ぐには、遅延二重削除(書いて消したあと、数百ミリ秒後にもう一度消す)や、変更ログ(CDC)の購読を使います。ほとんどのサービスでは、短いTTLのほうが安上がりな答えです。ずれても、TTLの分だけしかずれません。
現場での姿
TTLにジッターを入れることも重要です。デプロイ直後にキャッシュを一斉に埋めると、TTLがまったく同じなので、1時間後にすべてが同時に期限切れになります。その瞬間がスタンピードです。ttl = base + random(-spread, +spread)の1行で、期限切れが時間軸に散らばります。
そして、監査が必要です。SCANで走査しながらTTLが-1のキー(期限なし)を数えるスクリプトを定期的に実行すれば、新しく入ったコードがTTLを付け忘れたときに、すぐ捕まります。
次のラボですること
TTLをかけて確認し、SETがTTLを消してしまう落とし穴を再現し、KEEPTTLで防ぎ、エビクションポリシーを変えて実際にキーがエビクトされるのを確認し、最後にTTLのないキーを見つける監査スクリプトを作ります。