【テクニカル・上級編】 期限切れキーとメモリ淘汰ポリシー – Redis

期限切れキーとメモリ淘汰ポリシー:Redisメモリ管理の深層と冷徹な実務

Redisのメモリ管理は、単に「データをRAMに載せるだけのシンプルなキャッシュ機構」などではない。C言語で書かれたシングルスレッドのイベントループが、ミリ秒以下のレイテンシを維持しながら、如何にして物理メモリの限界領域でアクロバットを演じているか。その核心にあるのが、期限切れキー(Expirations)の削除メカニズムと、メモリ淘汰ポリシー(Eviction Policies)の相互作用である。

私は数千台規模のRedisクラスタを運用する中で、これらの内部挙動を誤ったがために本番環境が突然のレイテンシスパイクに襲われ、あるいはOOM Killerの餌食になる瞬間を幾度となく目撃してきた。

今回は、Redisのソースコードレベルの振る舞いに踏み込み、この2つのサブシステムがどのように衝突し、協調するのかを徹底的に解剖する。

—

1. 期限切れキーの削除:能動的と受動的の二面作戦

Redisにおけるキーの有効期限(TTL)管理は、メモリリークを防ぐための生命線だが、CPUサイクルを無駄に消費してはならない。そのため、Redisは「受動的(Passive)削除」と「能動的(Active)削除」のハイブリッド戦略を採用している。

受動的削除(Lazy Expiration)

クライアントが特定のキーにアクセスしようとした瞬間、Redisはそのキーがexpireしているかをチェックする。もし期限切れであれば、その場で即座に削除し、`nil`を返す。
このアプローチの美しさは、CPUの無駄打ちがゼロであることだ。アクセスされない限り、メモリ上にゴミが残り続ける可能性がある点を除けば。

能動的削除(Active Expiration:hzとCron)

受動的削除だけでは、二度とアクセスされない「ゾンビキー」がメモリを食いつぶしてしまう。これを防ぐのが、Redisのバックグラウンドタスク(`serverCron`)による能動的削除だ。

Redisはデフォルトで毎秒10回(`hz 10`)、以下のアルゴリズムを実行する(`expire.c`の実装に基づく):

1. 有効期限が設定されているキーを持つ辞書(`db->expires`)から、ランダムに20個のキーをサンプリングする。
2. サンプリングされたキーのうち、期限切れになっているものをすべて削除する。
3. 期限切れだったキーの割合が25%を超えていた場合、ステップ1に戻る。

/ 擬似的な概念コード(expire.cのアルゴリズム的解釈) /
int activeExpireCycleTryExpire(redisDb db, dictEntry de, long long now) {
long long t = dictGetSignedIntegerVal(de);
if (now > t) {
// 期限切れ検出 -> 削除
// クラスタ環境やレプリケーションへの伝播処理もここで行われる
dbAsyncDelete(db, key);
return 1;
}
return 0;
}

この「20個のサンプリング」という設計が非常に巧妙だ。全数走査によるO(N)のロックを避けつつ、確率論的にメモリ空間のクレンジングを行っている。しかし、ここには落とし穴がある。書き込みが高頻度で発生する巨大なDBにおいて、期限切れのペースが能動的削除の処理能力を超えると、メモリはじわじわと圧迫されていく。

—

2. maxmemory発動:淘汰ポリシーの冷徹な選択

`maxmemory`に達した瞬間、Redisは新たな書き込み(メモリを消費するコマンド)に対してどのような態度をとるか。それを決めるのが `maxmemory-policy` である。

アーキテクトとして特筆すべきは、期限切れキーと淘汰ポリシー(Eviction)の実行順序である。

Redisは `maxmemory` を超えたとき、即座にLRUやLFUでキーを捨てるわけではない。まず最初に、まだ受動的・能動的に削除されていない期限切れキーの領域(`db->expires`)をスキャンし、そこから削除を試みる。

それでも `maxmemory` の制限を下回らない場合のみ、設定されたポリシーに基づく淘汰が発動する。

主要なポリシーのレイヤーと特性

| ポリシー名 | 対象範囲 | アルゴリズムの特性 | 実務上の推奨度 |
| :— | :— | :— | :— |
| noeviction | 全体 | メモリ制限を超えると書き込み系コマンドにエラーを返す(`OOM command not allowed`) | ★★★☆☆ (堅牢性重視) |
| volatile-lru | 期限切れ設定あり | 近似LRUアルゴリズムを使用 | ★★☆☆☆ (構造的リスクあり) |
| allkeys-lru | 全キー | 全キーからLRUで淘汰 | ★★★★★ (汎用キャッシュの王道) |
| volatile-lfu | 期限切れ設定あり | 近似LFUアルゴリズムを使用 | ★★★☆☆ |
| allkeys-lfu | 全キー | 全キーからLFUで淘汰 | ★★★★★ (アクセス頻度偏重型に最適) |
| volatile-random | 期限切れ設定あり | ランダムに削除 | ☆☆☆☆☆ (使う理由なし) |
| volatile-ttl | 期限切れ設定あり | TTLが短い順に削除 | ★☆☆☆☆ |

ここで重要なのは、`volatile-` 系ポリシーは、期限切れ設定(TTL)のないキーを絶対に淘汰しないという点だ。もしTTLを設定し忘れたマスターデータが混ざっている環境で `volatile-lru` を選ぶと、いくらメモリが逼迫してもそれらのキーは保護され、最終的に `noeviction` と同じ挙動(書き込み拒否またはOOM)に陥る。キャッシュとして使うならば、原則として `allkeys-lru` または `allkeys-lfu` を選択すべきだ。

—

3. LRU/LFUの実装の真実:完全なアルゴリズムではない

コンピュータサイエンスの教科書で習うLRU(Least Recently Used)やLFU(Least Frequently Used)を、Redisはそのまま実装してはいない。完全なLRUを実装するには、すべてのアクセスに対して双方向連結リストを組み替え、ポインタを更新する必要がある。これはシングルスレッドのRedisにおいて、アトミックなロックこそ不要なものの、ポインタ操作のオーバーヘッドとキャッシュミスを引き起こし、致命的なスループット低下を招く。

そのため、Redisは「近似LRU(Approximated LRU)」を採用している。

近似LRUの仕組み

1. 設定されたサンプル数(`maxmemory-samples`、デフォルトは5)だけ、キーをランダムにサンプリングする。
2. その中で、最も長い間アクセスされていない(LRUの度合いが高い)キーを1つ選んで削除する。
3. これをメモリが `maxmemory` を下回るまで繰り返す。

`maxmemory-samples` の値を変更することで、この精度のトレードオフを調整できる。

  • `samples = 3`: 高速だが、本当に古いキーを見落としやすい。
  • `samples = 10`: より正確なLRUに近づくが、CPUを消費する(通常はデフォルトの5で十分)。

また、Redis 4.0以降で導入された LFU(Least Frequently Used) では、キーのメタデータ(24ビットの `lru` フィールド)のうち、上位16ビットを「減衰するタイムスタンプ」、下位8ビットを「対数的なアクセス頻度カウンター(Logarithmic Access Frequency Counter)」として使用している。アクセスされるたびに確率的にカウンターがインクリメントされ、時間が経つと減衰する。この設計により、「最近使われていないが、過去に爆発的に使われたキー」と「コンスタントに使われているキー」を精緻に区別できる。

—

4. 現場の知見:メモリ管理のアンチパターンと極限チューニング

私が大規模システムで見てきた、メモリ管理に起因する障害の多くは、以下の設定ミスとデータ構造のミスマッチに起因する。

アンチパターン1:大量のキーの同時Expire(Thundering Herd of Expirations)

数百万件のキーに対して、まったく同じTTL(例:1時間後)を設定した場合、1時間後に何が起きるか。
その秒数が訪れた瞬間、能動的削除の25%ループが激しく回転し、さらに受動的削除が重なり、Redisのシングルスレッドがその処理にCPU時間の大部分を奪われる。結果として、通常のクライアントリクエストが数百ミリ秒〜数秒間ブロックされる「Expire死(エグスパイア・デス)」を引き起こす。

【対策】
TTLには必ずジッター(ランダムなゆらぎ)を付与せよ。例:`EXPIRE key 3600 + rand(0, 300)` のように、数分間のバラつきを持たせることで、削除負荷を時間軸上に分散させる。

アンチパターン2:メモリのフラグメンテーション(断片化)の放置

Redisは独自のメモリコンテクスト(Jemallocなど)を使用しているが、頻繁な書き込みと削除(特にExpireによる削除)が繰り返されると、メモリの断片化(Fragmentation)が進行する。
OSから見るとRedisプロセスは10GBのRSS(物理メモリ)を占有しているが、実際のデータは5GBしかない、という状況が生まれる。

現在のメモリ断片化率をチェックするコマンド
redis-cli INFO memory | grep mem_fragmentation_ratio

`mem_fragmentation_ratio` が `1.5` を超え、かつ絶対的なメモリ使用量が大きい場合は危険信号だ。
Redis 4.0以降では、アクティブ・メモリ断片化解消機能が備わっている。

redis.confでの動的断片化解消の有効化例
activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10
active-defrag-threshold-upper 30

ただし、この自動デフラグもCPUを消費するため、ピークタイムを避けて有効化するか、十分なサイジングを行った上で検証すべきである。

—

結言

Redisのメモリ管理と淘汰ポリシーは、単なる「設定の選択肢」ではない。それは、限られたRAMというリソースの上で、データの一貫性、アクセスのレイテンシ、そしてシステムの生存を賭けたアルゴリズムの攻防戦である。

`maxmemory-policy` の選択、`hz` とサンプリング精度のチューニング、そしてTTLの設計。これらすべてをコードの裏側の挙動(C言語のランタイム、Jemallocの挙動、イベントループのスケジューリング)まで透視して構築できたとき初めて、そのRedisクラスタは「真にプロダクションプルーフである」と胸を張って言えるのだ。

コメント

タイトルとURLをコピーしました