Redisの知られざる猛威:Intsetが生み出す極限のメモリ圧縮と、設計者が踏んではいけない地雷
こんにちは。チーフアーキテクトの私だ。
今日のコードレビューで、「とりあえず何でもかんでもHashかSetで持たせておけばいいや」という安易な実装を見かけた。メモリ使用量がパンク寸前になり、クライアントから悲鳴が上がる未来が手に取るように見える。
Redisをただの「速いKVS」だと思って使っているうちは、ジュニアクラスを脱することはできない。メモリは有限であり、キャッシュサーバーであっても無駄な領域は許されないのだ。
今回は、Redisが誇る内部データ構造の一つ「Intset(整数セット)」に焦点を当てる。
セット型(Set)が裏側でどのようにメモリを極限まで圧縮し、どのような条件でその魔法が解けるのか。実務の現場で即座に使える設計判断の基準を叩い込もう。
—
1. Intsetとは何か?:ポインタの海を泳ぐな
Redisの `Set` 型は、デフォルトではハッシュテーブル(DICT)として実装されている。しかし、セットに含まれる要素がすべて整数であり、かつ要素数が少ない場合、Redisはメモリ効率を劇的に最適化するために、データを連続したメモリ領域(単なるバイト配列)に詰め込む。これが Intset だ。
一般的なハッシュテーブルは、キーや値へのポインタ、ハッシュチェインのためのポインタ、メタデータ等で大量のオーバーヘッド(メモリの無駄)が発生する。64bit環境において、標準のポインタだけで1要素あたり数十バイトが消えていく。
一方、Intsetは「ヘッダー + 緊密にパッキングされた整数配列」という極めてシンプルな構造をしている。
+—————————-+——————-+
| Encoding (4 bytes) | Length (4 bytes) |
+—————————-+——————-+
| Integer Array (可変長) |
| [val_1][val_2][val_3] … |
+————————————————-+
ポインタのオーバーヘッドがほぼゼロ。キャッシュヒット率も跳ね上がる。これがRedisの真骨頂であるメモリ圧縮のカラクリだ。
—
2. 自動整数サイズ拡張(Upgrading)のメカニズム
Intsetの最も美しい(そして注意すべき)点は、格納される整数の最大値・最小値に応じて、自動的に内部の型サイズを拡張する挙動にある。
Intsetは内部で以下の3つのencoding(整数サイズ)を動的に切り替えている。
1. `INTSET_ENC_INT16`: 2バイト (-32,768 〜 32,767)
2. `INTSET_ENC_INT32`: 4バイト (-2,147,483,648 〜 2,147,483,647)
3. `INTSET_ENC_INT64`: 8バイト (-9,223,372,036,854,775,808 〜 9,223,372,036,854,775,807)
拡張の瞬間(アップグレード)
最初は小さな型(例: `INTSET_ENC_INT16`)でメモリを節約していても、その範囲を超える整数(例: `50000`)が1つでも挿入された瞬間、Intsetは以下のようなサイレントかつ高コストな処理を実行する。
- メモリ上の全要素を新しいサイズ(この場合は32bit)に合わせて再アロケーション・リサイズ。
- 既存の要素をすべて新しいビット幅に拡張してパッキングし直す。
この挙動を実際にRedisの挙動から確認してみよう。
1. 空のセットに小さな整数を入れる(内部でINT16と判定される)
127.0.0.1:6379> SADD active_users 100
(integer) 1
メモリ上の表現を確認するコマンド(DEBUG OBJECT)
127.0.0.1:6379> OBJECT ENCODING active_users
“intset”
2. ここで突如、INT16の範囲を超える大きなID(例: 70000)を投入する
127.0.0.1:6379> SADD active_users 70000
(integer) 1
-> この瞬間、Intset全体が INT32 へとアップグレードされる
> 【アーキテクトの警告】
> 注意してほしいのは、一度アップグレードされたIntsetは、要素を削除しても元の小さいサイズにはダウングレードしないという点だ。
> 誤って巨大なIDを1つでも混ぜ込んでしまうと、そのSetが持つすべての要素が最大のメモリフットプリントを永続的に消費し続けることになる。
—
3. 実務設計:Intsetの恩恵を最大化する境界値(Threshold)
では、実務においてIntsetはいつまでその「圧縮された楽園」を維持できるのか?
Redisのコンフィグレーションには、この挙動を制御する重要なパラメーターが存在する。
redis.conf
set-max-intset-entries 512
この設定値の意味を正しく理解しているエンジニアは意外と少ない。
「セット内の要素数が512以下であり、かつすべての要素が整数である場合、RedisはそのセットをIntsetとして保持する」という意味だ。
もし要素数が 513個 になった瞬間、あるいは文字列が1つでも混入した瞬間、Redisは即座にIntsetを捨て、標準のハッシュテーブル(Dict)へ構造変換(Conversion)を行う。
設計時のチェックリスト
1. 要素数の上限を見極めろ
- ユーザーIDやデバイスIDのリストなど、数万件規模になることが分かっているデータを単一のSetで管理してはならない。Intsetの上限(デフォルト512)を超えた瞬間にハッシュテーブルへ移行し、メモリ効率がガタ落ちする。
- 数万件規模の整数を扱うなら、最初から専用のデータ構造(Redis Streamsや、あるいはBitmaps/HyperLogLogなどユースケースに合わせた選択)を検討すべきだ。
2. 「文字列の混入」に殺されるな
- 「100」「200」という数字であっても、ダブルクォーテーションで囲まれた文字列(`”100″`)としてRedisに送れば、それは文字列とみなされ、Intsetの恩恵は受けられない。必ずクライアント側のORMやドライバが適切な整数型としてコマンドを送信しているか確認しろ。
—
4. パフォーマンス上の罠と正しいアンチパターン回避
コードレビューでよく見かける最悪のアンチパターンを挙げておく。
アンチパターン:時系列の巨大なIDリストをSetで保持する
悪い例:毎日増え続けるアクセスユーザーIDをひたすらSADDし続ける
redis_client.sadd(f”daily:users:{date_str}”, user_id)
これがなぜ悪手か?
- 要素数が512を超えた時点でIntsetからDictへ昇格し、メモリ消費量が跳ね上がる。
- 数百万件規模に膨れ上がったSetに対する操作は、Redisのシングルスレッドイベントループをブロックし、全体のレイテンシを悪化させる(O(N)の罠)。
正しいアプローチ
- ページネーションや最新N件の保持が必要な場合: `ZSET`(Sorted Set)を使い、スコアにタイムスタンプを仕込むか、メモリが逼迫するなら期限切れ(TTL)を適切に設計する。
- 「存在確認(メンバーシップテスト)」だけが目的な場合: 要素数が膨大になるなら、正確性を一部犠牲にしてでも HyperLogLog や Bloom Filter(RedisBloomモジュール) の採用を検討する。メモリ効率の次元が違う。
—
最後に:チーフからのメッセージ
Redisの内部構造を理解するということは、「なぜそのデータ構造がその設計になっているのか」という先人たちのエンジニアリングの意思決定を追体験することだ。
Intsetは、「小規模な整数集合」という特定のユースケースにおいて、極限までメモリを削ぎ落とすための鋭利な刃である。これを正しく理解し、適用箇所を見極められる者だけが、高負荷に耐えうる堅牢なシステムを構築できる。
明日のコードレビューでは、チームメンバーが書いた `SADD` の向こう側にあるメモリの海を想像してほしい。期待している。
コメント