iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

上一篇介紹了 Logical Plan,知道 SQL 會被轉換成一棵由 TableScanFilterAggregateProjection 組成的操作樹。

不過,實際使用 DataFusion 時,要怎麼查看這些計畫?

答案就是:

EXPLAIN

EXPLAIN 不會直接回傳查詢結果,而是顯示 DataFusion 為 SQL 建立的查詢計畫,讓我們觀察 SQL 如何從 Logical Plan 逐步轉換成 Physical Plan。


macOS 安裝 DataFusion CLI

本文使用 macOS 示範。

DataFusion 提供命令列工具 datafusion-cli。如果已經安裝 Homebrew,可以直接執行:

brew install datafusion

安裝完成後確認版本:

datafusion-cli --version

如果看到版本資訊,就代表安裝成功。

也可以使用 Rust 的 Cargo 安裝:

cargo install datafusion-cli

若 Cargo 安裝完成後仍找不到指令,請確認 Cargo 的執行檔路徑已加入 PATH

echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

接著再次確認:

datafusion-cli --version

官方安裝說明可以參考:

DataFusion CLI Installation


啟動 DataFusion CLI

在終端機輸入:

datafusion-cli

成功啟動後,會進入互動式 SQL 命令列環境。

為了示範,我們先建立一張簡單的 orders 表:

CREATE TABLE orders AS
VALUES
    (1, 'Taipei', 1200.0, true),
    (2, 'Taichung', 850.0, false),
    (3, 'Taipei', 2300.0, true),
    (4, 'Kaohsiung', 560.5, false),
    (5, 'Taoyuan', 1750.0, true);

這個範例沒有指定欄位名稱,因此 DataFusion 會自動使用:

column1
column2
column3
column4

分別代表:

column1:訂單編號
column2:城市
column3:訂單金額
column4:是否為會員

先確認資料:

SELECT * FROM orders;

最基本的 EXPLAIN

現在執行一段分析查詢:

EXPLAIN
SELECT column2 AS city,
       SUM(column3) AS total_amount
FROM orders
WHERE column4 = true
GROUP BY column2;

DataFusion 會顯示查詢計畫,而不是直接顯示最終結果。

輸出通常會包含兩個重要部分:

logical_plan
physical_plan

其中:

  • logical_plan 描述「這段查詢需要做什麼」
  • physical_plan 描述「實際要如何執行這段查詢」

使用縮排格式查看計畫

為了讓樹狀結構更容易閱讀,可以使用:

EXPLAIN FORMAT INDENT
SELECT column2 AS city,
       SUM(column3) AS total_amount
FROM orders
WHERE column4 = true
GROUP BY column2;

不同 DataFusion 版本、資料來源與執行設定,輸出的細節可能會不同。以下是常見的輸出形式:

+---------------+------------------------------------------------------------------------------------------------------------------------------------------------+
| plan_type     | plan                                                                                                                                           |
+---------------+------------------------------------------------------------------------------------------------------------------------------------------------+
| logical_plan  | Projection: orders.column2 AS city, SUM(orders.column3) AS total_amount                                                                        |
|               |   Aggregate: groupBy=[[orders.column2]], aggr=[[SUM(orders.column3)]]                                                                         |
|               |     Filter: orders.column4 = Boolean(true)                                                                                                     |
|               |       TableScan: orders projection=[column2, column3, column4]                                                                                 |
| physical_plan | ProjectionExec: expr=[column2@0 as city, SUM(orders.column3)@1 as total_amount]                                                                |
|               |   AggregateExec: mode=FinalPartitioned, gby=[column2@0 as column2], aggr=[SUM(orders.column3)]                                                 |
|               |     CoalesceBatchesExec: target_batch_size=8192                                                                                                |
|               |       RepartitionExec: partitioning=Hash([column2@0], 8), input_partitions=8                                                                  |
|               |         AggregateExec: mode=Partial, gby=[column2@0 as column2], aggr=[SUM(orders.column3)]                                                   |
|               |           CoalesceBatchesExec: target_batch_size=8192                                                                                          |
|               |             FilterExec: column4@2 = true                                                                                                       |
|               |               MemoryExec: partitions=1, partition_sizes=[1]                                                                                    |
+---------------+------------------------------------------------------------------------------------------------------------------------------------------------+

實際輸出會依照 DataFusion 版本、資料來源、分割區數量與執行設定而有所不同,因此不一定會與範例完全相同。


如何閱讀 Logical Plan?

先看輸出的 logical_plan

Projection
  Aggregate
    Filter
      TableScan

這棵樹通常要由下往上閱讀。

1. TableScan

TableScan: orders

代表從 orders 資料表讀取資料。

如果只需要部分欄位,計畫中可能會看到:

projection=[column2, column3, column4]

這表示查詢不需要讀取 column1,因此可以避免掃描不必要的欄位。

2. Filter

Filter: orders.column4 = Boolean(true)

代表套用:

WHERE column4 = true

只有會員資料會繼續往上傳遞。

3. Aggregate

Aggregate:
  groupBy=[[orders.column2]]
  aggr=[[SUM(orders.column3)]]

代表依照城市分組,並計算每個城市的訂單金額總和。

對應的 SQL 是:

GROUP BY column2
SUM(column3)

4. Projection

Projection:
  orders.column2 AS city
  SUM(orders.column3) AS total_amount

最後只輸出兩個欄位:

city
total_amount

因此,Logical Plan 可以理解成:

讀取 orders
    ↓
篩選會員資料
    ↓
依城市分組並加總金額
    ↓
輸出 city 與 total_amount

Logical Plan 與 Physical Plan 的差異

Logical Plan 描述的是查詢的邏輯需求:

我要掃描資料
我要篩選會員
我要依城市分組
我要計算金額總和

它不需要決定:

  • 使用哪一種聚合演算法
  • 使用多少個執行分割區
  • 是否要重新分區
  • 如何合併不同批次的資料
  • 使用哪一個實際執行算子

這些工作會交給 Physical Planner 與 Execution Engine。

Physical Plan 可能會出現:

ProjectionExec
AggregateExec
FilterExec
MemoryExec
RepartitionExec
CoalesceBatchesExec

名稱結尾的 Exec 可以理解成實際執行階段所使用的算子。

例如:

FilterExec

代表實際執行篩選條件。

AggregateExec

代表實際執行分組與聚合。

MemoryExec

代表從記憶體中的資料批次讀取資料。在讀取 CSV、JSON 或 Parquet 時,也可能看到其他資料來源相關的 Execution Plan 節點。


為什麼 Physical Plan 會比 Logical Plan 複雜?

同一段 SQL 可以有不同的執行方式。

例如:

SELECT column2, SUM(column3)
FROM orders
WHERE column4 = true
GROUP BY column2;

在 Logical Plan 中,只需要描述:

TableScan → Filter → Aggregate → Projection

但在 Physical Plan 中,DataFusion 還需要決定:

  • 是否先過濾資料
  • 聚合是否分成 Partial 與 Final 兩階段
  • 是否需要重新分區
  • 每次處理多少筆資料
  • 是否需要合併不同的 RecordBatch

因此 Physical Plan 通常會包含更多執行細節。

這也是為什麼 Logical Plan 比較適合用來理解查詢語意,而 Physical Plan 比較適合用來分析效能與實際執行方式。


EXPLAIN 不會執行查詢

一般的:

EXPLAIN SELECT ...;

主要是顯示查詢計畫,不會像一般 SELECT 一樣直接輸出查詢結果。

如果希望查看實際執行時的統計資訊,可以使用:

EXPLAIN ANALYZE
SELECT column2 AS city,
       SUM(column3) AS total_amount
FROM orders
WHERE column4 = true
GROUP BY column2;

EXPLAIN ANALYZE 會實際執行查詢,並顯示執行時間、輸出批次與其他執行指標。

因此可以這樣區分:

EXPLAIN
查看查詢計畫,不重點關注執行結果

EXPLAIN ANALYZE
執行查詢,同時查看實際執行統計

從 EXPLAIN 觀察欄位裁剪

在範例中,查詢只使用:

column2
column3
column4

因此 DataFusion 可能會在 TableScan 顯示:

projection=[column2, column3, column4]

這表示 column1 不需要被讀取。

如果資料來源支援欄位裁剪,例如 Parquet,DataFusion 就能只讀取真正需要的欄位,減少:

  • 磁碟 I/O
  • 記憶體使用量
  • 資料解碼成本
  • 後續運算量

這就是 Query Optimizer 可能帶來的效能改善之一。


從 EXPLAIN 觀察資料流向

可以把這個查詢簡化成:

TableScan
    ↓
Filter
    ↓
Aggregate
    ↓
Projection

資料會先從資料來源讀入,再經過每一個執行節點。

在 DataFusion 中,這些資料通常會以 Apache Arrow 的 RecordBatch 形式在不同算子之間傳遞。

因此,前幾天介紹的概念可以串在一起:

CSV / JSON / Parquet
        ↓
Data Source
        ↓
TableProvider
        ↓
Logical Plan
        ↓
Physical Plan
        ↓
Arrow RecordBatch
        ↓
Execution Operators
        ↓
Query Result

今日小結

今天使用 DataFusion CLI 實際執行了 EXPLAIN,觀察 SQL 背後的查詢計畫。

我們學到:

  • EXPLAIN 可以查看查詢計畫
  • Logical Plan 描述「要做什麼」
  • Physical Plan 描述「如何執行」
  • TableScan 負責讀取資料
  • Filter 負責篩選資料
  • Aggregate 負責分組與聚合
  • Projection 負責選擇輸出欄位
  • EXPLAIN ANALYZE 可以查看實際執行統計
  • 計畫中的欄位裁剪可以反映 Query Optimizer 的作用

下一篇將從 Logical Plan 往下一層,介紹 Physical Plan。

Logical Plan 描述的是「查詢想完成什麼」;Physical Plan 則會進一步決定「這些工作要如何實際執行」。

我們會看到 TableScan 如何變成資料來源執行算子、Filter 如何變成 FilterExec,以及 DataFusion 如何選擇聚合、分區與資料處理方式。


延伸閱讀


上一篇
Day 12|Logical Plan:SQL 的第一張執行藍圖
系列文
深入 SQL 查詢引擎:30 天用 Rust 與 Apache DataFusion 解構資料處理流程13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言