原文发布于 quant67.com,转载请保留出处。
Iceberg 元数据树 那篇里,planning 的最后一级是「row group / page pruning(Parquet column index)」------元数据把候选收敛到几个 Parquet 文件后,真正读盘前还要在文件内部再裁一层。这一层裁剪能裁多狠、靠什么裁,全取决于 Parquet 自己的物理结构。这篇就把一个 Parquet 文件从外到里拆开。
要回答的问题:
- 一个 Parquet 文件在磁盘上如何切成 row group / column chunk / page?footer 里的
FileMetaData存了什么、为什么放在文件末尾? - 同一列用 PLAIN、字典、RLE/bit-packing、DELTA_BINARY_PACKED、BYTE_STREAM_SPLIT 编码,体积差多少?谁压谁?
- 没有行号的列式文件,怎么表达「嵌套且可空」的数据(Dremel 的 repetition/definition level)?
- column index / offset index / bloom filter 这三样裁剪元数据,分别让谓词在读盘前跳过什么?
证据锚定 Apache Parquet 格式规范 (apache/parquet-format:parquet.thrift 的 Thrift 定义、Encodings.md、PageIndex.md、BloomFilter.md)。实验在本机用 pyarrow 24.0.0 真实建表、dump footer 与编码,正文里带 === 标记的输出均来自实跑。
实验环境:Intel Core i9-12900K,31 GiB 内存,WSL2(Linux 6.6.87.2-microsoft-standard-WSL2,x86_64),Python 3.14.5,pyarrow 24.0.0(footer 里
created_by显示parquet-cpp-arrow version 24.0.0)。数据集:30 万行、5 列(idint64 顺序、category低基数字符串、ts毫秒时间戳、amountdouble、hi高基数字符串),row_group_size=100000,故 3 个 row group。
一、整体布局:数据在前,元数据在尾
Parquet 是「先写数据、最后写元数据」的格式。一个文件从头到尾是:
text
PAR1 <- 4 字节 magic(开头)
<Column Chunk: col0 RG0> <- 一个个 page(dictionary page / data page)
<Column Chunk: col1 RG0>
...
<Column Chunk: colN RG0> <- RG0 的所有列
<Column Chunk: col0 RG1> <- RG1 的所有列
...
[可选: ColumnIndex / OffsetIndex 区]
FileMetaData <- Thrift 序列化的 footer
<4 字节: FileMetaData 长度,little-endian>
PAR1 <- 4 字节 magic(结尾)
实跑确认 magic 与 footer 大小:
text
=== file magic & size ===
head4: b'PAR1' tail4: b'PAR1' size: 9163402 bytes
=== FileMetaData ===
num_rows: 300000
num_columns: 5
num_row_groups: 3
format_version: 2.6
serialized_size(footer): 2550
created_by: parquet-cpp-arrow version 24.0.0
文件首尾都是 4 字节 magic PAR1(规范:未加密文件用 PAR1,加密 footer 用 PARE)。footer 放在末尾 是关键设计:写入端是流式的,写完所有 page 才知道每个 column chunk 的偏移、大小、统计,于是把这些汇总信息一次性写到末尾。读取端则先读文件最后 8 字节------4 字节 magic 加上前面 4 字节的 footer 长度(little-endian)------据此 seek 回去读 FileMetaData。
这意味着读一个 Parquet 文件最少要两次 IO :一次读尾部拿 footer,一次(或多次)按 footer 给的偏移读需要的 column chunk。30 万行的文件,footer(serialized_size)只有 2550 字节------元数据极小,却足以驱动整个裁剪与按列读取。
三级物理切分
Parquet 的物理结构是三级嵌套(规范 parquet.thrift 的 FileMetaData → RowGroup → ColumnChunk → ColumnMetaData):
| 层级 | 含义 | 默认/本例 | 作用 |
|---|---|---|---|
| Row Group | 一批行的水平切分;每个 row group 内每列各一个 column chunk | 本例每 10 万行一个,共 3 个 | 并行读/写的单位;统计裁剪的粗粒度 |
| Column Chunk | 一个 row group 内某一列的全部数据 | 每 row group × 5 列 = 15 个 | 按列读的单位;连续存放便于顺序 IO |
| Page | column chunk 内的最小编解码/压缩单位 | data page + 可选 dictionary page | 压缩与 page 级裁剪的单位 |
实跑看三个 row group 的行数与未压缩字节:
text
=== row groups ===
RG0: rows=100000 total_byte_size=4466509
RG1: rows=100000 total_byte_size=4466909
RG2: rows=100000 total_byte_size=4463253
total_byte_size 是该 row group 所有列未压缩的总字节。row group 是「列存按行批切块」的体现:先把表横切成多个 row group(每个含全部列的一段行),再在每个 row group 内按列纵切成 column chunk。这样既保留列式(同列连续、利于压缩与列裁剪),又能按 row group 并行、按 row group 裁剪。row group 大小是个权衡:太大则单个读放大、内存压力大、裁剪粒度粗;太小则 footer 膨胀、压缩字典命中差。Parquet 实现的默认 row group 目标通常在百 MB 量级。
二、column chunk 与 page:读盘的真实单位
钻进一个 column chunk。它由若干 page 组成:可选的一个 dictionary page (若用字典编码),加上一个或多个 data page 。每个 page 前面有一个 PageHeader(Thrift),记录 page 类型、未压缩大小、压缩大小、CRC,以及该 page 的编码与(data page v2 的)统计。
实跑 dump RG0 的 5 个 column chunk(snappy 压缩、字典开启、写了 page index):
text
=== RG0 column chunks ===
col=id type=INT64 enc=('PLAIN', 'RLE', 'RLE_DICTIONARY')
comp=SNAPPY dict=True colidx=True offidx=True comp_sz=603116 uncomp_sz=1002880
stats: min=0 max=99999 nulls=0
col=category type=BYTE_ARRAY enc=('PLAIN', 'RLE', 'RLE_DICTIONARY')
comp=SNAPPY dict=True colidx=True offidx=True comp_sz=1431 uncomp_sz=25395
stats: min=book max=toy nulls=0
col=ts type=INT64 enc=('PLAIN', 'RLE', 'RLE_DICTIONARY')
comp=SNAPPY dict=True colidx=True offidx=True comp_sz=704601 uncomp_sz=1002880
stats: min=2023-11-14 22:13:20 max=2023-11-16 01:59:59 nulls=0
col=amount type=DOUBLE enc=('PLAIN', 'RLE', 'RLE_DICTIONARY')
comp=SNAPPY dict=True colidx=True offidx=True comp_sz=503040 uncomp_sz=704456
stats: min=1.01 max=1000.0 nulls=0
col=hi type=BYTE_ARRAY enc=('PLAIN', 'RLE', 'RLE_DICTIONARY')
comp=SNAPPY dict=True colidx=True offidx=True comp_sz=1240725 uncomp_sz=1730898
stats: min=k-0000074769 max=k-fffeccd874 nulls=0
逐字段对照规范 ColumnMetaData:
| 字段 | 实跑值(以 id 列为例) |
含义 |
|---|---|---|
type(physical type) |
INT64 |
物理类型 ,Parquet 只有 6 种:BOOLEAN、INT32、INT64、INT96(弃)、FLOAT、DOUBLE、BYTE_ARRAY、FIXED_LEN_BYTE_ARRAY;逻辑类型(如 timestamp、string)靠 LogicalType 叠加 |
encodings |
('PLAIN','RLE','RLE_DICTIONARY') |
该 chunk 用到的编码集合(见第三节) |
codec(compression) |
SNAPPY |
page 数据的块压缩算法 |
has_dictionary_page |
True |
是否有 dictionary page |
total_compressed_size |
603116 |
压缩后字节 |
total_uncompressed_size |
1002880 |
压缩前字节 |
statistics |
min=0 max=99999 null_count=0 |
chunk 级统计,文件级裁剪的依据 |
column_index_offset/length |
(有,colidx=True) |
指向该 chunk 的 ColumnIndex |
offset_index_offset/length |
(有,offidx=True) |
指向该 chunk 的 OffsetIndex |
几个值得记住的点:
- physical type 极少,logical type 叠加 。
ts和id物理上都是INT64,但ts带LogicalType=Timestamp(MILLIS),读出来才是时间。category/hi物理上都是BYTE_ARRAY,带LogicalType=String。文件级裁剪比较时按列的ColumnOrder决定的比较规则(如有符号/无符号、UTF-8 字典序)来比。 - 编码差异已经显出威力 。
category(4 个取值循环)压到comp_sz=1431字节------字典编码把它压到几乎可忽略;hi(高基数随机串)comp_sz=1240725,字典帮不上忙。同样 10 万个值,体积差近千倍,全因数据的可压缩性不同。 encodings是集合,不是「每个值用哪个」。一个 chunk 可能 dictionary page 用 PLAIN 存字典、data page 用 RLE_DICTIONARY 存索引、再混 RLE 存 definition level,所以三种都列出来。
dictionary page 与 fallback
字典编码的工作方式(规范 Encodings.md,PLAIN_DICTIONARY/RLE_DICTIONARY):column chunk 开头写一个 dictionary page ,里面用 PLAIN 编码存下这一列出现过的所有不同值(去重后的字典);随后的 data page 不再存原值,只存「每个值在字典里的下标」,下标用 RLE/bit-packing 混合编码(RLE_DICTIONARY)。
字典编码有个回退(fallback)阈值 :如果字典本身长到超过配置上限(Parquet 实现常用 1 MB 的 dictionary page size 限制),写入端就放弃字典、对剩余 data page 改用 PLAIN(或其他直接编码)。这就是为什么高基数列 hi 即使开了字典也压不动------它的不同值太多,字典放不下或没有重复可利用。这一点在下一节用「关掉字典」的对照实测看得更直接。
三、编码:同一列,体积差几百倍
编码(encoding)决定「值如何变成字节」,压缩(compression,如 snappy/zstd)则在编码后的字节上再压一层。两者叠加。这一节用实测看不同编码对同一列的体积影响。
3.1 Parquet 的编码家族
规范 Encodings.md 定义的主要编码(plus 各自适合的数据):
| 编码 | 编号 | 适合的列 | 原理 |
|---|---|---|---|
PLAIN |
0 | 兜底,任何类型 | 直接按物理类型定长/变长写原值 |
RLE_DICTIONARY |
8 | 低/中基数列 | 字典下标用 RLE/bit-packing 混合编码 |
RLE |
3 | definition/repetition level、布尔 | 行程编码与 bit-packing 的混合 |
DELTA_BINARY_PACKED |
5 | 单调/近单调整数(id、时间戳) | 存相邻值的差分,再 bit-pack |
DELTA_LENGTH_BYTE_ARRAY |
6 | 变长字节串的长度 | 长度用 delta,数据连续拼接 |
DELTA_BYTE_ARRAY |
7 | 有公共前缀的字符串(路径、排序键) | 前缀复用 + 后缀 delta |
BYTE_STREAM_SPLIT |
9 | 浮点(float/double) | 把每个值的字节按位置拆到多个流,利于后续压缩 |
RLE(run-length encoding)在 Parquet 里特指一种 RLE 与 bit-packing 的混合编码:连续相同值用「行程长度 + 值」表示(RLE),杂乱段用固定位宽打包(bit-packing),两种交替。它既用于字典下标,也用于编码 definition/repetition level(第四节)。
3.2 实测:强制编码看体积
pyarrow 默认对几乎所有列先尝试字典编码。为了看清各编码单独的效果,关掉字典、关掉块压缩(compression=none),用 column_encoding 强制指定,dump RG0 各列:
text
=== forced column encodings (dict off, none compression) ===
col=id enc=('RLE', 'DELTA_BINARY_PACKED') comp_sz=2393
col=category enc=('RLE', 'PLAIN') comp_sz=775240
col=ts enc=('RLE', 'PLAIN') comp_sz=800385
col=amount enc=('RLE', 'BYTE_STREAM_SPLIT') comp_sz=800385
col=hi enc=('RLE', 'DELTA_BYTE_ARRAY') comp_sz=1052827
读这组数(都是 RG0 的 10 万行、无块压缩):
id用DELTA_BINARY_PACKED:10 万个顺序 int64 压到 2393 字节。 因为id是0,1,2,...,相邻差恒为 1,差分后全是 1,bit-packing 几乎压成常数。对照第二节里同样 10 万个id用字典编码(uncomp_sz=1002880,PLAIN 存就是 100000×8=800000 字节),delta 编码在「单调整数」上是数量级的胜利。这正是 id、自增主键、单调时间戳列的首选编码。category用PLAIN:775240 字节。 关掉字典后,4 个取值循环也只能逐个把字符串原样写出来(每个值 4 字节左右长度+内容),体积暴涨。对照第二节字典+snappy 时它只有 1431 字节------低基数列离开字典编码,体积差几百倍。amount用BYTE_STREAM_SPLIT:800385 字节 (= 100000×8 + 少量开销)。double 的字节被按位置拆流,本身不减小体积,但拆流后相邻字节更相似,留给后续块压缩(zstd 等)更大空间------它的收益要配合压缩才显现。hi用DELTA_BYTE_ARRAY:1052827 字节。高基数随机串没有公共前缀可复用,delta 前缀编码帮助有限,仍接近原始体积。
一句话:编码必须看数据形态选 。单调整数选 delta,低基数选字典,浮点用 byte-stream-split 配压缩,高基数随机串怎么都难压。Parquet 实现默认「先试字典、放不下回退」是个稳健的自动策略,但手工指定编码能在已知数据形态时再榨一截。编码与压缩的系统对照是 列式编码与压缩 的主题,也可对照 列存引擎压缩 看 ClickHouse 的同类思想。
3.3 实测:字典与压缩的叠加效果
把整文件(30 万行、5 列)在不同「编码策略 + 压缩」组合下的总体积摆出来:
text
=== size sensitivity (same data, 300000 rows, 3 row groups) ===
snappy + dict 9162833 bytes 8948.1 KiB
zstd + dict 5341628 bytes 5216.4 KiB
none + dict 13401936 bytes 13087.8 KiB
snappy, dict OFF 7599708 bytes 7421.6 KiB
none, dict OFF 14332272 bytes 13996.4 KiB
几个非直觉但真实的结论:
- zstd 比 snappy 小得多(5.2 MiB vs 8.9 MiB,同为字典开启)。zstd 压缩比更高、解压稍慢,是当前湖仓数据文件的常见默认。
snappy, dict OFF(7.4 MiB)反而比snappy + dict(8.9 MiB)小 。这条要解释清楚:本数据集里hi是高基数随机串,对它开字典等于先建一个几乎和原数据一样大的字典、再存一堆几乎互不相同的下标,纯属浪费;关掉字典后hi走直接编码反而省。字典不是越开越好------对高基数列它是负优化。生产里 Parquet 实现按列自适应(放不下就回退),但理解这条能解释「为什么有时关字典文件更小」。none(不压缩)最大 ,符合预期;none + dict(13.1 MiB)比none, dict OFF(14.0 MiB)略小,说明在不压缩时字典对低基数列仍有净收益,只是被hi的开销部分抵消。
这些是单次写出的体积,不是 benchmark;体积是确定性的(同输入同参数同库版本可复现),不涉及计时,因此不需要多轮取中位数。读取/解压吞吐的计时对比留到第 5 章按 benchmark 规范(≥3 轮中位数、交代环境)做。
四、嵌套与可空:Dremel 的两个 level
列式存储有个行存没有的难题:把一列单独抽出来后,怎么知道每个值属于哪一行、是不是 null、在嵌套结构(list/map/struct)里处于什么位置? 行存里这些靠「行」天然携带,列存里行被拆散了。Parquet 用 Google Dremel 论文提出的两个整数解决:definition level 与 repetition level (规范《Nested Encoding》,源自 Melnik 等 Dremel: Interactive Analysis of Web-Scale Datasets, VLDB 2010)。
4.1 两个 level 各管什么
对 schema 路径上每个值,Parquet 额外存两个小整数:
- definition level(定义层级) :这个路径上「本可以为 null 的层级」里,实际定义到了第几层。最大值 = 路径上
optional与repeated字段的个数。它编码 null 出现在哪一层:definition level 小于最大值,说明在某一层就是 null(没定义到底)。 - repetition level(重复层级) :当前值相对前一个值,是在哪一层「重复」开始的。最大值 = 路径上
repeated字段的个数。它编码 list 的边界:repetition level = 0 表示一个新行(顶层记录)的开始,>0 表示是某层 list 里的后续元素。
对一个「平坦、必填」的列(如本例的 id,required),两个 level 的最大值都是 0,等于不占空间(规范规定 max level 为 0 时不写 level 数据)。对 optional(可空)列,max definition level = 1,每个值带一个 0/1 的 definition level(1=有值,0=null)。对 repeated(list)列,才真正用到非平凡的 repetition level。
两个 level 都用 RLE(RLE/bit-packing 混合)编码,位宽 = ⌈log2(maxLevel+1)⌉。例如 max definition level=1 时位宽 1 bit,10 万个非空值会被 RLE 压成「一个长行程」,几乎不占空间。
4.2 一个嵌套例子
设 schema:
text
message doc {
required int64 id;
optional group user {
optional string name;
repeated string tags; // 一个用户多个标签
}
}
user.tags 这一列的路径上有 user(optional)、tags(repeated) 两个非 required 节点,加上 tags 自身 optional 语义,max definition level 与 max repetition level 由路径结构决定。考虑三行:
| 行 | user.tags 的值 |
该列要存的 (rep, def) 序列 |
|---|---|---|
| 1 | ["a", "b"] |
(0, dmax) a,(1, dmax) b |
| 2 | user 存在但 tags 为空 list |
(0, 较小 def) 表示空 |
| 3 | user 整个为 null |
(0, 更小 def) 表示更高层就 null |
读取时,引擎用 repetition level 把扁平的值序列重新组装成嵌套结构 :rep=0 开新行,rep>0 续上一行的某层 list;用 definition level 判断每个位置是真值还是某层的 null。这样不存任何行号、不存结构标记 ,只用两列小整数就无损还原任意嵌套。这是 Parquet(以及后面 Arrow 的嵌套布局)处理 JSON 式半结构化数据的根基。
关键直觉:definition/repetition level 是「把树形结构压成两列整数」的编码。平坦必填列里它们退化为零开销;只有真正用到嵌套/可空时才付出代价。这让 Parquet 既能高效存平坦宽表,也能存深层嵌套,而不需要两套格式。
五、裁剪元数据:让谓词在读盘前跳过数据
Parquet 真正的性能杀手锏,是一组让查询在读取数据前就跳过不相关部分的元数据。从粗到细三层:column chunk 统计 → page index(column index + offset index)→ bloom filter。
5.1 column chunk 统计:最粗的一刀
每个 column chunk 的 ColumnMetaData.statistics 存了该 chunk 的 min_value/max_value/null_count(规范 Statistics;旧的 min/max 已弃用,现用按列序定义比较的 min_value/max_value)。引擎拿到谓词后,先比对 chunk 统计:谓词的取值范围与 [min, max] 不相交,整个 column chunk 跳过,连 page 都不读。
第二节实跑里每个 chunk 都有 stats,例如 id 的 RG0 chunk 是 min=0 max=99999。于是查 id >= 250000 时,RG0([0,99999])、RG1([100000,199999])都不可能命中,只 RG2([200000,299999])需要读。实测验证这个裁剪是对的:
text
=== predicate pushdown: row-group pruning via statistics ===
filter id >= 250000 (only RG2 qualifies): returned rows = 50000
filter id < 50000 (only RG0 qualifies): returned rows = 50000
filter category == 'book' (all RG, dict): returned rows = 75000
id >= 250000 返回 5 万行(RG2 里 250000--299999),id < 50000 返回 5 万行(RG0 里 0--49999)------pyarrow 用 row group 统计裁掉了不相关的 row group,只读命中的那个。category == 'book' 返回 7.5 万行(30 万行里每 4 行一个 book),三个 row group 的 category 统计都是 [book, toy]、都覆盖 book,所以无法靠 min/max 跳过任何 row group------这正是 bloom filter 要补的场景(5.3)。
注意:min/max 裁剪只能保证不漏 (不产生假阴性),不保证选中的都命中。[book, toy] 覆盖 book 不代表 chunk 里一定有 book,只代表「不能排除」。同理 Iceberg manifest 里的文件级 stats 是同一思想的上一层(见 Iceberg 元数据树 第六节)。
5.2 page index:把裁剪粒度降到 page
chunk 统计只能整块跳过。page index 把粒度降到单个 data page(规范 PageIndex.md),由两部分组成,存在文件 footer 之前、由 ColumnMetaData 的 column_index_offset/length 与 offset_index_offset/length 指向:
- ColumnIndex :对该 column chunk 的每个 data page ,存
min_values[i]/max_values[i]/null_pages[i]/null_counts[i],外加一个boundary_order(页间是否按值有序:UNORDERED / ASCENDING / DESCENDING)。 - OffsetIndex :对每个 data page,存
PageLocation(offset文件内偏移、compressed_page_size压缩大小、first_row_index该页第一行的行号)。
两者配合实现 page 级裁剪 + 精准读取 :引擎先用 ColumnIndex 的每页 min/max 判断哪些 page 可能命中谓词,再用 OffsetIndex 拿到这些 page 的字节区间,只发起对选中 page 的读 ,跳过的 page 连读都不读。当 boundary_order 是有序时,还能二分定位,不必线性扫所有 page 的统计。
实跑里 5 个列都显示 colidx=True offidx=True,确认 page index 已写入。它的成本极小:本例主文件(带 page index)9163402 字节,而第三节里同参数不写 page index 的 snappy + dict 是 9162833 字节------page index 只多了约 569 字节,却能在窄范围查询时省掉整页整页的读取。这也是为什么 Parquet 实现近年默认开启 page index。
注意 page index 与「把统计塞进每个 data page 的 PageHeader」是两条路。早期 Parquet 把 page 统计放在 PageHeader 里,但那样必须顺序扫过 page 才能读到下一页的统计,无法跳读。独立的 page index 区集中放在 footer 附近,一次读就拿到所有 page 的 min/max 与偏移 ,才能真正跳页。这是
PageIndex.md设计的动机。
5.3 bloom filter:等值谓词的最后一招
min/max 对高基数列的等值查询 几乎无用:hi 列 chunk 统计是 [k-0000074769, k-fffeccd874],几乎覆盖整个取值域,查 hi = 'k-1234567890' 时 min/max 排除不掉任何 chunk。Parquet 的对策是 bloom filter (规范 BloomFilter.md)。
Parquet 用的是 split-block Bloom filter(SBBF) ,而非教科书的单一位数组(来源:BloomFilter.md,引用 Putze 等 Cache-, Hash- and Space-Efficient Bloom Filters)。结构要点:
- 整个 filter 切成若干 block,每个 block 是 256 bit(8 个 32-bit word)。
- 插入一个值时:用 xxHash 算 64-bit 哈希,高位选 block,低位经一组固定乘法常数派生出 8 个 word 各设 1 位(每 block 恰好设 8 位)。
- 查询时只触碰一个 block(256 bit,正好一两个 cache line),所以叫 cache-efficient------比传统 bloom filter 的多次随机访存对 CPU cache 友好得多。
bloom filter 数据存在 column chunk 区域,由 ColumnMetaData.bloom_filter_offset/length 指向,带一个 BloomFilterHeader(算法 BLOCK、哈希 XXHASH、压缩 UNCOMPRESSED、numBytes)。引擎读到等值谓词 hi = v 时,先查 bloom filter:返回「肯定不在」就整 chunk 跳过;返回「可能在」才去读 。它有假阳性(可能说「在」其实不在)、无假阴性(说「不在」就一定不在),所以只能用来排除,不能用来确认。
本写作环境的 pyarrow 24.0.0 没有暴露写 bloom filter 的高层参数(BloomFilterOptions 在该构建里不可用),因此不贴 bloom filter 体积/命中的伪造数字 。要复现 bloom filter 效果,可用支持它的写入端(如 Spark/Trino 的 Parquet writer,或 parquet-mr)对高基数列开启 parquet.bloom.filter.enabled,再用 parquet-tools/pqrs 查看 bloom_filter_offset。能跑的 chunk/page/编码裁剪实测已在 5.1--5.2 给出。
三层裁剪的分工
| 机制 | 粒度 | 擅长的谓词 | 数据结构 | 假阳性 |
|---|---|---|---|---|
| chunk 统计 | column chunk | 范围(>/</between) |
ColumnMetaData.statistics |
有(min/max 是边界) |
| column index | data page | 范围,尤其有序列 | ColumnIndex + OffsetIndex |
有 |
| bloom filter | column chunk | 高基数等值(=/in) |
SBBF | 有(无假阴性) |
三者互补:范围谓词靠 min/max(chunk → page 两级),高基数等值靠 bloom filter。它们都是「读盘前用小元数据排除大数据」,与 Iceberg 在更上层用 manifest stats 做文件级裁剪是同一思想的不同层级(见 Iceberg 元数据树 第七节的裁剪漏斗)。
六、谓词下推与投影下推:两条省 IO 的路
把前面的结构串成查询时的两种「下推」,这是 Parquet 在引擎里真正省 IO 的地方。
6.1 投影下推(projection pushdown)
只读查询用到的列。因为 Parquet 按列把数据切成 column chunk、footer 记录每个 chunk 的偏移与大小,引擎要 SELECT amount FROM t 时,只 seek 读 amount 那些 column chunk 的字节,完全不碰 id/category/ts/hi 。读取量按列数缩放------这是列存相对行存的根本优势(见 列存基础 的带宽模型)。Iceberg/引擎还能从 manifest 的 column_sizes 预估每列字节,做 IO 成本估算。
6.2 谓词下推(predicate pushdown)
把 WHERE 条件尽量下推到读取层,用第五节的裁剪元数据在读盘前排除数据。完整的下推链路(也是 lakehouse 的整条裁剪漏斗):
Iceberg manifest partition pruning→manifest 文件级 stats file pruning→chunk 统计 row group pruning→column index page pruning→等值 bloom
前两级在表格式元数据里完成(第 8 章),后三级在 Parquet 文件内完成(本章)。每一级都用「小元数据排除大数据」,越往右粒度越细、读得越少。一个写好谓词、数据布局合理(按过滤列排序/聚簇)的查询,可能只读到全表的极小一部分;反之布局差、谓词与排序不一致时,min/max 区间互相重叠,裁剪失效,退化成全扫。这也是第 17 章 compaction 与排序/聚簇要解决的问题。
6.3 下推的边界
下推不是万能:
- 裁剪只保证不漏,不保证精确。min/max、bloom 都可能让不含目标值的 chunk/page 被保留,读上来后引擎还要再过一遍谓词(残余过滤)。
- 数据布局决定裁剪效果。如果过滤列在文件内乱序,每个 page 的 min/max 都横跨全域,page 裁剪几乎无效。所以「按常用过滤列排序写入」是让 Parquet 裁剪生效的前提。
- 统计可能被关或被截断 。写入端可配置不写某列统计、或把 min/max 截断到前若干字节(省 footer 体积),裁剪能力随之下降。Iceberg 侧也有对应的 metrics 配置(见 Iceberg 元数据树 第九节)。
七、小结
一个 Parquet 文件是「数据在前、元数据在尾」的列式格式:
- 三级物理切分 :file → row group(行批)→ column chunk(行批内的一列)→ page(编解码/压缩单位)。footer 里的
FileMetaData(Thrift)记录所有偏移、大小与统计,放在末尾因为写入是流式的;首尾PAR1magic。 - 编码看数据形态 :单调整数用
DELTA_BINARY_PACKED(实测 10 万顺序 int64 压到 2393 字节),低基数用字典(实测category压到 1431 字节),浮点用BYTE_STREAM_SPLIT配压缩,高基数随机串怎么都难压。字典不是越开越好------高基数列开字典反而更大(实测snappy, dict OFF比snappy + dict小)。 - Dremel 两个 level:definition level 编码 null 在哪层、repetition level 编码 list 边界,用两列小整数无损还原任意嵌套;平坦必填列里退化为零开销。
- 三层裁剪元数据:chunk 统计(row group 级范围裁剪,实测裁出正确行数)、page index(page 级,column index + offset index,实测仅多约 569 字节)、bloom filter(高基数等值,SBBF 结构)。配合投影下推与谓词下推,让查询在读盘前跳过大量数据。
这套结构正是 Iceberg 元数据树 裁剪漏斗的最后两级。下一篇看同为列式磁盘格式的 ORC:它用 stripe 而非 row group、用三级统计而非 page index,与 Parquet 在结构与生态上如何取舍------并用本机实测对比两者的体积与物理布局。
返回 系列目录
上一篇:Lakehouse 全景:从 Hive 表到开放表格式
参考资料
- Apache Parquet,
apache/parquet-format---parquet.thrift(FileMetaData/RowGroup/ColumnChunk/ColumnMetaData/PageHeader/Statistics定义)。 - Apache Parquet,
apache/parquet-format---Encodings.md(PLAIN / RLE_DICTIONARY / RLE / DELTA_BINARY_PACKED / DELTA_BYTE_ARRAY / BYTE_STREAM_SPLIT 规范)。 - Apache Parquet,
apache/parquet-format---PageIndex.md(ColumnIndex / OffsetIndex 设计与跳页动机)。 - Apache Parquet,
apache/parquet-format---BloomFilter.md(split-block Bloom filter、XXHASH、block 结构)。 - S. Melnik 等, Dremel: Interactive Analysis of Web-Scale Datasets, VLDB 2010 --- repetition / definition level 的来源。
- F. Putze, P. Sanders, J. Singler, Cache-, Hash- and Space-Efficient Bloom Filters --- split-block Bloom filter 的算法来源(Parquet
BloomFilter.md引用)。 - 本机实验,2026-06-30:Intel Core i9-12900K / 31 GiB / WSL2(Linux 6.6.87.2-microsoft-standard-WSL2)/ Python 3.14.5 / pyarrow 24.0.0;30 万行 5 列,
row_group_size=100000,footer / 编码 / 裁剪均为实跑 dump。
附录、Parquet 工程注记
下面是读写 Parquet 时常踩或常问的点,每条尽量锚定 apache/parquet-format 规范或本机实跑,便于排障时按图索骥。
data page v1 与 v2 的区别
规范定义了两种 data page header:DataPageHeader(v1)与 DataPageHeaderV2。核心区别是 levels 是否参与压缩:
- v1 :把 repetition level、definition level 和值一起压缩成 page 体。要读统计或 null 信息必须先解压整页。
- v2 :把
repetition_levels_byte_length、definition_levels_byte_length单列出来,levels 不压缩、只压值部分 ,并在 header 里直接带num_values/num_nulls/num_rows与统计。好处是不解压就能拿 null 数与 levels,跳读更省。
本实验 footer 的 format_version 是 2.6(表级格式版本),但 pyarrow 的 data_page_version 默认仍是 1.0(data page 用 v1)。两者是不同的版本旋钮:format_version 控制能用哪些特性,data_page_version 控制 data page header 用 v1 还是 v2。要用 v2 需显式 data_page_version="2.0"。
physical type 只有少数几种,logical type 叠加语义
规范的物理类型(Type)只有:BOOLEAN、INT32、INT64、INT96(弃用)、FLOAT、DOUBLE、BYTE_ARRAY、FIXED_LEN_BYTE_ARRAY。日期、时间、字符串、decimal、UUID 等都是在物理类型上叠加 LogicalType(如 String、Timestamp{unit,isAdjustedToUTC}、Decimal{precision,scale})。所以本例 ts 物理是 INT64 + Timestamp logical,category 物理是 BYTE_ARRAY + String logical。裁剪时的比较规则由列的 ColumnOrder 决定 (当前统一为 TypeDefinedOrder,对每种 logical type 规定了 min/max 的比较语义,比如有符号整数按数值、字符串按 UTF-8 字节序)。
INT96 为什么被弃用
老 Parquet 用 INT96 存纳秒时间戳(Impala/早期 Spark),它是 12 字节的非标准类型,缺乏明确的时区语义、且不在标准逻辑类型体系内。规范已将其标记为 deprecated,新写入应使用 INT64 + Timestamp logical type。读老数据时仍可能遇到 INT96,需按写入端约定解释。
统计可能是「不精确边界」
Statistics 除了 min_value/max_value,还有 is_min_value_exact/is_max_value_exact 标志。写入端为省 footer 体积,会把长字符串的 min/max 截断 到前若干字节(截断后下界向下取整、上界向上取整以保持「不漏」),此时标志为 false。裁剪逻辑必须把截断后的边界当「保守边界」用------能保证不产生假阴性,但比真实极值更宽。这与 Iceberg manifest 的 truncate(N) metrics 是同一套思路(见 Iceberg 元数据树 第九节)。
sorting columns:声明排序以增强裁剪
RowGroup.sorting_columns 记录该 row group 按哪些列、什么方向排序。引擎据此知道数据有序,可在 page index 的 boundary_order 上做二分,或在 merge/join 时省排序。它只是声明,不强制写入端真的排序;声明与实际不符会导致裁剪错误。把数据按常用过滤列排序写入,是让 page 级 min/max 不互相重叠、裁剪真正生效的前提。
key_value_metadata 与 Arrow schema
FileMetaData.key_value_metadata 是一组自由的 KV,常被用来塞额外信息。pyarrow 会把完整的 Arrow schema 序列化后存进 ARROW:schema 这个 key,读回时优先用它还原精确类型(比 Parquet logical type 表达力更强的部分,如某些 extension type)。跨引擎读 pyarrow 写的文件时,不依赖这个 key 也能读,只是类型还原可能退化到 Parquet logical type。
块压缩算法与 LZ4 的坑
规范支持的 CompressionCodec:UNCOMPRESSED、SNAPPY、GZIP、LZO、BROTLI、LZ4、ZSTD、LZ4_RAW。一个历史坑是 LZ4 与 LZ4_RAW 不兼容 :早期不同实现对「LZ4」的帧格式理解不一致,社区后来定义了明确的 LZ4_RAW。跨引擎读写 LZ4 压缩的老文件可能遇到解不开的情况,新数据优先用 ZSTD(压缩比好、实现一致),这也是本系列实测里默认选 zstd 的原因之一。
模块化加密(modular encryption)
规范定义了 Parquet Modular Encryption:可对整个文件或按列 加密,footer 可加密(此时尾部 magic 是 PARE 而非 PAR1)。它允许「只加密敏感列、其余列明文」,并把加密元数据放在 crypto_metadata 字段。这让湖上做列级权限/合规成为文件格式层就支持的能力,不必依赖上层。本实验未启用加密。
page CRC 与坏块检测
PageHeader.crc 可选地存每个 page 的 CRC32 校验(pyarrow 的 write_page_checksum)。开启后读取端能在解压前发现 page 级数据损坏,代价是写入时多算一次 CRC、footer 略增。对象存储上数据极少静默损坏,但跨网络长期保存的关键数据可考虑开启。
为什么 footer 在末尾:单遍写入
把 FileMetaData 放末尾,是为了支持单遍流式写入 :写入端边收行边写 page,写完一个 column chunk 才知道它的偏移、压缩后大小、统计;全部写完再把这些汇总成 footer 一次性追加到末尾。如果 footer 在开头,就得先扫一遍数据算好所有偏移再回头写,无法流式。代价是读取端必须能 seek 到文件末尾(对象存储用 HTTP Range 读尾部,见 对象存储语义与代价 附录的 HEAD/Range 讨论)。
常见问题
Q:Parquet 文件和 Arrow IPC(Feather)文件是一回事吗? 不是。Parquet 是磁盘存储格式(重编码、重压缩、为长期存储优化);Arrow IPC(Feather V2)是内存格式的序列化(几乎零编码、为快速读写与零拷贝优化)。两者的分工是下一章 Apache Arrow 内存格式与零拷贝 的主题。
Q:为什么有时某列没有 min/max 统计? 写入端可配置对某些列不写统计(如超宽字符串列为省 footer),或某些类型/版本默认不写。没有统计的列无法做 min/max 裁剪,引擎只能读上来再过滤。Iceberg 侧也有对应的 metrics 级别配置。
Q:row group 应该多大? 权衡:太大则单次读放大、内存压力大、裁剪粒度粗(一个 row group 命中就得读整组);太小则 footer 膨胀、每组字典命中差、page 多。常见做法是按目标字节(如 128 MB--512 MB)切,并配合按过滤列排序,让 row group 间的 min/max 尽量不重叠。具体大小要按引擎并行度与对象存储 Range 读特性调,属于第 17 章 compaction 的范畴。
Q:谓词下推一定会发生吗? 不一定。要引擎支持把谓词下推到 Parquet reader,且谓词形式可裁剪(范围/等值,作用在有统计的列上),且数据布局让统计有区分度。复杂表达式、UDF、跨列条件通常下推不了,会退化成读上来再过滤。