Redis Streamsのメモリ爆発を防げ:Radix Treeの構造とMAXLEN設計の極意
テックリードの私だ。今日のコードレビューで、ジュニアエンジニアが書いたこんな実装を見つけた。
ユーザーのアクティビティログを無制限にストリームへ流し込むコード
XADD mystream user_id 12345 action “login”
「お、ストリーム使ってるね。でもこれ、本番環境で1週間放置したらOOM(Out of Memory)でRedisがクラッシュするぞ」と指摘したところ、彼はきょとんとしていた。
「えっ、Redisって自動でメモリ管理してくれないんですか?」と。
甘い。Redisは高速なインメモリDBだが、魔法の箱ではない。データ構造の内部挙動とメモリ管理のメカニズムを理解していなければ、高負荷時に容赦なくシステムを地獄に叩き落とす。
今回は、Redis Streamsが内部でどのようにメモリを喰らい、どのようにインデックスを管理しているのか。そして、システムを破綻させないための`MAXLEN`設計の極意を、アーキテクトの視点から叩き込む。
—
1. Streamsの裏側:Radix Treeによるインデックス管理の真実
Redis Streamsは、単なる「追記型のログリスト(List)」ではない。時刻順(Timestamp)とシーケンス番号を組み合わせたエントリIDを持ち、範囲クエリ(Range Query)を高速にこなす。
この高速なインデックス検索を支えているのが、Radix Tree(ラディックスツリー / 基数木)だ。
リスト(List)やZSETとの決定的な違い
- LPUSH/RPOPLPUSH (List): O(1)だが、特定IDの検索や範囲指定のレンジスキャンが苦手(全走査に近いコストがかかる)。
- ZSET: スコア順のソートは得意だが、エントリ数が増えるとSkiplistとHashの二重構造によりオーバーヘッドが大きい。
- Streams: 膨大なエントリのIDプレフィックスを圧縮し、メモリ効率と検索性能を極限まで高めたRadix Treeを採用している。
Radix Treeは、共通のプレフィックスを持つキーをまとめ上げることで、ツリーの深さを浅く保つ。これにより、何百万というストリームエントリがあっても、O(N)ではなく対数時間、体感として一瞬でIDの逆引きや範囲取得が可能になっている。
しかし、「検索が効率的であること」と「メモリ消費が勝手に減ること」は全く別問題だ。エントリを追加し続ければ、Radix Treeのノードは増殖し続け、物理メモリを確実に蝕んでいく。
—
2. MAXLENの罠:なぜ「おおよそ(~)」指定が必要なのか
メモリ肥大化を防ぐ特効薬が、`XADD`コマンドの`MAXLEN`オプションだ。しかし、ここにもプロが見落としてはならない罠がある。
まずは以下の2つのコマンドの違いを見てほしい。
パターンA: 厳密な長さを指定
XADD mystream MAXLEN 1000 sensor_id “A-1” temperature 23.5
パターンB: 概算(Approximate)を指定
XADD mystream MAXLEN ~ 1000 sensor_id “A-1” temperature 23.5
パターンA(厳密なMAXLEN)のパフォーマンス悪化
「1000件に厳密に制限したい」というビジネス要件から、パターンAを安易に選んではならない。
Redis Streamsのエントリは、内部的に複数のエントリを1つの「マクロノード(Macro Node)」にパッキングして保持している。
もし`MAXLEN 1000`と厳密に指定した場合、マクロノードの途中のエントリを削除・分割するために、Redisは内部でメモリの再割り渡しや複雑なツリーの再構築を高頻度で行うことになる。
結果として、XADDのレイテンシが跳ね上がり、CPU使用率が急上昇する。高スループットなシステムでは致命傷になり得る。
パターンB(`~` オプション)によるスマートな省メモリ
一方で、`MAXLEN ~ 1000`とチルダ(`~`)をつけると、Redisは「およそ1000件」を維持するように動作する。
マクロノード単位で不要になった古いデータをまとめてパージするため、パフォーマンスを一切落とさずにメモリ上限をコントロールできるのだ。
> architect’s rule:
> ログやイベントソーティングにおいて、厳密なエントリ数の一致を求められるケースはほぼ存在しない。「1000件前後を維持する」というトレードオフを受け入れ、必ず `MAXLEN ~ N` を使用せよ。
—
3. 実務で直面するメモリ管理のアンチパターンと設計パターン
では、実際のシステム設計において、Streamsのメモリ管理はどう構築すべきか。現場で使える具体的なプラットフォーム設計パターンを伝授する。
アンチパターン:期限切れのない巨大ストリーム
IoTのセンサーデータや、Webのアクセスログをそのまま1つのストリームに流し込み続ける設計。
前述の通り、`MAXLEN`を入れ忘れた瞬間に時限爆弾と化す。さらに、データが数千万件を超えると、RedisのRDB/AOFスナップショット生成時(fork時)のCopy-on-Writeによるメモリ急増で、OSのメモリを枯渇させる。
推奨設計パターン:容量逆算型 MAXLEN とコンシューマグループ連携
実務では、以下のステップで`MAXLEN`の数値を導き出し、設計に組み込むこと。
1. 許容メモリ量の算定:
Redisに割当可能な最大メモリ(`maxmemory`)から、他のデータ構造分を引いたリミットを算出する。
2. 1エントリあたりの平均サイズの計測:
検証環境で実際に想定されるペイロードを流し、`MEMORY USAGE mystream` で1エントリあたりのバイト数(例: 200バイト/エントリ)を割り出す。
3. MAXLENの算定:
例:メモリに50MBまでしか許容できない場合、 `50,000,000 / 200 = 250,000エントリ` を上限とし、余裕を持って `MAXLEN ~ 200000` と設定する。
実装例(Python / redis-py)
アプリケーション側でも、確実に対策を入れる。
import redis
client = redis.Redis(host=’localhost’, port=6379, decode_responses=True)
stream_key = “app:events”
max_len = 50000 # 概算で5万件に制限
def push_event(event_type: str, payload: str):
try:
# MAXLEN ~ を用いて、パフォーマンスを犠牲にせずメモリを保護
client.xadd(
stream_key,
{“type”: event_type, “data”: payload},
maxlen=max_len,
approximate=True
)
except redis.ResponseError as e:
# Redisのメモリ上限(maxmemory)に達した場合のエラーハンドリング
print(f”Memory limit reached or Redis error: {e}”)
# フォールバック処理やアラート発報をここに記述
—
4. チーフアーキテクトからの最終提言
Redis Streamsのメモリ管理は、「なんとなく動く」からといって放置していい領域ではない。
- Radix Treeの構造を理解し、無駄な巨大ツリーを作らない。
- `MAXLEN ~` を駆使し、パフォーマンスとメモリ消費のバランスを最適化する。
- Redis全体の `maxmemory-policy`(例: `noeviction` など)と組み合わせ、万が一のメモリ溢れ時の挙動を定義しておく。
君たちがコードレビューをする際、`XADD`の記述に`MAXLEN`が含まれていなかったら、こう問いかけてほしい。
「おい、このストリーム、1ヶ月後にメモリ何GB食うか計算したのか?」
その一言が、プロダクション環境の障害を防ぐ最高の防壁となる。シニアエンジニアたるもの、常にメモリの境界線に敏感であれ。
コメント