Ziplistの呪縛と解放:Redisメモリ最適化の極限バイブル
おい、設計レビューの手を止めてくれ。
今、君が書いたそのRedisのスキーマ設計、本当にメモリ効率とスループットの限界までチューニングされているか?
「とりあえずHashやSorted Setを使っておけばいいだろう」という安易な実装は、大規模トラフィックの荒波にもまれた瞬間、メモリフラグメンテーションという名のモンスターを呼び覚まし、OOM Killerの餌食になる。あるいは、CPUキャッシュを無駄にヒットさせ、レイテンシの悪化という致命傷を負う。
Redisが「世界最速のデータストア」と呼ばれる所以は、単にインメモリだからではない。データ構造のメモリ表現(Encoding)を動的に最適化しているからだ。
今回は、その核心であるZiplist(および近年のQuicklist/Listpack)の構造最適化閾値、すなわち `hash-max-ziplist-entries` や `zset-max-ziplist-value` といった設定値の真の意味と、実務で絶対に踏み抜いてはならない設計の罠について、チーフアーキテクトの私から徹底的に叩き込む。
—
1. Ziplistとは何か? なぜ「メモリの魔術師」と呼ばれるのか
通常、RedisのHashやList、Sorted Setは、ポインタやリンクリスト、スキップリストといったポインタベースのオーバーヘッドが大きい構造体としてメモリ上に展開される。例えば、小さな文字列をいくつもLinked Listで繋げば、ポインタのオーバーヘッドだけで実データの何倍ものメモリが溶けていく。
これを解決するのが Ziplist(ジップリスト) だ。
Ziplistは、データを連続したメモリ領域(Contiguous Memory)に隙間なくパッキングする形式である。
ポインタの概念を排除し、各要素の長さを表すプレフィックスとデータ本体を次々に配置していく。
[ZLBYTES][ZLTTAIL][ZLLEN][ENTRY 1][ENTRY 2]…[ENTRY N][ZLEND]
メリット
- 圧倒的なメモリ効率: ポインタのポインタを辿るオーバーヘッドがないため、メモリ消費量が劇的に少ない。
- CPUキャッシュ効率: メモリが連続しているため、CPUのL1/L2キャッシュにヒットしやすく、スキャンが高速。
デメリット(ここが最重要)
- 計算量が $O(N)$: 連続したメモリブロックであるため、要素の挿入や削除が発生すると、メモリの再割り当て(`realloc`)と、後ろの要素の全シフトが発生する。
- CPU負荷のトレードオフ: 要素数が多すぎると、更新時のコストが爆発的に跳ね上がる。
Redisはこのトレードオフを克服するため、「要素数が少なく、かつサイズが小さいうちはZiplistで省メモリに持ち、閾値を超えたら標準の高速なデータ構造(HTやSkipList)に自動変換する」という巧妙な仕組みをとっている。
それが、今回焦点を当てるパラメータ群だ。
—
2. チューニングすべきパラメータの正体
Redisの `redis.conf` には、以下のようなどこか呪文めいた設定が存在する。これらはすべて「Ziplistから標準構造へのエスカレーション条件」を定義している。
Hash
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
Sorted Set (ZSET)
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
List (※Redis 5以降はListpackベースのQuicklist内部で使用)
list-max-ziplist-size -2
この数値の裏にある設計思想をロジカルに読み解こう。
`-max-ziplist-entries`
コンテナ(HashやZSet)に含まれる要素数の上限。
これを超えると、Redisはバックグラウンドで強制的にハッシュテーブルやスキップリストへエンコーディングを変換する(これを `CONVERT` と呼ぶ)。
`-max-ziplist-value`
コンテナ内の個々の要素(フィールド名や値、メンバ)のバイト数の上限。
たった1つでもこのバイト数を超える要素が挿入されると、たとえ要素数が1つであっても、即座にZiplistから通常のデータ構造へ降格(変換)させられる。
> ⚠️ 現場のエンジニアがやりがちな致命的ミス
> 「メモリを節約したいから」と、これらの閾値を数千や数万に引き上げる馬鹿者がいる。絶対にやめろ。
> 前述の通り、Ziplistへの挿入・更新は $O(N)$ だ。要素数が数千もあるZiplistに対して `HSET` や `ZADD` を叩くと、CPU使用率が100%に張り付き、シングルスレッドのRedisが完全に詰まり、全リクエストがタイムアウトする障害を引き起こす。
—
3. 実践:エンコーディングの確認とデバッグ手法
現在、動いているRedisのキーが実際にどのエンコーディングで保持されているか、君はプロダクション環境で即座に確認できるか?
`OBJECT ENCODING` コマンドを使え。
テスト用のHashを作成
127.0.0.1:6379> HSET user:1001 name “Alice” role “engineer”
(integer) 2
エンコーディングを確認
127.0.0.1:6379> OBJECT ENCODING user:1001
“ziplist” # まだziplistで省メモリに保持されている
ここで、閾値を超える巨大なデータを突っ込んでみよう。
64バイトを超える長大な文字列をフィールドに突っ込む
127.0.0.1:6379> HSET user:1001 bio “A very very very very very very very very very very very very very very very very long bio string…”
(integer) 1
再びエンコーディングを確認
127.0.0.1:6379> OBJECT ENCODING user:1001
“hashtable” # 1つの値が 64 byte を超えた瞬間、強制的にhashtableへコンバートされた!
この挙動を知らずに設計すると、「メモリをケチっているつもりが、気づけば全キーが `hashtable` や `skiplist` に格上げされ、想定外のメモリ肥大化を招く」という悪夢を見る。
—
4. 堅牢な設計パターン:ユースケース別の最適解
では、実務の現場で我々はどのように設定と向き合うべきか。システム特性に応じた設計指針を授けよう。
パターンA: ユーザーセッション・キャッシュ(Hashの多用)
- 特徴: フィールド数は数個〜数十個と少ないが、頻繁に読み書きされる。
- 推奨設定: デフォルト(`entries: 512`, `value: 64`)のままで十分。
- 設計上の注意: JSON文字列をそのままHashの1フィールドに突っ込むような設計は最悪だ。JSONのサイズが `64 bytes` を軽々と超えるため、一瞬で `hashtable` に落ちる。セッション情報はフラットなHashのフィールドとして展開せよ。
パターンB: ランキング・スコア管理(Sorted Setの多用)
- 特徴: メンバー(ユーザーIDなど)の文字列は短い(数バイト〜数十バイト)。しかし要素数が数万〜数百万に達する。
- 推奨設定:
- `zset-max-ziplist-entries` は そのまま、あるいはやや下げる(例: 128〜256)。
- `zset-max-ziplist-value` は 64 のまま。
- 設計上の注意: 数万件のZSetをZiplistのまま維持させようとすると、更新(`ZINCRBY` 等)のたびにメモリのコピーが発生し、レイテンシが確実に悪化する。要素数が数千を超えることが確実なSorted Setは、初期からSkipList(通常のエンコーディング)になる運命を受け入れ、メモリよりもスループットを優先した設計にすべきだ。
—
5. チーフアーキテクトからの最終提言
Redisのメモリ最適化の本質は、「省メモリ性(Ziplist)」と「計算量・スループット(HT/SkipList)」の絶妙なバランスを取ることにある。
フレームワークのデフォルト値を盲信してはならない。扱うデータの平均サイズ、最大サイズ、そして書き込み頻度(Throughput)を計測し、`redis.conf` の閾値を意図を持ってコントロールしろ。
コードレビューで「なんでこのHash、こんなにメモリ食ってるの?」と聞かれたときに、この記事の内容をドヤ顔で語れるくらいでちょうどいい。
妥協のないメモリ設計をしろ。健闘を祈る。
コメント