儲存引擎與頁面結構:PostgreSQL 資料在磁碟上的組織方式 | PostgreSQL

2026/06/14
儲存引擎與頁面結構:PostgreSQL 資料在磁碟上的組織方式 | PostgreSQL

PostgreSQL 預設使用堆積表(Heap Table)作為儲存引擎,所有資料以 8KB 為單位的頁面(Page)組織在磁碟上。理解頁面內部結構,是診斷膨脹(Bloat)、調校 VACUUM 策略、以及優化 Update 密集工作負載的基礎。TOAST 機制則負責處理超過頁面大小的超大型值。

堆積表(Heap Table)

Heap Table 是 PostgreSQL 的預設表儲存格式。「堆積」指的是資料以無序方式堆放——新的 Tuple 直接寫入有空間的 Page,沒有特定排序。

與 MySQL InnoDB 的叢集索引(Clustered Index)不同:

特性PostgreSQL HeapMySQL InnoDB Clustered
資料排列無序堆積依主鍵排序
主鍵查詢透過索引 → heap直接在 clustered index 中
範圍掃描效率需回表(index scan + heap fetch)主鍵範圍掃描高效
表更新代價Tuple 本身更新,索引另行更新Clustered index 重排可能更昂貴

PostgreSQL 12+ 透過 Table Access Method API 支援可插拔儲存引擎,但 Heap 仍是預設且最通用的選擇。

頁面佈局(Page Layout)

所有 PostgreSQL 的資料——表、索引、序列——都以 8KB 頁面(Page,亦稱 Block)為單位存取。頁面是 I/O 的最小單位,即使只讀取一個 Tuple,也必須讀取整個 8KB 頁面。

8KB Page 結構:

┌─────────────────────────────────────┐  0
│          PageHeaderData (24B)        │
│  ├── pd_lsn      (8B) 最後修改的 WAL LSN
│  ├── pd_checksum (2B) page checksum
│  ├── pd_lower    (2B) free space 起始偏移
│  ├── pd_upper    (2B) free space 結束偏移
│  └── pd_special  (2B) special space 偏移
├─────────────────────────────────────┤  24
│  ItemId[1]  │  ItemId[2]  │  ...    │  ← pd_lower 向下成長
├─────────────────────────────────────┤
│                                     │
│            Free Space               │
│                                     │
├─────────────────────────────────────┤  ← pd_upper
│         Tuple Data(向上成長)      │
│  [Tuple 3]  [Tuple 2]  [Tuple 1]   │
├─────────────────────────────────────┤
│         Special Space               │  8192(8KB)
└─────────────────────────────────────┘

Page 的兩端向中間成長:ItemId 陣列從上往下、Tuple Data 從下往上。當 pd_lowerpd_upper 相遇,Page 就滿了。

ItemId(行指標)的重要性

每個 ItemId(4 bytes)包含三個欄位:

  • lp_off:Tuple 在 Page 內的偏移量
  • lp_flags:狀態(LP_UNUSED / LP_NORMAL / LP_REDIRECT / LP_DEAD
  • lp_len:Tuple 的長度

索引中儲存的是 (page_number, ItemId_index) 這個邏輯指標,稱為 ctid(如 (0,1) 表示第 0 個 Page 的第 1 個 ItemId)。即使 Tuple 因為 Update 被移動,索引無需更新——只要更新 ItemId 指向新位置(HOT Update 機制)。

Tuple Header 結構

每個 Tuple 前有 23 bytes 的 Header,儲存 MVCC 所需的版本資訊:

HeapTupleHeaderData(23 bytes):

位元組  欄位          說明
 0-3   t_xmin        插入此 Tuple 的 Transaction ID
 4-7   t_xmax        刪除或更新此 Tuple 的 Transaction ID(0 = 仍有效)
 8-11  t_cid         Command ID
12-15  t_ctid        此 Tuple 或新版本 Tuple 的 (page, offset)
16-17  t_infomask    各種狀態 bits(Hint Bits 等)
18     t_hoff        Header 總長(含 null bitmap)
19+    null bitmap   每個欄位一個 bit,標記 NULL 值

MVCC 的核心邏輯就建立在 t_xmint_xmax 上:

  • INSERT:設定 t_xmin = 當前 XIDt_xmax = 0
  • DELETE:設定 t_xmax = 當前 XID,保留舊 Tuple
  • UPDATE:設定舊 Tuple 的 t_xmax = 當前 XID,插入新 Tuple 並設 t_xmin = 當前 XID

UPDATE 操作實際上是「刪除舊版本 + 插入新版本」,是 PostgreSQL 需要 VACUUM 清理 Dead Tuple 的根本原因。

TOAST(The Oversized-Attribute Storage Technique)

PostgreSQL 的頁面固定為 8KB,但允許儲存遠超過此大小的資料。TOAST 機制負責處理超大型值。

當一個 Tuple 的大小超過約 TOAST_TUPLE_THRESHOLD(約 2KB)時,PostgreSQL 嘗試壓縮或將大型欄位移至 TOAST 表。

四種 TOAST 策略

策略行為
PLAIN不壓縮,不外移。適用於不可能超過頁面大小的型別(int, bool)
EXTENDED先壓縮,若仍太大則外移至 TOAST 表。大多數可變長度型別的預設值
EXTERNAL不壓縮,直接外移。適用於需要 substring 操作的資料
MAIN先壓縮,若仍太大也允許外移(但優先留在主表)
-- 查看表的 TOAST 表名稱
SELECT c.relname AS main_table,
       t.relname AS toast_table
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relname = 'my_table';

-- 查看各欄位的 TOAST 策略
SELECT attname, attstorage,
       CASE attstorage
           WHEN 'p' THEN 'PLAIN'
           WHEN 'e' THEN 'EXTERNAL'
           WHEN 'x' THEN 'EXTENDED'
           WHEN 'm' THEN 'MAIN'
       END AS strategy
FROM pg_attribute
WHERE attrelid = 'my_table'::regclass
  AND attnum > 0;

每個有 TOAST 欄位的表都有一個對應的 TOAST 表(pg_toast.pg_toast_<relid>),大型值被切成每片段最大 2KB 的 chunks 存放。

Free Space Map 與 Fill Factor

Free Space Map(FSM)

FSM 是每個表的 _fsm 後綴檔案,記錄每個 Page 的可用空間。PostgreSQL 在 INSERT 或 UPDATE 時,透過 FSM 快速找到有足夠空間的 Page。

-- 安裝 pg_freespacemap 查看 FSM 資訊
CREATE EXTENSION IF NOT EXISTS pg_freespacemap;

-- 查看表的 Page 可用空間分佈
SELECT page, avail
FROM pg_freespace('orders')
ORDER BY avail DESC
LIMIT 20;

-- 查看表的平均 Page 填充率
SELECT
    COUNT(*) AS total_pages,
    ROUND(AVG(avail) / 8192.0 * 100, 1) AS avg_free_pct
FROM pg_freespace('orders');

Fill Factor(填充因子)

Fill Factor 決定 INSERT 時 Page 的目標填充比例(預設 100%):

-- 設定 fill factor 為 80%,保留 20% 給 UPDATE
ALTER TABLE orders SET (fillfactor = 80);

Fill Factor < 100% 搭配 HOT(Heap Only Tuple)Update 的優化效果:

HOT Update 條件:
1. 被更新的欄位不在任何索引中
2. 新版本 Tuple 能放在同一個 Page 內

HOT Update 好處:索引無需更新,只需更新 Page 內的 ItemId chain

填充因子 = 80%,Page 佈局:

INSERT 後(80% 滿):         UPDATE 後(HOT update):
┌──────────────┐             ┌──────────────┐
│ Header       │             │ Header       │
│ ItemId[1]    │             │ ItemId[1]    │ → 重定向到 ItemId[3]
│ ItemId[2]    │             │ ItemId[2]    │
│              │             │ ItemId[3]    │ (新 version)
│ Free(20%)    │             │              │
│ Tuple 2      │             │ Tuple 3      │ (新版本 tuple)
│ Tuple 1      │             │ Tuple 2      │
└──────────────┘             │ Tuple 1(dead)│
                             └──────────────┘
-- 查看 HOT Update 的效果
SELECT
    n_tup_upd AS total_updates,
    n_tup_hot_upd AS hot_updates,
    ROUND(n_tup_hot_upd::numeric / NULLIF(n_tup_upd, 0) * 100, 1) AS hot_ratio
FROM pg_stat_user_tables
WHERE relname = 'user_sessions';
-- hot_ratio 越高代表 Fill Factor 設定越有效

查看表的大小與頁面資訊

-- 查看表的詳細大小分解
SELECT
    pg_size_pretty(pg_table_size(oid))          AS table_size,
    pg_size_pretty(pg_indexes_size(oid))        AS indexes_size,
    pg_size_pretty(pg_total_relation_size(oid)) AS total_size,
    relpages                                     AS page_count,
    reltuples::bigint                            AS estimated_rows
FROM pg_class
WHERE relname = 'orders';

-- 查看 TOAST 表的大小(有時比主表還大!)
SELECT
    c.relname AS main_table,
    pg_size_pretty(pg_relation_size(c.oid)) AS main_size,
    pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast_size
FROM pg_class c
WHERE c.relname = 'orders';

使用 pageinspect 檢查頁面內容

-- 安裝 pageinspect(診斷用)
CREATE EXTENSION IF NOT EXISTS pageinspect;

-- 查看第 0 個 Page 的 Header
SELECT * FROM page_header(get_raw_page('orders', 0));

-- 查看 Page 中的所有 Tuple(含 Dead Tuple)
SELECT t_xmin, t_xmax, t_ctid, t_infomask::text
FROM heap_page_items(get_raw_page('orders', 0));

Tablespace 管理

Tablespace 允許將不同的表或索引存放在不同的磁碟位置,實現儲存分層:

-- 建立 Tablespace
CREATE TABLESPACE fast_ssd LOCATION '/ssd/pg_data';
CREATE TABLESPACE slow_hdd LOCATION '/hdd/pg_archive';

-- 在特定 Tablespace 建立表
CREATE TABLE hot_data (
    id SERIAL PRIMARY KEY,
    payload JSONB
) TABLESPACE fast_ssd;

-- 將既有表移至不同 Tablespace(會鎖表!)
ALTER TABLE archive_data SET TABLESPACE slow_hdd;

Table Access Method API(PG12+)

PostgreSQL 12 引入了 Table Access Method API,允許第三方實作自訂的儲存引擎:

-- 查看已安裝的 Access Method
SELECT amname, amtype FROM pg_am;
-- amtype 'r' = table access method
-- amtype 'i' = index access method

-- Citus columnar(列式儲存,適合分析查詢)
CREATE TABLE analytics_events (
    event_date DATE,
    event_type TEXT,
    user_id INT,
    value NUMERIC
) USING columnar;
-- 壓縮比通常可達 5-10x,但不支援 UPDATE/DELETE

Page 層級 Checksum

# 初始化新 cluster 時啟用 checksum
initdb --data-checksums -D /var/lib/postgresql/data

# 事後對既有 cluster 啟用(PG12+,需要停機)
pg_checksums --enable -D /var/lib/postgresql/data
-- 查看 checksum 是否啟用
SHOW data_checksums;

Checksum 在 Page 讀取時驗證,偵測靜默資料損壞(Silent Data Corruption)。建議所有生產環境啟用。

常見陷阱

DELETE 不會立即回收磁碟空間

DELETE FROM orders WHERE created_at < '2023-01-01';
-- ↑ 只是標記 Dead Tuple,磁碟檔案大小不變

-- 若要回收空間(但需排他鎖,避免在大表使用):
VACUUM FULL orders;

-- 生產環境推薦使用 pg_repack(不需長時間鎖表)

忽略 TOAST 表的大小

-- 開發者常只看 table_size 而忽略 TOAST:
SELECT pg_size_pretty(pg_table_size('documents')); -- 可能只有 1MB
-- 而 TOAST 實際大小:
SELECT pg_size_pretty(pg_relation_size(
    (SELECT reltoastrelid FROM pg_class WHERE relname='documents')
)); -- 可能有 10GB!

ctid 不是穩定的識別符

ctid 是 Tuple 的物理位置,會在 VACUUM FULL、UPDATE、CLUSTER 時改變。不要在應用程式邏輯中依賴 ctid,應使用 PRIMARY KEY。

Fill Factor 最佳實踐

工作負載類型Fill Factor原因
高 UPDATE 頻率(session、狀態表)70-80%保留空間給 HOT Update
純追加寫入(log、audit)100%(預設)不需要 Update 空間
混合型85-90%適度平衡

總結

儲存引擎 是 PostgreSQL 資料組織的基礎:

  • Heap Table 以無序方式儲存資料,搭配索引提供高效查詢
  • 8KB Page 是 I/O 的最小單位,理解 Page 結構有助於診斷效能問題
  • Tuple Header 儲存 MVCC 版本資訊,Update = 刪除舊版本 + 插入新版本
  • TOAST 透過壓縮與外移處理超大型值,JSONB 預設使用 EXTENDED 策略
  • Fill Factor 搭配 HOT Update 可大幅減少索引維護開銷
  • PG12+ Table Access Method API 開啟了可插拔儲存引擎的可能性

下一篇,我們將深入探討 Transaction 管理——PostgreSQL 如何實現 ACID 中的原子性與持久性。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。