Redisメモリ削除の深層:OOM Killerを回避し、キャッシュを「武器」にするアーキテクチャ設計
おい、設計レビューを始めるぞ。
お前たちが組んだキャッシュレイヤー、綺麗に動いているように見えて、実は地雷原の上を歩いていることに気づいているか?
「Redisはメモリが一杯になったら勝手に古いデータを消してくれるんでしょ?」――そんな甘い認識で本番を迎えたシステムが、ピーク時に突然のOOM (Out of Memory) Killerに刈り取られ、データベースを直撃して全システムが雪崩式に沈没していく。私はこの惨劇を幾度となく目撃してきた。
Redisは単なる「キーバリューの箱」ではない。メモリ管理の挙動を完全に支配して初めて、堅牢なインフラストラクチャと言える。
今回は、Redisのメモリ削除ポリシー(Eviction Policies)の深層と、実務の現場で絶対に踏んではいけない地雷、そしてプロフェッショナルが採用する設計パターンを叩き込む。
—
1. Redisメモリ管理の根本原則:何が起きているのか?
まず、大前提を叩き込んでおく。
Redisは、設定された上限メモリ(`maxmemory`)に達した瞬間から、新規書き込みに対してエラーを返すか、既存データを削除してスペースを空けるかの二者択一を迫られる。この挙動を制御するのが `maxmemory-policy` だ。
だが、勘違いしてはならない。TTL(有効期限)の有無 と 削除ポリシー は別軸で動いている。
- 受動的削除(Lazy Deletion): クライアントがキーにアクセスした際、すでにTTLが切れていればその場で消す。
- 能動的削除(Active Expiration): Redisはバックグラウンドで定期的に(デフォルトでは秒間10回)ランダムなキーをサンプリングし、期限切れのものを削ぎ落としている。
- メモリ削除(Eviction): `maxmemory` に達した際、TTLの有無に関わらず ポリシーに従って強制的にキーを追い出す。
ここを混同していると、「なぜかメモリが解放されない」「意図しないデータが消えた」という謎のバグに悩まされることになる。
—
2. 削除ポリシーの全貌と「選んではいけない」地雷
Redis 4.0以降、選択できるポリシーは多岐にわたる。だが、実務でまともに使えるものは限られている。それぞれの本質を暴いていこう。
① `noeviction` (デフォルト)
- 挙動: メモリ上限に達すると、書き込み系コマンド(`SET`, `HSET` 等)に対してエラーを返す。読み取りや削除は可能。
- 判定: 絶対に使ってはならない(キャッシュ用途の場合)。
- 理由: キャッシュサーバーとして動かしているつもりが、突然書き込み拒否を返し始め、アプリケーション層で例外が連鎖する。マスター・スプリットや障害の元凶だ。ただし、厳密なカウンターやデータストアとしてRedisを使う場合は例外的に選択肢に入る。
② `allkeys-lru` vs `volatile-lru`
- LRU (Least Recently Used): 直近で最も使われていない(アクセスされていない)ものを消す。
- `allkeys-lru`: データベース内のすべてのキーを対象にLRUで削除。
- `volatile-lru`: TTLが設定されているキーの中だけで LRUで削除。
- プロの知見: 原則として、キャッシュとして割り切るなら `allkeys-lru` 一択 だ。すべてのキーにTTLを貼り忘れるポカミスをするジュニアエンジニアがいるが、`allkeys-lru` なら「アクセス頻度の低いデータ」から自動的に追い出してくれるため、セーフティネットとして完璧に機能する。
③ `allkeys-lfu` vs `volatile-lfu` (Redis 4.0〜)
- LFU (Least Frequently Used): アクセス頻度が最も低いものを消す。
- プロの知見: 「最近一度だけアクセスされたが、普段は全く使われない巨大なマスターデータ」のような偏りがあるシステムでは、LRUよりも圧倒的に LFU が優れている。LRUだと、一括バッチ処理で全キーをスキャンした瞬間に、本来残すべきホットなキャッシュが追い出される事故(Cache Pollution)が起きる。LFUはこの弱点を完璧に克服している。
—
3. Redisの「近似LRU/LFU」という割り切り
ここで、Redisのアーキテクチャにおける「強烈な割り切り」を共有しておこう。
Redisは、真のLRU/LFUアルゴリズムを実装していない。
厳密なLRUを実装しようとすると、すべてのアクセス順に二重連結リストを組み替える必要があり、マルチスレッド環境(あるいはシングルスレッドのイベントループ)において莫大なメモリオーバヘッドとCPUコスト(ロック競合やポインタ追跡のキャッシュミス)が発生する。
そのため、Redisは 「近似アルゴリズム(Approximated LRU/LFU)」 を採用している。
redis.conf でのサンプル設定
maxmemory 4gb
maxmemory-policy allkeys-lfu
maxmemory-samples 10
- `maxmemory-samples` の意味: メモリ上限に達した際、Redisは全キーから総当たりで最も古いものを探すのではなく、指定したサンプル数(デフォルトは5、高精度にしたいなら10程度)だけランダムにキーを抽出し、その中で最も条件に合うものを1つ削除する。
- 設計上の注意: サンプル数を上げすぎるとCPU使用率が跳ね上がる。逆に小さすぎると削除精度が落ちる。デフォルトの `5` は非常に絶妙なバランスだが、ミリ秒単位のレイテンシシビアな環境では、この削除処理(EvictionによるCPUブロック)がレイテンシスパイクの原因になることを覚えておけ。
—
4. 実務で直面するトラブルと「堅牢な設計パターン」
コードレビューをしていると、「とりあえず `maxmemory` だけ設定しました」というPRが後を絶たない。甘い。以下の3点を必ず設計に組み込め。
パターンA:OOMを防ぐための安全なメモリマージン設計
物理メモリが16GBのインスタンスがあるとする。OSやRedis自体のプロセスオーバーヘッド、スワップ、fork時のCopy-on-Write(RDBスナップショット取得時)のメモリ急増を考慮しろ。
- 鉄則: `maxmemory` は 物理メモリの 60% 〜 70% に設定しろ。
- RDBの `bgsave` や AOFの重水素化(rewrite)の際、Redisはメモリ消費量が一時的に最大2倍(COW領域)に膨れ上がる。これを忘れてメモリを90%まで割り当てているシステムは、バックアップの瞬間に確実に死ぬ。
パターンB:キャッシュ雪崩(Stampede)を防ぐTTLジッター
`volatile-lru` やTTLベースの削除を行っているシステムで、何万件ものキーに「全く同じTTL(例: 3600秒)」を設定するな。
一斉に期限が切れた瞬間、RedisのCPU使用率が100%に張り付き、バックエンドのDBへリクエストが雪崩を打って押し寄せる。
悪い例:一斉に失効する
redis.set(key, value, ex=3600)
良い例:TTLにランダムな揺らぎ(ジッター)を混ぜて失効タイミングを分散させる
import random
jitter = random.randint(-300, 300) (-5分〜+5分の揺らぎ)
redis.set(key, value, ex=3600 + jitter)
この一手間だけで、プロダクション障害の確率を桁違いに下げられる。
—
5. チーフアーキテクトからの最終通告
メモリ削除ポリシーの選定は、そのシステムのビジネスロジックの特性をコード以上に雄弁に物語る。
1. キャッシュとして割り切るなら: `allkeys-lfu` または `allkeys-lru` を使え。TTLの設定漏れをRedisのポリシーに肩代わりさせるのだ。
2. マスターデータやセッションなど、絶対に消えては困るデータが混ざるなら: Redisをキャッシュとして使うな。永続化ストアとして扱うか、キーのプレフィックスでインスタンスを完全に分離しろ。
3. モニタリングを怠るな: `INFO stats` の `evicted_keys` メトリックを常に監視しろ。ここが意図せず急増している場合、それは「メモリサイジングの失敗」または「悪質なキャッシュ・ポルーション(不要なデータがホットデータを追い出している現象)」のシグナルだ。
Redisを使いこなせるか否かで、バックエンドエンジニアとしての格が決まる。
お前たちの次の設計書、この知見が反映されていることを期待している。さっさとコードを修正しろ。
コメント