查詢規劃器與執行計畫:PostgreSQL 如何選擇最優路徑 | PostgreSQL
查詢規劃器(Query Planner) 是 PostgreSQL 效能的幕後操盤手——它決定每條 SQL 要走哪條索引、用哪種 Join 演算法、是否啟用平行查詢。理解 Cost-Based Optimizer 的成本模型與 EXPLAIN 輸出解讀,是從「會寫 SQL」進階到「會調效能」的關鍵轉折點。
SQL 查詢處理流程
一條 SQL 從送入到取得結果,需要經歷五個階段:
SQL 文字
│
▼
Parser(語法解析器)
│ 將 SQL 文字解析為 Abstract Syntax Tree
│ 進行語法檢查,不合法的 SQL 在此被拒絕
▼
Analyzer(語意分析器)
│ 驗證表名、欄名是否存在(查詢 pg_catalog)
│ 解析型別、函式、運算子,建立 Query Tree
▼
Rewriter(規則改寫器)
│ 套用系統規則(Rules)改寫 Query Tree
│ 展開 Views 為底層查詢
▼
Planner / Optimizer(規劃器) ← 核心
│ 枚舉所有可行的執行路徑(Paths)
│ 使用 Cost-Based Optimizer 估算每條路徑的成本
│ 選擇總成本最低的 Plan Tree
▼
Executor(執行器)
│ 按照 Plan Tree 逐節點遞迴執行
│ 讀取 Buffer Cache,必要時從磁碟載入
▼
Results
| 階段 | 輸入 | 輸出 | 關鍵工作 |
|---|---|---|---|
| Parser | SQL 文字 | Raw Parse Tree | 語法檢查 |
| Analyzer | Raw Parse Tree | Query Tree | 語意驗證、型別解析 |
| Rewriter | Query Tree | Query Tree(改寫後) | 視圖展開、規則套用 |
| Planner | Query Tree | Plan Tree | 成本估算、最佳路徑選擇 |
| Executor | Plan Tree | Tuples | 實際讀取資料並回傳 |
Cost-Based Optimizer 原理
PostgreSQL 的 Planner 使用成本模型(Cost Model)來比較不同執行計畫的代價,而非依賴固定規則。
成本的表示
每個執行計畫節點都有兩個成本值:
cost = startup_cost..total_cost
startup_cost:回傳第一行資料之前所需的代價(例如 Hash Join 需要先建完 hash table)total_cost:回傳所有資料的總代價- 若查詢有
LIMIT,Planner 會估算取出 N 行的 partial cost
基礎成本參數
| 參數 | 預設值 | 意義 |
|---|---|---|
seq_page_cost | 1.0 | 循序讀取一個 8KB 頁面的代價(基準值) |
random_page_cost | 4.0 | 隨機讀取一個頁面的代價 |
cpu_tuple_cost | 0.01 | 處理一個 tuple 的 CPU 代價 |
cpu_index_tuple_cost | 0.005 | 處理一個索引 tuple 的 CPU 代價 |
cpu_operator_cost | 0.0025 | 執行一個運算子的 CPU 代價 |
parallel_tuple_cost | 0.1 | 並行查詢傳遞一個 tuple 的代價 |
parallel_setup_cost | 1000.0 | 啟動並行 Worker 的固定代價 |
SSD 環境調整:SSD 的隨機存取與循序存取差異遠小於 HDD,建議調降 random_page_cost:
-- SSD 環境下的典型設定
SET random_page_cost = 1.1;
SET seq_page_cost = 1.0;
-- 讓 Planner 更願意選擇 Index Scan
成本計算範例
以 Seq Scan 為例:
cost = (頁面數 × seq_page_cost) + (總行數 × cpu_tuple_cost)
-- 例:10,000 頁的資料表,100 萬行
cost = (10000 × 1.0) + (1000000 × 0.01)
= 10000 + 10000
= 20000
統計資訊與選擇率
Planner 使用 pg_stats 中的統計資料來估算中間結果的行數(rows),行數估算的準確性直接決定計畫品質。
關鍵統計欄位
| 統計欄位 | 說明 | 用途 |
|---|---|---|
n_distinct | 不重複值數量(負數表示比例) | 估算 GROUP BY 後的行數 |
most_common_vals | 最常見的值列表 | 等值條件的選擇率估算 |
most_common_freqs | 對應的頻率列表 | 搭配 most_common_vals |
histogram_bounds | 直方圖邊界值 | 範圍查詢的選擇率估算 |
correlation | 實體順序與邏輯順序的相關性(-1 到 1) | 決定 Index Scan 的成本 |
null_frac | NULL 值的比例 | IS NULL / IS NOT NULL 估算 |
行數估算邏輯
-- 等值條件:WHERE status = 'active'
若 'active' 在 most_common_vals 中,直接取對應頻率:
rows = total_rows × most_common_freqs[i]
-- 範圍條件:WHERE age BETWEEN 20 AND 30
使用 histogram_bounds 插值估算涵蓋比例:
rows = total_rows × (涵蓋的直方圖比例)
-- 多條件 AND:選擇率相乘(假設獨立)
selectivity = sel_cond1 × sel_cond2
-- 多條件 OR:
selectivity = sel_cond1 + sel_cond2 - sel_cond1 × sel_cond2
-- 查看特定欄位的統計資訊
SELECT
attname,
null_frac,
n_distinct,
most_common_vals,
most_common_freqs,
histogram_bounds,
correlation
FROM pg_stats
WHERE tablename = 'orders'
AND attname = 'status';
四種掃描策略
Planner 根據統計資訊、可用索引和查詢條件,選擇最合適的掃描方式。
Sequential Scan(全表掃描)
┌──────────────────────────────────────────────┐
│ Heap(資料表) │
│ Page 1 │ Page 2 │ Page 3 │ ... │ Page N │
│ ←────── 循序讀取所有頁面 ──────────────→ │
└──────────────────────────────────────────────┘
- 優點:循序 I/O 效率高,適合讀取大比例資料
- 缺點:即使只需少數行,也必須掃描整個表
- 適用:選擇率高(>5-10%)、表格很小、無合適索引
EXPLAIN SELECT * FROM orders WHERE status = 'pending';
-- 若 pending 佔比很高(>10%),會選擇 Seq Scan
-- Seq Scan on orders (cost=0.00..2500.00 rows=50000 width=120)
-- Filter: (status = 'pending'::text)
Index Scan(索引掃描)
┌────────────────────────┐ ┌──────────────────────┐
│ B-tree 索引 │ │ Heap(資料表) │
│ key → ctid 指標 │────→│ 隨機跳讀對應頁面 │
└────────────────────────┘ └──────────────────────┘
- 先走索引取得符合條件的 ctid,再到 Heap 隨機讀取
correlation接近 1 或 -1 時效率較好(實體順序接近邏輯順序)- 適用:選擇率低(<1%)、有合適索引
EXPLAIN SELECT * FROM users WHERE user_id = 12345;
-- Index Scan using users_pkey on users (cost=0.43..8.45 rows=1 width=200)
-- Index Cond: (user_id = 12345)
Index Only Scan(純索引掃描)
┌────────────────────────────────────────┐
│ B-tree 索引 │
│ 包含所有需要的欄位 → 不需回 Heap │
└──────────────────┬─────────────────────┘
↓ 需確認 Visibility Map
┌──────────────────────────────────────┐
│ Visibility Map(確認 tuple 可見性) │
└──────────────────────────────────────┘
- 查詢所需的所有欄位都在索引中(Covering Index)
- 必須透過 Visibility Map 確認 tuple 可見性
- 效能最好的掃描方式:完全避免 Heap I/O
-- 建立 Covering Index
CREATE INDEX idx_orders_status_covering
ON orders(status) INCLUDE (order_id, created_at, total_amount);
EXPLAIN SELECT order_id, created_at, total_amount
FROM orders WHERE status = 'shipped';
-- Index Only Scan using idx_orders_status_covering on orders
-- Index Cond: (status = 'shipped'::text)
-- Heap Fetches: 0 ← 完全不碰 Heap
Bitmap Scan(點陣圖掃描)
第一階段:Bitmap Index Scan
走索引,建立記憶體中的 Bitmap
標記哪些 Heap 頁面包含符合條件的 tuple
第二階段:Bitmap Heap Scan
依 Bitmap 的頁面編號,以接近循序的順序讀取 Heap
避免重複讀取同一頁面
- 適用:中等選擇率(1-10%)
- 多個索引條件可以合併 Bitmap(BitmapAnd / BitmapOr)
- 若 Bitmap 超過
work_mem,會降格為 lossy 模式(需 Recheck)
EXPLAIN SELECT * FROM orders
WHERE status = 'processing' AND region = 'TW';
-- Bitmap Heap Scan on orders
-- Recheck Cond: ((status = 'processing') AND (region = 'TW'))
-- -> BitmapAnd
-- -> Bitmap Index Scan on idx_orders_status
-- -> Bitmap Index Scan on idx_orders_region
掃描策略選擇總結
| 掃描類型 | 選擇率 | I/O 模式 | 最適場景 |
|---|---|---|---|
| Seq Scan | 高(>5-10%) | 循序 | 大量讀取、無索引 |
| Index Scan | 低(<1%) | 隨機 | 極少量精確查詢 |
| Index Only Scan | 低 | 極少 | 查詢欄位皆在索引中 |
| Bitmap Scan | 中(1-10%) | 接近循序 | 中等選擇率、多條件合併 |
三種 Join 策略
當查詢涉及多張表時,Planner 需要選擇 Join 演算法。
Nested Loop Join
FOR each row in outer_table: ← 外表(驅動表)
FOR each matching row in inner: ← 內表(被驅動表)
output joined row
- 支援所有類型的 Join 條件(等值、範圍、任意表達式)
- 內表有索引時,每次只需 index lookup
- startup_cost 最低,適合外表結果集很小的場景
-- 典型 Nested Loop:主鍵連接
EXPLAIN SELECT o.*, u.name
FROM orders o JOIN users u ON o.user_id = u.user_id
WHERE o.order_id = 99999;
-- Nested Loop (cost=0.86..17.89 rows=1 width=220)
-- -> Index Scan on orders (cost=0.43..8.45 rows=1)
-- -> Index Scan on users (cost=0.43..8.45 rows=1)
Hash Join
Build Phase:
掃描小表,依 join key 建立 hash table (in memory)
Probe Phase:
掃描大表,依 join key 查 hash table,匹配則輸出
small_table ────→ Hash Table (in memory)
│ 查找
large_table ───────────→┘
- 只支援等值(equi-join)條件
- 需要足夠的
work_mem存放 hash table(不足則 spill to disk) - 最適場景:兩個中大型表的等值 Join、無合適索引
SET work_mem = '64MB';
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.order_id, p.product_name
FROM orders o JOIN products p ON o.product_id = p.product_id;
-- Hash Join (cost=450.00..12500.00 rows=200000 width=50)
-- Hash Cond: (o.product_id = p.product_id)
-- -> Seq Scan on orders
-- -> Hash
-- Buckets: 65536 Batches: 1 Memory Usage: 7825kB
-- ^^^^^^^^ Batches=1 表示全在記憶體中
Merge Join
前提:兩個輸入都已按 join key 排序
outer: [1, 3, 5, 7, 9, ...] (已排序)
inner: [1, 2, 3, 5, 8, ...] (已排序)
同步推進兩個指針,成本 O(N + M)
但需要先排序 O(N log N + M log M)
- 若兩邊有已排序的索引,可跳過排序步驟
- 最適場景:兩邊都有排序(如 Primary Key 索引)、大型等值 Join
三種 Join 比較
| 特性 | Nested Loop | Hash Join | Merge Join |
|---|---|---|---|
| 支援條件 | 所有條件 | 等值 | 等值、部份範圍 |
| startup_cost | 最低 | 中(建 hash table) | 高(需排序)或低(有索引) |
| 最適外表大小 | 小 | 任意 | 大(有排序) |
| 記憶體需求 | 低 | 高(work_mem) | 低(有索引時) |
| 支援並行 | 有限 | 完整(PG11+) | 有限 |
| 最差情境 | 大表 × 大表(無索引) | work_mem 不足 | 無索引需排序 |
-- 診斷用:強制停用特定 Join 策略(不建議長期使用)
SET enable_nestloop = off;
SET enable_hashjoin = off;
SET enable_mergejoin = off;
-- 觀察 Planner 在受限條件下的選擇
-- 使用後記得 RESET
EXPLAIN 輸出解讀
EXPLAIN 是診斷查詢效能的核心工具。
基本 EXPLAIN
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
Index Scan using idx_orders_user_id on orders (cost=0.43..52.20 rows=15 width=120)
Index Cond: (user_id = 100)
cost=0.43..52.20:startup_cost..total_cost(Planner 估算,非實際時間)rows=15:估算回傳行數width=120:每行平均位元組數
EXPLAIN ANALYZE(真實執行)
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 100;
Index Scan using idx_orders_user_id on orders
(cost=0.43..52.20 rows=15 width=120)
(actual time=0.042..0.318 rows=17 loops=1)
Index Cond: (user_id = 100)
Planning Time: 0.250 ms
Execution Time: 0.412 ms
actual time=0.042..0.318:實際 startup..完成時間(毫秒)rows=17:實際行數(與估算 rows=15 接近,估算良好)loops=1:節點被執行的次數(Nested Loop 內層可能 >1)
重要:EXPLAIN ANALYZE 會真實執行查詢。對於 DML 操作,務必包在 Transaction 中:
BEGIN;
EXPLAIN ANALYZE UPDATE orders SET status = 'processed' WHERE user_id = 100;
ROLLBACK; -- 撤銷實際修改
EXPLAIN (ANALYZE, BUFFERS)
加上 BUFFERS 可看到 I/O 細節:
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.*, u.name FROM orders o JOIN users u ON o.user_id = u.user_id
WHERE o.created_at > NOW() - INTERVAL '7 days';
Hash Join (cost=2500.00..18000.00 rows=150000 width=320)
(actual time=45.231..892.445 rows=143250 loops=1)
Hash Cond: (o.user_id = u.user_id)
Buffers: shared hit=12450 read=8920
-> Seq Scan on orders
Filter: (created_at > ...)
Rows Removed by Filter: 1856750
Buffers: shared hit=10200 read=8500
-> Hash
Buckets: 65536 Batches: 1 Memory Usage: 7825kB
Buffers: shared hit=2250 read=420
-> Seq Scan on users
Planning Time: 2.340 ms
Execution Time: 942.118 ms
Buffer 統計解讀:
| 欄位 | 意義 |
|---|---|
shared hit | 從 Shared Buffer Cache 命中的頁面數(好) |
shared read | 從磁碟讀入的頁面數(cache miss) |
shared written | 寫入的 dirty 頁面數 |
temp read/written | 溢出到磁碟的臨時文件(如 Hash Join spill) |
其他 EXPLAIN 選項
-- JSON 格式(適合程式解析,可貼入視覺化工具)
EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) SELECT ...;
-- PG16+:顯示記憶體使用
EXPLAIN (ANALYZE, MEMORY) SELECT ...;
-- PG16+:顯示通用計畫(Prepared Statements 診斷)
EXPLAIN (GENERIC_PLAN) SELECT $1::int + $2::int;
常見執行計畫節點
Aggregate 節點
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;
-- HashAggregate ← 記憶體中建 hash table 統計
-- 或 GroupAggregate ← 依賴已排序的輸入
EXPLAIN SELECT SUM(amount) FROM orders;
-- Aggregate(無 GROUP BY)
- HashAggregate:不要求輸入有序,適合分組鍵少且記憶體足夠
- GroupAggregate:要求輸入已排序(可利用索引),記憶體使用低
Sort 與 Incremental Sort
EXPLAIN SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;
-- Sort Sort Key: created_at DESC
-- -> Seq Scan on orders
-- PG13+ Incremental Sort(利用部份排序的索引)
-- 假設有索引 ON orders(user_id)
EXPLAIN SELECT * FROM orders ORDER BY user_id, created_at DESC;
-- Incremental Sort
-- Sort Key: user_id, created_at DESC
-- Presorted Key: user_id ← 利用索引已排好 user_id
-- -> Index Scan using idx_orders_user_id on orders
Gather / Gather Merge(平行查詢)
SET max_parallel_workers_per_gather = 4;
EXPLAIN SELECT COUNT(*) FROM very_large_table;
-- Finalize Aggregate
-- -> Gather (workers=4)
-- -> Partial Aggregate
-- -> Parallel Seq Scan on very_large_table
常見慢查詢模式與修正
模式一:大表 Seq Scan(應走索引)
-- 問題:500 萬行表對低選擇率條件全表掃描
EXPLAIN SELECT * FROM orders WHERE order_number = 'ORD-2024-00001';
-- Seq Scan on orders (cost=0.00..125000.00 rows=1 width=200)
-- 解決:建立索引
CREATE INDEX idx_orders_order_number ON orders(order_number);
-- 驗證:成本從 125000 降至 8.58
EXPLAIN SELECT * FROM orders WHERE order_number = 'ORD-2024-00001';
-- Index Scan using idx_orders_order_number (cost=0.56..8.58 rows=1)
模式二:Nested Loop 處理大結果集
-- 問題:Planner 的 rows 估算嚴重偏低,選了錯誤策略
EXPLAIN ANALYZE
SELECT * FROM orders o JOIN order_items i ON o.order_id = i.order_id
WHERE o.created_at > '2024-01-01';
-- Nested Loop (actual time=0.025..48230.000 rows=2000000 loops=1)
-- 解決方案一:更新統計資訊
ANALYZE orders;
ANALYZE order_items;
-- 解決方案二:增加 work_mem 讓 Hash Join 有足夠記憶體
SET work_mem = '256MB';
模式三:Hash Join 溢出磁碟
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM large_a JOIN large_b ON large_a.id = large_b.a_id;
-- Hash Join
-- Buckets: 131072 Batches: 32 Memory Usage: 8192kB
-- ^^^^^^^^ Batches > 1 = spill to disk
-- 解決:增加 work_mem
SET work_mem = '512MB';
-- Batches: 1 ← 不再溢出,效能大幅提升
模式四:統計資訊過時
-- 問題:批量匯入後統計資訊未更新
INSERT INTO events SELECT * FROM events_staging; -- 匯入 100 萬筆
EXPLAIN SELECT COUNT(*) FROM events WHERE type = 'click';
-- actual rows=150000 vs 估算 rows=1500 ← 100 倍誤差
-- 檢查最後 ANALYZE 時間
SELECT tablename, last_analyze, last_autoanalyze, n_live_tup
FROM pg_stat_user_tables WHERE tablename = 'events';
-- 解決:手動更新統計
ANALYZE events;
模式五:函式破壞索引
-- 問題:WHERE 中對索引欄使用函式
SELECT * FROM users WHERE UPPER(email) = 'USER@EXAMPLE.COM';
-- Seq Scan ← 無法使用 idx_users_email
-- 解決方案一:Expression Index
CREATE INDEX idx_users_email_upper ON users(UPPER(email));
-- 解決方案二:調整查詢(應用層預處理)
SELECT * FROM users WHERE email = LOWER('USER@EXAMPLE.COM');
統計資訊管理
調整 Statistics Target
default_statistics_target(預設 100)控制直方圖的桶數。值越高,估算越精確,但 ANALYZE 耗時越長。
-- 針對特定欄位調整(估算一直不準時)
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders(status);
-- 資料分布均勻的欄位可降低
ALTER TABLE logs ALTER COLUMN log_level SET STATISTICS 50;
監控 ANALYZE 執行
-- 找出統計資訊最舊的大表(高風險)
SELECT
tablename,
n_live_tup,
last_autoanalyze,
EXTRACT(EPOCH FROM (NOW() - last_autoanalyze))/3600 AS hours_ago
FROM pg_stat_user_tables
WHERE n_live_tup > 100000
ORDER BY last_autoanalyze NULLS FIRST
LIMIT 20;
Autovacuum 與 Autoanalyze
-- 確認 autoanalyze 正常運作
SHOW autovacuum; -- on
SHOW autovacuum_analyze_threshold; -- 50(行數觸發閾值)
SHOW autovacuum_analyze_scale_factor; -- 0.2(20% 變動觸發)
-- 觸發條件:dead tuples > threshold + scale_factor × n_live_tup
JIT Compilation(即時編譯)
JIT 是 PG11 引入的功能,基於 LLVM 將查詢執行過程中的部份計算編譯為機器碼。
三個編譯階段
- Expression Evaluation:將 WHERE 條件、投影計算編譯為機器碼
- Tuple Deforming:優化行解包(磁碟格式 → 記憶體格式)
- Function Inlining:將 PL/pgSQL 函式內聯到查詢中
-- 在 EXPLAIN ANALYZE 輸出中觀察 JIT
EXPLAIN (ANALYZE) SELECT COUNT(*), SUM(amount)
FROM very_large_orders
WHERE created_at > '2023-01-01' AND status IN ('completed', 'shipped');
-- 輸出末尾:
-- JIT:
-- Functions: 8
-- Options: Inlining true, Optimization true, Expressions true, Deforming true
-- Timing: Generation 2.5 ms, Inlining 15.2 ms, Optimization 32.1 ms,
-- Emission 12.5 ms, Total 62.4 ms
JIT 何時反而更慢
- 短暫查詢(執行時間 < JIT 編譯時間)
- OLTP 工作負載(每次都是小查詢)
-- 針對 OLTP 角色停用 JIT
ALTER ROLE oltp_user SET jit = off;
-- JIT 啟用門檻(預設值通常足夠)
SHOW jit_above_cost; -- 100000
SHOW jit_inline_above_cost; -- 500000
SHOW jit_optimize_above_cost; -- 500000
Parallel Query(平行查詢)
平行查詢讓一個查詢可以使用多個 CPU 核心。
Leader Process(領導程序)
Gather / Gather Merge
│ 分配工作
┌───────────┼───────────┐
│ │ │
Worker #1 Worker #2 Worker #3
Parallel Parallel Parallel
Seq Scan Seq Scan Seq Scan
(分片 1) (分片 2) (分片 3)
相關參數
SHOW max_parallel_workers; -- 8(系統總並行 Worker 上限)
SHOW max_parallel_workers_per_gather; -- 2(單一 Gather 的 Worker 數)
SHOW min_parallel_table_scan_size; -- 8MB(表超過此大小才考慮並行)
SHOW min_parallel_index_scan_size; -- 512kB
-- 允許更多並行 Worker
SET max_parallel_workers_per_gather = 4;
支援並行的節點類型
| 節點類型 | 支援版本 |
|---|---|
| Parallel Seq Scan | PG9.6+ |
| Parallel Index Scan | PG10+ |
| Parallel Index Only Scan | PG10+ |
| Parallel Bitmap Heap Scan | PG10+ |
| Parallel Hash Join | PG11+ |
| Parallel Aggregate | PG9.6+ |
不支援並行的情況
-- 1. 查詢包含 parallel-unsafe 的函式
-- 需明確標記 PARALLEL SAFE
CREATE FUNCTION my_func() RETURNS void
AS $$ ... $$ LANGUAGE plpgsql PARALLEL SAFE;
-- 2. DML 操作(UPDATE/DELETE/INSERT)
-- 3. SERIALIZABLE 隔離級別
-- 4. 使用 nextval()、currval() 等序列函式
效能診斷工具
auto_explain 擴展
自動記錄超過閾值的查詢執行計畫到日誌:
-- 載入擴展
LOAD 'auto_explain';
SET auto_explain.log_min_duration = 1000; -- 超過 1 秒才記錄
SET auto_explain.log_analyze = on;
SET auto_explain.log_buffers = on;
SET auto_explain.log_nested_statements = on;
-- 永久啟用(postgresql.conf)
-- shared_preload_libraries = 'auto_explain'
-- auto_explain.log_min_duration = '1s'
pg_stat_statements
追蹤所有查詢的統計資訊:
-- 找出最耗時的查詢
SELECT
query,
calls,
round(total_exec_time::numeric, 2) AS total_ms,
round((total_exec_time / calls)::numeric, 2) AS avg_ms,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
-- 找出平均最慢的查詢(至少執行 100 次)
SELECT query, calls,
round(mean_exec_time::numeric, 2) AS avg_ms
FROM pg_stat_statements
WHERE calls > 100
ORDER BY mean_exec_time DESC
LIMIT 10;
視覺化分析工具
將 EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) 的輸出貼入以下工具進行視覺化:
- explain.depesz.com:老牌工具,顏色標記高成本節點
- explain.dalibo.com:視覺化樹狀圖,直觀易懂
- PEV2:開源版本,可自行部署
-- 輔助診斷:找出使用 Seq Scan 最多的表(可能需要索引)
SELECT relname, seq_scan, seq_tup_read, idx_scan,
CASE WHEN seq_scan > 0
THEN round(seq_tup_read::numeric / seq_scan, 0)
END AS avg_seq_tup
FROM pg_stat_user_tables
WHERE seq_scan > 1000
ORDER BY seq_tup_read DESC
LIMIT 20;
版本演進
| 版本 | 主要查詢規劃改進 |
|---|---|
| PG9.6 | Parallel Query 初步支援(Parallel Seq Scan、Parallel Aggregate) |
| PG10 | Parallel Index Scan、Parallel Bitmap Scan |
| PG11 | Parallel Hash Join、JIT Compilation(LLVM) |
| PG12 | CTE NOT MATERIALIZED(inline 優化)、plan_cache_mode 控制 |
| PG13 | Incremental Sort(利用部份排序減少工作量) |
| PG14 | 非同步執行(Async Append for FDW)、改進 multirange 索引 |
| PG15 | 改進 Hash Join 記憶體使用、增強統計資訊收集 |
| PG16 | EXPLAIN 新增 MEMORY 選項、更多節點支援並行 |
| PG17 | 改進 Merge Join 效能、增強聚合優化、更好的 JIT 決策 |
常見陷阱
Prepared Statements 的 Generic Plan
PREPARE stmt AS SELECT * FROM orders WHERE status = $1;
-- 前 5 次:Custom Plan(針對實際參數值優化)
-- 第 6 次起:可能切換為 Generic Plan(通用計畫)
-- 若資料分佈不均,Generic Plan 可能效能差很多
-- 強制使用 Custom Plan
SET plan_cache_mode = force_custom_plan;
-- 強制使用 Generic Plan(參數分布均勻時)
SET plan_cache_mode = force_generic_plan;
CTE 的 Materialization(PG12 前後差異)
-- PG11:CTE 是 optimization fence,強制 materialize
-- PG12+:CTE 預設可被 inline,Planner 能優化整個查詢
-- 強制 materialize(保證 CTE 只執行一次)
WITH recent AS MATERIALIZED (
SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '30 days'
)
SELECT * FROM recent WHERE user_id = 100;
-- 明確允許 inline 優化
WITH recent AS NOT MATERIALIZED (
SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '30 days'
)
SELECT * FROM recent WHERE user_id = 100;
型別不匹配導致索引失效
-- 欄位為 integer,傳入字串可能導致索引失效
SELECT * FROM orders WHERE user_id = '12345';
-- 更危險:ORM 傳入錯誤型別
-- 解決:確保查詢參數型別與欄位型別完全匹配
相關子查詢效能陷阱
-- 問題:相關子查詢每行執行一次
SELECT o.order_id,
(SELECT COUNT(*) FROM order_items WHERE order_id = o.order_id)
FROM orders o;
-- 若 orders 有 100 萬行,子查詢執行 100 萬次
-- 解決:改用 JOIN
SELECT o.order_id, COUNT(i.item_id) AS item_count
FROM orders o LEFT JOIN order_items i ON o.order_id = i.order_id
GROUP BY o.order_id;
總結
查詢規劃器 是 PostgreSQL 效能調校的核心:
- Cost-Based Optimizer 基於統計資訊估算成本,選擇最優執行路徑
- 四種掃描策略(Seq / Index / Index Only / Bitmap)各有適用場景,選擇率是關鍵指標
- 三種 Join 演算法(Nested Loop / Hash / Merge)依資料量、索引、記憶體而定
- EXPLAIN ANALYZE BUFFERS 是診斷效能問題的第一步,關注估算 vs 實際的差異
- 統計資訊是 Planner 決策的基礎,批量匯入後務必執行 ANALYZE
- JIT 適合 OLAP 長查詢,OLTP 場景建議停用
- Parallel Query 可大幅加速大資料量查詢,PG11+ 支援 Parallel Hash Join
下一篇,我們將深入探討 窗口函式與進階查詢——PostgreSQL 強大的分析型查詢能力。