Redisの寿命管理:なぜあなたのキーは「消えない」のか?
設計レビューをしていると、未だに「Redisに有効期限(TTL)を設定しておけば、時間ぴったりにメモリから綺麗に消えてくれる」と誤解しているエンジニアに出会う。
残念ながら、現実のRedisはそこまでお人好しではない。
大規模なキャッシュ基盤やセッションストアを運用していて、突然のメモリ枯渇(OOM)や、謎のレイテンシスパイクに頭を抱えた経験はないだろうか? その犯人は、大抵の場合 Redisの有効期限の内部メカニズム に対する無理解にある。
今回は、Redisがどのように期限切れキーを追跡し、メモリからパージしているのか、その深淵なるアルゴリズムをコードレビューの視点からロジカルに解き明かしていく。
—
1. 期限切れの裏側:2つの削除戦略
Redisは、キーの有効期限が切れた瞬間に対象メモリを即座に解放するわけではない。CPUリソースとメモリリソースのトレードオフを極限まで最適化するため、以下の2つの異なる戦略を組み合わせている。
A. パッシブ削除(Passive Expiration) — 「受動的パージ」
クライアントがそのキーにアクセスしようとした際、Redis内部でアクセス時に「おっと、このキーは寿命を迎えているな」と判定し、その場で削除する方式。
- メリット: 無駄なバックグラウンド処理が発生しない。
- デメリット: アクセスされない限り、永遠にメモリ上に残る。これがいわゆる「ゾンビキー」問題の温床となる。
B. アクティブ削除(Active Expiration) — 「能動的パージ(サンプリング)」
パッシブ削除だけでは、二度とアクセスされない期限切れキーがメモリを圧迫し続ける。これを防ぐため、Redisはバックグラウンドで定期的にアクティブ削除を実行している。
これが実務上、最も挙動を把握しておかなければならない仕組みだ。
アクティブ削除のアルゴリズム(精読すべき核心)
Redisのメインループ(イベントループ)は、デフォルトで1秒間に10回(Hz = 10)、以下の処理(`activeExpireCycle`)を実行する。
1. 有効期限が設定されているキーのメタデータを持つ辞書(Expires Dictionary)から、ランダムに 20個 のキーをサンプリングする。
2. その20個のうち、すでに期限切れになっているものをすべて削除する。
3. 期限切れだったキーの割合が 25%以上 であった場合、ステップ1に戻り即座に追加サンプリングを行う。
つまり、「期限切れキーの比率が全体の25%を下回るまで、CPU時間を消費してループし続ける」 という設計になっている。
[Redis Event Loop (10Hz)]
│
▼
20個のキーをランダムサンプリング
│
├─ 期限切れが < 25% ──► 終了(次のサイクルへ)
│
└─ 期限切れが ≧ 25% ──► 即座に再サンプリング(ループ継続)
---
2. 現場で起きる「恐怖のシナリオ」とパフォーマンスへの影響
このアクティブ削除の仕組みを知っていれば、プロダクション環境で何が起こるか容易に想像がつくだろう。
シナリオ:一斉有効期限切れ(Mass Expiry)の悲劇
キャンペーンの終了時刻や、毎正時に「有効期限を1時間に設定した数百万件のセッションやキャッシュ」を投入したとする。
これらがほぼ同時に寿命を迎えると、何が起きるか?
1. 次のアクティブ削除サイクル(100msに1回)で、サンプリングされた20個のキーの大半が期限切れ(> 25%)になっている。
2. Redisはアルゴリズムに従い、CPUをガンガン回してサンプリングと削除のループを延々と繰り返す。
3. シングルスレッドであるRedisは、この削除処理にCPUコアを占有され、肝心のクライアントからのリクエスト(GET/SETなど)を処理できなくなる。
4. 結果、アプリケーション側でコネクションプールの枯渇やタイムアウトが連鎖的に発生する。
これが、「TTLを設定しただけなのにRedisが突然高負荷になる」という現象の正体だ。
—
3. 堅牢な設計パターン:プロはどう防ぐか?
このメカニズムを踏まえ、シニアエンジニアとして実務の設計にどう落とし込むべきか。いくつかの実践的なパターンを提示する。
パターン1: ジッター(Jitter)の導入
バッチ処理や初期データ投入時に、すべてのキーのTTLを完全に同一にしてはならない。ミリ秒単位、あるいは数分単位のランダムな揺らぎ(ジッター)を付与し、有効期限のピークを分散させよ。
import random
import redis
client = redis.Redis(host=’localhost’, port=6379)
def set_cache_with_jitter(key, value, base_ttl_seconds=3600):
# ±10%(最大360秒)のランダムなジッターを付与
jitter = random.randint(-360, 360)
actual_ttl = base_ttl_seconds + jitter
client.setex(key, actual_ttl, value)
実行例
set_cache_with_jitter(“user:session:12345”, “data”, 3600)
パターン2: メモリ上限とEviction Policyの適切な選択
万が一、アクティブ削除が間に合わずメモリが上限(`maxmemory`)に達した場合に備え、適切なメモリ淘汰ポリシー(Eviction Policy)を設定しておく必要がある。
redis.conf の推奨設定例(大規模キャッシュの場合)
maxmemory 4gb
maxmemory-policy volatile-lru
- `volatile-lru`: 有効期限が設定されているキーの中から、LRU(最近最も使われていない)アルゴリズムに基づいて削除する。
- アンチパターン: `noeviction`を本番のキャッシュサーバーで使うこと。メモリが溢れた瞬間に書き込みが一切できなくなり、システム全体が沈没する。
—
4. チーフアーキテクトからのコードレビュー的提言
設計レビューで以下のコードを見かけたら、即座に差し戻しを要求してほしい。
> NGレビュー例:
> 「このマスタデータは更新されないので、一律でTTLを `EXPIRE 86400`(24時間)に設定しました」
何が問題か?
アクセス頻度が極めて低い静的なマスタデータにまでTTLを設定すると、Expires Dictionaryのメモリ領域を無駄に消費するだけでなく、無駄なアクティブ削除の負荷をRedisに強いることになる。
正しいアプローチ:
- 本当に有効期限が必要か? 更新がないデータならTTLは設定せず、データ更新時(DBのUPDATE時など)にアプリケーションから明示的に `DEL` または `UNLINK` を叩く(キャッシュ無効化パターンの基本)方が、メモリ効率もCPU効率も圧倒的に高い。
- 非同期削除の活用: 巨大なハッシュやリストを一括削除する場合、通常の `DEL` はメインスレッドをブロックする。Redis 4.0以降であれば、別スレッドで非同期削除を行う `UNLINK` を必ず選択すること。
—
まとめ
Redisの有効期限メカニズムは、シンプルに見えて非常に洗練されたトレードオフの上に成り立っている。
- パッシブ削除(アクセス時)とアクティブ削除(100msごとのサンプリング)の二刀流であること。
- アクティブ削除は「期限切れ率25%」を閾値とする適応型ループであり、一斉に期限が切れる設計をするとCPUを焼き尽くすこと。
この深層を理解していれば、単なる「便利なキャッシュストア」から、大規模トラフィックに耐えうる「堅牢なインメモリデータグリッド」へとRedisのポテンシャルを極限まで引き出すことができるはずだ。次の設計では、ぜひ「時間の分散」と「削除のコスト」を意識してみてほしい。
コメント