【実務・中級編】 Ziplistによるメモリ圧縮 – Redis

Ziplistの呪縛と解放:Redisメモリ最適化の裏側と実務設計の鉄則

こんにちは。アーキテクチャチームのリードを務める私だ。
日々、数千万〜数億のキーを抱えるRedisクラスタのパフォーマンスチューニングや、メモリ圧迫によるOOM(Out of Memory)アラート対応に追われているエンジニアなら、一度は「なぜRedisはこれほどメモリ効率が良いのか、そしてなぜある瞬間から急激にメモリを食い始めるのか」という疑問に直面したことがあるはずだ。

今回は、Redisのメモリ最適化の隠れた立役者であり、同時に諸刃の剣でもある「Ziplist(ジップリスト)」に焦点を当てる。
教科書的な「こういうデータ構造です」という説明は省く。実務でシステムを設計・運用するプロフェッショナルとして、コードの裏側で何が起きているのか、そしてどう設計を誤るとシステムが崩壊するのかをロジカルかつシャープに伝授しよう。

—

1. Ziplistとは何か? なぜ「連続したメモリ領域」にこだわるのか

通常、linked list(双方向リスト)やhash tableをメモリ上に展開すると、各ノードやエントリがポインタ(64bit環境なら8バイト)を持ち、それぞれがヒープ上のバラバラの場所にアロケートされる。
結果として何が起きるか? ポインタオーバーヘッドによるメモリの肥大化と、CPUキャッシュヒット率の低下(キャッシュミス頻発)だ。

Ziplistは、この非効率性を極限まで排除するために作られた。
一言で言えば、「複数の要素を、一つの連続したメモリブロック(contiguous memory block)に隙間なくパッキングしたデータ構造」である。

Ziplistの内部メモリレイアウト

Ziplistの構造は、C言語のバイナリレベルで見ると非常に美しい。

…

  • `zlbytes` (4バイト): Ziplist全体が占めるバイト数。
  • `zltail` (4バイト): リストの末尾のエントリ(Tail)がどこにあるかを示すオフセット。これのおかげで末尾へのアクセスが$O(1)$になる。
  • `zllen` (2バイト): 格納されている要素数(エンティティ数)。ただし、これが65535を超える場合は「全体をスキャンして数えろ」という意味で特別な値(`65535`)が入り、実際の正確な数は都度走査して求める設計になっている(ここ、テストに出るポイントだ)。
  • `entry` (可変長): 各要素。ここが肝。
  • `zlend` (1バイト): 常に `0xFF` が入る終端マーカー。

エントリ(`entry`)の魔術

Ziplist内の各エントリは、前後の要素を辿るために「前のエントリの長さ」を保持している。これにより、前方向・後方向の双方向走査が可能になっている。
さらに、データの内容(整数か文字列か、その長さはいくつか)に応じて、メタデータのサイズを動的に可変させる。例えば、小さな整数であれば、わざわざ文字列として保持せず、1バイトのバイナリ整数としてエンコードする。

この徹底した「無駄の排除」により、ポインタのオーバーヘッドが完全に消失する。これが、少量の要素を持つHash, List, Sorted Set(ZSet)が驚異的な少メモリで動作する理由だ。

—

2. エンコーディング変換のメカニズムと「しきい値」の罠

実務において最も重要なのは、「いつZiplistから通常のデータ構造(ハッシュテーブルやスキップリストなど)に変換されるのか」を完全に把握しておくことだ。

Redisは、メモリ効率の良さ(Ziplist)と、大量データ時の操作耐性($O(1)$や$O(\log N)$のデータ構造)のトレードオフを自動で管理している。これをエンコーディング変換(Encoding Conversion)と呼ぶ。

Hashの例

Hash型(`HSET`などで操作)の場合、初期状態ではZiplistでエンコードされている(内部表現は `ziplist`)。
しかし、以下の条件のどちらか一方でも超えた瞬間、Redisは自動的にHT(Hash Table / `dict`)へ変換する。

1. フィールド・値の個数が設定値を超えたとき(デフォルト: `hash-max-ziplist-entries 512`)
2. 個々のフィールド名または値のバイト長が設定値を超えたとき(デフォルト: `hash-max-ziplist-value 64`)

redis.conf のデフォルト値
hash-max-ziplist-entries 512
hash-max-ziplist-value 64

list-max-ziplist-size -2 # リストの場合の特殊なしきい値(後述)

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

> 【警告】この変換は「不可逆」である。
> 一度HT(ハッシュテーブル)に変換されたキーは、要素を削除してサイズが小さくなっても、自動的にZiplistには戻らない。(`MEMORY PURGE`や再投入を行わない限り、メモリ効率の悪い状態が維持される)。

—

3. 実務設計:Ziplistの限界が引き起こす「CPUスパイク」の恐怖

「メモリ効率が良いなら、何でもZiplistのしきい値を引き上げて、巨大なデータを突っ込んでおけばいいのでは?」
もしそう考えたなら、あなたはジュニアエンジニアの思考から抜け出せていない。プロのアーキテクトなら、ここで「計算量(Time Complexity)」の悪夢を想像できなければならない。

なぜZiplistを巨大にしてはいけないのか?

Ziplistは「連続したメモリ領域」である。ということは、中央に要素の挿入(Insert)や削除(Delete)、あるいは可変長データの更新(Update)が発生した場合、何が起きるか?

1. メモリの再割り当て(realloc): 領域の後続データをすべて後ろにずらす、あるいは前に詰める必要がある。
2. メモリコピー(Memcpy): データサイズが大きければ大きいほど、C言語レベルでの`memcpy`が走る。
3. 計算量の悪化: Ziplistの検索・挿入・削除は、本質的に$O(N)$($N$はエントリ数)である。

もし、`hash-max-ziplist-entries` を `100,000` などと巨大に設定し、1つのHashに10万件のフィールドを詰め込んだとしよう。
その状態で `HSET` や `HDEL` を頻繁に呼び出すとどうなるか。
Redisはシングルスレッドで動作している。 その巨大なZiplistのメモリコピーを行っている間、その単一スレッドは完全にブロックされ、他のすべてのクライアントからのリクエストが秒単位で停止する。
結果として、アプリケーション全体でタイムアウトが続発し、システム全体が崩壊する。これが、Ziplistの限界を無視した設計の末路だ。

—

4. 堅牢な設計パターンとチューニングの指針

では、実務においてZiplistとどう向き合うべきか。設計の指針を授けよう。

指針1: デフォルト値を安易にいじらない

Redisのコア開発チームが設定したデフォルト値(Hashなら512エントリ / 64バイト)は、数千回のベンチマークと実運用データに基づいた「メモリ効率とCPU負荷の黄金比」だ。特段の理由がない限り、この値を大きく変更すべきではない。

指針2: 「巨大なHash/ZSet」を作らないデータモデリング

例えば、1人のユーザーの属性情報を管理するために、何千ものキー・バリューを1つのHashに詰め込む設計は避けるべきだ。
もし要素数が数千を超えることが確実なユースケースであれば、最初からZiplistの適用外(HTやSkipList)になることを前提にするか、あるいはデータを適切にシャーディング(分割)して複数のキーに分散させる設計を採用せよ。

指針3: `OBJECT ENCODING` による監視の自動化

プロダクション環境では、意図しないデータ構造の肥大化を検知する仕組みが不可欠だ。
定期的に、あるいはデプロイのタイミングで `OBJECT ENCODING ` を叩き、想定通りのエンコーディングになっているかをモニタリングするスクリプトやメトリクス収集を組み込むこと。

キーの内部エンコーディングを確認する実例
127.0.0.1:6379> HSET user:1001 name “Alice” age “30”
(integer) 2
127.0.0.1:6379> OBJECT ENCODING user:1001
“ziplist” # 小規模なのでziplistで保持されている

ここで巨大な文字列を突っ込んでみる(64バイト超え)
127.0.0.1:6379> HSET user:1001 bio “Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.”
(integer) 1
127.0.0.1:6379> OBJECT ENCODING user:1001
“hashtable” # 値のサイズが上限を超えたため、即座にハッシュテーブルに変換される

—

5. 補足:Redis 7におけるZiplistの進化(Listpack)

最後に、現代のRedisにおける最新の知見に触れておこう。
Redis 5、そしてRedis 6/7にかけて、Redisは従来のZiplistを徐々に「Listpack(リストパック)」へと置き換えている。

Ziplistには、「前エントリの長さを保持する」構造ゆえに、データの書き換え時に連鎖的なサイズ再計算が発生しうる「カスケードアップデート(Cascade Updates)」という微小な脆弱性(最悪ケースでのCPU負荷)があった。
Listpackは、各エントリが「自分自身の長さ」だけを保持し、前のエントリの長さを持たない構造にリデザインされることで、この問題を完全に解決している。

しかし、「連続したメモリ領域にパッキングし、メモリ効率を最大化する」というアーキテクチャの思想自体は1ミリも変わっていない。
したがって、今回解説した「要素数とサイズ制限の罠」「不可逆な変換」「CPUブロックのリスク」というエンジニアリングの原則は、Listpack時代になった現在でもそのまま通用する、いや、より重要になっていると言っていい。

—

結びにかえて

メモリ管理とは、突き詰めれば「ハードウェアの物理的制約と、ソフトウェアの処理効率の妥協点を探る旅」だ。
Ziplistはその旅路において、美しく、かつ強力な武器となる。しかし、その特性を理解せずに振り回せば、いとも簡単にシステムの足元をすくわれる。

コードレビューで「なぜこのデータ構造を選択したのか」「このキーの最大要素数はいくつを想定しているのか」を問われたとき、明確なロジックを持って答えられるエンジニアであれ。
君たちの設計するRedisが、極限の負荷の中でも涼しい顔をして高速に応答し続けるシステムであることを期待している。

コメント

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