【Redisの罠】数千万キーの限界:メタデータオーバーヘッドという見えざるコスト
テックリードの私だ。今日のコードレビュー、あるいは新規システムのアーキテクチャ設計で、こんな会話をしていないか?
> 「数百万件のユーザーセッションだから、1キーあたり数百バイトだろ? メモリは数GBあれば余裕だな」
甘い。致命的に甘いと言わざるを得ない。
Redisはインメモリデータベースとして極めて高速だが、「メモリは買った分だけ素直に使える」という幻想を抱いていると、本番稼働直後にOOM(Out of Memory) Killerの餌食になるか、あるいは物理メモリの3倍以上のコストをAWSに払い続ける羽目になる。
今回は、Redisのメモリ管理の深淵、特に「キーごとのメモリオーバーヘッド(メタデータ)」の本質と、それを踏まえた堅牢な設計パターンを叩き込む。
—
1. Redisが消費するメモリの正体:データ本体 + 「見えざる税金」
Redisにデータを保存するとき、我々は値(Value)の大きさばかりに気を取られる。しかし、Redis内部では、すべてのキーに対して以下の「構造体」が必ず割り当てられている。
Redis内部の構造:`redisObject` と `dictEntry`
Redisのキーバリューストアは、ハッシュテーブルをベースに構築されている。キーと値のペアは、C言語のレベルで次のようなオーバーヘッドを伴う。
1. `dictEntry` 構造体: ハッシュテーブルのエレメント。キーへのポインタ、値へのポインタ、次のエントリへのポインタなどを保持する(通常、64bit環境で24〜32バイト)。
2. `redisObject` 構造体: すべての値のラッパー。データ型(String, Hash, Zsetなど)、エンコーディング方式、LRU/LFUのためのタイムスタンプ、参照カウント(`refcount`)を保持する(16バイト)。
3. 動的文字列(SDS: Simple Dynamic String): キー文字列自体のメタデータ(長さ、空き容量など、最低3〜9バイト + 文字列本体)。
つまり、「中身がたった1バイトの文字列であっても、キーが存在するだけで60〜80バイト以上のメモリが問答無用で消え去る」という事実をまず叩き込んでほしい。
メモリプロファイリングの現実
実際にローカルのRedisで試してみよう。何もデータがない状態から、空の文字列をキーにして100万件突っ込んでみる。
import redis
r = redis.Redis(host=’localhost’, port=6379, db=0)
パイプラインで100万件の空文字キーを突っ込む
pipe = r.pipeline()
for i in range(1_000_000):
pipe.set(f”user:meta:{i}”, “”)
pipe.execute()
print(“Insertion completed.”)
この状態で `INFO memory` を叩き、メモリ使用量を確認すると、値のデータサイズは「0バイト」であるにもかかわらず、数十MB〜100MB以上のメモリが消費されていることが確認できる(jemallocの断片化率やバージョンにもよるが、固定オーバーヘッドが確実に積算される)。
—
2. 実務で踏む地雷:「細かいキーの乱立」がシステムを殺す
設計レビューで私が最も厳しく差し戻すアンチパターンがこれだ。
❌ アンチパターン:プレフィックス+個別キーの多用
例えば、ユーザーごとの設定値を保存するために、以下のようなキー設計をしたとする。
- `user:1001:setting:theme` = `”dark”`
- `user:1001:setting:lang` = `”ja”`
- `user:1001:setting:notifications` = `”true”`
一見、RESTfulでキレイな設計に見えるかもしれない。しかし、これをユーザー100万人分展開した瞬間どうなるか。
キーの数が数百万〜数千万単位になり、メタデータ(`dictEntry` や `redisObject`)のオーバーヘッドが、実際のデータ本体のサイズを遥かに凌駕する。
メモリ効率は最悪になり、Redisの肝心な強みであるキャッシュのヒット率やスキャン性能もガタ落ちする。
—
3. 解決策:メモリオーバーヘッドを極限まで圧縮する設計パターン
では、どう設計すべきか。テックリードとして推奨する2つのアプローチを伝授する。
パターンA:Hash構造による「キーのフラット化(マージ)」
前述の設定値の例であれば、バラバラのキーとして保存するのではなく、1人のユーザーにつき1つの `Hash` 型として集約する。
1ユーザーを1つのHashにまとめる
HSET user:1001:settings theme “dark” lang “ja” notifications “true”
なぜこれが効くのか?
Redisの `Hash` 型は、要素数が少ない(かつサイズが小さい)場合、内部で `ziplist`(現在は `listpack`) という連続したメモリ領域に圧縮されて格納される。
これにより、個別のキーごとに発生していた `dictEntry` のオーバーヘッドが消滅し、1つのキーにメタデータが統合されるため、メモリ消費量を劇的に削減できる。
- 個別キーの場合: 3つのキー × メタデータ構造体 = 巨大なオーバーヘッド
- Hash型の場合: 1つのキー + 内部フィールド群 = 極小のオーバーヘッド
パターンB:IDのバケツ分け(Bucketing / Chunking)
時系列データや、大量のエンティティを管理する場合、数千万のキーを作る代わりに、一定の範囲ごとにデータを束ねる(チャンク化する)。
例えば、IoTデバイスのログ(デバイスID毎)を扱う場合:
- ❌ `device:log:98123741` (数千万個のキー)
- ⭕ `device:chunk:9812:3741` (上位桁でバケツ分けし、その中でHashやSorted Setを使う)
—
4. パフォーマンス上の注意点とチーフアーキテクトからの忠告
最後に、メモリ最適化を進める上での実務的な落とし穴を指摘しておこう。
1. `MEMORY USAGE` コマンドの罠
特定キーのメモリ消費量を見るために `MEMORY USAGE key` を本番環境で多用するな。このコマンドは指定されたキーの構造を再帰的にスキャンするため、巨大な Hash や Zset に対して実行するとCPUをブロックし、Redis全体のレイテンシが跳ね上がる(Redisはシングルスレッドだということを忘れるな)。
2. jemallocの断片化(Fragmentation)
Redisはメモリ管理に `jemalloc` を採用している。キーの作成と削除が激しく行われるシステムでは、メモリの断片化(Fragmentation)が発生しやすい。`INFO memory` の `mem_fragmentation_ratio` が 1.5 を超えてきたら、メモリの再割り当てや設計の見直しを検討せよ。
3. 有効期限(TTL)のメタデータコスト
すべてのキーに `EXPIRE` を設定すると、Redisはそのための期限管理用構造体(expire dictionary)も維持する必要がある。数千万キーに個別のTTLを付与する場合のメモリコストも計算に入れておくこと。
—
まとめ
Redisのメモリ管理は、コードを書く際の「ノリ」で決めていいものではない。
1つのキー、1つのデータ構造の裏側で、どれだけのメタデータがメモリを蝕んでいるか。それを計算できるかどうかが、プロのエンジニアとアマチュアの分かれ道だ。
次の設計レビューでは、ただ「動くコード」ではなく、「メモリ効率まで計算し尽くされた美しい構造」を持って私のところに来たまえ。期待している。
コメント