主題: PostgreSQL

PostgreSQL 18 Skip Scan:複合索引最左規則多了一個星號

PostgreSQL 18 能在未限制最左欄位時重用複合 B-tree;效果是真的,但 cardinality 與實際 query plan 仍決定它值不值得。

動態迷因(展開/收合)
Skip scan 能在 B-tree 的有效區段之間跳躍,不必老老實實走過每一個 leaf page。 · 來源:GIPHY

複合 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 ScanIndex 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 是否存在」,不能證明它比較便宜。

動態迷因(展開/收合)
EXPLAIN 出現 index 不是答案;searches、buffers、rows 與實際時間都要量。 · 來源:GIPHY

改 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 更合理:

  • status cardinality 很低,而且變動不大。
  • 只查 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,還是比多觀察一個週期麻煩。

五步評估法

  1. 確認 query 跑在 PostgreSQL 18 或更新版本;舊版無法證明這項 optimization。
  2. 檢查每一個被省略前導欄位的 distinct-value distribution。
  3. 更新具代表性的 statistics,並使用接近 production 的參數。
  4. 對窄範圍與寬範圍 predicate 比較 EXPLAIN (ANALYZE, BUFFERS),記錄 Index Searches、buffers、rows 與時間。
  5. 只有 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 決定誰贏。


外部參考資料