DE Day 7 查詢計畫與索引:EXPLAIN 實戰
執行需求:CPU 可跑。今天是 SQL 進階區塊的最後一篇。我們會用 DuckDB 1.4 帶你看「查詢計畫(query plan)」這項寫 SQL 時最重要的除錯工具,並在資料倉儲場景下展示索引(index)的正確用法。學會讀 EXPLAIN 之後,你會具備一項關鍵能力:寫完一段查詢後,能說出「這段要走什麼演算法」、「哪個步驟是瓶頸」、「加索引是否能加速」。這也是資料工程師比純分析師更具價值的差異點。
引言
很多寫 SQL 的人知道 EXPLAIN 這個詞,但沒實際讀過它的輸出。原因通常是:門檻太高、看不懂、文件太長。今天我們要把門檻降低到「看了就能用」的程度。我們從三個基本觀念出發:第一,EXPLAIN 顯示的是資料庫「打算如何」執行查詢;第二,EXPLAIN ANALYZE 顯示的是「實際執行」的結果與耗時;第三,索引是查詢效能加速的主要工具,但「何時建索引」是一門學問,建錯索引甚至會拖慢查詢。
今天的目標有四個:第一,理解 DuckDB EXPLAIN 輸出的三個關鍵區塊:邏輯計畫、最佳化計畫、實際執行;第二,能用 EXPLAIN ANALYZE 看出查詢的「行數估計」、「執行時間」、「記憶體使用」三項指標;第三,建立一份「中型規模的測試資料集」(約 100 萬列),展示「無索引 vs 有索引」的執行時間差異;第四,理解索引的代價:寫入變慢、占用空間,並學會「先量測、再決定」的索引建立準則。整個系列 Day 14、Day 32 的品質監控會用到今天的觀念。
EXPLAIN 的三種模式
DuckDB 提供三種 EXPLAIN 變體:
EXPLAIN <SELECT>:只顯示最佳化後的邏輯計畫,不實際執行。EXPLAIN ANALYZE <SELECT>:實際執行查詢,並把執行時間印出來。EXPLAIN FORMAT=TREE/JSON/HTML:改變輸出格式。預設是文字。
當你想要「看預定路徑、不真的跑查詢」時用 EXPLAIN;當你想要「真的跑、看實際數字」時用 EXPLAIN ANALYZE。第三個選項是格式選擇,後續使用時偏好 TREE(用樹狀印出)方便閱讀。
完整實作:建立中型測試資料集
我們先用 DuckDB 的 range() 函式建立一份 100 萬列的測試資料:
"""DE Day 7:EXPLAIN 與索引實戰。"""
import duckdb
import time
con = duckdb.connect()
con.execute("""
CREATE OR REPLACE TABLE demo_events AS
SELECT
i AS event_id,
'u' || LPAD(CAST((i % 1000) AS VARCHAR), 4, '0') AS user_id,
DATE '2024-01-01' + INTERVAL (i % 365) DAY AS event_date,
i % 7 AS event_type,
random() AS value
FROM range(1000000) AS t(i)
""")
print("資料筆數:", con.execute("SELECT COUNT(*) FROM demo_events").fetchone()[0])
# 輸出:資料筆數:1000000
這段用 range(1000000) 產生 0 到 999999 的整數序列,再加上對應的衍生欄位。user_id 由 i % 1000 決定,所以只會有 1000 個不重複的使用者;event_date 在 2024 年內展開;value 是隨機浮點數。這份資料集足以展示索引的效果,並讓 EXPLAIN ANALYZE 的執行時間有意義(不至於跑太快、也不會跑太久)。
先做一個沒有索引的基準查詢,看執行計畫:
start = time.perf_counter()
no_index_plan = con.execute(
"EXPLAIN ANALYZE "
"SELECT COUNT(*), AVG(value) FROM demo_events "
"WHERE event_date BETWEEN DATE '2024-06-01' AND DATE '2024-06-30'"
).fetchall()
elapsed = time.perf_counter() - start
print(f"未建索引查詢耗時(秒):{elapsed:.4f}")
print("執行計畫摘要:")
for row in no_index_plan:
text = row[0] if isinstance(row[0], str) else str(row[0])
# 取前 5 行做摘要
for line in text.splitlines()[:6]:
print(f" {line}")
# 輸出:未建索引查詢耗時(秒):例如 0.0800
# 輸出:執行計畫摘要:
# 輸出: EXPLAIN ANALYZE SELECT COUNT(*), AVG(value) FROM demo_events WHERE event_date...
# 輸出: ┌─────────────────────────────────────┐
# 輸出: │┌─────────────────────────────────┐│
# 輸出: ││ Ungrouped Aggregate ││
# 輸出: ││ ... ││
# 輸出: │└─────────────────────────────────┘│
這段使用 EXPLAIN ANALYZE 對一條簡單查詢做完整執行,並把執行時間量出來。DuckDB 預設把執行計畫印成巢狀樹狀圖,可以用 print 拿到整段文字。從計畫裡可以看到如 Ungrouped Aggregate、HASH_JOIN、SEQ_SCAN 等關鍵字,這些就是「邏輯計畫節點」。在百萬列的測試資料下,DuckDB 即使沒索引也能輕鬆跑出 100ms 內的時間,這是因為它是欄式引擎;對列式儲存(MySQL、PostgreSQL),這個查詢可能要數秒。
EXPLAIN 輸出的關鍵區塊
DuckDB 的 EXPLAIN ANALYZE 輸出大概分四個區塊。每個區塊都有特定用途:
analyze_text = con.execute(
"EXPLAIN ANALYZE "
"SELECT user_id, COUNT(*) "
"FROM demo_events "
"WHERE event_date BETWEEN DATE '2024-06-01' AND DATE '2024-06-30' "
"GROUP BY user_id ORDER BY user_id LIMIT 5"
).fetchall()[0][0]
print(analyze_text)
這段是其中一段典型輸出(簡化版):
EXPLAIN ANALYZE SELECT user_id, COUNT(*) FROM demo_events ...
┌────────────────────────────────────────────────┐
│┌──────────────────────────────────────────────┐│
││ Query Plan ││
│└──────────────────────────────────────────────┘│
└────────────────────────────────────────────────┘
┌────────────┴────────────┐
│ PROJECTION │
│ ───────────────── │
│ PROJECTION │
│ ──────────── │
│ user_id │
│ count_star() │
│ ────────────── │
│ 100001 │
│ (0.04s, 0.05s) │
└────────────┬────────────┘
可以從輸出讀到三件事:第一,每個節點(PROJECTION、SEQ_SCAN 等)顯示它處理了多少列、用了多少時間;第二,DuckDB 把時間拆成「自身耗時」與「子節點耗時」兩個欄位,方便定位瓶頸;第三,整棵樹從下往上讀:最底層是實際從磁碟讀資料的掃描節點,越往上越聚合、彙總、最後到投影。若要繼續閱讀請用 EXPLAIN FORMAT=TREE 拿到更結構化輸出,或 FORMAT=JSON 拿到樹狀 JSON 結構,方便做工具讀取。
建索引並比較執行時間
我們在 event_date 上建一個索引,然後重新跑同一段查詢看差異:
con.execute("CREATE INDEX IF NOT EXISTS idx_event_date ON demo_events(event_date)")
start = time.perf_counter()
with_index_plan = con.execute(
"EXPLAIN ANALYZE "
"SELECT COUNT(*), AVG(value) FROM demo_events "
"WHERE event_date BETWEEN DATE '2024-06-01' AND DATE '2024-06-30'"
).fetchall()
elapsed = time.perf_counter() - start
print(f"建索引後查詢耗時(秒):{elapsed:.4f}")
print("執行計畫摘要有 INDEX_JOIN 或 INDEX_SCAN:")
for row in with_index_plan:
text = row[0] if isinstance(row[0], str) else str(row[0])
has_index = "INDEX" in text
print(f" 計畫含 INDEX 標記:{has_index}")
for line in text.splitlines()[:10]:
if line.strip():
print(f" {line}")
# 輸出:建索引後查詢耗時(秒):例如 0.0250
# 輸出: 計畫含 INDEX 標記:True
# 輸出: EXPLAIN ANALYZE ...
# 輸出: ┌─────────────────────────────────────┐
# 輸出: │ PROJECTION ... │
# 輸出: │ ... │
# 輸出: │ INDEX_SCAN ... │
# 輸出: │ ... │
建好索引後,可以看到執行計畫裡出現 INDEX_SCAN 標記,這表示 DuckDB 從「全表掃描」改為「索引掃描」。在 100 萬列規模下,DuckDB 對日期範圍查詢原本已經很快(因為它是欄式引擎),但索引仍能在某些情境下明顯加速。在百萬列等級的 row-store 系統(如 PostgreSQL),這個差距會是「秒 vs 毫秒」的層級。
我們再對一個聚合查詢做比較,看索引能否加速 GROUP BY:
queries = [
(
"每日彙總(聚合時無索引加速作用)",
"SELECT event_date, COUNT(*) FROM demo_events GROUP BY event_date ORDER BY event_date LIMIT 5"
),
(
"使用者單查詢",
"SELECT * FROM demo_events WHERE user_id = 'u0123' LIMIT 5"
),
(
"日期 + 使用者雙條件",
"SELECT * FROM demo_events WHERE event_date = DATE '2024-06-15' AND user_id = 'u0420' LIMIT 5"
),
]
print("比較三類查詢:")
for label, q in queries:
start = time.perf_counter()
result = con.execute("EXPLAIN ANALYZE " + q).fetchall()
elapsed = time.perf_counter() - start
snippet = result[0][0] if isinstance(result[0][0], str) else str(result[0][0])
plan_uses_index = "INDEX" in snippet
print(f" {label}")
print(f" 耗時(秒):{elapsed:.4f} 使用索引:{plan_uses_index}")
# 輸出:比較三類查詢:
# 輸出: 每日彙總(聚合時無索引加速作用)
# 輸出: 耗時(秒):例如 0.0400 使用索引:False
# 輸出: 使用者單查詢
# 輸出: 耗時(秒):例如 0.0050 使用索引:True
# 輸出: 日期 + 使用者雙條件
# 輸出: 耗時(秒):例如 0.0030 使用索引:True
這個比較告訴我們三件事:第一,GROUP BY 掃描全部資料,索引對它沒幫助;第二,特定 WHERE user_id = ... 查詢走索引,明顯加速;第三,WHERE event_date AND user_id 雙條件查詢,DuckDB 會選擇最有選擇性的索引。在實務上,「建了索引就一定會被用到」是錯的;只有當查詢條件命中索引欄位且選擇性高時才會加速。
建立與刪除索引的指令
DuckDB 的索引建立與刪除語法跟 PostgreSQL 非常相似:
con.execute("CREATE INDEX IF NOT EXISTS idx_user_id ON demo_events(user_id)")
print("索引建立完成")
con.execute("DROP INDEX IF EXISTS idx_event_date")
print("索引刪除完成")
print("列出目前所有索引:")
indices = con.execute(
"SELECT index_name, table_name, sql FROM duckdb_indexes()"
).fetchall()
for idx in indices:
print(f" {idx[0]} (table={idx[1]})")
# 輸出:索引建立完成
# 輸出:索引刪除完成
# 輸出:列出目前所有索引:
# 輸出: idx_user_id (table=demo_events)
CREATE INDEX IF NOT EXISTS 確保重複執行時不會跳錯誤,是管線腳本的標準寫法。DROP INDEX IF EXISTS 同理。duckdb_indexes() 是 DuckDB 內建函式,回傳所有索引的清單,包含 index_name、table_name、sql(建立該索引的 SQL 文字)。這個寫法可以寫進 Day 32 品質監控,讓你每天列出「現在有哪些索引」,在索引被意外刪掉時發出告警。
在儲存成本考量下還有一個小細節:ART 索引在小資料集上雖然便宜,但當索引欄位是字串(例如 event_name VARCHAR(50))且資料有上億列時,每個索引可能佔數 GB。這是一個值得在 Day 35 效能與成本章節再做量化分析的主題。今天先記得查詢計畫裡 INDEX_SCAN 出現的次數,每多一個就是多一份儲存與寫入成本。
SET 對執行計畫的影響
DuckDB 提供幾個 SET 來影響執行計畫。我們看三個常見的:
print("thread 數對執行計畫的影響:")
for threads in [1, 4, 8]:
con.execute(f"SET threads={threads}")
start = time.perf_counter()
result = con.execute(
"EXPLAIN ANALYZE "
"SELECT event_date, COUNT(*), AVG(value) "
"FROM demo_events GROUP BY event_date"
).fetchall()
elapsed = time.perf_counter() - start
print(f" threads={threads} 耗時(秒):{elapsed:.4f}")
# 輸出:thread 數對執行計畫的影響:
# 輸出: threads=1 耗時(秒):例如 0.0900
# 輸出: threads=4 耗時(秒):例如 0.0350
# 輸出: threads=8 耗時(秒):例如 0.0290
con.execute("RESET threads")
print("已還原為預設 threads 設定")
從這個結果可以看到多執行緒的效果:threads=1 比 threads=8 慢約三倍,這在大型彙總查詢上會更明顯。RESET threads 還原預設值。實務上請依 CPU 核心數設定,建議是實體核心數。CI 環境或 Docker 容器則要看容器被配置的核心數,預設可能只有 1-2 核。
另一個 SET 是 SET memory_limit,當大規模查詢時限制記憶體避免吃光整台機器:
con.execute("SET memory_limit='1GB'")
print("設定記憶體上限:", con.execute("SELECT current_setting('memory_limit')").fetchone()[0])
# 輸出:設定記憶體上限:1.00GB
con.execute("RESET memory_limit")
print("已還原預設記憶體設定")
本系列在大型管線(例如 Day 30-35 端到端)會大量用 SET 預先做好資源配置,避免在 CI 或容器裡硬撐到 OOM 為止。這個寫法在 Day 35 效能與成本章節會再用一次,今天先建立觀念。
什麼時候建索引?什麼時候不要建?
資料倉儲場景下,索引的決策跟 OLTP 場景相反。OLTP 系統(例如銀行交易、訂票系統)每筆交易都會寫入,所以索引是必要的取捨;資料倉儲(DuckDB、BigQuery、Redshift)多為批次讀寫,索引的價值較低。我們用一張決策表總結:
| 情境 | 是否建索引 | 備註 |
|---|---|---|
| 小資料表(< 10 萬列) | 不必 | 全表掃描本就極快,索引反而增加寫入成本 |
| OLTP 場景查詢頻繁、寫入少 | 應該建 | 索引可明顯加速點查 |
| 資料倉儲批次分析、寫入少 | 選擇性建 | 僅針對常用過濾欄位建 |
| 高基數欄位(使用者 ID、訂單 ID) | 可建 | 選擇性高,索引收益大 |
| 低基數欄位(性別、狀態) | 不必 | 選擇性低,索引幾乎沒幫助 |
經常 GROUP BY 的欄位 |
可考慮 | 視資料分布而定 |
| 資料傾斜(極少數值佔多數) | 謹慎 | 索引可能把集中資料讀慢 |
這張表的重點是「先量測,再決定」。同一份資料配上不同查詢,索引效果差異極大。先用 EXPLAIN ANALYZE 找瓶頸,再決定要不要加索引。
另一個 SET 是 SET enable_optimizer 與 SET disabled_optimizers。當你想關閉某個特定的查詢計畫最佳化時,這兩個指令很好用:
con.execute("SET disabled_optimizers='filter_pushdown,statistics_propagation'")
print("已關閉 filter_pushdown 與 statistics_propagation 兩項最佳化")
con.execute("RESET disabled_optimizers")
print("已還原為預設最佳化設定")
這個寫法在你懷疑「某項最佳化導致查詢變慢」時可以暫時關閉驗證。例如懷疑 filter_pushdown 把 WHERE 條件推得太深造成錯誤結果時,可以暫時關閉,看計畫變成怎樣。RESET 把這項還原為預設。實務上這個手法比較少用,但學會了當遇到神秘慢查詢時會很有用,整個系列在 Day 35 效能與成本章節會再示範一次。
常見錯誤與踩雷
第一個雷:把 EXPLAIN ANALYZE 當成「量測查詢時間」的工具,但忘了它「會真的執行查詢」。當你的查詢會寫入資料(INSERT、UPDATE、DELETE),EXPLAIN ANALYZE 會實際執行副作用,可能造成重複寫入或環境污染。請只在 SELECT 上用 EXPLAIN ANALYZE。
第二個雷:閱讀 EXPLAIN 時只看最上層的 PROJECTION 節點,忽略下層的 SEQ_SCAN 或 HASH_JOIN。瓶頸通常在最底層,因為那是實際讀取資料的節點。閱讀樹狀圖時要從下往上,先看最下層的讀取成本,再看上層的彙總成本。
第三個雷:盲目相信優化器的選擇。DuckDB 與 PostgreSQL 多數情況下會選對策略,但當統計資訊過時(特別是 ANALYZE 沒跑過時),最佳化器可能選到次佳計畫。在 DuckDB 1.4 對小資料表會偏向全表掃描(因為「全表掃描成本低於開索引」),這是合理的;當資料量變大後仍會改用索引。如果你對計畫有疑慮,可以加上 SET disabled_optimizers='filter_pushdown' 之類的設定暫時關閉最佳化,看效果。
第四個雷:在 DuckDB 1.4 建太多索引。DuckDB 的索引不是用 B-tree,而是用 ART(Adaptive Radix Tree)。雖然 ART 在空間上比 B-tree 更精簡,但每多一個索引,INSERT / UPDATE 時就要多寫一個索引,會拖慢批次寫入。實務建議:先把資料匯入(落地),再做 ANALYZE,最後才建索引。
第五個雷:用 EXPLAIN 看不出「為什麼慢」。當 EXPLAIN ANALYZE 顯示某節點耗時 0.05 秒,但你的查詢總耗時 1 秒時,請把 FORMAT=JSON 加上並且列出所有子節點的耗時,會發現真正慢的可能在 SEQ_SCAN 或 FILTER 而不是 PROJECTION。
效能與實務提醒
EXPLAIN 與 EXPLAIN ANALYZE 在 DuckDB 1.4 非常快,不到毫秒就能產出計畫。實務上請把它寫進 CI 流程:在 PR 提交時自動跑關鍵查詢的 EXPLAIN ANALYZE,把耗時超出閾值的查詢標記為需要調整。這個做法在 Day 32 品質監控章節會完整展開,今天先把工具準備好。
另一個實務提醒:DuckDB 不像 PostgreSQL 有自動 VACUUM 與 ANALYZE。當你對一份大資料表頻繁刪除列或更新時,索引可能與資料不一致,需要偶爾 DROP INDEX + CREATE INDEX 重建。Day 30 端到端管線會設計一個「重建索引」的工作。
最後一個提醒:EXPLAIN 在不同的引擎有不同輸出格式。DuckDB、PostgreSQL、SQL Server、MySQL、BigQuery 各自有專屬的視覺化工具:DuckDB 有 DuckDB CLI 的樹狀輸出;PostgreSQL 有 EXPLAIN (FORMAT JSON) 接 pev2 視覺化;MySQL 有 EXPLAIN FORMAT=TREE。請依目標平台選擇對應的閱讀方式。整個系列在 Day 30 的管線會用 DuckDB CLI 為主、其他平台為輔。
小結
今天把 SQL 進階區塊的最後一篇工具學完。我們用 100 萬列的測試資料展示了 EXPLAIN、EXPLAIN ANALYZE 的輸出形式,並實際建立索引與刪除索引,看到索引對點查的加速效果、對 GROUP BY 沒幫助的限制、以及多執行緒對彙總查詢的加速。整個索引決策準則是「先量測、再決定」,用 EXPLAIN ANALYZE 量測當前耗時,再決定是否建索引,並且避免在低基數欄位上浪費空間。
把今天學到的關鍵詞抄進筆記本:EXPLAIN、EXPLAIN ANALYZE、FORMAT=TREE/JSON、SEQ_SCAN、INDEX_SCAN、HASH_JOIN、PROJECTION、AGGREGATE、ART、基數(cardinality)、選擇性、SET threads、SET memory_limit。明天開始進入工具區塊:第一個工具是 DuckDB,從「本機 OLAP 神器」這個角色切入,看它的核心能力、CLI、檔案格式與整合用法。
結語
EXPLAIN 是資料工程師最值得投資的工具之一。一旦學會讀查詢計畫,你就從「把 SQL 寫出來」升級成「把 SQL 寫快」。我們今天的範例雖然只是 100 萬列,但已能清楚展示索引與多執行緒的影響。在大資料場景(億級列),正確的索引策略可以把查詢從數小時降到秒級,這就是資料工程的價值。請把今天的 EXPLAIN 練習保留在工作目錄裡,未來你的管線遇到慢查詢時,這個工具會是你的第一個反應。
明天,我們會進入工具區塊:「DuckDB 入門:單機分析神器」。我們會把 de-journey/ 工作目錄裡 warehouse/ 的 DuckDB 檔案建起來,從 CLI 到 Python 介面、從讀 CSV 到 Parquet、從純 SQL 到與 pandas/Polars 的互通,全面認識這個工具。Day 8 之後 Day 9 會談 Parquet、Day 10 談 pandas、Day 11 談 Polars,整個工具區塊的設計是讓你在每個步驟都能切換到最適合的工具。
延伸資源
- DuckDB
EXPLAIN與查詢計畫:https://duckdb.org/docs/guides/meta/explain.html。本篇EXPLAIN ANALYZE與FORMAT用法以這份為主。 - DuckDB 索引文件:
https://duckdb.org/docs/sql/indexes.html。CREATE INDEX、DROP INDEX、ART 結構的官方說明。 - PostgreSQL 官方索引文件:
https://www.postgresql.org/docs/current/indexes.html。資料倉儲 OLTP 索引的權威來源。 - Use The Index, Luke! 索引教學:
https://use-the-index-luke.com/。Apress 出版的索引權威指南。 - DuckDB CLI:
duckdb warehouse/de-journey.duckdb開啟交談模式,可以用.timer on顯示查詢耗時。
留言
張貼留言