parquet是一种列存储格式, 是 Apache 开源的一种列式存储文件格式,主要用在大数据和机器学习的数据处理里。Spark、Pandas、PyArrow、DuckDB、Hugging Face Datasets 都能直接读写它。
1. Intro
**核心特点:按列存储。**CSV、JSON 这类格式是一行一行存的,Parquet 则把同一列的数据放在一起存。比如一张表有 id、text、score 三列:
行式存储的顺序是
(1, "hello", 0.9), (2, "world", 0.8), ...
列式存储的顺序是
[1, 2, ...]
["hello", "world", ...]
[0.9, 0.8, ...]
列式存储的好处:
- 只读需要的列:如果只要 score,就不用把整列 text 从磁盘读出来,I/O 大幅减少。
- 压缩率高:同一列的类型相同、值也相似,配合字典编码、游程编码(RLE),再加上 Snappy、Zstd 等压缩算法,文件通常比 CSV 小好几倍。
- 自带 schema:文件里记录了每列的类型,比如 int64、string 或嵌套结构,读的时候不用猜类型。
- 可以跳过无关数据:文件分成多个行组(row group),每个行组都存有统计信息,比如每列的最小值和最大值。按条件过滤时,可以直接跳过不可能有匹配行的行组。
- 适合分布式处理:一个大数据集通常切成很多个 Parquet 分片,多个 worker 可以并行读取。
缺点:
- 不适合频繁地单行更新或追加写入,因为文件写完基本就不再改动。
- 二进制格式,不能像 CSV 那样直接用文本编辑器查看。
2. row groups, column chunk和page
Parquet 文件的结构分四层,从外到内是:
文件(File)→ 行组(Row Group)→ 列块(Column Chunk)→ 页(Page)
Parquet 文件
├── 魔数 "PAR1"
├── Row Group 0
│ ├── Column Chunk: id → Page, Page, ...
│ ├── Column Chunk: text → Page, Page, ...
│ └── Column Chunk: score → Page, Page, ...
├── Row Group 1
│ └── ...
├── Footer(元数据:schema、各行组和列块的位置与统计信息)
└── 魔数 "PAR1"
Row Group(行组)
行组先把整张表按行横向切开。比如 1000 万行的数据可以分成 10 个行组,每个行组 100 万行。行组内部再按列存储,每一列的数据叫一个列块(Column Chunk)。
所以 Parquet 实际上是"先按行分组,组内再按列存"。这样既有列式存储的好处,又不需要一次把整列都读进内存。
行组的作用:
最小的并行和读取单位:Spark、PyArrow 这类引擎通常按行组分配任务,一个 worker 读一个或几个行组。
用统计信息跳过数据:footer 里记录了每个行组中每个列块的最小值、最大值和 null 数量。比如查询 score > 0.95 时,如果某个行组里 score 的最大值只有 0.8,整个行组都可以直接跳过。
决定内存占用:写文件时,一个行组的数据要先在内存里攒满才能写出去;读的时候,通常也是一次处理一个行组。
行组大小怎么选:
太大(比如几个 GB)会导致读写内存占用高,并行度也低。
太小会让元数据变多、压缩率下降,读取时随机 I/O 也更多。
常见做法是让每个行组在 128MB 到 1GB 之间,或者在几十万到一百万行左右。PyArrow 用 row_group_size 参数按行数控制。
Page(页)
每个列块又被切成若干页。页是编码和压缩的最小单位,默认大小约 1MB。
常见的页类型:
Data Page(数据页):存实际的列值,以及处理 null 和嵌套结构用的 repetition/definition levels。
Dictionary Page(字典页):如果某列用了字典编码,列块开头会有一个字典页,记录"编号 → 真实值"的映射。后面的数据页只存编号,对重复值多的列(比如 language、source)压缩效果特别好。
页的作用:
编码和压缩以页为单位:读取时可以逐页解压,不用一次解压整个列块。
更细粒度的跳读:较新版本的 Parquet 支持 Page Index,记录了每个页的最小值和最大值。这样过滤时不仅能跳过行组,还能在行组内部跳过页。
各层职责
层级 怎么切分 主要作用
Row Group
按行横切
并行读取、按统计信息跳过、控制内存
Column Chunk
行组内按列
只读需要的列
Page
列块内再切
编码和压缩、更细粒度的跳读
Footer
文件末尾
存 schema 和所有位置、统计信息,读文件时最先读它
元数据放在文件末尾,是因为写文件时要等所有数据写完,才知道每一块的位置和统计信息。所以读 Parquet 时,会先读取文件结尾的 footer,再按需定位到具体的行组和列块。
python
"""Write a few rows to Parquet, then read them back in several ways."""
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq
PATH = "demo.parquet"
NUM_ROWS = 10
ROW_GROUP_SIZE = 4
# 1. 构造数据:一列整数、一列文本、一列浮点、一列重复值多的类别
rows = [
{
"id": i,
"text": f"document number {i} " + "lorem ipsum " * (i + 1),
"score": round(i / NUM_ROWS, 2),
"source": ["web", "books", "code"][i % 3],
}
for i in range(NUM_ROWS)
]
table = pa.Table.from_pylist(rows)
print("=== 原始 schema ===")
print(table.schema, "\n")
# 2. 写文件:每 4 行一个行组,数据页上限设得很小,让列块被切成多个页
pq.write_table(
table,
PATH,
row_group_size=ROW_GROUP_SIZE,
data_page_size=64, # 字节;默认约 1MB
compression="zstd",
write_statistics=True,
write_page_index=True,
)
# 3. 读元数据:先读 footer
pf = pq.ParquetFile(PATH)
meta = pf.metadata
print("=== 文件元数据 ===")
print(f"总行数={meta.num_rows} 行组数={meta.num_row_groups} 列数={meta.num_columns}\n")
print("=== 每个行组 / 列块 ===")
for rg_idx in range(meta.num_row_groups):
rg = meta.row_group(rg_idx)
print(f"Row Group {rg_idx}: {rg.num_rows} 行, {rg.total_byte_size} 字节(未压缩)")
for col_idx in range(rg.num_columns):
col = rg.column(col_idx)
stats = col.statistics
print(
f" {col.path_in_schema:<7} "
f"编码={col.encodings} 压缩后={col.total_compressed_size}B "
f"min={stats.min!r} max={stats.max!r} "
f"有字典页={col.has_dictionary_page}"
)
print()
# 4. 全量读取
print("=== 全量读取 ===")
print(pq.read_table(PATH).to_pandas(), "\n")
# 5. 只读一个行组的部分列
print("=== 只读 Row Group 1 的 id 和 source ===")
print(pf.read_row_group(1, columns=["id", "source"]).to_pandas(), "\n")
# 6. 流式读取:每批 3 行
print("=== iter_batches(batch_size=3) ===")
for batch in pf.iter_batches(batch_size=3, columns=["id", "score"]):
print(f" batch: id={batch.column('id').to_pylist()} score={batch.column('score').to_pylist()}")
print()
# 7. 带过滤条件读取:score >= 0.7 时,Row Group 0 (max=0.3) 可以直接跳过
print("=== 过滤读取 score >= 0.7 ===")
filtered = pq.read_table(PATH, columns=["id", "score"], filters=[("score", ">=", 0.7)])
print(filtered.to_pandas(), "\n")
# 8. 校验读回的数据与原始数据一致
roundtrip = pq.read_table(PATH)
assert roundtrip.equals(table), "读回的数据和原始数据不一致"
assert pc.sum(roundtrip["id"]).as_py() == sum(range(NUM_ROWS))
print("往返校验通过:读回的数据与写入的完全一致")