簡要概述
Apache Parquet 是一個開源、列式存儲文件格式,最初由 Twitter 與 Cloudera 聯合開發,旨在提供高效的壓縮與編碼方案以支持大規模復雜數據的快速分析與處理。Parquet 文件采用分離式元數據設計 —— 在數據寫入完成后,再追加文件級元數據(包括 Schema、Row Group 與 Column Chunk 的偏移信息等),使得寫入過程只需一次順序掃描,同時讀取端只需先解析元數據即可隨機訪問任意列或任意 Row Group。其核心結構包括:
- Header(魔數“PAR1”)
- 若干 Row Group(行組)
- Footer(文件級元數據 + 魔數“PAR1”)
文件格式總體布局
1. Header(文件頭)
- 文件以4字節的魔數
PAR1
開始,標識這是一個 Parquet 文件格式。
2. Row Group(行組)
- Row Group 是水平切分的數據分片,每個 Row Group 包含表中若干行,通常大小在幾十 MB 到幾百 MB 之間,以兼顧并行處理與內存使用。
- 每個 Row Group 內部再按列拆分為若干 Column Chunk,支持對單個列或多個 Row Group 進行跳過式讀取。
3. Column Chunk(列區塊)
- Column Chunk 即某一列在某個 Row Group 中的數據塊,它是 Parquet 最基本的物理存儲單元之一。
- 每個 Column Chunk 中又分成多個Page,以便更細粒度地管理壓縮與解壓效率。
4. Page(頁)
- Page 是 Column Chunk 中的最小讀寫單元,主要有三種類型:
- Dictionary Page:存放字典編碼字典。
- Data Page:存放實際列數據(可進一步分為 Data Page V1/V2)。
- Index Page(可選):存放 Page-level 索引,加速定位。
- 通過 Page 級別的壓縮與編碼,Parquet 能夠對不同類型的數據采用最優策略,極大提升 I/O 性能。
5. Footer(文件尾)
- 寫入完全部 Row Group 后,接著寫入File Metadata,其中包含:
- Schema 定義
- 每個 Row Group 的偏移及長度
- 每個 Column Chunk 的偏移、頁信息、編碼與壓縮算法
- 最后再寫入 4 字節魔數
PAR1
,完整標識文件結束。
元數據管理
- Thrift 定義:Parquet 使用 Apache Thrift 描述元數據結構(Schema、Row Group 元信息、Column Chunk 元信息、Page Header 等),以保證跨語言支持。
- 單次寫入:由于元數據緊跟數據之后寫入,寫入端無需回寫或多次掃描,保證單次順序寫入效率。
- 快速讀取:讀端先讀取文件末尾的 Footer,加載所有元數據后即可隨機、并行地讀取任意列與任意 Row Group,極大減少磁盤 I/O 與網絡傳輸開銷。
編碼與壓縮技術
字典編碼(Dictionary Encoding)
- 針對低基數(unique values 較少)的列,自動開啟字典編碼,將原值映射到字典索引,從而顯著減少存儲量。
混合 RLE + Bit-Packing
- Parquet 對整數類型實現了混合 RLE(Run-Length Encoding)與Bit-Packing,根據數據分布動態選擇最佳方案,例如對于長串相同值使用 RLE,對于小整數值使用 Bit-Packing。
其它壓縮算法
- 支持 Snappy、Gzip、LZO、Brotli、ZSTD、LZ4 等多種壓縮格式,可按列靈活選擇。
寫入過程原理
- Schema 序列化:將 DataFrame/表結構映射為 Thrift Schema。
- Row Group 切分:按預設行數或大小切分成多個 Row Group。
- Column Chunk 生成:對每個列分別收集數據,分 Page 編碼與壓縮。
- 順序寫入:先寫 Header → 各 Row Group 的 Column Chunk(含 Page)→ Footer(含全部元數據)→ 終止魔數。
讀取過程原理
- 讀取 Footer:讀取文件末尾 8 字節(4 字節元數據長度 + 4 字節魔數),定位并加載全部元數據。
- 篩選元數據:根據查詢列與過濾條件,決定需要加載的 Column Chunk 與 Page 位置。
- 隨機/并行讀取:直接跳轉到各 Column Chunk 的偏移位置,按需加載并解壓 Page。
- 重建記錄:將各列 Page 解碼后重組成行,供上層引擎消費。
實踐示例:使用 Python 創建并寫入 Parquet
下面示例演示如何用 pandas+pyarrow 將一個簡單表格寫入 Parquet,并簡要注釋寫入過程:
import pandas as pd# 1. 創建 DataFrame
df = pd.DataFrame({"user_id": [1001, 1002, 1003],"event": ["login", "purchase", "logout"],"timestamp": pd.to_datetime(["2025-04-20 10:00:00","2025-04-20 10:05:00","2025-04-20 10:10:00"])
})# 2. 寫入 Parquet(默認 row_group_size=64MB,可通過 partition_cols 分區)
df.to_parquet("events.parquet", engine="pyarrow", compression="snappy")
- pandas 在內部將 DataFrame Schema 轉為 Thrift Schema;
- 按列生成 Column Chunk 并分 Page 編碼;
- 最后輸出 Header、Column Chunk、Footer 以及魔數字段。
1. Row Group 的概念與分割原則
- Row Group 是 Parquet 中最小的水平切分單元,表示若干行的完整切片。一個 Parquet 文件由一個或多個 Row Group 依次組成,每個 Row Group 內部再按列拆分為若干 Column Chunk,支持列式讀寫與跳過式掃描。
- 選擇合適的 Row Group 大小,是在I/O 效率(大塊順序讀更快)和并行度/內存占用(小塊更易并行、占用更少)間的權衡。
2. Row Group 的“大小”度量方式
Parquet 對 Row Group 的大小有兩種常見度量:
- 未壓縮字節數(uncompressed bytes)
- 行數(number of rows)
- 在 Parquet-Java(Hadoop/Spark 常用)中,
ParquetProperties.DEFAULT_ROW_GROUP_SIZE
默認為 128 MiB(未壓縮),并以字節為單位控制切分。 - 在 PyArrow(Python 生態)中,
row_group_size
參數實際上是以行數為單位;若不指定(None
),默認值已被調整為 1 Mi 行(絕對最大值仍為 64 Mi 行)。
3. 不同實現中的默認值與推薦配置
實現 / 場景 | 默認值 | 推薦范圍 | 說明 |
---|---|---|---|
Parquet-Java | 128 MiB(未壓縮) | 512 MiB – 1 GiB(未壓縮) | 與 HDFS 塊大小對齊,一般 HDFS 塊也應設為 1 GiB,以便一組 Row Group 剛好填滿一個塊 |
Spark/HDFS | spark.sql.files.maxPartitionBytes 默認 128 MiB | 同上 | Spark 會將每個 Row Group 當作一個分區,讀寫時以此并行 |
PyArrow | 1 Mi 行 | 視數據表寬度換算成字節 | 若表寬為 10 列、每列單元假設 4 字節,1 Mi 行 ≈ 40 MiB(未壓縮) |
其他工具(如 DuckDB/Polars) | 通常基于“每行最大允許字節數”來推算,默認約 1 Mi 行 | 需參考各自文檔 | 如 Polars 中 row_group_size_bytes 默認 122 880 KiB(≈ 120 MiB) |
4. Row Group 在文件中的物理結構
一個 Row Group 在文件中的布局可以抽象為:
+--------------------------------------------+
| Row Group |
| ┌───────────────────────────────────────┐ |
| │ Column Chunk for Column 0 │ │
| │ ┌── Page 1 (Dictionary Page?) │ │
| │ │ - Page Header │ │
| │ │ - 字典或數據塊 │ │
| │ └── Page 2 (Data Page v1/v2) │ │
| │ - Page Header │ │
| │ - 壓縮/編碼后的列數據 │ │
| │ … │ │
| └───────────────────────────────────────┘ │
| ┌───────────────────────────────────────┐ │
| │ Column Chunk for Column 1 │ │
| │ … │ │
| └───────────────────────────────────────┘ │
| ? |
| ┌───────────────────────────────────────┐ │
| │ Column Chunk for Column N │ │
| │ … │ │
| └───────────────────────────────────────┘ │
+--------------------------------------------+
同時,文件末尾的 Footer 中包含了每個 Row Group 的元數據(Thrift 定義),主要字段有:
total_byte_size
:該 Row Group 內所有 Column Chunk 未壓縮數據總字節數num_rows
:該 Row Group 包含的行數columns: list<ColumnChunk>
:每個 ColumnChunk 的偏移、長度、編碼、壓縮算法、Page 統計等
讀寫時,寫端按序將上述 Row Group 數據寫入磁盤,待所有 Row Group 完成后再寫 Footer 與尾部魔數;讀端則先讀 Footer,加載元數據后即可隨機定位到任意 Row Group 和任意字段的 Column Chunk 進行解壓與解碼。
5. 舉例說明
假設有一個表 10 列,每列單元平均占用 4 字節,我們若以 1 Mi 行為 Row Group(PyArrow 默認值),則:
- 未壓縮 Row Group 大小 ≈ 1 Mi × 10 列 × 4 B ≈ 40 MiB。
- 壓縮后:若使用 Snappy,通常可壓縮到 1/2 – 1/4,大約 10 MiB – 20 MiB。
- 如果改為 Parquet-Java 默認 128 MiB(未壓縮),則 Row Group 行數可按上述公式反算:128 MiB ÷ (10 × 4 B) ≈ 3.3 Mi 行。
小結
- Row Group 大小既可以用字節也可以用行數度量,具體含義取決于所用寫入器與參數;
- Parquet-Java 默認 128 MiB(未壓縮),官方推薦 512 MiB – 1 GiB;
- PyArrow 默認 1 Mi 行,且新版已調整絕對最大 64 Mi 行;
- 結構上,Row Group 包含多列的 Column Chunk,每個 Column Chunk 又由若干 Page 組成;
- 選取合適的 Row Group 大小,可在 順序 I/O 性能 與 并行度/內存占用 之間取得平衡。
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq# 1. 創建示例 DataFrame
df = pd.DataFrame({"user_id": [1001, 1002, 1003, 1004],"event": ["login", "purchase", "logout", "login"],"timestamp": pd.to_datetime(["2025-04-20 10:00:00","2025-04-20 10:05:00","2025-04-20 10:10:00","2025-04-20 10:15:00"])
})# 2. 寫入 Parquet(指定 row_group_size 為 2 行,以演示多個 Row Group)
pq.write_table(pa.Table.from_pandas(df), 'events.parquet', row_group_size=2)# 3. 讀取 Parquet 元數據并提取 Row Group 信息
pq_file = pq.ParquetFile('events.parquet')
row_groups = []
for i in range(pq_file.num_row_groups):rg = pq_file.metadata.row_group(i)row_groups.append({"row_group_id": i,"num_rows": rg.num_rows,"total_byte_size": rg.total_byte_size})# 4. 展示 Row Group 元數據
rg_df = pd.DataFrame(row_groups)
import ace_tools as tools; tools.display_dataframe_to_user("Row Group Metadata", rg_df)