Postgres 19 ではデフォルトの TOAST 圧縮を pglz から LZ4 に変更する予定です。そこで、Postgres がテーブルストレージとインデックスでどのようにデータを圧縮するかを確認しましょう。Postgres はテーブル(ヒープ)、TOAST、インデックスに対して単一の統合圧縮フレームワークを使用しています。ヒープと TOAST では、可変長型(TEXT、VARCHAR、BYTEA、JSONB など)に対して圧縮が自動的に有効になっています。インデックスでは、個々のキーがサイズ閾値を超えた場合にのみ圧縮が実行され、すべての可変長値に対して圧縮されるわけではありません。

Postgres Compression &#38 Toast Decision Diagram click to expand

Postgres 圧縮の歴史

圧縮機能は 2000 年にリリースされた Postgres 7.0 で初めて追加されましたが、当時の形とは異なります。当時 Postgres には厳格な 8kB の最大行サイズ制限があり、8kB を超えるデータを挿入しようとするとエラーが発生していました。この行サイズ制限の解消が Postgres コアチームの最優先事項でした。

この制限を回避するための最初の試みは、明示的に圧縮されたフィールドでした。Postgres 7.0 では pglz 圧縮アルゴリズムを使用する lztext データ型が提供されました。この実装にはトレードオフがあり、8kB の行制限は依然として存在し、ユーザーは圧縮データ型を明示的に選択する必要がありました。

7.0 リリース後、次の論理的なステップとして LONGBLOB のような圧縮データ型を追加することが考えられました(当時、多くのクローズドソースデータベースがそうしていました)。しかし Postgres コアチームは追加のデータ型を拒否し、TOAST に集結しました。pgsql-hackers メーリングリストでの議論では、8kB の問題をデータ型の問題と物理ストレージの問題の 2 つに分けて考えるという意見でグループが分裂しました。圧縮と TOAST が物理ストレージの問題に対する答えでした。

pglz を用いた TOAST

Postgres 7.1 で TOAST が pglz を使用して実装されました。

pglz アルゴリズムは pg_lzcompress.c ファイルに存在します。Postgres はオープンソースであるため、独自の圧縮アルゴリズムを開発した理由がコメントに直接記載されています:

  • 速度と圧縮率のトレードオフ: pglz は圧縮・展開が高速で、圧縮率を犠牲にして速度を優先します。ソースコードでは「速度と圧縮率のトレードオフ」というアルゴリズムの特徴と表現されています。
  • 最小限のメモリ使用量: アルゴリズムは 4096 バイトのスライディングウィンドウを使用し、Postgres のメモリ要件に影響を与えない程度に小さくなっています。コメントでは、1K から 1M のサイズの属性で最も効果を発揮するとされており、非常に大きな値では性能を犠牲にしています。
  • 積極的なフェイルセーフ: データがうまく圧縮できない場合に高速に失敗するように設計されており、CPU サイクルの無駄を避けます。
  • 外部依存なし: アルゴリズムは自己完結型で、外部ライブラリを必要としません。当時は Linux がクラウドのデフォルトサーバー OS ではなく(「クラウド」自体も存在しませんでした)、Postgres はどこでコンパイルされても動作する圧縮アルゴリズムを必要としていました。

Jan Wieck がこのアルゴリズムを開発し、ファイルの最後に謝辞を残しています:

Many thanks to Adisak Pochanayon, who's article about SLZ inspired me to write the PostgreSQL compression this way.

Adisak Pochanayon の SLZ 記事は、ゲーム業界向けに構築された圧縮方式を説明しており、グラフィックスやオーディオデータの圧縮に使用されていました。

pglz から LZ4 への移行理由

pglz は異なる時代に構築されたものであり、LZ4 は現代のハードウェアに適合するトレードオフを持つ現代的なアルゴリズムです。Postgres の LZ4 導入は、元の pglz 実装と同様のパスをたどっています:最初はオプションとして、次に標準として。Postgres 14 以降、LZ4 はシステム全体の設定(default_toast_compression = 'lz4')または列固有の設定(column_name text COMPRESSION lz4)で利用可能になっています。

  • より高速な圧縮: LZ4 は pglz よりも大幅に高速に圧縮します。全く非科学的なテストでは、約 10 kB の値を 2,000 行 INSERT した場合、LZ4 は約 6 ms で完了し、pglz の 50 ms に対して 8 倍高速でした。このデータセットでは、I/O とメモリレイテンシが CPU デコード時間を上回るため、2 つのアルゴリズムの展開スループットは同等でした。
  • より良い圧縮率(場合による): LZ4 の 64 kB スライディングウィンドウ(pglz の 4 kB と比較)はデータ内のより多くのバックリファレンスを見つけ、圧縮を拡大します。同様の全く非科学的なワークロードでは、LZ4 は 10,400 バイトの生データに対して 111 バイトを保存(98.9% 削減)したのに対し、pglz は 186 バイト(98.2% 削減)で、総ヒープスペースは pglz の 472 kB に対して 312 kB を使用しました。pglz の方が圧縮率が優れている状況もあります。
  • 高速失敗の継続: LZ4 には、ランダムまたは非圧縮可能なデータを素早く検出するための効率的な早期中止メカニズムがあります。

Postgres のストレージ戦略

まず、Postgres には列に対して複数の異なるストレージ戦略があることを知っておく必要があります:

EXTENDED(Postgres のドキュメントで大文字になっているため大文字表記しており、叫んでいるわけではありません)では、Postgres が利用可能なすべてのツールを使用できます。これは TEXT、VARCHAR、BYTEA、JSONB などの可変長型のデフォルトです。値は非圧縮、圧縮、ヒープ内、または TOAST 内に保存できます。

PLAIN は列をヒープ内にインラインで非圧縮で保存します。これは INT、FLOAT、BOOL などの固定幅型のデフォルトです。これらの値は圧縮の恩恵を受けることはほとんどありません。

EXTERNAL は列を TOAST に保存するよう指示しますが、圧縮はしません。

MAIN は列の圧縮を試みますが、可能な限り TOAST への移動を避けるよう指示します。

varlena 形式

可変長型に対して Postgres は varlena と呼ばれる形式を使用してデータを保存します。varlena はデータの長さと圧縮されているかどうかを記録するヘッダーを持つ自己記述形式です。すべての可変長型(TEXT、VARCHAR、BYTEA、JSONB を含む)に使用され、ヒープ、TOAST、インデックスに保存されるものです。

可変長型のみが圧縮されます。INT、FLOAT、BOOL などの固定長型は決して圧縮されず、ヒープに直接保存されます。

圧縮の判断ツリー

データを書き込む(挿入または更新)際、Postgres は行サイズを約 2 kB(toast_tuple_target、デフォルト 2040 バイト)の閾値以下に抑えようとします。EXTENDED 列に対しては以下の判断ツリーを使用します:

  1. 行全体がすでに ~2 kB より小さい場合、Postgres は行を非圧縮でヒープに直接書き込みます。
  2. 行が閾値を超える場合、Postgres は EXTENDED 列をサイズ順に並べ替え、最大の列から圧縮を試みます。圧縮が成功して行全体のサイズが閾値以下になった場合、処理を停止してヒープに書き込みます。失敗した場合は次の大きな列に移動します。
  3. 対象となるすべての列が圧縮されても行が閾値を超える場合、Postgres は最大の EXTENDED または EXTERNAL 列を TOAST テーブルにアウトオブラインで移動し始め、各列をメインのヒープタプル内の 18 バイトのポインタに置き換え、行が収まるまで続けます。
  4. それでも収まらない場合、Postgres は MAIN 列をインラインで圧縮するループに戻ります。それでも失敗した場合、最後の手段として MAIN 列を TOAST にアウトオブラインで移動します。

TOAST の詳細については、Postgres TOAST: The Greatest Thing Since Sliced Bread? を参照してください。

実際の圧縮節約の確認

-- pg_column_size: ヒープまたは TOAST テーブルに保存されているバイト数(圧縮後)
-- octet_length:  生の文字データのバイト数(非圧縮)

pg_column_size は圧縮データのサイズを報告します。返される値が octet_length より大幅に小さい場合、値は正常に圧縮されたことを示します。2 つの値がほぼ等しい場合、値はスペースを節約するのに十分な圧縮ができなかったことを示します。その場合、値が大きい(~2 kB の閾値を超える)場合は、TOAST テーブルに非圧縮で移動されます。

インデックス内の圧縮

B-tree インデックスページはキーの値を IndexTuple エントリとして保存します。各エントリには IndexTupleData ヘッダー(8 バイト)が含まれ、その後にキーのデータが続きます。可変長型の場合、データはヒープタプルと同じ varlena 形式を使用します(8kB の行制限に対して構築された回避策を活用)。

Postgres は TOAST アーキテクチャに結びついた単一の圧縮フレームワークを使用します。varlena ヘッダーにはデータが圧縮されているかどうかのフラグが設定されます。ヒープで既に値が圧縮されている場合、インデックスには圧縮された状態で格納されます。値が非圧縮で 510 バイト(TOAST_INDEX_TARGET、8 kB バッファページの約 1/16)を超える場合、インデックスコードは同じ TOAST 圧縮ルーチンをインラインで呼び出して収まるように試みます。圧縮形式が収まる場合は圧縮された状態で保存されます。圧縮形式でも制限を超える場合、書き込みトランザクションは失敗します。

これが repeat('x', 5000) が B-tree でインデックス化できる理由です:LZ4 は 5,000 文字の繰り返しを ~38 バイトに圧縮し、2704 バイトの上限内に収まります。同じ長さのランダムまたは疑似ランダムデータは、元のデータとほぼ等しい圧縮形式を生成し、上限を超えてインデックス化できません。

-- These succeed: both compressible, both fit after LZ4 compression
CREATE INDEX ON docs (body);

INSERT INTO docs VALUES (repeat('x', 5000));    -- stored: ~38 bytes compressed
INSERT INTO docs VALUES (repeat('ab', 2000));   -- stored: ~35 bytes compressed

-- This fails: md5 output is pseudo-random, essentially incompressible
INSERT INTO docs VALUES (
  (SELECT string_agg(md5(g::text), '') FROM generate_series(1, 88) g)
);
-- 2816 chars of MD5 → compressed form ≈ 2816 bytes → index row size 2832 > 2704
-- ERROR:  index row size 2832 exceeds btree version 4 maximum 2704 for index "..."
-- HINT:   Values larger than 1/3 of a buffer page cannot be indexed.

Postgres における圧縮の未来

Postgres が圧縮を通じてどのように前進しているかについて学ぶべきことは多くあります。専用圧縮データ型の初期の誤ったステップは認識され、基盤となる pglz の作業は TOAST に再利用され、大きな成功を収めました。pglz から LZ4 への移行において、Postgres は同様のアプローチを取っています:最初はテスト、次に移行。コアチームは圧縮アルゴリズムの変更が正しい道であることを確認するために意図的に進めています。