前幾天收到一些讀者回饋,希望文章可以更像教材:先把概念與資料處理流程說清楚,再進入程式實作。
我覺得這個建議很好,謝謝大家給我回饋~
對資料工程來說,成功把資料寫成檔案,只完成了一半的工作!
我們還要考慮:
因此,今天先不急著寫程式,而是先回答一個問題:
同樣都能保存資料,為什麼 Parquet 特別適合分析型查詢?
假設我們有一份訂單資料:
order_id
customer_id
city
amount
order_date
is_member
payment_method
product_name
現在只想找出金額大於或等於 1000 的訂單:
SELECT city, amount
FROM orders
WHERE amount >= 1000;
這段 SQL 只需要:
city
amount
amount >= 1000 的資料從 SQL 使用者的角度來看,需求很簡單。
但對 Query Engine 而言,真正影響效能的問題是:
為了回答這段 SQL,底層究竟需要讀取多少資料?
答案會受到資料格式影響。
前幾天使用的 CSV 可能像這樣:
1,1001,Taipei,1200.5,2026-08-01,true,credit_card,Keyboard
2,1002,Taichung,850.0,2026-08-02,false,cash,Mouse
3,1003,Taipei,2300.0,2026-08-03,true,credit_card,Monitor
CSV 以一列保存一筆完整資料,每個欄位使用逗號分隔。
當 Query Engine 執行查詢時,讀取流程通常接近:
讀取 CSV 文字
↓
辨認資料列與分隔符號
↓
拆解每個欄位
↓
將 amount 從文字轉換成數字
↓
判斷 amount >= 1000
↓
輸出 city 與 amount
即使查詢只需要 city 與 amount,原始資料仍然混合在每一列文字中。
CSV 並不是不能用來分析,而是它提供給 Query Engine 的結構資訊較少。Reader 通常需要先讀取並解析文字,才能理解資料內容。
Parquet 是為分析型工作負載設計的欄式儲存格式。
它與 CSV 的差別,不只是二進位與文字,也包括資料在檔案中的排列方式。
CSV 比較接近按照資料列排列:
第 1 筆:1, Taipei, 1200.5
第 2 筆:2, Taichung, 850.0
第 3 筆:3, Taipei, 2300.0
Parquet 則會讓同一欄位的資料被組織在一起:
order_id:1, 2, 3
city:Taipei, Taichung, Taipei
amount:1200.5, 850.0, 2300.0
真正的 Parquet 檔案結構比這個例子複雜,不過可以先記住:
CSV 主要按照資料列組織文字;Parquet 則讓相同欄位的資料集中保存。
因此,面對:
SELECT city, amount
FROM orders;
Parquet Reader 有機會只讀取 city 與 amount 對應的資料區域,不必讀取及解碼其他欄位。
這種只讀取需要欄位的方式,稱為 Projection Pruning,也可以理解成欄位裁剪。
欄式儲存的完整概念會在 Day 7 繼續說明。今天先聚焦在它如何改變 Query Engine 的資料讀取流程。
CSV 通常不會完整保存欄位型別,因此 Reader 需要推論 Schema,或由開發者明確指定。
Parquet 除了保存資料,也會保存:
例如:
order_id
└── Int64
city
└── Utf8
amount
└── Float64
is_member
└── Boolean
Query Engine 不需要先將每個值當成普通文字,再猜測 amount 是數字還是字串。
這讓 Parquet 不只是資料容器,也成為儲存層與 Query Engine 之間的結構化介面。
一個 Parquet 檔案可以先簡化成:
Parquet File
├── Row Group 1
│ ├── order_id Column Chunk
│ ├── city Column Chunk
│ └── amount Column Chunk
├── Row Group 2
│ ├── order_id Column Chunk
│ ├── city Column Chunk
│ └── amount Column Chunk
└── File Metadata
上面的文字圖是方便理解的簡化版本。接著透過 Apache Parquet 官方提供的物理結構圖,觀察資料實際如何排列在檔案中。

圖一:Apache Parquet 官方物理結構圖。資料來源:Apache Parquet-File Format,Apache Software Foundation。
從圖一可以看到,一個 Parquet File 可以包含多個 Row Group;每個 Row Group 中,則包含各個欄位對應的 Column Chunk。
檔案尾端會保存 File Metadata、Metadata 長度,以及 PAR1 Magic Number。
Parquet Reader 可以先從檔案尾端取得 Metadata,得知各個 Column Chunk 的位置,再讀取查詢真正需要的資料區域,而不必從檔案開頭逐筆解析到最後。
接著認識三個重要名詞。
Row Group 是一組資料列。
一份大型 Parquet 檔案可以包含多個 Row Group,讓 Query Engine 分批讀取或平行處理資料。
每個 Row Group 中,相同欄位的資料會形成一個 Column Chunk。
例如:
Row Group 1
├── city 的資料
├── amount 的資料
└── order_date 的資料
因此,Query Engine 可以選擇只讀取查詢需要的欄位。
File Metadata 描述:
Query Engine 可以先查看 Metadata,再決定後續要讀取哪些資料區塊。
假設一個 Parquet 檔案有三個 Row Group,而且 Metadata 保存了 amount 的最小值與最大值:
Row Group 1
amount:100 ~ 800
Row Group 2
amount:500 ~ 1800
Row Group 3
amount:2000 ~ 5000
現在執行:
SELECT city, amount
FROM orders
WHERE amount >= 1000;
Query Engine 可以先根據 Metadata 判斷:
Row Group 1
最大值只有 800
→ 不可能出現 amount >= 1000
→ 可以跳過
Row Group 2
範圍包含 1000
→ 可能包含符合條件的資料
→ 需要讀取
Row Group 3
最小值已經是 2000
→ 資料可能符合查詢需求
→ 需要讀取
這種根據 Statistics 跳過整個 Row Group 的方式,稱為 Row Group Pruning。
它和一般的 WHERE Filter 不完全相同:
WHERE Filter
└── 資料讀取後,逐筆判斷是否符合條件
Row Group Pruning
└── 根據 Metadata,在讀取前排除整個資料區塊
真正執行時,兩者可能同時存在:
先用 Metadata 排除部分 Row Group
↓
讀取剩餘 Row Group
↓
再對資料執行完整的 WHERE Filter
需要注意的是,Pruning 不一定每次都能排除資料。它還要考慮:
Day 3 曾經提到 B-tree 與 B+ tree。
傳統資料庫可能建立索引,協助系統快速定位特定資料:
SELECT *
FROM orders
WHERE order_id = 100;
如果 order_id 具有合適的索引,資料庫可能透過索引找到資料位置,而不必掃描整張表。
Parquet Statistics 的作用不同。
它通常不是記錄每一筆資料的位置,而是描述一個資料區塊的範圍:
這個 Row Group 的 amount 範圍是 100~800
如果查詢條件是:
amount >= 1000
就能確定整個 Row Group 不需要讀取。
兩者可以簡化比較成:
B/B+ tree Index
└── 協助定位特定資料或範圍
Parquet Statistics
└── 協助排除不可能符合條件的資料區塊
所以 Parquet Pruning 不是傳統資料庫的索引查找,而是透過 Metadata 減少需要掃描的資料範圍。
重新看一次 SQL:
SELECT city, amount
FROM orders
WHERE amount >= 1000;
當資料來源是 Parquet 時,資料處理流程可以簡化成:
接收 SQL
↓
建立並最佳化 Query Plan
↓
找出需要的欄位與篩選條件
↓
讀取 Parquet Metadata
↓
Projection Pruning
↓
Row Group/Page Pruning
↓
讀取需要的資料範圍
↓
解碼成 Arrow RecordBatch
↓
執行剩餘運算
↓
輸出結果
下面使用圖二將完整流程串起來。

*圖二:Parquet 查詢優化與剪裁流程圖(Parquet Query Optimization & Pruning Workflow)。
資料來源:本文整理,參考 Apache DataFusion-Parquet Pruning in DataFusion。
圖二可以分成幾個階段理解。
SQL 描述使用者想要的結果。Query Planner 與 Optimizer 則整理出:
Parquet Reader 先取得檔案的 Schema、Row Group、Column Chunk 位置與 Statistics。
這些資訊會成為後續資料剪裁的依據。
如果 SQL 只需要 city 與 amount,Reader 就有機會避免讀取其他欄位。
Reader 可以使用欄位的最小值、最大值,以及可用的 Page Index 或其他 Metadata,跳過不可能符合查詢條件的區塊。
Page 是 Column Chunk 內更細的資料單位,詳細結構會留到後面的 Parquet Pruning 文章再深入說明。
完成剪裁後,Reader 才會取得需要的資料範圍,進行解壓縮與解碼,並轉換成 Arrow RecordBatch。
剩下的 Filter、Projection 或其他 Operator,再繼續處理這些 RecordBatch。
這就是儲存格式與 Query Engine 合作的地方:
SQL
└── 描述需要什麼資料
Query Plan
└── 整理欄位與篩選條件
Parquet
└── 提供欄式結構與 Metadata
Arrow RecordBatch
└── 將讀取結果交給後續 Operator
Parquet 不會自己執行 SQL,而是提供足夠的資訊,讓 Query Engine 有機會減少 I/O、解碼與後續運算。
同一欄位的值通常具有相同型別,而且內容可能重複。
例如 city:
Taipei
Taipei
Taipei
Taichung
Taichung
Kaohsiung
或 is_member:
true
true
false
true
false
當相同型別、相似內容的資料集中在一起時,通常更容易進行編碼與壓縮。
因此,Parquet 的優勢不只是檔案可能比較小,也包括:
不過,檔案比較小不代表任何查詢都一定比較快。查詢效能仍然會受到檔案數量、Row Group 設計、資料分布與查詢條件影響。
目前我們已經接觸三種格式:
| 比較項目 | CSV | JSON/NDJSON | Parquet |
|---|---|---|---|
| 人類直接閱讀 | 容易 | 容易 | 不容易 |
| 資料結構 | 平坦表格 | 可包含巢狀資料 | 支援結構化與巢狀資料 |
| Schema | 通常需要推論 | 通常需要推論 | 儲存在檔案中 |
| 儲存方式 | 文字、資料列導向 | 文字、物件導向 | 二進位、欄式 |
| 選擇性讀取欄位 | 能力有限 | 能力有限 | 適合 |
| Metadata/Statistics | 很少 | 很少 | 支援 |
| 常見用途 | 匯入匯出、資料交換 | API、事件、Log | Data Lake、分析型查詢 |
它們不是單純的好壞關係,而是用途不同:
CSV
└── 適合簡單且通用的表格資料交換
JSON/NDJSON
└── 適合 API、事件與半結構化資料
Parquet
└── 適合大量資料的分析與選擇性讀取
如果需求是:
CSV、JSON 或其他格式可能更合適。
Parquet 比較適合:
資料工程的重點不是把所有檔案都轉成 Parquet,而是根據下游如何使用資料來選擇格式。
概念說明完成後,接著使用 DataFusion 實際驗證。
目前專案中已經有 Day 4 建立的:
data/orders.csv
我們先把 CSV 轉換成 Parquet。
資料流程如下:
orders.csv
↓
DataFusion 解析 CSV 與 Schema
↓
轉換成 Arrow RecordBatch
↓
Parquet Writer
↓
data/orders_parquet
先將 src/main.rs 改成:
use datafusion::prelude::{
CsvReadOptions, SessionContext,
};
#[tokio::main]
async fn main() -> datafusion::error::Result<()> {
let ctx = SessionContext::new();
ctx.register_csv(
"orders_csv",
"data/orders.csv",
CsvReadOptions::new().has_header(true),
)
.await?;
ctx.sql(
r#"
COPY orders_csv
TO 'data/orders_parquet'
STORED AS PARQUET
"#,
)
.await?
.collect()
.await?;
println!(
"Parquet data created at data/orders_parquet"
);
Ok(())
}
執行:
cargo run
完成後,專案結構會變成類似:
datafusion-day3
├── Cargo.toml
├── data
│ ├── orders.csv
│ └── orders_parquet
│ └── 一個或多個 .parquet 檔案
└── src
└── main.rs
DataFusion 可能在輸出目錄中產生一個或多個 Parquet 檔案,後續可以直接將整個目錄註冊成 Table。
這段轉換只需要執行一次。如果重新執行時輸出目錄已存在,可以先移除舊目錄,或改用新的輸出位置。
Parquet 資料準備完成後,再將 src/main.rs 改成:
use datafusion::prelude::{
ParquetReadOptions, SessionContext,
};
#[tokio::main]
async fn main() -> datafusion::error::Result<()> {
let ctx = SessionContext::new();
ctx.register_parquet(
"orders",
"data/orders_parquet",
ParquetReadOptions::default(),
)
.await?;
let dataframe = ctx
.sql(
r#"
SELECT city, amount
FROM orders
WHERE amount >= 1000
ORDER BY amount DESC
"#,
)
.await?;
dataframe.show().await?;
Ok(())
}
執行:
cargo run
結果如下:
+---------+--------+
| city | amount |
+---------+--------+
| Taipei | 2300.0 |
| Taoyuan | 1750.0 |
| Taipei | 1200.5 |
+---------+--------+
這次查詢的資料流程是:
SQL Table:orders
↓
Data Source:data/orders_parquet
↓
Parquet Reader
↓
Arrow RecordBatch
↓
Filter、Projection、Sort
↓
Result
對 SQL 使用者來說,查詢方式和 CSV 幾乎相同。
真正改變的是底層資料來源,以及 Query Engine 可以採用的讀取策略。
在 Data Pipeline 中,檔案格式不是最後隨便選擇的副檔名。
它會影響下游系統:
因此,設計 Pipeline 時,不只要問:
資料能不能成功寫出去?
也要問:
下游會如何讀取?
最常查詢哪些欄位?
最常使用哪些篩選條件?
資料是否適合批次寫入?
檔案是否切得太碎?
同一段 SQL 在 CSV 與 Parquet 上可能得到相同結果,底層需要讀取與處理的資料量卻可能不同。
這就是儲存格式與 Query Engine 之間的關係。
今天先從資料讀取成本理解 Parquet,再使用 DataFusion 實際驗證。
我們學到:
總結:
Parquet 不只保存資料,也保存了能幫助 Query Engine 少讀資料的結構與資訊。
明天會進一步解釋:
Row-based 與 Columnar 有什麼不同?為什麼分析型系統偏好欄式資料?