昨天有讀者問:
規劃和執行兩階段,是先規劃後執行嗎?還是獨立的兩塊?
答案是:
先規劃,後執行。
兩者不是獨立的兩條流程,而是同一次查詢的前後階段。
規劃階段
→ 決定這段 SQL 要如何執行
執行階段
→ 依照規劃結果,真的讀取與處理資料
昨天的圖把兩個階段分開,是為了說明它們的職責不同;但實際上,執行階段必須使用規劃階段產生的 Physical Plan,才能開始工作。
可以把它想成導航:
規劃
→ 計算從起點到終點的路線
執行
→ 按照路線真正行駛
今天就從 SQL 本身開始,看看一段 SQL 如何一步步變成 DataFusion 可以執行的 Query Plan。
我們平常看到的是一段 SQL 文字:
SELECT city, SUM(amount) AS total_amount
FROM orders
WHERE is_member = true
GROUP BY city;
但對 Query Engine 而言,這只是一串字元。
DataFusion 必須先理解:
SELECT 是什麼?
orders 是哪一張表?
city、amount、is_member 是否存在?
amount 是否可以執行 SUM?
資料要先篩選,還是先聚合?
因此,SQL 會經過多個轉換階段:
SQL String
↓
Parser
↓
AST
↓
Logical Plan
↓
Logical Optimizer
↓
Physical Plan
↓
Execution
↓
Query Result
今天先聚焦前三個部分:
SQL String
↓
Parser
↓
AST
↓
Logical Plan
Query Engine 收到 SQL 後,第一個工作是 Parsing,也就是語法解析。
Parser 會將 SQL 拆成一個個 Token:
SELECT city, SUM(amount)
FROM orders
WHERE is_member = true
GROUP BY city;
概念上可以拆成:
SELECT
city
,
SUM
(
amount
)
FROM
orders
WHERE
is_member
=
true
GROUP
BY
city
;
接著,Parser 會根據 SQL 語法規則,判斷這些 Token 是否能組成合法的 SQL。
例如:
SELECT city SUM(amount)
FROM orders;
這段少了逗號,Parser 可能直接回傳語法錯誤。
但要注意:
語法正確,不代表查詢一定可以執行。
例如:
SELECT unknown_column
FROM orders;
這段 SQL 的文法可能完全正確,但 unknown_column 是否真的存在,要等後續的分析與規劃階段才能確認。
Parser 通常會將 SQL 轉換成 AST。
AST 是 Abstract Syntax Tree,中文可以稱為抽象語法樹。
它使用樹狀結構保存 SQL 的語法關係。
以上面的查詢為例,可以概念化成:
SELECT Statement
├── Projection
│ ├── city
│ └── SUM(amount) AS total_amount
├── FROM
│ └── orders
├── WHERE
│ └── is_member = true
└── GROUP BY
└── city
此時 AST 已經知道:
這是一段 SELECT 查詢
有 FROM
有 WHERE
有 GROUP BY
有 SUM(amount)
但它還不知道:
orders 實際是哪一張表?
city 是否存在?
amount 的資料型別是什麼?
orders 儲存在 CSV、JSON 還是 Parquet?
所以 AST 描述的是:
SQL 的語法結構
而不是:
資料表的實際內容
這兩個階段很容易混在一起,可以先用下面的表格區分:
| 階段 | 主要問題 | 例子 |
|---|---|---|
| Parser | SQL 文法是否正確? | SELECT city FROM orders |
| Analyzer / Planner | 表、欄位與型別是否合理? | city 是否存在? |
例如:
SELECT missing_column
FROM orders;
Parser 看到的是:
SELECT 後面有一個欄位名稱
FROM 後面有一個資料表名稱
所以語法可能正確。
Analyzer 取得 orders 的 Schema 後,才會發現:
missing_column 不在 Schema 中
這時才會產生「找不到欄位」的錯誤。
DataFusion 要將 AST 轉成 Logical Plan,就必須使用資料表的 Schema。
假設 orders 的 Schema 是:
order_id : Int64
city : Utf8
amount : Float64
is_member : Boolean
DataFusion 就能檢查:
orders 是否存在?
city 是否存在?
amount 是否為數值型別?
is_member 是否能和 true 比較?
SUM(amount) 的輸出型別是什麼?
這也說明了昨天讀者提到的問題:
Schema 不只在讀取檔案時使用,
也會參與 SQL 的語意分析與 Query Plan 建立。
Parser 只能判斷:
SELECT unknown_column FROM orders;
這句話的文法是否正確。
但 DataFusion 必須搭配 Schema,才能判斷:
unknown_column 是否真的存在?
當 DataFusion 理解了表格、欄位與型別後,就可以將 AST 轉換成 Logical Plan。
Logical Plan 是一棵描述資料操作的樹。
對這段 SQL:
SELECT city, SUM(amount) AS total_amount
FROM orders
WHERE is_member = true
GROUP BY city;
概念上的 Logical Plan 可以表示為:
Aggregate
├── group_by: city
└── aggregate: SUM(amount)
↓
Filter
└── predicate: is_member = true
↓
TableScan
└── table: orders
這棵樹通常由下往上理解:
1. 掃描 orders
2. 篩選 is_member = true
3. 依 city 分組
4. 計算 SUM(amount)
5. 輸出查詢結果
Logical Plan 描述的是:
要做哪些資料操作?
這些操作之間的關係是什麼?
它還沒有決定:
使用哪一種 Join 演算法?
資料分成幾個 Partition?
如何平行讀取 Parquet?
要使用哪個具體的 Execution Operator?
這些會在後面的 Physical Planning 階段決定。
我們寫 SQL 時通常是:
SELECT ...
FROM ...
WHERE ...
GROUP BY ...
但理解資料操作時,可以先想成:
FROM
↓
WHERE
↓
GROUP BY
↓
SELECT
因為查詢必須先找到資料,再篩選資料,接著分組與聚合,最後產生輸出欄位。
不過,這不代表 Query Engine 永遠會按照這個順序逐步執行。
Optimizer 可能會改寫計畫,例如:
先把 Filter 推近資料來源
先裁剪完全不需要的欄位
改變某些運算的執行順序
只要最後結果相同,就可能採用更有效率的計畫。
因此可以區分:
SQL 書寫順序
→ 方便人類表達需求
Logical Plan
→ 表達資料操作的邏輯關係
Physical Plan
→ 決定實際執行方式
初始 Logical Plan 產生後,DataFusion 會使用 Logical Optimizer 尋找更有效率的寫法。
例如原本的資料表有:
order_id
city
amount
is_member
created_at
customer_name
但查詢只使用:
city
amount
is_member
Optimizer 就可能移除不會被使用的欄位:
Projection Pruning
→ 不讀取 order_id、created_at、customer_name
同樣地:
Filter Pushdown
→ 讓 is_member = true 更靠近資料來源
這些最佳化不會改變 SQL 的結果,只會嘗試減少不必要的資料讀取與運算。
最佳化完成後,可以得到一個 Optimized Logical Plan:
Aggregate
↓
Projection:city、amount、is_member
↓
Filter:is_member = true
↓
TableScan:orders
接著,Physical Planner 會將它轉換成實際的 Physical Plan。
概念上可能變成:
AggregateExec
↓
FilterExec
↓
DataSourceExec
這些以 Exec 結尾的節點,代表實際執行時會處理資料的 Physical Operator。
Physical Plan 會更關心:
如何讀取檔案?
如何分割資料?
如何平行執行?
如何選擇 Join 或 Aggregate 的演算法?
這就是規劃階段與執行階段接上的地方:
Physical Plan
↓
Execution Engine
↓
Arrow RecordBatch Stream
DataFusion 提供 EXPLAIN,可以查看查詢的 Logical Plan 與 Physical Plan。
例如:
EXPLAIN
SELECT city, SUM(amount) AS total_amount
FROM orders
WHERE is_member = true
GROUP BY city;
你可以用它觀察:
Logical Plan
Optimized Logical Plan
Physical Plan
不同版本或設定的輸出格式可能略有差異,但通常會看到類似:
Aggregate
Filter
TableScan
或:
AggregateExec
FilterExec
DataSourceExec
EXPLAIN 很適合用來回答:
DataFusion 是否讀取了不需要的欄位?
Filter 是否被推到資料來源附近?
查詢最後使用了哪些 Physical Operator?
今天先記住使用方式即可;下一篇會專門拆解 Logical Plan 中常見的 Operator。
現在把今天的內容串起來:
SQL String
↓
Parser
↓
AST
↓
Analyzer 使用 Schema / Catalog
↓
Logical Plan
↓
Logical Optimizer
↓
Optimized Logical Plan
↓
Physical Planner
↓
Physical Plan
↓
Execution Engine
↓
Arrow RecordBatch Stream
↓
Query Result
每個階段都有不同責任:
Parser
→ 理解 SQL 文法
AST
→ 保存 SQL 的語法結構
Analyzer
→ 使用 Schema 確認表、欄位與型別
Logical Plan
→ 描述查詢要做什麼
Optimizer
→ 嘗試減少不必要的工作
Physical Plan
→ 決定實際怎麼執行
Execution Engine
→ 讀取資料並產生查詢結果
規劃和執行因此不是獨立的兩塊,而是:
規劃產生執行所需的計畫;
執行依照這份計畫處理資料。
今天追蹤了一段 SQL 如何從文字變成 Query Plan:
SQL
↓
Parser
↓
AST
↓
Schema 驗證與語意分析
↓
Logical Plan
↓
Logical Optimization
↓
Physical Plan
↓
Execution
最重要的三個觀念是:
Parser 確認 SQL 的文法。
Schema 協助 DataFusion 理解欄位與型別。
Logical Plan 將 SQL 轉成可分析、可最佳化的資料操作樹。
明天會進一步拆解 Logical Plan,認識 TableScan、Filter、Projection、Aggregate 與 Join 等常見運算節點。