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 的单位(一次连续读) |
| Page | column 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、不能改写的对象存储里写文件所需要的性质。
读的时候则反过来,读取器的动作是固定的三步:
- 读文件最后 8 字节 → 拿到 footer 长度和魔数(确认这确实是个 Parquet)
- 按这个长度回退,读出整个 footer → 拿到 schema 和每块的偏移量、统计信息
- 只对真正需要的那几块发起读取(第 4 课的全部跳过逻辑都发生在这一步)
这就是第 1 课那个 count(*) 只花 0.2 ms 的原因:行数写在 footer 里,第 1 步就拿到了,第 3 步根本没发生。
不用任何库,直接看字节。先看头 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 │ └────┴─────────┴───────┘
这张表已经能让你读出三件事,而它们正是后三课的主题:
- 钱花在哪:
value和ts两列吃掉 21.62 MB,占了 99%。要减体积只有从这两列下手(第 3 课)。 - 压缩率很不平均:
device_id的x只有 1.0,因为它编码之后已经几乎没东西了(0.02 MB),压缩无事可做;而value靠 zstd 拿到 2.1 倍。 - row group 是 1,048,576 行一个——这是 pyarrow 的默认值,不是 Parquet 规范规定的。这个数字直接决定跳过的粒度(第 4、5 课)。
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 └─ …
三个要点:
- 字典是 per row group 的,不是全文件共享。所以 row group 越大,字典越可能超限而回退成原样存储——这是第 5 课调 row group 大小时的隐藏成本。
- 压缩发生在 page 级:每页单独压。所以读取器可以只解压它要的那几页,而不是整列。
- 每页自带 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,把输出贴给我,我逐行帮你读——尤其是"这文件写得好不好、该改什么"。这是本课程最值得反复做的练习。