土法炼钢兴趣小组的算法知识备份

【数据湖与开放表格式】Parquet 文件格式深拆

文章导航

分类入口
databasestorage
标签入口
#parquet#columnar-format#encoding#dictionary#rle#bloom-filter#page-index#dremel

目录

Iceberg 元数据树 那篇里,planning 的最后一级是「row group / page pruning(Parquet column index)」——元数据把候选收敛到几个 Parquet 文件后,真正读盘前还要在文件内部再裁一层。这一层裁剪能裁多狠、靠什么裁,全取决于 Parquet 自己的物理结构。这篇就把一个 Parquet 文件从外到里拆开。

要回答的问题:

证据锚定 Apache Parquet 格式规范apache/parquet-formatparquet.thrift 的 Thrift 定义、Encodings.mdPageIndex.mdBloomFilter.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 列(id int64 顺序、category 低基数字符串、ts 毫秒时间戳、amount double、hi 高基数字符串),row_group_size=100000,故 3 个 row group。


一、整体布局:数据在前,元数据在尾

Parquet 是「先写数据、最后写元数据」的格式。一个文件从头到尾是:

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 大小:

=== 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.thriftFileMetaDataRowGroupColumnChunkColumnMetaData):

flowchart TD
  F["File (PAR1 ... FileMetaData ... PAR1)"]
  RG0["Row Group 0 (一批行的水平切分)"]
  RG1["Row Group 1"]
  CC0["Column Chunk: id"]
  CC1["Column Chunk: category"]
  CCN["Column Chunk: ..."]
  DP["Dictionary Page (可选)"]
  D1["Data Page 0"]
  D2["Data Page 1"]
  F --> RG0
  F --> RG1
  RG0 --> CC0
  RG0 --> CC1
  RG0 --> CCN
  CC0 --> DP
  CC0 --> D1
  CC0 --> D2
层级 含义 默认/本例 作用
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 的行数与未压缩字节:

=== 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):

=== 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

几个值得记住的点:

dictionary page 与 fallback

字典编码的工作方式(规范 Encodings.mdPLAIN_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 各列:

=== 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 万行、无块压缩):

一句话:编码必须看数据形态选。单调整数选 delta,低基数选字典,浮点用 byte-stream-split 配压缩,高基数随机串怎么都难压。Parquet 实现默认「先试字典、放不下回退」是个稳健的自动策略,但手工指定编码能在已知数据形态时再榨一截。编码与压缩的系统对照是 列式编码与压缩 的主题,也可对照 列存引擎压缩 看 ClickHouse 的同类思想。

3.3 实测:字典与压缩的叠加效果

把整文件(30 万行、5 列)在不同「编码策略 + 压缩」组合下的总体积摆出来:

=== 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

几个非直觉但真实的结论:

这些是单次写出的体积,不是 benchmark;体积是确定性的(同输入同参数同库版本可复现),不涉及计时,因此不需要多轮取中位数。读取/解压吞吐的计时对比留到第 5 章按 benchmark 规范(≥3 轮中位数、交代环境)做。


四、嵌套与可空:Dremel 的两个 level

列式存储有个行存没有的难题:把一列单独抽出来后,怎么知道每个值属于哪一行、是不是 null、在嵌套结构(list/map/struct)里处于什么位置? 行存里这些靠「行」天然携带,列存里行被拆散了。Parquet 用 Google Dremel 论文提出的两个整数解决:definition levelrepetition level(规范《Nested Encoding》,源自 Melnik 等 Dremel: Interactive Analysis of Web-Scale Datasets, VLDB 2010)。

4.1 两个 level 各管什么

对 schema 路径上每个值,Parquet 额外存两个小整数:

对一个「平坦、必填」的列(如本例的 idrequired),两个 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 混合)编码,位宽 = \(\lceil \log_2(\text{maxLevel}+1) \rceil\)。例如 max definition level=1 时位宽 1 bit,10 万个非空值会被 RLE 压成「一个长行程」,几乎不占空间。

4.2 一个嵌套例子

设 schema:

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。

flowchart LR
  P["谓词 WHERE x ..."]
  S1["chunk 统计 min/max<br/>跳过整个 column chunk"]
  S2["ColumnIndex 每页 min/max<br/>跳过 page"]
  S3["OffsetIndex 定位 page 字节<br/>只读选中的 page"]
  S4["Bloom filter<br/>等值谓词跳过 chunk"]
  P --> S1 --> S2 --> S3
  P --> S4

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])需要读。实测验证这个裁剪是对的:

=== 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 之前、由 ColumnMetaDatacolumn_index_offset/lengthoffset_index_offset/length 指向:

两者配合实现 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)。结构要点:

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 的整条裁剪漏斗):

\[ \underbrace{\text{partition pruning}}_{\text{Iceberg manifest}} \to \underbrace{\text{file pruning}}_{\text{manifest 文件级 stats}} \to \underbrace{\text{row group pruning}}_{\text{chunk 统计}} \to \underbrace{\text{page pruning}}_{\text{column index}} \to \underbrace{\text{bloom}}_{\text{等值}} \]

前两级在表格式元数据里完成(第 8 章),后三级在 Parquet 文件内完成(本章)。每一级都用「小元数据排除大数据」,越往右粒度越细、读得越少。一个写好谓词、数据布局合理(按过滤列排序/聚簇)的查询,可能只读到全表的极小一部分;反之布局差、谓词与排序不一致时,min/max 区间互相重叠,裁剪失效,退化成全扫。这也是第 17 章 compaction 与排序/聚簇要解决的问题。

6.3 下推的边界

下推不是万能:


七、小结

一个 Parquet 文件是「数据在前、元数据在尾」的列式格式:

这套结构正是 Iceberg 元数据树 裁剪漏斗的最后两级。下一篇看同为列式磁盘格式的 ORC:它用 stripe 而非 row group、用三级统计而非 page index,与 Parquet 在结构与生态上如何取舍——并用本机实测对比两者的体积与物理布局。


返回 系列目录

上一篇:Lakehouse 全景:从 Hive 表到开放表格式

下一篇:ORC 文件格式与 Parquet 对照


参考资料

  1. Apache Parquet, apache/parquet-formatparquet.thriftFileMetaData / RowGroup / ColumnChunk / ColumnMetaData / PageHeader / Statistics 定义)。
  2. Apache Parquet, apache/parquet-formatEncodings.md(PLAIN / RLE_DICTIONARY / RLE / DELTA_BINARY_PACKED / DELTA_BYTE_ARRAY / BYTE_STREAM_SPLIT 规范)。
  3. Apache Parquet, apache/parquet-formatPageIndex.md(ColumnIndex / OffsetIndex 设计与跳页动机)。
  4. Apache Parquet, apache/parquet-formatBloomFilter.md(split-block Bloom filter、XXHASH、block 结构)。
  5. S. Melnik 等, Dremel: Interactive Analysis of Web-Scale Datasets, VLDB 2010 — repetition / definition level 的来源。
  6. F. Putze, P. Sanders, J. Singler, Cache-, Hash- and Space-Efficient Bloom Filters — split-block Bloom filter 的算法来源(Parquet BloomFilter.md 引用)。
  7. 本机实验,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 是否参与压缩

本实验 footer 的 format_version2.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)只有:BOOLEANINT32INT64INT96(弃用)、FLOATDOUBLEBYTE_ARRAYFIXED_LEN_BYTE_ARRAY。日期、时间、字符串、decimal、UUID 等都是在物理类型上叠加 LogicalType(如 StringTimestamp{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 的坑

规范支持的 CompressionCodecUNCOMPRESSEDSNAPPYGZIPLZOBROTLILZ4ZSTDLZ4_RAW。一个历史坑是 LZ4LZ4_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 略增。对象存储上数据极少静默损坏,但跨网络长期保存的关键数据可考虑开启。

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、跨列条件通常下推不了,会退化成读上来再过滤。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-30 · database / storage

【数据湖与开放表格式】ORC 文件格式与 Parquet 对照

ORC 用 stripe 而非 row group、用三级统计(file/stripe/row-group index)而非独立 page index、用 PRESENT/DATA 等 stream 而非 page 组织一列。本文按 ORC 规范拆其文件尾(postscript + footer)、stripe 内部结构与 RLEv2 整数编码,并用本机 pyarrow 24.0.0 把同一份 30 万行数据写成 ORC 与 Parquet,对比真实体积与物理布局,最后给出什么场景仍用 ORC。

2026-06-30 · database / storage

【数据湖与开放表格式】列式编码与压缩

拆解 Parquet 的两层缩减:专用编码(dictionary / RLE / DELTA_BINARY_PACKED / BYTE_STREAM_SPLIT)降熵,再用 zstd/snappy/lz4/gzip 压字节。用 pyarrow 在同一列上实测不同编码+压缩组合的体积与读取耗时(3M 行,7 轮中位数),并与 ClickHouse CODEC 做同思想不同落地的对照。

2026-06-30 · database / storage

【数据湖与开放表格式】Apache Arrow 内存格式与零拷贝

拆解 Arrow 列式内存布局(validity bitmap + value buffer + offset buffer)、零拷贝从何而来,以及 C Data Interface、IPC、Flight 三层跨边界传递。讲清 Arrow(内存计算格式)与 Parquet(磁盘存储格式)如何分工衔接。含 pyarrow 实测 C Data Interface 同地址零拷贝。

2026-06-29 · database / storage

【数据湖与开放表格式】Parquet · Iceberg · Delta · Hudi 内核拆解

拆解 lakehouse 的两层基础:列式文件格式(Parquet/ORC/Arrow)与开放表格式(Iceberg/Delta/Hudi)。讲清没有数据库进程时,如何在对象存储上做 ACID、行级更新、快照与并发,以及 catalog、查询引擎、流式入湖如何拼成可运维的湖仓。面向数据平台工程师与从 OLAP/数仓转型的开发者。


By .