课程首页· 术语表· 速查表· 第 2 课 / 共 6 课

Lesson 02 · 内核

拆开一个 Parquet 文件

Magic → Row Group → Column Chunk → Page → Footer。这五个词是后面四课的全部地基。

第 1 课看的是外部效果。这一课我们把文件切开,看清楚字节是怎么排的。看懂结构不是为了炫技——第 3 课调编码、第 4 课谈跳过、第 5 课定 row group 大小,全都是在这张结构图上做决定。

整体布局

一个 Parquet 文件从头到尾长这样(官方 File Format 页):

┌──────────────────────────────────────────────┐
│ "PAR1"                        4 字节魔数       │ ← 文件开头
├──────────────────────────────────────────────┤
│ Row Group 0                                   │  一段"横切":全部列的前 N 行
│   ├ Column Chunk: ts        ┐                 │  每列在这段里各占一块连续字节
│   │    ├ Dictionary Page    │                 │
│   │    ├ Data Page 0        │ 一块 = 一列      │
│   │    ├ Data Page 1        │   在这个 rg 内   │
│   │    └ …                  │   的全部值       │
│   ├ Column Chunk: device_id ┘                 │
│   ├ Column Chunk: value                       │
│   └ …(每列一块,顺序固定)                      │
├──────────────────────────────────────────────┤
│ Row Group 1                                   │  下一段横切
│ …                                             │
├──────────────────────────────────────────────┤
│ File Metadata(Footer,Thrift 编码)            │ ← 全文件的"目录"
│   schema / 每个 rg 每列的偏移量、体积、min/max    │
├──────────────────────────────────────────────┤
│ footer 长度  4 字节小端 uint32                  │
│ "PAR1"       4 字节魔数                        │ ← 文件结尾
└──────────────────────────────────────────────┘

四个层级,各自是不同东西的单位(官方 Concepts):

层级是什么它是什么的单位
Row Group一批行的横切,包含这批行的所有列并行与跳过的单位
Column Chunk某个 row group 里某一列的全部数据,字节连续I/O 的单位(一次连续读)
Pagecolumn chunk 内的小块。pyarrow 默认 data_page_size=1 MB 且 max_rows_per_page=20000,两者谁先到算谁编码与压缩的单位
Footer整个文件的目录:schema + 每块的偏移量和统计读取的入口

为什么元数据在尾部

这是新手最容易觉得"设计得怪"的地方——目录不放开头,放结尾?官方给的理由只有一句话,但它解释了 Parquet 的一半性格:

"File metadata is written after the data to allow for single pass writing."
——元数据写在数据之后,是为了支持单遍写入。

写的时候,你事先并不知道每一列压缩完有多大、min/max 是多少。如果目录在开头,就得先算完全部数据、回头改文件头——那意味着写入方必须能随机写。而放在尾部,写入方可以边算边流式吐出字节,最后补一段目录就收工。这正是往 S3 / OSS 这种只能顺序 PUT、不能改写的对象存储里写文件所需要的性质。

读的时候则反过来,读取器的动作是固定的三步:

  1. 读文件最后 8 字节 → 拿到 footer 长度和魔数(确认这确实是个 Parquet)
  2. 按这个长度回退,读出整个 footer → 拿到 schema 和每块的偏移量、统计信息
  3. 只对真正需要的那几块发起读取(第 4 课的全部跳过逻辑都发生在这一步)

这就是第 1 课那个 count(*) 只花 0.2 ms 的原因:行数写在 footer 里,第 1 步就拿到了,第 3 步根本没发生。

动手做 · 5 分钟 · 用 hexdump 验证

不用任何库,直接看字节。先看头 16 字节:

$ head -c 16 data/iot_sorted.parquet | xxd
00000000: 5041 5231 1504 1580 8c15 15dc 9a07 4c15  PAR1..........L.

开头 5041 5231 就是 ASCII 的 PAR1。再看最后 8 字节:

$ tail -c 8 data/iot_sorted.parquet | xxd
00000000: 7812 0000 5041 5231                      x...PAR1

动手算一下:前 4 字节 78 12 00 00 是小端 uint32,倒过来读就是 0x00001278 = 4728。这就是 footer 的字节数。现在用 pyarrow 对答案:

import pyarrow.parquet as pq
md = pq.ParquetFile("data/iot_sorted.parquet").metadata
print(md.serialized_size)   # → 4728,和你手算的一致
print(md.num_rows)          # → 4320000
print(md.num_row_groups)    # → 5

顺手算个比例:4728 字节的目录,管着 21.79 MB 的文件——footer 只占 0.021%。这就是为什么读元数据几乎不要钱。

把整个文件的内核打印出来

手动 hexdump 只能验证结构存在。日常真正要用的是一条能看清全貌的命令。DuckDB 内置了三个元数据函数,不用装任何东西:

-- 文件级:行数、row group 数、写入者、footer 大小
SELECT * FROM parquet_file_metadata('data/iot_sorted.parquet');

-- schema:每列的物理类型 + 逻辑类型
SELECT name, type, logical_type FROM parquet_schema('data/iot_sorted.parquet');

-- 最有用的一个:每个 row group、每列的体积、编码、min/max
SELECT path_in_schema, row_group_id, total_compressed_size,
       encodings, stats_min_value, stats_max_value
FROM parquet_metadata('data/iot_sorted.parquet');

课程把这三条包成了一个脚本 inspect_parquet.py,以后每写出一个文件都可以拿它体检:

$ python inspect_parquet.py data/iot_sorted.parquet
文件      : data/iot_sorted.parquet  (21.79 MB)
行数      : 4,320,000
row group : 5 个
footer    : 4,728 字节  (0.021% 的文件)
写入者    : parquet-cpp-arrow version 25.0.0

== 每列体积与编码 ==
┌───────────────┬────────────┬─────────┬────────┬─────┬────────────────────────────┬───────┐
│      col      │ phys_type  │ comp_mb │ raw_mb │  x  │         encodings          │ codec │
├───────────────┼────────────┼─────────┼────────┼─────┼────────────────────────────┼───────┤
│ value         │ DOUBLE     │   13.77 │  28.53 │ 2.1 │ PLAIN, RLE, RLE_DICTIONARY │ ZSTD  │
│ ts            │ INT64      │    7.85 │   8.57 │ 1.1 │ PLAIN, RLE, RLE_DICTIONARY │ ZSTD  │
│ quality       │ INT32      │    0.11 │   0.18 │ 1.6 │ PLAIN, RLE, RLE_DICTIONARY │ ZSTD  │
│ device_id     │ BYTE_ARRAY │    0.02 │   0.02 │ 1.0 │ PLAIN, RLE, RLE_DICTIONARY │ ZSTD  │
│ building_code │ BYTE_ARRAY │    0.01 │   0.01 │ 0.9 │ PLAIN, RLE, RLE_DICTIONARY │ ZSTD  │
│ metric        │ BYTE_ARRAY │    0.01 │   0.01 │ 0.9 │ PLAIN, RLE, RLE_DICTIONARY │ ZSTD  │
│ tenant_id     │ BYTE_ARRAY │    0.01 │   0.01 │ 0.8 │ PLAIN, RLE, RLE_DICTIONARY │ ZSTD  │
└───────────────┴────────────┴─────────┴────────┴─────┴────────────────────────────┴───────┘

== row group 划分 ==
┌────┬─────────┬───────┐
│ rg │  rows   │ rg_mb │
├────┼─────────┼───────┤
│  0 │ 1048576 │  8.39 │
│  1 │ 1048576 │  9.68 │
│  2 │ 1048576 │  8.86 │
│  3 │ 1048576 │  9.19 │
│  4 │  125696 │  1.20 │
└────┴─────────┴───────┘

这张表已经能让你读出三件事,而它们正是后三课的主题:

Page:编码和压缩真正发生的地方

column chunk 内部还要再切成 page,这是最容易被忽略但很关键的一层。一个 column chunk 通常是这样:

Column Chunk: device_id  (row group 0 内的全部 1,048,576 个值)
├─ Dictionary Page   ← 这一列在本 rg 内出现过的所有不同值,只存一份
│    ["AHU-01-001", "AHU-01-002", … ]
├─ Data Page 0       ← 存的不是字符串,是字典下标:[0,0,0,0,1,1,1,…]
│    Page Header: 值个数 / 编码方式 / 压缩前后大小 / 本页 min-max
├─ Data Page 1
└─ …

三个要点:

  1. 字典是 per row group 的,不是全文件共享。所以 row group 越大,字典越可能超限而回退成原样存储——这是第 5 课调 row group 大小时的隐藏成本。
  2. 压缩发生在 page 级:每页单独压。所以读取器可以只解压它要的那几页,而不是整列。
  3. 每页自带 min/max,但默认不把这些页级统计单独收集到文件尾部;要享受页级跳过,得显式开 Page Index(第 4 课)。
这一课的小收获

文件 = PAR1 + 若干 Row Group(横切,跳过与并行的单位)+ Footer(目录)+ 长度 + PAR1。每个 row group 里,每列是一块连续的 Column Chunk(I/O 单位),内部再切成 Page(编码压缩单位,字典 per row group)。元数据在尾部是为了单遍写入,这也是它天然适配对象存储的原因。读取永远是"先读尾巴 8 字节 → 读 footer → 只取需要的块"。

自测一下

Parquet 把文件元数据放在尾部而不是头部,最主要的原因是什么?

随时问我:拿你自己手上的任何一个 Parquet 文件跑一遍 inspect_parquet.py,把输出贴给我,我逐行帮你读——尤其是"这文件写得好不好、该改什么"。这是本课程最值得反复做的练习。

延伸阅读(本课推荐先读这一篇) Apache Parquet — File Format——官方结构说明,本课那张布局图与"单遍写入"的引文都出自这里 · Concepts:Row Group / Column Chunk / Page 的权威定义 · DuckDB Parquet 元数据函数文档