儲存引擎與頁面結構:PostgreSQL 資料在磁碟上的組織方式 | PostgreSQL
PostgreSQL 預設使用堆積表(Heap Table)作為儲存引擎,所有資料以 8KB 為單位的頁面(Page)組織在磁碟上。理解頁面內部結構,是診斷膨脹(Bloat)、調校 VACUUM 策略、以及優化 Update 密集工作負載的基礎。TOAST 機制則負責處理超過頁面大小的超大型值。
堆積表(Heap Table)
Heap Table 是 PostgreSQL 的預設表儲存格式。「堆積」指的是資料以無序方式堆放——新的 Tuple 直接寫入有空間的 Page,沒有特定排序。
與 MySQL InnoDB 的叢集索引(Clustered Index)不同:
| 特性 | PostgreSQL Heap | MySQL 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_lower 與 pd_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_xmin 和 t_xmax 上:
- INSERT:設定
t_xmin = 當前 XID,t_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 中的原子性與持久性。