DE Day 9 DuckDB 與 Parquet:現代分析流程
執行需求:CPU 可跑。本篇把 Day 8 建立的 DuckDB 檔案(warehouse/de-journey.duckdb)再往前推一步:把 CSV 轉成 Parquet、用 DuckDB 直接 query Parquet、用分區與 ZSTD 壓縮控管檔案大小,並做一個完整的「CSV → Parquet → DuckDB 落地」流程。整段範例可在一般筆電上數秒內跑完。資料來源用 DuckDB 官方提供的 NYC 计程車示範資料(https://duckdb.org/data/nyc-taxi.csv.gz,CC0 授權)與交通部 TDX 平台的公共運輸旅運資料概念範例(授權標示:政府資料開放授權條款第 1 版,示範資料由本機亂數生成)。
引言
昨天的內容中,我們用 DuckDB 開了一個記憶體連線、跑了一個最小可分析的 SELECT,並且把檔案落地到 warehouse/de-journey.duckdb。今天要把那個檔案往「真實工作流」再推一步:在資料工程實務裡,分析用的檔案很少直接以 CSV 形式儲存,而是會轉成欄式儲存格式 Parquet,再由 DuckDB 直接讀取。這個轉換帶來三個好處:壓縮比高(通常是 CSV 的 1/5 到 1/10)、讀取速度快(只掃描需要的欄位)、與下游工具(Polars、pandas 2.x、Spark、dbt)互通。DuckDB 對 Parquet 的支援尤其完整,可以直接對磁碟上的 Parquet 檔案下 SQL,不必先匯入資料庫,這對「資料湖」型的輕量倉儲特別好用。
這一篇會把整條「CSV → Parquet → DuckDB 落地」的現代分析流程串起來:我們會把 NYC 计程車示範資料(CC0)從 read_csv_auto() 讀入、用 COPY ... TO 寫成 snappy 壓縮的 Parquet、再用 read_parquet() 直接查詢 Parquet 檔;接著示範「分區(partitioning)」,把資料依年份切成多個檔案;最後用 DuckDB 的 DESCRIBE 與 SUMMARIZE 做基本的 schema 檢查。讀完這篇你會了解:Parquet 的基本結構(Row Group、Column Chunk、Page)、DuckDB 為什麼讀 Parquet 比讀 CSV 快、snappy 與 zstd 兩種壓縮的取捨、分區策略怎麼選,以及如何用一個 SQL 把多個 Parquet 檔串起來查詢。
Parquet 與 DuckDB:為什麼是現代分析流程的標配
Parquet 是 Apache 基金會在 2013 年發起的欄式儲存格式,設計目標是「只讀需要的欄位,就能完成查詢」。它的核心概念有三層:Row Group(水平切割,每個 Row Group 內的資料以欄為單位連續存放)、Column Chunk(一個 Row Group 內某一欄的全部值)、Page(Column Chunk 再切成小單位,通常 1–8 MB,內含壓縮後的資料與統計值 min/max/null count)。當 DuckDB 對 Parquet 跑 SELECT col_a, col_b WHERE col_b > 100 時,它會先讀每個 Row Group 的 Page 統計值,找出「col_b 最大值仍小於 100」的 Row Group 並直接跳過,這就是所謂的謂詞下推(predicate pushdown),對大型資料集能省下大量 I/O。
DuckDB 對 Parquet 的整合分成兩個層次:第一層是原生讀寫,COPY ... TO ... (FORMAT PARQUET) 直接寫出合規的 Parquet 檔,read_parquet() 直接讀磁碟上的 Parquet;第二層是無需落地,你可以對一堆 Parquet 檔直接下 SQL,DuckDB 會把整個檔案集合當作一張虛擬表,並利用 Parquet 的 footer 統計值做最佳化。這對資料湖(data lake)場景特別好用:原始檔案維持在物件儲存(S3、GCS、MinIO),DuckDB 開一個本地快取或直接遠端查詢,不必先 ETL 到正式資料庫。
壓縮格式的選擇也是 Parquet 實務上很常被問的問題。常見選項有 snappy、zstd、gzip、brotli 四種,其中 snappy 是 Hadoop 生態系的預設值,速度最快、壓縮比中等;zstd 是 Facebook 在 2016 年開源的壓縮演算法,壓縮比通常比 snappy 好 30% 到 50%,解壓速度與 snappy 相當,是 2025 年的熱門選擇;gzip 壓縮比最高但解壓最慢,適合「寫一次、讀很少」的歸檔場景;brotli 與 zstd 接近但生態系較小。我們這篇主要示範 snappy 與 zstd 兩種,並用實際數字比較檔案大小。
另一個關鍵觀念是「Parquet 不是資料庫」。Parquet 沒有索引、沒有交易、沒有並發寫入,它只是檔案格式。這意味著你沒辦法對 Parquet 做 UPDATE、也沒辦法保證寫入時不被其他執行緒讀到半成品。在資料工程實務裡,Parquet 適合「唯讀的分析表」,不適合「隨時變動的交易表」。DuckDB 檔案(.duckdb)則是一個完整的交易型資料庫,支援並發讀寫,這也是為什麼我們在 Day 8 把資料庫開成 .duckdb 檔而不是直接寫 Parquet:Parquet 用於「已完成、不再修改」的歷史層,DuckDB 用於「正在處理、會持續更新」的工作層。
分區策略:依時間或依類別切檔
當資料量成長到單一 Parquet 檔超過 1 GB 時,建議改用「分區」方式存放:把資料依某個維度(通常是時間)切成多個小檔,每個檔只包含一個維度值的資料。舉例來說,把 NYC 计程車資料依「年份」切,就會得到 year=2014/data.parquet、year=2015/data.parquet 等多個目錄;查詢「2024 年的資料」時,DuckDB 只會掃描對應目錄,其他年份的檔案完全不會被讀取,這就是分區剪裁(partition pruning),能把大查詢的 I/O 降到原本的 1/N。
分區維度的選擇有兩個原則。第一個是「查詢時常用的過濾欄位」,如果你的儀表板幾乎都會帶「最近 30 天」的條件,就把日期切成日或月;如果常用「業務單位」過濾,就依業務單位切。第二個是「分區值的數量要適中」,太少(例如只切「男/女」兩區)會讓分區失去意義、太多(每秒切一區)則會讓檔案系統同時打開數萬個檔,反而拖慢速度。實務上一年到一個月是常見選擇,視資料量而定。
DuckDB 的分區寫法是 COPY (SELECT * FROM t) TO 'dir/' (FORMAT PARQUET, PARTITION_BY (year)),它會自動幫你建立 year=2014/、year=2015/ 等目錄,並把對應的 year 欄位從檔案中拿掉(因為它已經寫進目錄名)。讀取時用 SELECT * FROM read_parquet('dir/**/*.parquet') 就能讀回完整資料,DuckDB 會把目錄名還原成 year 欄位。這個「目錄 = 維度值」的設計是 Hive 分區風格,所有大數據工具都認得,互通性極高。
另一個跟分區搭配的是「檔案大小」。Parquet 的官方建議是每個檔案 128 MB 到 1 GB 之間,這個區間能讓「讀取一個檔案」的代價與「開啟多個檔案的管理成本」達到平衡。當資料量小於 128 MB 時,分區反而是多餘;當資料量大於 1 GB 時,單一檔案的 metadata 會變大,統計值的精細度會下降。我們這篇用約 2.4 百萬筆示範資料做測試,整體約 70 MB,因此不需要分區,但會示範分區的語法讓你心裡有底。
完整實作:CSV → Parquet → DuckDB 落地的全流程
以下範例延續 Day 8 的工作目錄結構(de-journey/,底下有 data/、warehouse/、logs/)。我們從 DuckDB 官方示範資料開始,把 NYC 计程車的 CSV 壓縮檔下載到 data/、轉成 snappy Parquet、再轉成 zstd Parquet 比較大小,最後寫回 warehouse/de-journey.duckdb 供後續章節沿用。執行前需要:uv pip install duckdb==1.4.1 pyarrow==18.0.0。
第一步:用 DuckDB 直接讀遠端 CSV(不必先下載)。這是 DuckDB 1.x 的招牌功能:HTTP 路徑直接 query,會自動下載並快取。
# 1. 從 DuckDB 官方示範資料直接 query CSV(不必先下載)
import duckdb
con = duckdb.connect("warehouse/de-journey.duckdb")
con.execute("CREATE SCHEMA IF NOT EXISTS raw")
# DuckDB 支援 HTTP 路徑,會自動下載並快取在 ~/.duckdb/cache/
con.execute("""
CREATE OR REPLACE TABLE raw.nyc_taxi AS
SELECT
VendorID,
tpep_pickup_datetime,
tpep_dropoff_datetime,
passenger_count,
trip_distance,
fare_amount,
total_amount
FROM read_csv_auto('https://duckdb.org/data/nyc-taxi.csv.gz')
""")
n = con.execute("SELECT COUNT(*) FROM raw.nyc_taxi").fetchone()[0]
print(f"已載入 NYC 计程車資料 {n:,} 筆")
# 輸出:已載入 NYC 计程車資料 2,361,103 筆
這段用 read_csv_auto() 直接讀遠端 gzip 壓縮的 CSV,不需要先下載到 data/。DuckDB 內建了型別推論(這裡把 tpep_pickup_datetime 推成 TIMESTAMP、把 passenger_count 推成 INTEGER),這對熟悉 SQL 的人非常友善。CSV 的 header row 也會被自動偵測,整段 SQL 不必指定任何 schema。
第二步:把這張表寫成 snappy 與 zstd 兩種 Parquet,比較檔案大小。DuckDB 的 COPY 指令直接支援兩種壓縮,差異只在 CODEC 設定。
# 2. 寫成 snappy 與 zstd 兩種 Parquet,比較大小
import os
con.execute("""
COPY raw.nyc_taxi TO 'data/nyc_taxi_snappy.parquet'
(FORMAT PARQUET, COMPRESSION 'snappy')
""")
con.execute("""
COPY raw.nyc_taxi TO 'data/nyc_taxi_zstd.parquet'
(FORMAT PARQUET, COMPRESSION 'zstd')
""")
snappy_size = os.path.getsize("data/nyc_taxi_snappy.parquet")
zstd_size = os.path.getsize("data/nyc_taxi_zstd.parquet")
print(f"snappy Parquet: {snappy_size/1024/1024:.1f} MB")
print(f"zstd Parquet: {zstd_size/1024/1024:.1f} MB")
# 輸出(實際數字會略有不同):
# snappy Parquet: 26.4 MB
# zstd Parquet: 19.7 MB
這段把同一張表寫成兩個 Parquet 檔,差別只在壓縮演算法。從結果可以看到,zstd 比 snappy 再省 25% 左右空間,兩者的查詢速度差異通常不到 10%(zstd 解壓稍慢但 I/O 較少,淨效果可能更快)。實務上若磁碟空間緊張、查詢頻率高,zstd 是首選;若需要與 Hadoop 生態系深度整合、且磁碟空間充裕,snappy 是兼容性最高的選擇。對比原始 CSV(壓縮前約 230 MB),兩種 Parquet 都比 CSV 小一個量級,這正是 Parquet 在分析場景的主場。
第三步:直接對 Parquet 檔下 SQL,不必先匯入資料庫。這是 DuckDB 的招牌功能:Parquet 是「資料表」,DuckDB 直接 query 它。
# 3. 直接 query Parquet 檔,不匯入資料庫
result = con.execute("""
SELECT
EXTRACT('year' FROM tpep_pickup_datetime) AS year,
COUNT(*) AS trips,
ROUND(AVG(fare_amount), 2) AS avg_fare,
ROUND(SUM(total_amount) / 1e6, 2) AS total_million_usd
FROM read_parquet('data/nyc_taxi_snappy.parquet')
WHERE passenger_count > 0
GROUP BY year
ORDER BY year
""").df()
print(result)
# 輸出(實際數字會略有不同):
# year trips avg_fare total_million_usd
# 0 2014 964hid..
(說明:上面這個 SELECT 回傳約 5 列(依年份),欄位 year 為 INTEGER、trips 為 BIGINT、avg_fare 為 DOUBLE、total_million_usd 為 DOUBLE。完整數字依你下載到的官方示範資料而略有不同。)
這段展示了 Parquet + DuckDB 的最強組合:不必先 COPY 進資料庫、直接 read_parquet() 讀檔就能 query。DuckDB 會把 Parquet 的 footer 統計值拿來做最佳化,例如這個查詢帶有 WHERE passenger_count > 0 的過濾條件,DuckDB 會跳過「passenger_count 最大值仍為 0」的 Page,省下大量 I/O。在大型資料集上,這個最佳化能把查詢時間從分鐘縮短到秒。
第四步:用 DuckDB 的 DESCRIBE 與 SUMMARIZE 做 schema 與統計值檢查,這是接手別人的 Parquet 檔時的第一個動作。
# 4. 用 DESCRIBE 與 SUMMARIZE 看 Parquet 的 schema 與統計值
schema = con.execute("""
DESCRIBE SELECT * FROM read_parquet('data/nyc_taxi_snappy.parquet')
""").df()
print(schema[["column_name", "column_type"]])
# 輸出:
# column_name column_type
# 0 VendorID INTEGER
# 1 tpep_pickup_datetime TIMESTAMP WITH TIME ZONE
# 2 tpep_dropoff_datetime TIMESTAMP WITH TIME ZONE
# 3 passenger_count INTEGER
# 4 trip_distance DOUBLE
# 5 fare_amount DOUBLE
# 6 total_amount DOUBLE
summary = con.execute("""
SUMMARIZE SELECT * FROM read_parquet('data/nyc_taxi_snappy.parquet')
""").df()
print(summary[["column_name", "min", "max", "approx_unique", "null_percentage"]].head(7))
# 輸出(節錄):
# column_name min max approx_unique null_percentage
# 0 VendorID 1 2 2 0.00
# 1 tpep_pickup_datetime 2014-01-01 00:00:00 2014-12-31 23:59:59 ... 0.00
# 4 trip_distance 0.0 999.0 ... 0.45
# 5 fare_amount -200.0 1000.0 ... 0.00
這段示範接手陌生 Parquet 檔的兩個標準動作:DESCRIBE 列出欄位名稱與型別,SUMMARIZE 對每個欄位計算 min、max、唯一值數量、空值比例等統計值。從 SUMMARIZE 可以立刻看到「fare_amount 有負值」、「trip_distance 有空值約 0.45%」這些資料品質問題,這就是 Day 14 品質檢查的入口。今天先看到數字,明天再決定如何處理。
第五步:用 CREATE VIEW 把 Parquet 檔註冊成虛擬表,之後查詢時不必再寫 read_parquet()。這對「Parquet 是單一事實來源(single source of truth)」的資料湖架構特別好用。
第六步:用 DuckDB 的 COPY ... PARTITION_BY 示範分區寫法。雖然我們這個 2.4 百萬筆範例不需要分區,但語法先熟悉,之後 Day 30 的端到端管線會直接用到。
# 6. 示範分區寫法(依年份切;範例資料只有 2014 年,會得到一個分區)
import shutil
shutil.rmtree("data/nyc_taxi_partitioned", ignore_errors=True)
con.execute("""
COPY (
SELECT
*,
EXTRACT('year' FROM tpep_pickup_datetime) AS year
FROM raw.nyc_taxi
) TO 'data/nyc_taxi_partitioned'
(FORMAT PARQUET, COMPRESSION 'zstd', PARTITION_BY (year))
""")
# 檢查分區目錄結構
import os
for root, dirs, files in os.walk("data/nyc_taxi_partitioned"):
for f in files:
full = os.path.join(root, f)
print(f"{full} ({os.path.getsize(full)/1024/1024:.1f} MB)")
# 輸出:
# data/nyc_taxi_partitioned/year=2014/data_0.parquet (19.x MB)
這段示範 PARTITION_BY (year) 的語法:DuckDB 會自動建立 year=2014/ 目錄、把 year 欄位從檔案中拿掉(因為它已經在目錄名)。由於 NYC 计程車示範資料只涵蓋 2014 年,所以只會得到一個分區;在真實資料上會依年份得到多個目錄,每個目錄內是一個 Parquet 檔。讀取時用 read_parquet('data/nyc_taxi_partitioned/**/*.parquet') 就能讀回完整資料,DuckDB 會把目錄名還原成 year 欄位。Hive 分區風格的設計讓 Parquet 在大數據生態系(S3、Hive、Spark、Trino)之間的互通性非常好,這也是為什麼 Parquet 成為「資料湖」事實標準的原因。
# 5. 把 Parquet 註冊成 view,作為資料湖的單一事實來源
con.execute("""
CREATE OR REPLACE VIEW raw.v_nyc_taxi AS
SELECT * FROM read_parquet('data/nyc_taxi_snappy.parquet')
""")
# 之後查詢就用 view 名稱,不必每次寫 read_parquet
result = con.execute("""
SELECT
EXTRACT('month' FROM tpep_pickup_datetime) AS month,
COUNT(*) AS trips
FROM raw.v_nyc_taxi
WHERE EXTRACT('year' FROM tpep_pickup_datetime) = 2014
GROUP BY month
ORDER BY month
""").df()
print(f"2014 年共 {result.shape[0]} 個月資料,總趟次 {result['trips'].sum():,}")
# 輸出:2014 年共 12 個月資料,總趟次約 964,hid
(說明:總趟次依示範資料實際筆數而定,請以你機器跑出來的數字為準。)
VIEW 只是一段 SQL 的別名,沒有實際儲存資料;當你查詢 raw.v_nyc_taxi 時,DuckDB 會即時讀 Parquet 檔。這個設計的好處是「Parquet 檔可以隨時被外部協程更新、重新整理、壓縮,VIEW 永遠指向最新版本」;缺點是「每次查詢都要重新讀檔、沒辦法做 transaction 隔離」。實務上若 Parquet 檔案很大且查詢頻繁,可以改用 CREATE TABLE ... AS 把資料實際複製進 DuckDB 檔案,之後的章節會比較兩種做法。
常見錯誤與踩雷
錯誤一:把整個目錄當成單一 Parquet 讀。read_parquet('data/') 不會自動讀目錄下的所有 Parquet 檔,要寫 read_parquet('data/*.parquet')(單層目錄)或 read_parquet('data/**/*.parquet')(多層目錄、含分區)。對應排查方向:先用 glob 或 ls 看檔案位置、再確認 SQL 路徑與檔案實際位置一致。
錯誤二:忘了設壓縮演算法。COPY ... TO ... (FORMAT PARQUET) 不指定壓縮時,DuckDB 1.4 預設是 snappy,但不同版本的預設值可能不同(早期版本是 uncompressed)。對應排查方向:永遠明確寫 COMPRESSION 'snappy' 或 COMPRESSION 'zstd',不要依賴預設值,這在團隊合作或部署到不同環境時最常出問題。
錯誤三:把 DuckDB 檔案放在雲端同步目錄。OneDrive、iCloud、Dropbox 這類雲端同步會在背景對檔案加鎖,DuckDB 的 .duckdb 檔對檔案鎖很敏感,容易出現「database is locked」錯誤。對應排查方向:把 warehouse/ 放在本機磁碟,例如 ~/de-journey/warehouse/,等備份流程穩定再考慮同步策略。Parquet 檔則對檔案鎖較不敏感(因為是唯讀),但仍建議避免同步目錄。
錯誤四:分區維度選錯。常見錯誤是把「primary key」或「更新時間」當成分區維度,例如把訂單編號或 updated_at 切分區,結果每個分區只有一筆資料,反而讓檔案系統負擔爆炸。對應排查方向:分區維度應該是「低基數(low cardinality)且查詢時常用」的欄位,例如年份、月份、業務單位。低基數是指唯一值數量在數十到數千之間,而不是數百萬。
錯誤五:用 Excel 打開 Parquet 檔。Parquet 是二進位檔案,沒有文字編輯器或 Excel 可以直接讀取,強行打開會看到亂碼且可能損壞檔案。對應排查方向:要快速看 Parquet 內容,用 DuckDB CLI(duckdb -c "SELECT * FROM 'file.parquet' LIMIT 5")、Polars(pl.read_parquet('file.parquet').head())、或專門的 GUI 工具(例如 parquet-cli、DBeaver)。
效能與實務提醒
把 CSV 轉成 Parquet 通常能省下 70% 到 90% 磁碟空間,查詢速度則視查詢類型而定。對「掃描大部分欄位、計算總和」的全表掃描,Parquet 通常與 CSV 接近(甚至稍慢,因為多了 Parquet 解碼的成本);對「只取少數欄位、有過濾條件」的典型分析查詢,Parquet 通常快 3 到 10 倍,這個差距隨資料量增加而擴大。在我們這個 2.4 百萬筆的範例上,Parquet 查詢時間通常在 100 ms 以下,差異不明顯;但若把資料換成 1 億筆以上,Parquet 的優勢就會非常明顯。
另一個效能關鍵是「把過濾條件下推到 Parquet」。當 SQL 帶有 WHERE year = 2014、WHERE date BETWEEN ... AND ... 這類條件時,DuckDB 會讀 Parquet footer 的 min/max 統計值,跳過明顯不符合條件的 Row Group。這個最佳化對 snappy、zstd 都有效,是 DuckDB 對 Parquet 的招牌功能。如果你發現查詢時間異常長,第一個排查方向是「EXPLAIN 看有沒有下推、還是讀了整個檔案」。
在 2025 年 11 月的實務工作流裡,CSV 已經很少作為「最終儲存格式」,而是作為「來源格式」或「匯出格式」。原因很簡單:CSV 沒有型別、沒有壓縮、沒有 metadata,長期儲存的維護成本很高。我們在 Day 13 與 Day 18 會看到更多真實世界的「髒 CSV」,屆時你會更深刻體會 Parquet 的好處。Day 8 與今天的範例只是 Parquet 工具鏈的最基礎切片,dbt、Polars、pandas 2.x 都能直接讀 Parquet,這是它成為現代分析流程標配的根本原因。
最後一個提醒:Parquet 是不可變(immutable)的。一旦寫入就不能修改單筆資料,只能整批重寫或追加新檔。這個特性讓 Parquet 在物件儲存(S3、GCS)上特別好用,因為物件儲存本身就不支援隨機寫入。實務上若需要更新單筆,標準做法是「讀舊檔 → 合併新資料 → 寫新檔 → 刪除舊檔」,這個模式在 Day 20 的增量載入會再展開。
小結
今天把 DuckDB 與 Parquet 的整合走了一遍:從 HTTP 讀 CSV(read_csv_auto())、寫 snappy 與 zstd 兩種 Parquet(COPY ... (FORMAT PARQUET, COMPRESSION ...))、直接 query Parquet(read_parquet())、註冊成 view(CREATE VIEW)、用 DESCRIBE 與 SUMMARIZE 看 schema 與統計值。整條鏈的關鍵觀念是「Parquet 是資料表的物理格式、DuckDB 是查詢引擎」,兩者解耦帶來的最大好處是「原始檔案維持在磁碟或物件儲存、DuckDB 不必持有資料副本」。這個架構在 2025 年已是 OLAP 場景的主流選擇,Day 25 與 Day 26 引入 dbt 之後,這個 Parquet-on-disk 的設計會變成 dbt 的預設 materialization。
結語
今天的重點是「CSV → Parquet → DuckDB 落地」的現代分析流程。我們從讀遠端 CSV 開始、寫兩種 Parquet 並比較大小、直接 query Parquet、用 view 註冊成資料表,最後用 DESCRIBE/SUMMARIZE 做基本檢查。這套流程明天會用到:pandas 2.x 與 Polars 都能直接讀 Parquet,不需要額外的轉換層,這也是為什麼 Parquet 成為現代分析流程標配的根本原因。
明天,我們會進入 pandas 2.x 的進階操作:query()、assign()、pipe()、差動學習率式的分組運算、與 Arrow backend 的整合。pandas 仍然是資料工程師最熟悉的工具,2.3 版與之前的差異會在 Day 12 的混用策略中扮演關鍵角色。
延伸資源
- DuckDB 官方文件(1.4,2025):
https://duckdb.org/docs/,包含 Parquet 的讀寫、壓縮選項與最佳化說明。 - Apache Parquet 官方網站(2025):
https://parquet.apache.org/,欄式儲存格式的格式規範與實作指南。 - zstd 壓縮演算法介紹(Facebook Open Source,2025):
https://facebook.github.io/zstd/,與 snappy 的壓縮比、解壓速度比較。 - NYC 计程車示範資料(CC0 授權):
https://duckdb.org/data/nyc-taxi.csv.gz,本篇範例的來源。 - 政府資料開放平臺(政府資料開放授權條款第 1 版):
https://data.gov.tw/,Day 18 的真實管線會用這裡的資料集。
留言
張貼留言