查詢規劃器與執行計畫:PostgreSQL 如何選擇最優路徑 | PostgreSQL

2026/06/22
查詢規劃器與執行計畫: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
階段輸入輸出關鍵工作
ParserSQL 文字Raw Parse Tree語法檢查
AnalyzerRaw Parse TreeQuery Tree語意驗證、型別解析
RewriterQuery TreeQuery Tree(改寫後)視圖展開、規則套用
PlannerQuery TreePlan Tree成本估算、最佳路徑選擇
ExecutorPlan TreeTuples實際讀取資料並回傳

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_cost1.0循序讀取一個 8KB 頁面的代價(基準值)
random_page_cost4.0隨機讀取一個頁面的代價
cpu_tuple_cost0.01處理一個 tuple 的 CPU 代價
cpu_index_tuple_cost0.005處理一個索引 tuple 的 CPU 代價
cpu_operator_cost0.0025執行一個運算子的 CPU 代價
parallel_tuple_cost0.1並行查詢傳遞一個 tuple 的代價
parallel_setup_cost1000.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_fracNULL 值的比例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 LoopHash JoinMerge 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 將查詢執行過程中的部份計算編譯為機器碼。

三個編譯階段

  1. Expression Evaluation:將 WHERE 條件、投影計算編譯為機器碼
  2. Tuple Deforming:優化行解包(磁碟格式 → 記憶體格式)
  3. 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 ScanPG9.6+
Parallel Index ScanPG10+
Parallel Index Only ScanPG10+
Parallel Bitmap Heap ScanPG10+
Parallel Hash JoinPG11+
Parallel AggregatePG9.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.6Parallel Query 初步支援(Parallel Seq Scan、Parallel Aggregate)
PG10Parallel Index Scan、Parallel Bitmap Scan
PG11Parallel Hash Join、JIT Compilation(LLVM)
PG12CTE NOT MATERIALIZED(inline 優化)、plan_cache_mode 控制
PG13Incremental Sort(利用部份排序減少工作量)
PG14非同步執行(Async Append for FDW)、改進 multirange 索引
PG15改進 Hash Join 記憶體使用、增強統計資訊收集
PG16EXPLAIN 新增 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 強大的分析型查詢能力。

BenZ Software Developer

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