【Redisアーキテクチャの深層】EXPIREの裏側:なぜメモリは即座に解放されないのか?
おい、設計レビューを始めるぞ。
お前たちが実装したセッション管理やキャッシュのモジュール、コード自体は綺麗にまとまっている。だがな、ここに書かれている `EXPIRE` を使った有効期限の設計――これでは本番環境のピーク時にOOM Killer(Out Of Memory Killer)の餌食になるか、レイテンシスパイクを引き起こしてサービスが沈没する。
「キーに期限を設定したんだから、時間になれば勝手にメモリが綺麗に掃除されるはずだ」なんてナイーブな幻想を抱いていないか?
Redisは魔法の箱ではない。シングルスレッドで極限のパフォーマンスを発揮するインメモリデータベースだからこそ、メモリの解放メカニズムを正確に理解していなければ、予期せぬメモリリークやCPU使用率の跳ね上がりに足元をすくわれる。
今日は、Redisが裏側でどのように期限切れキーを処刑しているのか、その冷徹かつ合理的なアルゴリズムの全貌を叩き込む。耳の穴をかっぽじじよく聞いておけ。
—
1. 幻想の否定:「期限切れ = 即座のメモリ解放」ではない
まず、大前提を叩き込む。
Redisは、キーの有効期限が切れた瞬間に、OSへ向けてメモリを解放するわけではない。
もし、数百万件のキーが同時に期限切れを迎えた瞬間、それらすべてを検知してCPUが逐一メモリを解放しようとすればどうなるか? シングルスレッドであるRedisは、その掃除のせいで完全にブロックされ、クライアントからのリクエスト処理が数秒、あるいは数十秒間ストップする。
Redisの設計思想は一貫している。「メインの処理性能(低レイテンシ)を絶対に落とさないこと」だ。
そのため、期限切れキーの削除は、以下の2つのアプローチを裏で巧みに組み合わせて非同期に行われている。
1. パッシブ削除(Passive Expiration):クライアントがアクセスした時
2. アクティブ削除(Active Expiration):Redisがバックグラウンドで自律的に行うサンプリング
この2つの仕組みを解剖していくぞ。
—
2. 削除メカニズムの二段構え
① パッシブ削除(受動的削除)
これは最もシンプルだ。クライアントが特定のキーに対して `GET` や `HGET` などのコマンドを発行した際、Redisはそのキーがアクセスされた瞬間に「おっと、寿命が切れているな」と気づく。その場でキーを削除し、キーが存在しない(nil)として振る舞う。
- メリット: CPUの無駄な巡回コストがかからない。
- デメリット: 二度とアクセスされない期限切れキーは、永遠にメモリに居座り続ける。
「アクセスされないならメモリの無駄遣いじゃないか」と思ったな?その通り。だからこそ、もう一つの仕組みが必要になるのだ。
② アクティブ削除(能動的削除)
パッシブ削除の欠点を補うのが、Redisのバックグラウンドで常時(正確にはデフォルトで秒間10回)実行されているアクティブ削除プロセスだ。
これがRedisのメモリ管理のキモである。動きをステップごとにロジカルに追うぞ。
1. Redisは毎秒10回(Hz設定で変更可能)、期限切れ情報(expires dictionary)を持つキーの中からランダムに20個のキーをサンプリングする。
2. その20個のうち、実際に有効期限が切れているキーをすべて削除する。
3. もし、サンプリングした20個のうち、25%以上(5個以上)のキーが期限切れだった場合、Redisは「この空間はまだゴミだらけだ」と判断し、即座にステップ1に戻って再び20個のサンプリングと削除を行う。
[アクティブ削除のアルゴリズム]
1. 期限切れ管理辞書からランダムに 20個のキーを抽出
2. 期限切れのキーを特定し、即座に削除
3. 期限切れキーの割合が 25% を超えているか?
├── YES ──> すぐにステップ1へ戻る(高頻度ループ)
└── NO ──> 終了(次の実行タイミングまで待機)
このループ機構により、メモリ上に眠るゴミの割合が常に25%未満に抑え込まれるよう、動的にCPUリソースを配分している。実にエグいほど合理的だろう?
—
3. 実務でハマる「最悪のシナリオ」とアンチパターン
さて、この仕組みを理解していれば、お前たちがやりがちな「やってはいけない設計」が手に取るようにわかるはずだ。
アンチパターン:一斉有効期限の設定(Stampede / Expiration Storm)
例えば、深夜0時に一斉に有効期限が切れるように、数百万件のセッションキーやキャッシュに対して `EXPIRE 86400` を同じタイミングで設定したとする。
何が起きるか?
深夜0時を過ぎた瞬間、メモリ上の膨大なキーが一気に「期限切れ」になる。しかし、それらはまだアクティブ削除のサンプリングに引っかかっていない。
そこに大量のユーザーがアクセスし、パッシブ削除が連鎖的に発生するか、あるいはアクティブ削除のループ(25%超えの条件)がCPUを完全に飽和させる。
結果として、Redisのレイテンシが跳ね上がり、CPU使用率が100%に張り付き、後続のリクエストがタイムアウトする「エグスプロージョン(有効期限の嵐)」が完成する。本番障害の定番だ。
【対策】ジッター(Jitter)の導入
もし大量のキーに有効期限を持たせる場合は、必ずランダムな揺らぎ(ジッター)を加えろ。
import random
import redis
client = redis.Redis(host=’localhost’, port=6379)
def set_cache_with_jitter(key, value, base_ttl=86400):
# 基本のTTLに、±10%(最大8640秒)のランダムな揺らぎを付与する
jitter = random.randint(-8640, 8640)
actual_ttl = base_ttl + jitter
client.setex(key, actual_ttl, value)
print(f”Key: {key}, TTL set to: {actual_ttl}s”)
たったこれだけで、期限切れのタイミングが綺麗に分散され、アクティブ削除やCPUへの負荷を美しいまでに均すことができる。プロのエンジニアなら、こういう泥臭い配慮を忘れない。
—
4. メモリ管理のもう一つのリミッター:`maxmemory-policy`
キーが期限切れを迎えて削除されるまでの間、あるいはメモリが物理的限界に達した時、Redisはどう振る舞うべきか。ここで重要になるのが `maxmemory` とそのポリシーの設定だ。
もし書き込みが頻発し、アクティブ削除が追いつかずにメモリ上限(`maxmemory`)に達した場合、Redisは設定されたポリシー(`volatile-lru`, `allkeys-lru`, `noeviction` など)に従って容赦なくキーを追い出す(Eviction)。
ここで、期限切れ管理をしているキーに特化したポリシーの選び方を伝授しておこう。
- `volatile-lru`: 有効期限(`EXPIRE` など)が設定されているキーの中から、LRU(最近最も使われていない)アルゴリズムに基づいて削除する。
- `volatile-ttl`: 有効期限が最も短い(=もうすぐ切れる)キーから優先的に削除する。
- `noeviction`: メモリ上限に達したら書き込み系コマンドをすべてエラー(OOM)にする(キャッシュ以外の、絶対にデータを落としたくないマスターデータ用途)。
キャッシュサーバーとしてRedisを使うなら、`volatile-lru` や、より近代的な `volatile-lfu`(使用頻度が低いものを狙う)を適切に選定し、メモリのオーバーフローを防ぐ防壁を張っておく必要がある。
—
5. チーフアーキテクトからの最終通告
今日の講義のまとめだ。
1. EXPIREは即座にメモリを解放しない。 解放は「パッシブ(アクセス時)」と「アクティブ(バックグラウンドの確率的サンプリング)」によって非同期に行われる。
2. 有効期限の同時刻一括設定は厳禁。 必ずジッター(ランダムな揺らぎ)を混ぜて、負荷の波を分散させろ。
3. メモリ上限とエビクションポリシーを必ず定義しろ。 デフォルトのままで本番稼働させるのは、シートベルトなしでF1を運転するようなものだ。
仕組みの本質を知っていれば、障害は未然に防げる。
お前たちのコード、もう一度見直してこい。ジッターの実装漏れや、無謀なTTL設定がないか、次のレビューまでに完璧に仕上げておくように。
解散!
コメント