近年來,Parquet 已成為大數據生態系統中數據存儲的標準格式。它的列導向格式提供了幾個優勢:
- 當只處理部分列時,查詢執行速度更快
- 能快速計算所有數據的統計數據
- 由於高效壓縮,減少存儲空間
當與像 Delta Lake 或 Apache Iceberg 這樣的存儲框架結合使用時,它能無縫整合查詢引擎(例如 Trino)和數據倉庫計算集群(例如 Snowflake、BigQuery)。在這篇文章中,我們將使用主要的標準 Python 工具來解析 Parquet 文件的內容,以更好地理解其結構以及它如何促進這樣的性能。
寫入 Parquet 文件
為了生成 Parquet 文件,我們使用 PyArrow,這是一個用於 Apache Arrow 的 Python 綁定,能以列格式在內存中存儲數據框。PyArrow 允許在寫入文件時進行精細的參數調整,這使得 PyArrow 成為操作 Parquet 的理想選擇(也可以簡單地使用 Pandas)。
# generator.py
import pyarrow as pa
import pyarrow.parquet as pq
from faker import Faker
fake = Faker()
Faker.seed(12345)
num_records = 100
# 生成假數據
names = [fake.name() for _ in range(num_records)]
addresses = [fake.address().replace("\n", ", ") for _ in range(num_records)]
birth_dates = [
fake.date_of_birth(minimum_age=67, maximum_age=75) for _ in range(num_records)
]
cities = [addr.split(", ")[1] for addr in addresses]
birth_years = [date.year for date in birth_dates]
# 將數據轉換為 Arrow 格式
name_array = pa.array(names, type=pa.string())
address_array = pa.array(addresses, type=pa.string())
birth_date_array = pa.array(birth_dates, type=pa.date32())
city_array = pa.array(cities, type=pa.string())
birth_year_array = pa.array(birth_years, type=pa.int32())
# 創建包含非空字段的模式
schema = pa.schema(
[
pa.field("name", pa.string(), nullable=False),
pa.field("address", pa.string(), nullable=False),
pa.field("date_of_birth", pa.date32(), nullable=False),
pa.field("city", pa.string(), nullable=False),
pa.field("birth_year", pa.int32(), nullable=False),
]
)
table = pa.Table.from_arrays(
[name_array, address_array, birth_date_array, city_array, birth_year_array],
schema=schema,
)
print(table)
輸出明確顯示了列導向的存儲,與 Pandas 通常顯示的傳統“行導向”表格不同。
Parquet 文件是如何存儲的?
Parquet 文件通常存儲在像 S3(AWS)或 GCS(GCP)這樣的便宜對象存儲數據庫中,以便數據處理管道能輕鬆訪問。這些文件通常利用目錄結構進行分區策略組織:
# generator.py
num_records = 100
# ...
# 將 parquet 文件寫入磁碟
pq.write_to_dataset(
table,
root_path='dataset',
partition_cols=['birth_year', 'city']
)
如果 birth_year 和 city 列被定義為分區鍵,PyArrow 將在目錄 dataset 中創建這樣的樹狀結構:
dataset/
├─ birth_year=1949/
├─ birth_year=1950/
│ ├─ city=Aaronbury/
│ │ ├─ 828d313a915a43559f3111ee8d8e6c1a-0.parquet
│ │ ├─ 828d313a915a43559f3111ee8d8e6c1a-0.parquet
│ │ ├─ …
│ ├─ city=Alicialand/
│ ├─ …
├─ birth_year=1951 ├─ ...
這種策略使得分區修剪成為可能:當查詢在這些列上過濾時,引擎可以使用文件夾名稱僅讀取必要的文件。這就是為什麼分區策略對於限制延遲、I/O 和計算資源在處理大量數據時至關重要(這在傳統關聯數據庫中已經存在了幾十年)。
修剪效果可以通過計算一個過濾出生年份的 Python 腳本所打開的文件數量來輕鬆驗證:
# query.py
import duckdb
duckdb.sql(
"""
SELECT *
FROM read_parquet('dataset/*/*/*.parquet', hive_partitioning = true)
where birth_year = 1949
"""
).show()
只有 23 個文件被讀取出來,總共 100 個。
讀取原始 Parquet 文件
讓我們在沒有專門庫的情況下解碼一個原始的 Parquet 文件。為了簡化,數據集被轉儲到一個單一的文件中,沒有壓縮或編碼。
# generator.py
# ...
pq.write_table(
table,
"dataset.parquet",
use_dictionary=False,
compression="NONE",
write_statistics=True,
column_encoding=None,
)
首先要知道的是,二進制文件的開頭和結尾各有 4 個字節,其 ASCII 表示為 “PAR1”。如果不是這樣,則文件損壞。
# reader.py
with open("dataset.parquet", "rb") as file:
parquet_data = file.read()
assert parquet_data[:4] == b"PAR1", "不是有效的 parquet 文件"
assert parquet_data[-4:] == b"PAR1", "文件尾部損壞"
根據文檔,文件分為兩部分:包含實際數據的“行組”和包含元數據的尾部(如下所示)。
尾部
尾部的大小在結尾標記之前的 4 個字節中以無符號整數的形式表示,並以“小端”格式寫入(在 unpack 函數中標記為 “<I”)。
# reader.py
import struct
# ...
footer_length = struct.unpack("<I", parquet_data[-8:-4])[0]
print(f"尾部大小(字節):{footer_length}")
footer_start = len(parquet_data) - footer_length - 8
footer_data = parquet_data[footer_start:-8]
尾部信息以一種稱為 Apache Thrift 的跨語言序列化格式編碼。使用像 JSON 這樣的可讀性強但冗長的格式,然後將其轉換為二進制,在內存使用方面效率較低。使用 Thrift,可以這樣聲明數據結構:
struct Customer
1: required string name,
2: optional i16 birthYear,
3: optional list<string> interests
根據這一聲明,Thrift 可以生成 Python 代碼來解碼具有這種數據結構的字節字符串(它還生成執行編碼部分的代碼)。包含 Parquet 文件中所有數據結構的 Thrift 文件可以在這裡下載。在安裝了 Thrift 二進制文件後,讓我們運行:
thrift -r --gen py parquet.thrift
生成的 Python 代碼放在 “gen-py” 文件夾中。尾部的數據結構由 FileMetaData 類表示,這是一個自動生成的 Python 類。使用 Thrift 的 Python 工具,二進制數據被解析並填充到這個 FileMetaData 類的實例中。
# reader.py
import sys
# ...
# 將生成的類添加到 Python 路徑中
sys.path.append("gen-py")
from parquet.ttypes import FileMetaData, PageHeader
from thrift.transport import TTransport
from thrift.protocol import TCompactProtocol
def read_thrift(data, thrift_instance):
"""
從二進制緩衝區讀取 Thrift 對象。
返回 Thrift 對象和讀取的字節數。
"""
transport = TTransport.TMemoryBuffer(data)
protocol = TCompactProtocol.TCompactProtocol(transport)
thrift_instance.read(protocol)
return thrift_instance, transport._buffer.tell()
# 當前未使用讀取的字節數
file_metadata_thrift, _ = read_thrift(footer_data, FileMetaData())
print(f"整個文件中的行數:{file_metadata_thrift.num_rows}")
print(f"行組的數量:{len(file_metadata_thrift.row_groups)}")
尾部包含有關文件結構和內容的詳細信息。例如,它準確地跟踪生成的數據框中的行數。這些行都包含在一個“行組”中。但是什麼是“行組”?
行組
與純粹的列導向格式不同,Parquet 採用混合方法。在寫入列塊之前,數據框首先被垂直劃分為行組(我們生成的 parquet 文件太小,無法分割成多個行組)。

這種混合結構提供了幾個優勢:
Parquet 為每個行組中的每列計算統計數據(例如最小值/最大值)。這些統計數據對查詢優化至關重要,允許查詢引擎跳過不符合過濾條件的整個行組。例如,如果查詢過濾條件為 birth_year > 1955,而某個行組的最大出生年份為 1954,則引擎可以有效地跳過該整個數據部分。這種優化稱為“謂詞下推”。Parquet 還存儲其他有用的統計數據,如不同值計數和空值計數。
# reader.py
# ...
first_row_group = file_metadata_thrift.row_groups[0]
birth_year_column = first_row_group.columns[4]
min_stat_bytes = birth_year_column.meta_data.statistics.min
max_stat_bytes = birth_year_column.meta_data.statistics.max
min_year = struct.unpack("<I", min_stat_bytes)[0]
max_year = struct.unpack("<I", max_stat_bytes)[0]
print(f"出生年份範圍在 {min_year} 和 {max_year} 之間")
行組使數據的並行處理成為可能(這對像 Apache Spark 這樣的框架特別有價值)。這些行組的大小可以根據可用的計算資源進行配置(在使用 PyArrow 的 write_table 函數時使用 row_group_size 屬性)。
# generator.py
# ...
pq.write_table(
table,
"dataset.parquet",
row_group_size=100,
)
# /!\ 在下一部分中保持 "row_group_size" 的默認值
即使這不是列格式的主要目標,Parquet 的混合結構在重建完整行時仍能保持合理的性能。如果沒有行組,重建整個行可能需要掃描每一列的全部內容,這對於大型文件來說效率極低。
數據頁
Parquet 文件的最小子結構是頁。它包含來自同一列的值序列,因此屬於同一類型。頁大小的選擇是權衡的結果:
- 較大的頁意味著需要存儲和讀取的元數據較少,這對於過濾最少的查詢是最佳的。
- 較小的頁減少了讀取不必要數據的量,這在查詢針對小的、分散的數據範圍時更好。

現在讓我們解碼第一頁的內容,該頁專門用於地址,其位置可以在尾部找到(由 right ColumnMetaData 的 data_page_offset 屬性給出)。每個頁的前面都有一個 Thrift PageHeader 對象,包含一些元數據。偏移量實際上指向頁元數據的 Thrift 二進制表示,該表示位於頁本身之前。Thrift 類稱為 PageHeader,也可以在 gen-py 目錄中找到。
💡 在 PageHeader 和頁中實際包含的值之間,可能有幾個字節用於實現 Dremel 格式,該格式允許編碼嵌套數據結構。由於我們的數據具有常規的表格格式且值不是可空的,因此在寫入文件時會跳過這些字節(https://parquet.apache.org/docs/file-format/data-pages/)。
# reader.py
# ...
address_column = first_row_group.columns[1]
column_start = address_column.meta_data.data_page_offset
column_end = column_start + address_column.meta_data.total_compressed_size
column_content = parquet_data[column_start:column_end]
page_thrift, page_header_size = read_thrift(column_content, PageHeader())
page_content = column_content[
page_header_size : (page_header_size + page_thrift.compressed_page_size)
]
print(column_content[:100])
生成的值最終以純文本形式出現,而不是編碼(如在寫入 Parquet 文件時所指定的)。然而,為了優化列格式,建議使用以下編碼算法之一:字典編碼、行長編碼(RLE)或增量編碼(後者僅保留給 int32 和 int64 類型),然後使用 gzip 或 snappy 進行壓縮(可用的編解碼器在這裡列出)。由於編碼頁包含相似的值(所有地址、所有十進制數字等),壓縮比率可能特別有利。
根據規範,當字符字符串(BYTE_ARRAY)未編碼時,每個值前面都有其大小,以 4 字節整數表示。這可以在之前的輸出中觀察到:

要讀取所有值(例如,前 10 個),循環相當簡單:
idx = 0
for _ in range(10):
str_size = struct.unpack("<I", page_content[idx : (idx + 4)])[0]
print(page_content[(idx + 4) : (idx + 4 + str_size)].decode())
idx += 4 + str_size
而我們成功地重建了,這是一個非常簡單的方式,專門庫將如何讀取 Parquet 文件。通過理解其組成部分,包括標頭、尾部、行組和數據頁,我們可以更好地欣賞像謂詞下推和分區修剪這樣的功能如何在數據密集型環境中提供如此令人印象深刻的性能優勢。我相信,了解 Parquet 在底層的工作原理有助於更好地做出有關存儲策略、壓縮選擇和性能優化的決策。
本文中使用的所有代碼都可以在我的 GitHub 倉庫中找到,網址是 https://github.com/kili-mandjaro/anatomy-parquet,您可以探索更多示例並嘗試不同的 Parquet 文件配置。
無論您是在構建數據管道、優化查詢性能,還是僅僅對數據存儲格式感到好奇,我希望這次深入探討 Parquet 的內部結構能為您的數據工程之旅提供有價值的見解。
所有圖片均由作者提供。
本文由 AI 台灣 運用 AI 技術編撰,內容僅供參考,請自行核實相關資訊。
歡迎加入我們的 AI TAIWAN 台灣人工智慧中心 FB 社團,
隨時掌握最新 AI 動態與實用資訊!