主題: PostgreSQL
PostgreSQL 18 Skip Scan:複合索引最左規則多了一個星號
PostgreSQL 18 能在未限制最左欄位時重用複合 B-tree;效果是真的,但 cardinality 與實際 query plan 仍決定它值不值得。
動態迷因(展開/收合)
複合 B-tree 有一條很好記的規則:欄位順序很重要。(status, created_at) 很適合先限制 status 的 query,若只查 created_at,通常就沒那麼好用。
PostgreSQL 18 多了一個實用例外。Skip scan optimization 能替缺少的前導條件動態產生值,在同一個 index 內重複搜尋。某些只限制後方欄位的 query,因此不必掃完整張表或整棵 index。
最近一篇討論 PostgreSQL index 容易被忽略的行為的 Reddit thread 裡,有開發者提到 skip scan 上線後,確實讓他們移除了一些不再需要的 index。這很值得研究,但還不構成馬上刪 index 的理由。
比較精準的說法是:
Skip scan 讓既有複合索引多接住一些 query,不代表欄位順序從此無所謂。
PostgreSQL 到底跳過了什麼?
假設訂單表有這個 index:
CREATE INDEX orders_status_created_at_idx
ON orders (status, created_at);
主要 dashboard 會同時限制兩個欄位:
SELECT id, status, created_at
FROM orders
WHERE status = 'pending'
AND created_at >= now() - interval '1 hour';
這是標準的 leftmost prefix。PostgreSQL 先定位到 status = 'pending',再讀取對應的 created_at 區間。
但營運報表可能不在意 status:
SELECT id, status, created_at
FROM orders
WHERE created_at >= now() - interval '5 minutes';
在 PostgreSQL 18 以前,(status, created_at) 往往不是好選擇。符合條件的新資料散落在每一組 status 裡,planner 可能直接選 sequential scan,或需要另一個 (created_at) index。
有了 skip scan,PostgreSQL 可以近似執行數次搜尋:
status = 'pending' AND created_at >= ...
status = 'paid' AND created_at >= ...
status = 'cancelled' AND created_at >= ...
status = 'refunded' AND created_at >= ...
這些條件不是偷偷改寫進 SQL。B-tree 內部會替缺少的 equality 產生值,重新定位到下一個 status group,跳過不可能含有近期訂單的 leaf pages。
PostgreSQL 18 release notes寫得很清楚:前面的 index 欄位沒有 restriction,或只有 non-equality restriction,而後面的欄位有可用條件時,skip scan 才多提供一條路。
被省略的欄位必須便宜到能逐一嘗試
Skip scan 最喜歡前導欄位的 distinct values 很少。四種 status,大致就是四次有目標的搜尋,通常比掃過大表或整棵 index 便宜。
如果把 status 換成 customer_id,帳就完全不同。為數十萬名 customer 逐一重跑搜尋,不叫捷徑,只是多附贈 B-tree navigation 的 full scan。官方文件也說,前導欄位 distinct values 太多時,planner 多半會回去選 sequential scan。
後方 predicate 的 selectivity 同樣重要。最近五分鐘可能只佔全表極小部分;最近一年可能直接拿走大多數 rows。後者就算有 index,連續讀 table pages 仍可能比較合理。
所以「現在 composite index 任一欄都能有效使用」這句話,聽起來很接近事實,實務上卻會誤導。Planner 只是多了候選路徑,cost model 還是會把不划算的路徑淘汰。
Plan 裡不一定會出現一個叫 Skip Scan 的 node
Execution plan 可能顯示 Index Scan 或 Index Only Scan,不必然會有一個字面上叫 Skip Scan 的 node。
PostgreSQL 18 加了一個更有用的線索:B-tree scan 的 EXPLAIN ANALYZE 會顯示 Index Searches。官方範例的 index 第一欄只有四個值,針對其中三組做 skip scan 時,plan 就記錄三次搜尋。
實際檢查可以從這裡開始:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, created_at
FROM orders
WHERE created_at >= now() - interval '5 minutes';
不要只看有沒有出現 index 名稱,要看完整結果:
Index Searches:是否真的發生多次 navigation。Buffers:是否實際少讀了足夠多的 blocks,而不是只有 plan 名稱變漂亮。- Estimated rows 與 actual rows:差距過大通常代表 statistics 或資料分布有問題。
- Execution time 與 loops:改善要在完整 plan 裡成立。
- Index-only scan 的 heap fetches:visibility map 仍會決定它是否真的只讀 index。
也不要設定 enable_seqscan = off,看到剩下的 index plan 就宣布成功。這種作法能協助診斷「index path 是否存在」,不能證明它比較便宜。
動態迷因(展開/收合)
改 index 以前,先確認 statistics
Skip scan 仰賴 planner 估計需要拜訪多少個前導值。這個估算來自 table statistics,不是 schema declaration。
先看前導欄位:
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE schemaname = 'public'
AND tablename = 'orders'
AND attname = 'status';
接著確認 autovacuum 留下的 statistics 足夠新;資料大量改變後,也可以在合適的 maintenance window 執行 ANALYZE。拿一個空蕩蕩的 staging database 測試,幾乎什麼都證明不了,因為 planner 面對的是另一個問題。
PostgreSQL 18 也開始支援透過 pg_dump --statistics-only 攜帶 optimizer statistics。近期 daily.dev 整理了如何在沒有 production data 時重現 query plan,官方 pg_dump 文件則說明了限制。它適合用在經過清理的測試環境,但 restored statistics 仍只是近似值,也可能被下一次 ANALYZE 覆蓋。
那個獨立的 created_at index 可以刪了嗎?
有可能,但「planner 曾經用過一次 composite index」還不夠。
只保留 (status, created_at),可以少掉另一個 index 的 storage、WAL、cache pressure、vacuum 工作與 write amplification。以下條件成立時,skip scan 讓這種 consolidation 更合理:
statuscardinality 很低,而且變動不大。- 只查
created_at的 query 有 selectivity,但不是最主要的 workload。 - 實測 latency 仍在 service budget 內。
- 常見的 status-and-time query 本來就需要這個 composite index。
以下情況則值得保留 (created_at):
- 前導欄位有很多 distinct values。
- 後方欄位的 query 很頻繁,或 latency 要求嚴格。
- Query 回傳的 rows 夠多,重複搜尋成本已看得見。
- 較小的 single-column index 更容易留在 cache。
- 它提供 composite index 無法便宜取代的 ordering 或 covering path。
PostgreSQL 關於 combining indexes 的說明也把取捨講得很直接:前導欄位不超過數百個 distinct values 時,一個 composite index 可能同時服務兩種 query shape;但替後方欄位保留獨立 index 仍可能合理。把所有組合全建出來,通常只有 read 遠多於 update,而且每種 query 都真的常見時才划算。
因此我升級後的預設很保守:先保留現有 indexes,收集代表性的 plans 與 latency,再一次移除一個確定 redundant 的 index。雖然能用 CREATE INDEX CONCURRENTLY 回復,但在大型 production table 重建 index,還是比多觀察一個週期麻煩。
五步評估法
- 確認 query 跑在 PostgreSQL 18 或更新版本;舊版無法證明這項 optimization。
- 檢查每一個被省略前導欄位的 distinct-value distribution。
- 更新具代表性的 statistics,並使用接近 production 的參數。
- 對窄範圍與寬範圍 predicate 比較
EXPLAIN (ANALYZE, BUFFERS),記錄Index Searches、buffers、rows 與時間。 - 只有 workload-level evidence 證明 write savings 大於 read regression risk,才移除獨立 index。
沒有必要因為 skip scan 出現,就重新設計一套原本健康的 indexes。它最好的用途常常更安靜:升級後,讓偶爾出現的 query 從 full scan 變成還不錯的 plan,順便讓一個邊際效益很低的 duplicate index 失去存在理由。
結論:最左規則變得更懂成本,不是失效
PostgreSQL 18 沒有廢除 B-tree 的排序方式,而是教 planner 在缺少 prefix 時換一種 navigation。
省略的前導欄位只有少數值,而且後方 predicate 有足夠 selectivity 時,重複搜尋能跳過 index 的大部分內容。前導欄位 cardinality 很高,或 query 想拿走大量 rows 時,同一個技巧就不再有優勢。
把 skip scan 當成一個量測與簡化的機會,不要當成停止設計 index 的許可。Planner 多了一條路,最後仍是 workload 決定誰贏。