Postgres 19 計畫將 TOAST 壓縮的預設值從 pglz 改為 LZ4,因此讓我們來看看 Postgres 如何在資料表儲存與索引中壓縮資料。Postgres 對資料表(heap)、TOAST 與索引使用單一、統一的壓縮框架。在 heap 與 TOAST 中,壓縮是自動且預設開啟,適用於 TEXT、VARCHAR、BYTEA 與 JSONB 等可變長度型別。在索引中則是機會性壓縮:只有當個別鍵值超過大小門檻時才會觸發,而非針對索引中儲存的每個可變長度值。

Postgres Compression &#38 Toast Decision Diagram 點擊展開

Postgres 壓縮的歷史

壓縮功能最早於 2000 年釋出的 Postgres 7.0 加入,但形式與今日不同。當時 Postgres 對資料列大小有嚴格的 8kB 上限,插入超過 8kB 的資料會拋出錯誤。修正這個資料列大小限制是 Postgres 核心團隊的優先事項。

第一個繞過此限制的嘗試是明確壓縮的欄位。Postgres 7.0 提供了 lztext 資料型別,使用 pglz 壓縮演算法。此實作有其取捨:8kB 資料列上限仍然存在,且使用者必須明確選擇壓縮資料型別。

7.0 版本釋出後,下一步邏輯上可能是加入更多壓縮資料型別,例如 LONGBLOB(當時許多閉源資料庫就是這麼做)。然而 Postgres 核心團隊拒絕新增資料型別,轉而全力發展 TOAST。pgsql-hackers 郵件清單的討論顯示,團隊將 8kB 問題拆解為兩個不同的問題:資料型別問題與實體儲存問題。壓縮與 TOAST 正是針對實體儲存問題的解答。

使用 pglz 的 TOAST

Postgres 7.1 開始使用 pglz 實作 TOAST。

pglz 演算法位於 pg_lzcompress.c 檔案中。因為 Postgres 是開源專案,我們可以直接在註解中閱讀作者自行開發壓縮演算法的考量:

  • 以速度換取壓縮比: pglz 壓縮與解壓縮速度很快,願意犧牲壓縮比來換取速度。原始碼將此描述為演算法另一個「速度對壓縮比」的偏好特性。
  • 最小記憶體用量: 此演算法使用 4096 位元組的滑動視窗,大小不會影響 Postgres 的記憶體需求。註解指出,此壓縮器對大小介於 1K 至 1M 的屬性表現最佳,因此會犧牲極大值的效能。
  • 積極的快速失敗機制: 當資料不易壓縮時,演算法設計為快速失敗,以避免浪費 CPU 週期。
  • 無外部相依性: 此演算法為自包含,不需任何外部函式庫。當時 Linux 尚未成為雲端預設的伺服器作業系統(當時也還沒有「雲端」概念),因此 Postgres 需要一套能在任何編譯環境下運作的壓縮演算法。

Jan Wieck 撰寫了此演算法,並在檔案底部留下致謝:

非常感謝 Adisak Pochanayon,他的 SLZ 文章啟發我以這種方式撰寫 PostgreSQL 壓縮。

Adisak Pochanayon 的 SLZ 文章描述了一種為遊戲產業打造的壓縮方案,用於壓縮圖形與音訊資料。

為什麼從 pglz 轉向 LZ4?

pglz 是為不同時代打造的,而 LZ4 是一種現代演算法,其取捨更符合現代硬體。Postgres 的 LZ4 推廣路徑與最初的 pglz 實作類似:先作為選項,再成為標準。自 Postgres 14 起,LZ4 已可透過系統層級設定 (default_toast_compression = 'lz4') 或欄位特定設定 (column_name text COMPRESSION lz4) 使用。

  • 更快的壓縮速度: LZ4 壓縮速度明顯快於 pglz。在一次完全非科學的測試中,插入約 2,000 筆 ~10 kB 的資料,LZ4 約需 6 毫秒,pglz 則需 50 毫秒(快 8 倍)。在此資料集中,兩種演算法的解壓縮吞吐量相近,因為 I/O 與記憶體延遲主導了 CPU 解碼時間。
  • 更好的壓縮比(有時): LZ4 使用 64 kB 滑動視窗(相較 pglz 的 4 kB),能在資料中找到更多反向參照,從而提升壓縮效果。在類似的非科學工作負載中,LZ4 將 10,400 位元組的原始資料壓縮至 111 位元組(減少 98.9%),而 pglz 則壓縮至 186 位元組(減少 98.2%),總 heap 空間使用量分別為 312 kB 與 472 kB。不過在某些情況下,pglz 的壓縮比可能更好。
  • 持續快速失敗: LZ4 擁有高效的提前中止機制,設計用於快速偵測隨機或不易壓縮的資料。

Postgres 儲存策略

首先,你需要了解 Postgres 對欄位有幾種不同的儲存策略:

EXTENDED(我們使用大寫是因為 Postgres 文件如此,而非大聲喊叫)允許 Postgres 使用所有可用工具。這是 TEXT、VARCHAR、BYTEA 與 JSONB 等可變長度型別的預設值。值可以以未壓縮、已壓縮、儲存在 heap 或 TOAST 中。

PLAIN 將欄位以未壓縮方式內嵌儲存在 heap 中。這是 INT、FLOAT 與 BOOL 等固定寬度型別的預設值。這些值很少從壓縮中受益。

EXTERNAL 告訴 Postgres 將欄位儲存在 TOAST 中,但不壓縮。

MAIN 告訴 Postgres 嘗試壓縮欄位,但盡可能避免將其移至 TOAST。

varlena 格式

對於可變長度型別,Postgres 使用稱為 varlena 的格式儲存資料。varlena 是一種自我描述格式,其標頭記錄資料長度以及是否已壓縮。它用於所有可變長度型別(包括 TEXT、VARCHAR、BYTEA 與 JSONB),也是儲存在 heap、TOAST 與索引中的格式。

只有可變長度型別會被壓縮。固定長度型別(如 INT、FLOAT 與 BOOL)永遠不會被壓縮;它們會直接儲存在 heap 中。

壓縮決策樹

在寫入資料(插入或更新)時,Postgres 會嘗試讓資料列大小低於約 2 kB 的門檻(toast_tuple_target,預設 2040 位元組)。它對 EXTENDED 欄位使用以下決策樹:

  1. 若整個資料列已小於約 2 kB,Postgres 會直接將資料列以未壓縮方式寫入 heap。
  2. 若資料列超過門檻,Postgres 會依大小排序 EXTENDED 欄位,並嘗試壓縮最大的欄位。若壓縮成功且使總資料列大小低於門檻,則停止並寫入 heap。若否,則繼續處理下一個較大的欄位。
  3. 若所有符合條件的欄位都已壓縮,但資料列仍超過門檻,Postgres 會開始將最大的 EXTENDED 或 EXTERNAL 欄位移出主資料列,放入 TOAST 資料表,並在主 heap tuple 中以 18 位元組的指標取代,直到資料列符合大小限制。
  4. 若仍無法符合,Postgres 會回頭嘗試將 MAIN 欄位內嵌壓縮。若失敗,則採取最後手段:將這些 MAIN 欄位移出至 TOAST。

更多關於 TOAST 的資訊,請參閱 Postgres TOAST: The Greatest Thing Since Sliced Bread?

檢查實際壓縮節省

-- pg_column_size: 儲存在 heap 或 TOAST 資料表中的位元組數(壓縮後)
-- octet_length:  原始字元資料的位元組數(未壓縮)

SELECT
  pg_column_size(payload) AS stored_bytes,
  octet_length(payload)   AS raw_bytes,
  round(
    (1 - pg_column_size(payload)::numeric
          / NULLIF(octet_length(payload), 0)) * 100, 1
  ) AS compression_pct
FROM events WHERE length(payload) > 100 LIMIT 5;

pg_column_size 回報壓縮後的資料大小。當其回傳的值遠小於 octet_length 時,表示該值已成功壓縮。當兩者近似時,表示該值未壓縮到足以節省空間。此時若該值很大(超過約 2 kB 門檻),仍會被移至 TOAST 資料表,但不壓縮。

索引中的壓縮

B-tree 索引頁面以 IndexTuple 項目儲存鍵值。每個項目包含一個 IndexTupleData 標頭(8 位元組)以及鍵值資料。對於可變長度型別,資料使用與 heap tuple 相同的 varlena 格式(沿用原本為解決 8kB 資料列限制而建立的機制)。

Postgres 使用與 TOAST 架構綁定的單一壓縮框架。它在 varlena 標頭中標記資料是否已壓縮。若 heap 已壓縮某個值,該值會以壓縮形式進入索引。若某個值未壓縮且超過 510 位元組(TOAST_INDEX_TARGET,約為 8 kB 緩衝頁的 1/16),索引程式碼會呼叫相同的 TOAST 壓縮常式進行內嵌壓縮以試圖使其符合。若壓縮後的形式符合,則以壓縮形式儲存。若即使壓縮後仍超過限制,寫入交易將失敗。

這就是為什麼 repeat('x', 5000) 可以建立 B-tree 索引:LZ4 將 5,000 個重複字元壓縮至約 38 位元組,遠低於 2704 位元組的上限。相同長度的隨機或偽隨機資料壓縮後的大小幾乎與原始大小相同,超過上限而無法建立索引。

-- 以下成功:兩者皆可壓縮,經 LZ4 壓縮後皆符合大小
CREATE INDEX ON docs (body);

INSERT INTO docs VALUES (repeat('x', 5000));    -- 儲存大小:約 38 位元組(壓縮後)
INSERT INTO docs VALUES (repeat('ab', 2000));   -- 儲存大小:約 35 位元組(壓縮後)

-- 以下失敗:md5 輸出為偽隨機,基本上無法壓縮
INSERT INTO docs VALUES (
  (SELECT string_agg(md5(g::text), '') FROM generate_series(1, 88) g)
);
-- 2816 字元的 MD5 → 壓縮後約 2816 位元組 → 索引資料列大小 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 採取了類似的方法:先測試,再進行遷移。核心團隊刻意謹慎地確保壓縮演算法的變更是正確的方向。