讀懂 SQL EXPLAIN 執行計畫

資料工程 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 —— 資料工程面試所考的查詢計畫判讀:EXPLAIN、EXPLAIN ANALYZE 與 BUFFERS 各自回報什麼、如何走讀計畫樹、scan 節點、估計列數與實際列數、三種 join 演算法在計畫中的樣子、sort 與 hash 的 spill、buffer 與 I/O 計數器、一個慢查詢的完整診斷,以及同一套判讀方式如何套用到 Spark SQL。以 PostgreSQL 16–18 為準。SQL 本身的寫法、索引設計與資料倉儲的調校手段歸 sql_for_data.md;Spark 的執行模型與調校歸 spark.md。 另見:./sql_for_data.md —— §10 查詢效能,本文的精簡版;./spark.md —— Spark 計畫背後的 shuffle、AQE 與 skew;../db/faq_DB_DW.md —— MySQL EXPLAIN 的 type 欄位。

SQL 描述你要什麼資料;執行計畫則是資料庫決定如何取得它的方式。EXPLAIN 讓你讀到這個決定,EXPLAIN ANALYZE 讓你拿它和實際發生的情況對照。除非該節另有說明, 以下計畫都是 PostgreSQL,而且都只保留重要的幾行。


0) 一句話回答 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

「我會執行 EXPLAIN (ANALYZE, BUFFERS),由下往上讀這棵樹,先找估計列數與實際列數 差距最大的節點 —— 大多數爛計畫的根本原因都是估計錯誤。接著檢查 scan 有沒有讀了又丟掉的資料、 join 有沒有選錯演算法、sort 或 hash 有沒有 spill 到磁碟。每次只改一件事,然後重跑。」

一份計畫回答六個問題:

  1. Optimizer 為什麼選這個策略?
  2. 哪個節點花的時間最多?
  3. 查詢是否讀了資料又把它丟掉?
  4. 有沒有 join 用錯演算法?
  5. Optimizer 的列數估計對不對?
  6. 那麼,真正的瓶頸在哪裡?

1) EXPLAIN 顯示什麼 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

1-1) 從 SQL 到執行計畫

決定 scan 方式、join 順序、join 演算法以及要不要排序的是 optimizer,不是 SQL —— 而它是 根據統計資訊做決定,不是根據資料本身。

text
SQL text
  -> Parser / analyser   syntax, tables, columns, types
  -> Rewriter            views, rules
  -> Planner/optimiser   enumerates scans, join orders, join methods; costs each
  -> Plan                a tree of operators (nodes)
  -> Executor            runs the tree, returns rows

成本模型的輸入包括資料表統計資訊(列數、相異值個數、最常見的值、直方圖 —— 由 ANALYZE / autovacuum 收集)、可用的索引,以及 seq_page_cost、random_page_cost 等成本參數。 統計資訊一旦錯了,計畫就是為一份不存在的資料挑的。

1-2) EXPLAIN vs EXPLAIN ANALYZE vs BUFFERS 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

EXPLAIN 是預測;EXPLAIN ANALYZE 會真的執行查詢並回報實際情況;BUFFERS 再加上每個節點碰了多少個 8 KB 的 page。

指令 會真的執行查詢嗎? 顯示
EXPLAIN 否 選中的計畫,含估計成本、列數與寬度
EXPLAIN ANALYZE 會 計畫,加上每個節點的實際時間、實際列數與 loops
EXPLAIN (ANALYZE, BUFFERS) 會 再加上每個節點的 shared / local / temp page 數
EXPLAIN (FORMAT JSON) 否(除非加 ANALYZE) 同一份計畫的 JSON 版,供計畫視覺化工具使用
  • PostgreSQL 18 只要指定 ANALYZE 就會自動包含 BUFFERS。
  • 查詢慢到跑不完或危險到不能跑時,用純 EXPLAIN;其他情況都用 ANALYZE,因為只有它能顯示 估計在哪裡出錯。

1-3) 讀一個節點:cost、rows、width

sql
EXPLAIN
SELECT *
FROM   orders
WHERE  order_date >= DATE '2026-10-01';
text
Seq Scan on orders  (cost=0.00..18000.00 rows=500000 width=64)
  Filter: (order_date >= '2026-10-01'::date)
欄位 意義
Seq Scan on orders 節點類型與它讀取的資料表
cost=0.00..18000.00 啟動成本 … 總成本:輸出第一列之前的成本,以及輸出全部列的成本
rows=500000 這個節點估計輸出的列數(套用過濾條件之後)
width=64 估計每列輸出的平均 bytes
Filter: 節點讀到的每一列都要檢查的條件 —— 不符合的列被丟棄

cost 不是毫秒。 它是任意單位,以循序讀取一個 page 的成本 seq_page_cost = 1.0 為基準, 只能用來比較同一台伺服器上同一個查詢的不同計畫。父節點的成本包含子節點的成本。

1-4) ANALYZE 多給了什麼:actual time、rows、loops

sql
EXPLAIN ANALYZE
SELECT *
FROM   orders
WHERE  order_date >= DATE '2026-10-01';
text
Seq Scan on orders  (cost=0.00..18000.00 rows=500000 width=64)
                    (actual time=0.050..850.000 rows=1200000 loops=1)
  Filter: (order_date >= '2026-10-01'::date)
  Rows Removed by Filter: 8800000
Planning Time: 0.300 ms
Execution Time: 920.000 ms
欄位 意義
actual time=0.050..850.000 到第一列的毫秒數 … 到最後一列的毫秒數,以每次 loop 計
rows=1200000 實際輸出的列數,以每次 loop 計
loops=1 這個節點被執行了幾次
Rows Removed by Filter 讀了又丟掉的列 —— 數字高代表 scan 讀的遠比留下的多
Planning Time 產生計畫的時間
Execution Time 執行的時間,不含把結果送到 client 的時間

這裡估計是 500,000 列,實際是 1,200,000 列。先比較估計列數與實際列數,再看時間: 時間告訴你哪裡痛,列數落差告訴你為什麼痛。

loops 很重要。 當 loops > 1 時,actual time 與 rows 是每次 loop 的平均值。 乘上 loops 才是該節點的總量:actual time=0.010..0.020 rows=1 loops=1000000 約等於 20 秒、一百萬列,而不是 0.02 毫秒、一列。

1-5) EXPLAIN ANALYZE 會真的執行語句

EXPLAIN ANALYZE DELETE … 真的會刪掉資料。 想量測寫入操作又不想保留結果,就把它包在 transaction 裡再 rollback:

sql
BEGIN;

EXPLAIN ANALYZE
DELETE FROM orders
WHERE  order_date < DATE '2020-01-01';

ROLLBACK;
  • Rollback 會還原資料列,但不是每個副作用都能還原:已取用的 sequence 值不會退回, trigger 對資料庫外部送出的東西也已經送出去了。
  • DELETE 在 rollback 之前仍會持有它的 row lock,所以不要在忙碌的正式環境資料表上這樣做。

2) 讀懂計畫樹 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

2-1) 由下往上:每個子節點餵給它的父節點

計畫是一棵樹:縮排最深的節點讀取資料表,上面的節點負責過濾、join、排序與彙總,最上層的 節點回傳結果。從葉節點往上讀。

sql
SELECT u.country, SUM(o.amount)
FROM   orders o
JOIN   users  u ON u.user_id = o.user_id
WHERE  o.order_date >= DATE '2026-10-01'
GROUP  BY u.country;
text
HashAggregate                                   4. group by country, SUM(amount)
  Group Key: u.country
  ->  Hash Join                                 3. match orders to users on user_id
        Hash Cond: (o.user_id = u.user_id)
        ->  Seq Scan on orders o                1. read orders, keep October onward
              Filter: (order_date >= '2026-10-01'::date)
        ->  Hash                                2. build a hash table from users
              ->  Seq Scan on users u
  • -> 開頭的一行是子節點;縮排說明它餵給哪個父節點。
  • 同一個節點的兩個子節點不一定同時執行:Hash Join 會先把 Hash 子節點完整建好, 再讓另一個子節點的資料流過它。
  • 沒有 -> 的行(Filter:、Hash Cond:、Group Key:)是上方節點的細節,不是節點。
  • 在大表上你常會看到 Gather 底下掛著 Parallel Seq Scan,並有一行 Workers Launched: —— 這是 PostgreSQL 用多個 worker process 一起執行同一個節點。

2-2) 你會遇到的節點類型

節點 做什麼 要檢查什麼
Seq Scan 讀取資料表的每一個 page Filter 移除了多少列
Index Scan 走訪索引,再到資料表取回每一筆符合的列 列數多寡;每列一次隨機 I/O
Index Only Scan 只靠索引回答 Heap Fetches —— 仍需回資料表讀取的列數
Bitmap Index Scan + Bitmap Heap Scan 先收集符合條件的列位置,再依序讀取那些 page Heap Blocks: lossy
Nested Loop 外層每一列都執行一次內層 內層的 loops
Hash Join + Hash 把一側建成 hash table,用另一側去查 Hash 節點上的 Batches
Merge Join 合併兩個已依 join key 排序的輸入 底下的 Sort 節點
Sort 排序輸入 Sort Method —— external merge 代表有 spill
HashAggregate 用 hash table 分組 Batches / Disk Usage
GroupAggregate 對已排序的輸入分組 底下的 Sort
Limit 取到 n 列就停 子節點是否真的能提早停下
Gather / Gather Merge 收集 parallel worker 的輸出 Workers Launched 與 Workers Planned

3) Scan 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

假設 orders 有一千萬列,且 order_date 上有索引:

sql
CREATE INDEX idx_orders_date ON orders (order_date);

3-1) Seq Scan

Seq Scan 讀取每一個 page、檢查每一列。只要查詢需要資料表的一大部分,它就是正確的選擇 —— 看到它本身並不代表有問題。

text
Seq Scan on orders  (cost=0.00..180000.00 rows=100000 width=64)
  Filter: (order_date >= '2026-10-01'::date)

以下情況它是最便宜的計畫:

  • 查詢需要大部分的列(透過索引讀一千萬列中的九百萬列,遠比依序把整張表讀一遍貴);
  • 資料表小到只占幾個 page;
  • 沒有索引符合條件,或符合的索引選擇性不夠。

當一張大表被整張掃過、只為了回傳少數幾列時,它才是問題 —— 訊號是 Rows Removed by Filter 很高、輸出卻很少。

3-2) Index Scan 與 Index Only Scan

Index Scan 先在索引中找到符合的項目,再到資料表取回每一列。條件選擇性高時 —— 從很多列中 只取少數幾列 —— 它會勝出。

text
Index Scan using idx_orders_date on orders  (cost=0.43..8500.00 rows=10000 width=64)
  Index Cond: (order_date >= '2026-11-01'::date)
  • Index Cond 在索引內就套用;Index Scan 底下的 Filter 行則是在取回資料列之後才檢查, 所以被它移除的列都是白取的。
  • 每次取回資料列都可能是一次隨機 page 讀取,這就是符合比例越高、索引越吃虧的原因。
  • 當所需欄位全都在索引裡(例如透過 INCLUDE),Index Only Scan 就不必讀資料表。但對於 visibility map 沒有標記為 all-visible 的 page,它仍得回資料表讀取;Heap Fetches 計算的就是這些, 最近做過 VACUUM 就會降下來。

3-3) Bitmap Heap Scan

Bitmap scan 是折衷方案:先從索引收集所有符合的列位置,再依實體順序讀取資料表的 page, 每個 page 只讀一次。

text
Bitmap Heap Scan on orders
  Recheck Cond: (order_date >= '2026-10-01'::date)
  Heap Blocks: exact=41000
  ->  Bitmap Index Scan on idx_orders_date
        Index Cond: (order_date >= '2026-10-01'::date)
  • 它適合符合列數太多、不適合 Index Scan,但又太少、不值得 Seq Scan 的條件,而且它能結合多個 索引(BitmapAnd / BitmapOr)。
  • Bitmap 若超過 work_mem 就會變成 lossy —— 只記住整個 page 而不是個別列 —— 這些 page 上的 每一列都要用 Recheck Cond 重新檢查。ANALYZE 輸出中的 Heap Blocks: lossy=… 表示發生了這件事。

3-4) 「明明有索引,為什麼還是 Seq Scan?」 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

要嘛 planner 認為 Seq Scan 比較便宜(常常是對的),要嘛這個條件根本用不了索引。

原因 例子 解法
查詢需要資料表的一大部分 orders 有 20% 符合 不用解 —— Seq Scan 是對的;強制走索引前先量測
資料表很小 50 列的對照表 不用解
過舊的統計資訊讓符合列數看起來很大 大量匯入之後 ANALYZE orders;
對欄位套了函式或運算 WHERE DATE(order_ts) = …、WHERE amount * 2 > … 改寫成針對原始欄位的範圍條件,或建立 expression index
型別或 collation 不符 text 欄位拿去和數字比較;非 C collation 下的 LIKE 'abc%' 讓型別一致;使用 text_pattern_ops 索引
條件不在複合索引的第一個欄位 索引是 (user_id, order_date),只過濾 order_date 建一個以被過濾欄位開頭的索引
開頭是萬用字元 WHERE email LIKE '%@gmail.com' trigram(pg_trgm)索引

面試官期待你講的是「對欄位套函式」這一種。下面的條件把 order_ts 藏在函式裡,所以 order_ts 上的一般索引無法使用:

sql
SELECT * FROM orders
WHERE  DATE(order_ts) = DATE '2026-11-10';

同樣的過濾改寫成針對原始欄位的半開區間,就能使用索引:

sql
SELECT * FROM orders
WHERE  order_ts >= TIMESTAMP '2026-11-10 00:00:00'
  AND  order_ts <  TIMESTAMP '2026-11-11 00:00:00';

想測試索引是否可能有幫助,可以在自己的 session 執行 SET enable_seqscan = off;,再比較 EXPLAIN ANALYZE 的輸出 —— 這只是診斷手段,絕不是正式環境的設定。


4) 估計列數 vs 實際列數 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

4-1) 為什麼要先檢查估計

Optimizer 依估計列數選擇 join 順序與 join 演算法。當估計差了好幾個數量級,這個節點以上的 每一個決定都是為錯誤的資料做的。

text
Hash Join  (cost=1000.00..50000.00 rows=10000 width=40)
           (actual time=10.000..500.000 rows=2000000 loops=1)

Planner 預期 10,000 列,結果得到 2,000,000 列 —— 多了 200 倍。常見的後果:

  • 它以為外層很小而選了 Nested Loop,結果內層跑了兩百萬次;
  • 它用 10,000 列的規模來配置 hash table,結果 spill 到磁碟;
  • 它把「小」的輸入放到 join 錯誤的那一側。

快速排序節點的方法是看誤差倍數:估計列數與實際列數中較大者除以較小者(0 視為 1)。 大約 10 倍以內算正常;在上層節點成本很高的地方出現 100 倍以上,就值得調查。有三件事讓比較不失準:

  • 以每次 loop 比較。 估計值與實際值都是以每次 loop 計,所以一個估計 rows=1、實際顯示 rows=1 loops=1000 的內層節點,估計其實是對的。實際列數 × loops 是節點的總輸出列數, 不是它的工作量 —— 一個 scan 可能什麼都沒輸出,卻讀完了整張表;工作量要看它的時間與 buffer 計數。
  • 顯示的 0 可能是四捨五入來的。 PostgreSQL 17 及更早版本會把每次 loop 的實際列數四捨五入為 整數,所以一百萬次 loop 共 10,000 列會印成 rows=0,精確的倍數也就無從得知。PostgreSQL 18 會印到小數點後兩位。
  • 被提早停下的節點會偏低。 Limit 的子節點,或 Merge Join 在另一側輸入讀完後就不再讀的那一側, 即使估計正確,實際列數也可能少於估計。這裡的偏低可能是提早結束,而不是統計資訊不準 —— 兩者都先確認再下結論。

接著看有大落差的最低那個節點 —— 它上面的誤差通常是從它繼承來的。

4-2) 原因與解法

原因 發生了什麼 解法
統計資訊過舊 上次 ANALYZE 之後有大量匯入或刪除 ANALYZE orders; —— 並確認 autovacuum 有跟上
抽樣漏掉了偏斜的值 某個值占了 30% 的列 提高該欄位的抽樣量:ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000; 再 ANALYZE
欄位彼此相關 country = 'TW' AND city = 'Taipei':planner 把兩個選擇率當成獨立的相乘,因而低估 extended statistics(見下)
planner 無法估計的運算式 WHERE lower(email) = …、回傳集合的函式 expression index(它會有自己的統計資訊)
planner 看不到的參數 prepared statement 重用了不含實際值的 generic plan 用字面值比較看看;檢查 plan_cache_mode

Extended statistics 讓 planner 知道兩個欄位會一起變動:

sql
CREATE STATISTICS stats_users_country_city (dependencies, mcv)
ON country, city
FROM users;

ANALYZE users;

5) 計畫中的 Join 演算法 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

5-1) Nested Loop

外層(第一個)子節點的每一列,都執行一次內層(第二個)子節點。外層小、內層是索引查找時 很快;外層大時就是災難。

text
Nested Loop  (actual rows=1000 loops=1)
  ->  Seq Scan on orders  (actual rows=1000 loops=1)
  ->  Index Scan using users_pkey on users  (actual rows=1 loops=1000)
        Index Cond: (user_id = orders.user_id)
  • 內層的 Index Scan 執行了 1,000 次(loops=1000)。外層 1,000 列時很便宜;外層一千萬列時 就是一千萬次索引查找。
  • 它是唯一能處理任何 join 條件的演算法,包括不等式與 LIKE,所以 non-equi join 只能用它。
  • 估計很小的外層底下,內層的 loops 卻很大 —— 這是估計錯誤的典型症狀(§4)。

5-2) Hash Join

把一個輸入(Hash 子節點)建成 hash table,再讓另一個輸入流過去查找匹配。這是大型 等值 join 的預設選擇。

text
Hash Join
  Hash Cond: (orders.user_id = users.user_id)
  ->  Seq Scan on orders            probe side: streamed
  ->  Hash                          build side: fully read first
        Buckets: 262144  Batches: 1  Memory Usage: 18000kB
        ->  Seq Scan on users
  • 需要等值條件。對兩個輸入都是線性時間。
  • Planner 會把它估計比較小的那一側放在 Hash 底下。如果那一側比預期大,hash table 就會 超出記憶體,拆成多個寫到磁碟的 batch(Batches > 1,§6)。
  • Hash table 的記憶體上限是 work_mem × hash_mem_multiplier(自 PostgreSQL 15 起預設為 2.0)。

5-3) Merge Join

把兩個輸入依 join key 排序,再像 merge sort 的合併步驟一樣一起往前走。輸入本來就有序時很便宜; 兩個大輸入都得先排序時就很貴。

text
Merge Join
  Merge Cond: (orders.user_id = users.user_id)
  ->  Sort
        Sort Key: orders.user_id
        ->  Seq Scan on orders
  ->  Index Scan using users_pkey on users       already in user_id order
  • Join key 上的索引(如這裡的 users)或先前的排序,可以省掉那一側的 Sort。
  • 它不需要 hash table 的記憶體,所以在兩側都放不進記憶體時可能勝出 —— 但底下的 Sort 節點 反而可能 spill。

5-4) 如何在三者之間選擇

Join 運作方式 適合 要注意
Nested Loop 外層每一列 → 查內層 外層小 + 內層 key 有索引;任何 join 條件 內層 loops 遠高於估計
Hash Join 把一側建成 hash,用另一側查 大型等值 join,沒有可利用的順序 Hash 節點的 Batches > 1
Merge Join 合併兩個已排序的輸入 輸入已有序(索引、先前的排序);非常大的輸入 昂貴的 Sort 子節點、external merge

沒有哪個演算法永遠最好。Hash Join 並不「優於」Nested Loop:50 筆訂單加上有索引的 users, Nested Loop 做 50 次查找,Hash Join 卻要把 users 整張讀完。要看實際列數,而不是節點名稱。


6) Spill:Sort 與 Hash 記憶體不足 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

當 sort 或 hash 需要的記憶體超過上限,PostgreSQL 會把溢出的部分寫到暫存檔 —— 這就是 spill to disk。上限是每個 sort 或 hash 操作的 work_mem(預設 4 MB),hash 還要再乘上 hash_mem_multiplier。

6-1) Sort spill

sql
SELECT * FROM orders ORDER BY amount DESC;
text
Sort  (actual time=4200.000..5100.000 rows=10000000 loops=1)
  Sort Key: amount DESC
  Sort Method: external merge  Disk: 204800kB
  • external merge Disk: 204800kB —— 排序放不進記憶體,於是把約 200 MB 已排序的 run 寫到磁碟, 再合併起來。
  • quicksort Memory: … 代表放得下。top-N heapsort 代表 LIMIT 讓它只需保留前幾名 —— 真正的解法常常是補上呼叫端本來就想要的 LIMIT。

6-2) Hash spill

sql
SELECT customer_id, SUM(amount)
FROM   orders
GROUP  BY customer_id;
text
HashAggregate
  Group Key: customer_id
  Batches: 16  Memory Usage: 8241kB  Disk Usage: 204800kB

Hash Join 的 build side 也會出現同樣的訊號:

text
Hash
  Buckets: 65536  Batches: 8  Memory Usage: 4096kB

Batches: 1 代表全部放得進記憶體。Batches > 1 代表輸入被拆開,部分經過暫存檔。 這是線索,不是定論 —— 要搭配節點的實際時間以及 temp read / temp written 的數量一起判斷。

6-3) 處理 spill

解法 為什麼有效
更早過濾、選更少欄位 到達 sort 或 hash 的資料更少,較窄的列讓每 MB 放得下更多
拿掉沒人需要的 ORDER BY;補上真正需要的 LIMIT 省掉排序,或把它變成 top-N heapsort
修正估計(§4) planner 可能改選別的 join,或正確配置 hash 大小
為查詢單獨開一個 transaction,在其中 SET LOCAL work_mem = '256MB'; 在該 transaction 結束前都有更多記憶體;commit 或 rollback 後自動還原
預先彙總,或分批處理 每一步處理的資料更少

work_mem 要按 transaction 調高,不要全域調。單純的 SET 會一直持續到 session 結束 —— 在連線池上,它還會漏給下一個借用那條連線的人 —— 所以要用 SET LOCAL(它會持續到 transaction 結束,所以讓查詢自己用一個 transaction), 或事後 RESET work_mem。 另外,這個上限套用在每個查詢的每個 sort 與 hash 節點上,而且每個 parallel worker 各有一份。一個有五個 hash 節點、四個 worker 的查詢, 可能用掉設定值的二十倍。而如果真正的原因是讀了十億列你根本不需要的資料,加記憶體只會讓 錯誤的計畫稍微快一點跑完。


7) BUFFERS 與 I/O 優先度 3/5 — 值得會 — 多半是必備模式的變形

7-1) 計數器

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE  order_date >= DATE '2026-10-01';
text
Seq Scan on orders  (actual time=0.050..850.000 rows=1200000 loops=1)
  Buffers: shared hit=12000 read=80000

每個數字的單位都是 page,通常每個 8 KB,而且父節點的數字包含子節點的。

計數器 意義
shared hit page 已經在 PostgreSQL 的 shared buffer cache 裡
shared read page 必須讀進來 —— 來自 OS page cache 或磁碟
shared dirtied 這個節點修改過的 page
shared written 這個節點為了騰出 buffer 空間而必須寫出的 page
temp read / temp written 暫存檔的 page —— sort 與 hash 的 spill
local … 同上,但針對 temporary table

這裡 scan 碰了 92,000 個 page(約 720 MB);12,000 個已在快取中,80,000 個是讀進來的。

7-2) 如何解讀,以及它們的限制

  • shared read 很高 —— 查詢拉進了大量資料。問問看它是否掃描了超過需要的範圍:缺索引、 沒有 partition pruning、過濾套用得太晚。
  • shared hit 很高 —— 資料已在快取中。現在很快,但冷快取時(重啟後、在 replica 上) 就會表現得像 read 的情況。
  • temp read / temp written 很高 —— 有 sort 或 hash spill 了(§6)。
  • 碰到的 page 數比時間更穩定:它不受快取冷熱影響,所以是修正前後比較時更好的數字。

shared read 不是磁碟讀取次數:不在 shared buffers 裡的 page 仍可能在作業系統的 page cache 裡。想看真正花在等待 I/O 的時間,要開啟 track_io_timing(superuser 設定,或寫在 postgresql.conf);之後 EXPLAIN (ANALYZE, BUFFERS) 會在每個節點印出一行 I/O Timings::

sql
SET track_io_timing = on;

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE  order_date >= DATE '2026-10-01';

8) 完整範例:一個很慢的營收查詢 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

orders 有一億列(20 GB),users 有一千萬列(2 GB)。這份報表要跑 120 秒:

sql
SELECT u.country, SUM(o.amount) AS revenue
FROM   orders o
JOIN   users  u ON o.user_id = u.user_id
WHERE  o.order_date >= DATE '2026-10-01'
GROUP  BY u.country;

8-1) 執行計畫

EXPLAIN (ANALYZE, BUFFERS) 的結果簡化如下(實際的計畫還會以 parallel 方式執行 scan):

text
HashAggregate  (actual time=118000..118005 rows=20 loops=1)
  Group Key: u.country
  ->  Hash Join  (actual time=30000..110000 rows=20000000 loops=1)
        Hash Cond: (o.user_id = u.user_id)
        ->  Seq Scan on orders o  (actual rows=20000000 loops=1)
              Filter: (order_date >= '2026-10-01'::date)
              Rows Removed by Filter: 80000000
        ->  Hash  (actual rows=10000000 loops=1)
              Buckets: 262144  Batches: 32  Memory Usage: 8192kB
              ->  Seq Scan on users u  (actual rows=10000000 loops=1)

8-2) 找出瓶頸

由下往上讀:

  1. Seq Scan on orders —— 一億列全部讀過,八千萬列被過濾掉。大部分 I/O 都花在查詢 不要的列上。
  2. users 上的 Hash —— 一千萬列、Batches: 32:build side 遠大於記憶體上限,所以大部分 都經過了暫存檔。
  3. Hash Join —— 輸出兩千萬列,從第 30 秒(建 hash 的時間)開始,一直跑到第 110 秒。
  4. HashAggregate —— 輸入兩千萬列、輸出 20 列,花 8 秒。不是問題所在。

時間花在 join 以及它底下的 scan。

8-3) 候選的解法

A —— 少讀一點 orders。 如果依日期範圍查詢很常見,就把 orders 依 order_date 分區, 讓 planner 剪掉舊的分區,或加一個索引。由於有 20% 的列符合,order_date 上的索引很可能輸給 Seq Scan —— 要實測。Covering index (order_date) INCLUDE (user_id, amount) 可以讓 Index Only Scan 成為可能。

B —— 縮小 hash 的 build side。 Planner 對 users 建 hash,是因為它是較小的輸入;它只需要 user_id 與 country,而針對這個 transaction(SET LOCAL)調高 work_mem 可能讓 Batches 降到 1。

C —— 先彙總再 join。 先把兩千萬筆訂單收斂成每位使用者一列,讓 join 最多只處理每位 活躍使用者一列:

sql
WITH user_revenue AS (
    SELECT user_id, SUM(amount) AS revenue
    FROM   orders
    WHERE  order_date >= DATE '2026-10-01'
    GROUP  BY user_id
)
SELECT u.country, SUM(r.revenue) AS revenue
FROM   user_revenue r
JOIN   users u ON r.user_id = u.user_id
GROUP  BY u.country;

當使用者平均下很多筆訂單時這會有幫助;若不是,就只是多了一次彙總。由計畫決定,而不是直覺。

8-4) 證明解法有效

一次只改一件事,重跑 EXPLAIN (ANALYZE, BUFFERS),然後比較兩份計畫:

指標 修改前 目標
Execution Time 120 秒 降低
從 orders 讀取的列數 一億 降低
Hash batches 32 1,或更少
temp written 高 接近零
結果 20 列 完全相同,另外驗證

結果必須完全一致,而 EXPLAIN ANALYZE 無法證明這一點:它會丟棄查詢的輸出,所以它的列數只能 顯示兩個版本都回傳 20 列,不能證明這 20 個營收數字相同。要讓兩個查詢跑在同一份資料快照上 (同一個 REPEATABLE READ transaction,或一份凍結的副本),再比對結果列 —— 兩個方向的 EXCEPT 都應該回傳空集合。更快但答案不同的查詢是 bug。


9) Spark SQL 與資料倉儲的計畫 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

9-1) Spark 的 EXPLAIN FORMATTED

讀法一樣 —— 由下往上,看 scan 與 join —— 但分散式計畫中昂貴的是 Exchange:資料在網路上的 shuffle。

python
df.explain("formatted")
sql
EXPLAIN FORMATTED
SELECT customer_id, SUM(amount)
FROM   orders
GROUP  BY customer_id;
Operator 意義
Scan(FileScan parquet …) 讀取檔案;檢查 PartitionFilters(pruning)與 PushedFilters
Filter / Project 過濾列 / 選擇欄位
Exchange 一次 shuffle —— 把資料列重新分配到各 executor
BroadcastExchange 把小表送到每個 executor
HashAggregate / SortAggregate 彙總;通常 Exchange 之前做一次 partial、之後做一次 final
BroadcastHashJoin 對小表的 broadcast 副本做 join,大表不需 shuffle
SortMergeJoin 兩側都依 key shuffle 並排序,再合併
AdaptiveSparkPlan 開啟 Adaptive Query Execution(AQE)時的根節點

開啟 AQE 時,explain 顯示的是初始計畫(AdaptiveSparkPlan isFinalPlan=false)。AQE 可能在 執行期把 join 改成 broadcast、合併分區或切分 skewed 分區,所以實際跑的計畫要到 Spark UI 的 SQL 分頁看。

9-2) SortMergeJoin vs BroadcastHashJoin

text
AdaptiveSparkPlan isFinalPlan=false
+- SortMergeJoin [customer_id], [customer_id], Inner
   :- Sort [customer_id ASC]
   :  +- Exchange hashpartitioning(customer_id, 200)
   :     +- Scan orders
   +- Sort [customer_id ASC]
      +- Exchange hashpartitioning(customer_id, 200)
         +- Scan customers

兩側都先 scan,再依 customer_id shuffle 到 200 個分區(spark.sql.shuffle.partitions), 排序後合併。兩個 Exchange 節點代表兩張表都要跨網路傳輸。

text
BroadcastHashJoin [customer_id], [customer_id], Inner, BuildRight
:- Scan orders
+- BroadcastExchange HashedRelationBroadcastMode(...)
   +- Scan customers

customers 被複製到每個 executor,orders 則在原地做 join —— fact table 完全不用 shuffle。 小於 spark.sql.autoBroadcastJoinThreshold(預設 10 MB)時 Spark 會自動 broadcast,而 broadcast 副本會占用 driver 與 executor 的記憶體,所以一張「小」表其實不小時,就會造成記憶體不足的錯誤。

9-3) PostgreSQL vs Spark

PostgreSQL Spark
執行環境 單一伺服器(可用 parallel worker) 由 executor 組成的叢集
Scan Seq / Index / Bitmap 帶 partition pruning 與 pushed filter 的檔案 scan
Join Nested Loop / Hash / Merge Broadcast Hash / Sort-Merge / Shuffle Hash
昂貴的資料搬移 磁碟 I/O、暫存檔 網路上的 shuffle
記憶體壓力 work_mem、spill executor 記憶體、spill
實際數字 EXPLAIN ANALYZE Spark UI:SQL metrics、stage、task

在 Spark 裡,計畫說明會發生什麼;Spark UI 說明實際怎麼跑的。要在那裡檢查 shuffle read 與 write 的大小、spill、task 執行時間分布(某個 task 遠慢於中位數代表 skew),以及 broadcast 的大小。

9-4) 其他引擎

引擎 計畫 實際執行
MySQL EXPLAIN(type 欄位:ALL 代表全表掃描) EXPLAIN ANALYZE(8.0.18+)
BigQuery — job 的 Execution details:stage、bytes、slot time
Snowflake EXPLAIN Query Profile:operator、掃描的 bytes、被剪掉的分區、spill
Trino EXPLAIN EXPLAIN ANALYZE

在資料倉儲中,要看掃描的分區占總數多少(pruning)、spill 的 bytes,以及 shuffle 資料最多的 stage。


10) 面試工具箱 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

10-1) 除錯順序

  1. 先確認查詢是正確的。 Join key、資料粒度(grain)、NULL、重複資料。絕不拿正確性換速度。
  2. 取得真實的計畫。 PostgreSQL:EXPLAIN (ANALYZE, BUFFERS)。Spark:EXPLAIN FORMATTED 加上 Spark UI。
  3. Scan。 讀的是否遠多於留下的?Pruning 或索引有沒有發揮作用?
  4. 估計列數 vs 實際列數。 找出落差大的最低節點;檢查統計資訊、skew、欄位相關性。
  5. Join 與彙總。 內層 loops 爆量的 Nested Loop?Batch 很多的 Hash?Spark 中有沒有 不必要的 shuffle?
  6. 記憶體與 I/O。 Sort Method、Batches、temp page、buffers;Spark 則看 spill 與 shuffle 大小。
  7. 一次只改一件事,然後重新量測 —— 時間、讀取的 page 數,以及結果沒有改變。

10-2) 關鍵字速查表

你看到 代表 接著看
Seq Scan + 很大的 Rows Removed by Filter 讀很多、留很少 索引、條件能否用索引、分區
回傳很多列的 Index Scan 大量隨機讀取 Seq 或 Bitmap scan 是否更便宜
Index Only Scan 的 Heap Fetches 很高 visibility map 過舊 VACUUM
估計列數 ≪ 實際列數 cardinality 估計錯誤 ANALYZE、statistics target、extended statistics
Nested Loop,內層 loops 達百萬 對這個規模選錯 join 外層的估計
Hash 的 Batches > 1 hash build spill 了 build side 大小、work_mem
Sort Method: external merge 排序 spill 了 排序輸入大小、work_mem、缺少的 LIMIT
temp written 很高 spill 到暫存檔 上方的 sort 或 hash
shared read 很高 拉進大量資料 scan 的資料量
Exchange(Spark) shuffle join 策略、分區、skew
SortMergeJoin(Spark) 兩側都 shuffle 能否 broadcast 其中一側?
isFinalPlan=false(Spark) AQE 調整前的計畫 Spark UI 中的最終計畫

10-3) 「你的查詢要跑 10 分鐘,你會怎麼做?」

一個範例回答,依照面試官想聽的順序(面試時通常以英文回答,這裡附上中文版):

「我會從執行計畫著手,而不是先去改設定。

首先,我執行 EXPLAIN ANALYZE,比較每個節點的估計列數與實際列數,因為大的落差通常就能 解釋為什麼選錯 join 或發生 spill。

第二,看 scan:查詢讀的資料是否遠多於回傳的?索引或 partition pruning 有沒有用上?過濾條件 有沒有下推?

第三,檢查 join 策略 —— 內層跑了數百萬次的 nested loop、build side spill 的 hash join, 或是餵給 merge join 的昂貴排序。

我也會檢查記憶體與 I/O:buffer 讀取、暫存檔用量、sort 與 hash 的 spill。

在 Spark 上,我還會看 shuffle read 與 write 的大小、用來判斷 skew 的 task 執行時間分布, 以及 Spark UI 中 adaptive 執行後的最終計畫。

接著一次只改一件事,重新量測,並確認結果完全相同。」

一句話版本: EXPLAIN 給你一個「時間花在哪裡」的假設;EXPLAIN ANALYZE 與執行期的指標 負責證實或推翻它。目標是在答案不變的前提下降低端到端的時間與成本 —— 而不是一份只是看起來 比較漂亮的計畫。