第 2 章 拆了 Parquet 的物理结构(row group / column chunk / page),第 4 章 讲了数据在内存里(Arrow)是怎样的裸 buffer。这一章回到磁盘侧的一个核心问题:同样一列数据,为什么换个编码 + 压缩组合,文件能从 23 MiB 缩到 1 MiB,而读取耗时却不一定同步上涨? 选错组合,可能体积没省多少、CPU 还白烧。
列存能高压缩的根因是 同列同质:一列里的值类型相同、分布相近,熵低。Parquet 把缩减拆成两层——先用专用编码利用数据模式降熵(字典、delta、byte-stream-split),再用通用压缩(zstd/snappy/lz4/gzip)做字节级压缩。本章逐个拆这两层的机制(锚定 Parquet 编码规范与各压缩库官方文档),然后用 pyarrow 在同一列上实测不同组合的体积与读取耗时,最后与 列存引擎的压缩与编码(ClickHouse CODEC)做对照——同一套思想,两种落地。
凡涉及具体体积比、读取耗时的数字,全部来自本机 pyarrow 24.0.0 真实执行(环境见第九节),不引用未跑过的倍数。
一、两层模型:编码降熵,压缩压字节
把一列原始值变小,有两个互补的着力点:
- 专用编码(encoding):利用「这列数据有什么模式」。时间戳单调递增、ID 自增、地区名只有几十种、浮点是平滑信号——这些模式能让数据用更少的比特表达。编码是类型感知的,知道这是整数还是字符串。
- 通用压缩(compression):把编码后的字节流当成不透明字节,做 LZ 系/熵编码的字节级压缩。它不关心数据语义,只找重复子串和字节频率。
两层叠加的逻辑是:编码先把熵降下来、把重复模式暴露出来,压缩再把剩下的冗余吃掉。顺序固定——先编码,后压缩;读取时反过来,先解压再解码。
flowchart LR
RAW["原始列值"] -->|专用编码| ENC["编码后字节\n(dict/delta/BSS...)"]
ENC -->|通用压缩| CMP["压缩后 page\n(zstd/snappy/...)"]
CMP -->|落盘| DISK[".parquet column chunk"]
这与 ClickHouse 的 CODEC 链 是同一个两层结构(专用 codec + 通用压缩),区别在「谁决定、记在哪、能不能跨引擎读」——第十节专门对照,这里先把 Parquet 这一侧讲透。
信息论上的直觉:一列值的理论压缩下界由其熵 \(H\) 决定,
\[ H = -\sum_{i} p_i \log_2 p_i \quad (\text{bit/值}) \]
其中 \(p_i\) 是第 \(i\) 个取值的频率。低基数列(\(p_i\) 集中在少数值)熵低,字典编码几乎能逼近这个下界;高基数随机列熵高,怎么编码都省不下来——第九节的实测会把这条规律量化出来。
举个具体数:第九节的 lowcard_str 列有 6
个等概率取值,
\[ H = -\sum_{i=1}^{6} \tfrac{1}{6}\log_2 \tfrac{1}{6} = \log_2 6 \approx 2.585\ \text{bit/值} \]
即理论上每个值约 2.585 bit、300 万行约 0.92 MiB(加字典本身)。实测字典编码后约 1.03–1.09 MiB,正贴着这个下界——说明字典编码对低基数列基本「压到头了」,再叠通用压缩只剩零头可榨。反观均匀随机 int64,熵接近 64 bit/值,下界就是原始大小,于是第九节里它怎么编码压缩都贴着 1.00 倍。熵决定上限,编码只是去逼近它。
二、Parquet 的编码字典
Parquet 的编码集合定义在
apache/parquet-format 的
Encodings.md,每个 data page 的 header 里带一个
encoding 枚举,自描述——任何符合规范的
reader 看 header 就知道怎么解码,不依赖外部 schema
声明。这是和 ClickHouse「编码写在 DDL 里」的根本差别。
主要编码:
| 编码 | 适用物理类型 | 利用的模式 |
|---|---|---|
PLAIN |
全部 | 无(定长直存 / 变长 length+bytes) |
RLE_DICTIONARY |
全部(经字典) | 低基数、重复值 |
RLE(RLE/bit-packing 混合) |
boolean、level、字典下标 | 连续重复、小整数 |
DELTA_BINARY_PACKED |
INT32 / INT64 | 单调或邻近值差小 |
DELTA_LENGTH_BYTE_ARRAY |
BYTE_ARRAY | 变长字符串的长度序列 |
DELTA_BYTE_ARRAY |
BYTE_ARRAY | 有公共前缀的有序字符串 |
BYTE_STREAM_SPLIT |
FLOAT/DOUBLE(及定长) | 浮点/定长,配合通用压缩 |
PLAIN 是兜底:定长类型按宽度直存,变长类型存
length + bytes。其余都是为某类模式优化的。下面挑机制上最值得讲、且实验里会用到的几个展开。
历史枚举:早期的
PLAIN_DICTIONARY已被RLE_DICTIONARY取代,BIT_PACKED已废弃。新写入按现行规范。
三、字典编码:低基数列的杀手锏
字典编码(RLE_DICTIONARY)是 Parquet
写入器默认优先尝试的编码,机制分两部分:
- dictionary page:把该 column chunk
里出现过的 distinct 值收集起来,用
PLAIN编码存成一张字典,每个值得到一个整数下标。 - data
page:原列变成一串下标,下标再用
RLE/bit-packing 混合(见第四节)编码——下标取值范围小(\(0\) 到 distinct 数减一),bit 宽度小,且重复下标会被 RLE 折叠。
对低基数列,这一步几乎就把数据压到熵下界附近:一列 300 万行、只有 6 个地区名,字典就 6 个字符串,数据页是 300 万个 0–5 的小下标,bit 宽度 3 就够。
但字典不是免费的。字典本身也要占空间,且当
distinct 值太多时,字典会膨胀到比原数据还大、下标 bit
宽度也逼近原值宽度,编码反而是负优化。Parquet
写入器对此有回退机制:当字典大小超过阈值(parquet-mr
默认约 1 MiB,pyarrow
有对应参数),写入器停止字典编码,对该 column chunk
剩余部分回退到
PLAIN,避免字典爆炸。
第九节实测里能直接看到这条规律的两面:低基数字符串列字典编码做到约
40
倍体积压缩;而近似唯一的高基数整数列,字典编码(DICT/none)体积反而大于不编码(比值
0.97,即变大约 3%)——字典+下标的总开销超过了直存。
四、RLE / bit-packing 混合
Parquet 里有一种编码身兼数职:RLE/bit-packing
混合(规范里就叫
RLE)。它同时用于布尔列、嵌套数据的
repetition/definition level(第 2
章),以及字典编码的下标。
它的格式是把数据切成一段段,每段要么是 RLE run(一个值重复 N 次,存「值 + 次数」),要么是 bit-packed run(一组值各自按固定 bit 宽度紧凑打包),用一个 header 的最低位区分这两种。bit 宽度取决于值域:
\[ \text{bitwidth} = \lceil \log_2 (\text{max\_value} + 1) \rceil \]
例如 definition level 最大为 3,则每个 level 只用 2 bit。对字典下标,bit 宽度由字典大小决定。这套混合让「大量重复」走 RLE、「无重复但值域小」走 bit-packing,两种局部模式都能省。
五、DELTA_BINARY_PACKED:整数差分
对单调或邻近值差小的整数列(自增
ID、时间戳、排序后的键),存差分比存原值省得多。DELTA_BINARY_PACKED(规范
Encodings.md,仅
INT32/INT64)就是干这个的,结构大致是:
- 页头:block size、每 block 的 miniblock 数、总值数、第一个值。
- 其余值先转差分 \(d_i = x_i - x_{i-1}\),按 block 分组。
- 每个 block 取该块的最小差分 \(\delta_{\min}\),对所有差分减去它得到非负值,再按 miniblock 各自的 bit 宽度紧凑打包;\(\delta_{\min}\) 用 zigzag varint 存(处理负差分)。
直觉:若原序列近似等差,差分序列近似常数,减去最小差分后接近全 0,bit 宽度趋近 0,体积塌缩。数学上,对差分后再 bit-pack,单值平均比特数从 \(\sim 64\) 降到约
\[ \lceil \log_2 (\max_i d_i - \min_i d_i + 1) \rceil \]
实验里 seq_int64 列(近似等差 + 小抖动)用
DELTA_BINARY_PACKED 不加任何通用压缩就做到
15.48 倍,加 zstd 到 20.22
倍——远超字典或纯压缩。这与 ClickHouse 的
Delta/DoubleDelta 是同一思想(列存引擎第
3 章),只是 Parquet 把它做成页内自描述编码,ClickHouse
做成 DDL 里的 CODEC。
注意边界:差分编码对随机整数无效甚至有害——差分序列同样随机、值域可能更大。实验里
rand_int64(均匀随机)用 DELTA 比值 0.99,基本没动。
六、BYTE_STREAM_SPLIT:给浮点喂给压缩器之前的重排
浮点数(IEEE
754)很难被通用压缩器直接吃下:相邻浮点的字节看似杂乱,重复子串少。BYTE_STREAM_SPLIT(规范
Encodings.md,FLOAT/DOUBLE,新版扩展到定长与整数)的思路是重排而非压缩:
把每个 \(K\) 字节的值拆开,第 1 个字节全部收集成流 1,第 2 个字节成流 2……共 \(K\) 个字节流顺序拼接。对 double(\(K=8\))就是 8 条流。它本身不改变总字节数,但把「语义相近的字节」聚到一起——例如多数值的符号位/指数高字节相同或相近,归并后这些流里出现大量重复,后接的通用压缩器(zstd 等)就能压得动了。
所以 BYTE_STREAM_SPLIT
几乎必须和压缩搭配才有意义。实验里
walk_f64(平滑随机游走):BSS/none
体积与 PLAIN/none 一样(1.00
倍,符合「不改变总大小」),但 BSS/zstd 做到
1.31 倍,明显高于 PLAIN/zstd
的 1.10 倍——重排让 zstd 多压了一截。
Parquet 没有 ClickHouse 那种基于 XOR 的
Gorilla 浮点编码;浮点这条线,Parquet
的对应物就是
BYTE_STREAM_SPLIT。两者目标一致(让浮点更可压),路径不同。
七、通用压缩:zstd / snappy / lz4 / gzip
编码之后,每个 page
的字节再过一道通用压缩,编解码器由 column
chunk 的 codec 字段指定(规范
Compression.md),逐 page
独立压缩/解压。常见选项与各自官方文档定位:
| 编解码器 | 官方定位 | 取舍 |
|---|---|---|
UNCOMPRESSED |
不压缩 | 最快,最大 |
SNAPPY(google/snappy) |
目标是「高速」而非最高压缩比 | 压缩/解压都快,比中等 |
LZ4_RAW(lz4/lz4) |
极快的 LZ 系压缩 | 解压极快,比中等 |
ZSTD(facebook/zstd) |
高压缩比、可调 level、解压快 | 比高,压缩 CPU 视 level |
GZIP(DEFLATE,RFC 1951) |
通用、生态广 | 比中高,速度偏慢 |
BROTLI |
高压缩比(偏静态资源) | 比高,压缩慢 |
互通注记:Parquet 规范将早期的
LZ4标记为废弃(历史上用了 Hadoop 的 LZ4 帧格式,跨实现不一致),改用LZ4_RAW。pyarrow 的compression="lz4"走现行实现;跨引擎读旧 LZ4 文件时仍可能踩兼容问题,新写入优先ZSTD或LZ4_RAW。
这里不引用各库 README 的吞吐数字(不同硬件差异大),具体取舍交给第九节本机实测。要点先记住:没有免费午餐——高压缩比通常要付出更高的压缩(有时解压)CPU,zstd 的 level、gzip 的慢都体现这一点。
这些压缩器内部在做什么
它们都属 LZ 系(找重复子串)+ 可选熵编码,差别在做不做、做多狠:
- LZ4 / Snappy:只做 LZ77 式的「重复子串引用」(找到前面出现过的相同片段,用「距离 + 长度」替代),不做熵编码。所以极快,但比有限。Snappy 还刻意限制查找窗口换速度。
- GZIP(DEFLATE):LZ77 + Huffman 熵编码。Huffman 给高频字节短码、低频字节长码,进一步压。比 LZ4 高,但慢,窗口也小(32 KiB)。
- ZSTD:LZ77(更大窗口、更强匹配查找)+ FSE/Huffman 熵编码,并支持 level 调节匹配努力程度,还能用训练好的字典预热小数据。所以它在「比」和「解压速度」上都好,压缩 CPU 随 level 上升。
理解这点就能解释第九节的现象:对已被专用编码降过熵的数据(如 DELTA 后的小差分),LZ 系还能再找重复,但熵编码的边际收益变小(数据已接近随机的小值),于是高 level 收益递减(结果六)。而对纯随机数据,LZ 找不到重复、熵已满,谁都压不动(结果三)。
page 压缩的一个细节:DataPage V1 把 level 和 values 一起压缩;DataPage V2 把 rep/def level 留作不压缩、只压缩 values 段,便于读取时先看 level 再决定是否解压。本实验未刻意切换 page 版本,体积结论以实测为准。
八、为什么顺序是「先编码后压缩」
两层不能交换顺序,原因在于它们处理的对象不同:
- 编码是类型感知的:它知道这是 int64、知道相邻值可以差分、知道这是浮点可以按字节重排。一旦先压缩成不透明字节流,类型信息就被抹掉了,差分、字典都无从谈起。
- 压缩是字节感知的:它在编码暴露出的重复模式上做字节级压缩。编码把「自增 ID」变成「一串接近 0 的小差分」,压缩才有大量重复可吃。
所以正确的流水线永远是
原值 → 编码 → 压缩 → 落盘,读取
读盘 → 解压 → 解码 → 原值。这条顺序和 ClickHouse
CODEC 链
的「专用编码在前、通用压缩在后、解码逆序」完全一致——是两层模型的必然,不是某个系统的实现选择。
九、实验:同一列不同编码+压缩组合
下面用 pyarrow 在四种代表性列上,对比不同「编码 + 压缩」组合的文件体积与读取耗时。这是本章的硬数据。
环境与口径
| 项 | 值 |
|---|---|
| CPU | 12th Gen Intel Core i9-12900K |
| OS | Linux 6.6.87.2-microsoft-standard-WSL2 x86_64(glibc 2.43) |
| Python / pyarrow / numpy | 3.14.5 / 24.0.0 / 2.5.0 |
| 行数 | 3,000,000 |
| 采样 | 每组合读取 7 轮取中位数;体积为写入字节数 |
| 写入参数 | data_page_size=1 MiB,write_statistics=True |
| 体积口径 | pq.write_table 到内存 buffer 的总字节(含
footer),单位 MiB(\(2^{20}\)) |
| 读取口径 | pq.read_table 全表 +
combine_chunks(),墙钟时间(ms) |
| ratio | 以该列 PLAIN/none
体积为基准的倍数(越大越省) |
四种列(固定随机种子 42,可复现):
seq_int64:近似等差整数(自增 ID/排序键的代表),\(x_i = 7i + \text{jitter}(0\text{–}4)\)。lowcard_str:低基数字符串,6 个地区名随机取(枚举/维度列代表)。rand_int64:均匀随机 int64(高基数/高熵代表)。walk_f64:平滑随机游走 double(传感器/指标信号代表)。
组合记法 编码/压缩:PLAIN
直存、DICT
字典、DELTA=DELTA_BINARY_PACKED、BSS=BYTE_STREAM_SPLIT;压缩
none/snappy/zstd/lz4/gzip。
结果一:seq_int64(近似等差整数)
| combo | size (MiB) | ratio | read (ms) |
|---|---|---|---|
| PLAIN/none | 22.90 | 1.00 | 8.6 |
| PLAIN/zstd | 3.10 | 7.40 | 18.3 |
| DICT/none | 23.67 | 0.97 | 6.0 |
| DICT/snappy | 12.31 | 1.86 | 19.6 |
| DICT/zstd | 3.83 | 5.97 | 18.8 |
| DICT/lz4 | 12.31 | 1.86 | 18.6 |
| DICT/gzip | 5.15 | 4.45 | 30.5 |
| DELTA/none | 1.48 | 15.48 | 6.3 |
| DELTA/zstd | 1.13 | 20.22 | 7.1 |
读法:DELTA_BINARY_PACKED 完胜。不加压缩就
15.48 倍,且读取耗时(6.3 ms)和不编码不压缩(8.6
ms)相当——差分解码很轻。加 zstd 进一步到 20.22 倍,读取也只
7.1
ms。反观字典编码在这种近似唯一的列上几乎无效(DICT/none
还变大
3%),说明编码必须匹配数据模式,默认字典不是万能。
结果二:lowcard_str(低基数字符串)
| combo | size (MiB) | ratio | read (ms) |
|---|---|---|---|
| PLAIN/none | 43.40 | 1.00 | 27.4 |
| PLAIN/zstd | 5.12 | 8.47 | 37.6 |
| DICT/none | 1.09 | 39.86 | 26.2 |
| DICT/snappy | 1.09 | 39.84 | 26.0 |
| DICT/zstd | 1.03 | 42.05 | 27.1 |
| DICT/lz4 | 1.09 | 39.70 | 26.4 |
| DICT/gzip | 1.03 | 41.99 | 29.2 |
读法:字典编码独自就做到约 40 倍——300 万行塌成「6 条字典
+ 一串 3-bit 下标」。此时再叠通用压缩,边际收益很小(39.86 →
42.05),因为字典编码已逼近这列的熵下界。值得注意:纯压缩(PLAIN/zstd
8.47
倍)远不如字典编码,类型感知的编码在低基数列上碾压字节级压缩。读取耗时几乎被字符串物化主导,各组合都在
26–30 ms。
结果三:rand_int64(均匀随机整数)
| combo | size (MiB) | ratio | read (ms) |
|---|---|---|---|
| PLAIN/none | 22.90 | 1.00 | 5.4 |
| PLAIN/zstd | 22.90 | 1.00 | 6.1 |
| DICT/none | 23.67 | 0.97 | 6.0 |
| DICT/zstd | 23.64 | 0.97 | 7.0 |
| DICT/gzip | 23.62 | 0.97 | 10.7 |
| DELTA/none | 23.10 | 0.99 | 6.6 |
| DELTA/zstd | 23.10 | 0.99 | 7.5 |
读法:高熵列怎么折腾都省不下来。所有组合体积都贴着 1.00,字典甚至略微变大(多了字典/下标开销)。这正对应第一节的熵下界——均匀随机 int64 的熵接近 64 bit/值,没有可利用的模式。工程含义:对已 hash、已加密、随机 ID 这类列,别上专用编码,也别指望压缩,省下的是 CPU 不是空间。
结果四:walk_f64(平滑浮点信号)
| combo | size (MiB) | ratio | read (ms) |
|---|---|---|---|
| PLAIN/none | 22.90 | 1.00 | 5.5 |
| PLAIN/zstd | 20.85 | 1.10 | 15.0 |
| DICT/zstd | 21.57 | 1.06 | 15.8 |
| DICT/gzip | 20.65 | 1.11 | 66.3 |
| BSS/none | 22.90 | 1.00 | 5.8 |
| BSS/zstd | 17.51 | 1.31 | 7.7 |
读法:浮点是最难压的。纯 zstd 只 1.10
倍。BYTE_STREAM_SPLIT
单独不改变体积(1.00,符合「只重排不压缩」),但
BSS/zstd 把字节重排后交给 zstd,做到 1.31
倍,且读取只 7.7 ms——明显优于 PLAIN/zstd(1.10
倍、15.0 ms)。注意 DICT/gzip 读取高达 66.3
ms:gzip 解压 +
对浮点无效的字典,是最差组合。浮点列优先考虑
BYTE_STREAM_SPLIT + zstd。
结果五:有序 vs 乱序字符串(DELTA_BYTE_ARRAY)
DELTA_BYTE_ARRAY
对「有公共前缀的有序字符串」(URL、文件路径、有序键)特别有效——它存「与上一个值的公共前缀长度
+
不同的后缀」。前提是有序:排序后相邻值前缀重叠多。用一列形如
https://example.com/path/segment/00000001/page
的 URL,分别按有序和乱序写入(300 万行):
| 顺序 | PLAIN/none | DBA/none | PLAIN/zstd | DBA/zstd |
|---|---|---|---|---|
| 有序 | 143.07 | 19.47 | 2.13 | 0.28 |
| 乱序 | 143.07 | 35.41 | 12.11 | 13.50 |
(单位 MiB)读法:有序时
DELTA_BYTE_ARRAY + zstd 做到 0.28 MiB,是
PLAIN/zstd(2.13)的约 1/7、原始(143)的约
1/500——前缀编码 + zstd 双重吃掉了有序 URL
的冗余。一旦乱序,前缀重叠消失,DBA/zstd(13.50)反而略差于
PLAIN/zstd(12.11)。结论:前缀编码的收益强依赖排序,这也是
lakehouse
里对高基数有序列(如按时间/路径排序后写入)能省大量空间的原因,与
第 17 章
的 sort/clustering 重写直接相关。
结果六:zstd level 的边际收益
zstd 支持压缩 level(约 1–22),level 越高压缩 CPU
越高、解压基本不变。但收益不是线性的。对
walk_f64 用
BYTE_STREAM_SPLIT + zstd,改 level:
| zstd level | size (MiB) |
|---|---|
| 1 | 17.34 |
| 3 | 17.34 |
| 9 | 17.30 |
| 19 | 17.02 |
从 level 1 到 19,体积只从 17.34 降到 17.02(约 1.8%),却要付出高得多的压缩 CPU。对这类已经被 BSS 重排过、剩余冗余不多的浮点数据,调高 level 几乎不划算。高 level 真正有用的是冷归档、且数据本身仍有较多可压冗余的场景。默认 level 通常是合理起点,调高前先实测。
实际用了哪些编码
写入器自动选编码后,可以从文件元数据核对实际结果。对一个高基数字符串列和一个低基数列分别写入(use_dictionary=True),读
column.encodings:
md = pq.ParquetFile(buf).metadata
print(md.row_group(0).column(0).encodings)两者真实输出都是:
('PLAIN', 'RLE', 'RLE_DICTIONARY')
要正确解读这个元组:RLE_DICTIONARY
是数据页的字典下标编码,RLE 是
level(rep/def)的编码,而 PLAIN
是字典页本身(distinct 值用 PLAIN
存)的编码——所以即便没有发生回退,PLAIN
也会出现。换言之,单看 encodings
这个集合无法判断是否触发了字典回退(第三节),两种情况都含
PLAIN。要确认回退,得看更细的
encoding_stats
或对比体积。这一点容易误判,特此点明。
跨列结论
把六组实验放一起看,三条规律清楚:
- 编码必须匹配数据模式:等差整数选 DELTA、低基数选 DICT、浮点选 BSS、高熵随机谁都救不了。错配(如等差整数上字典)不仅无收益还可能变大。
- 类型感知编码常胜过纯压缩:低基数列字典 40 倍 vs 纯 zstd 8 倍;等差列 DELTA 15 倍 vs 纯 zstd 7 倍。压缩是兜底,不是首选。
- 体积和读取耗时不必同向:DELTA 又小又快(解码轻),gzip 慢、zstd 适中、lz4/snappy 快。高压缩比的代价主要在 CPU——和 列存引擎压缩 里「无免费午餐」一致。
复现说明:上表数字来自本机单次完整运行(每组合读取 7 轮取中位数)。绝对耗时随机器与负载波动,但「哪种编码适合哪种列」的相对关系稳定。读者可改
ROUNDS/N/列定义复跑。
十、与 ClickHouse CODEC 的对照:同思想,不同落地
列存引擎第 3 章 讲的 ClickHouse 压缩,和本章是同一套两层思想(专用编码降熵 + 通用压缩压字节、解码逆序),但落地方式不同。把差别列清楚,避免把两者当成一回事:
| 维度 | Parquet 编码/压缩 | ClickHouse CODEC |
|---|---|---|
| 谁决定编码 | 写入器按列启发式自动选(默认先试字典) | 用户在 DDL 显式写
CODEC(...) 链 |
| 记在哪 | page header 自描述,随文件走 | 表 schema,随库走 |
| 跨引擎可读 | 是(任何 Parquet reader 按 header 解码) | 否(ClickHouse 私有) |
| 专用编码集合 | dictionary、RLE、DELTA_BINARY_PACKED、DELTA_BYTE_ARRAY、BYTE_STREAM_SPLIT | Delta、DoubleDelta、Gorilla、T64、FPC… |
| 浮点专用 | BYTE_STREAM_SPLIT(重排) | Gorilla(XOR) |
| 压缩粒度 | 每 page 独立 | 每压缩块(granule 覆盖) |
| 字典处理 | 自动字典 + 超阈值回退 PLAIN | LowCardinality 显式类型 |
几条值得记的差异:
- 自动 vs 手动:Parquet
写入器替你选编码(并对字典做回退保护),上手成本低、可移植;ClickHouse
把选择权交给 schema
设计者,调优上限高但要求懂数据。本章实验里「等差列该选
DELTA」这种判断,在 Parquet 里很多写入器会自动尝试,在
ClickHouse 里需要你写
CODEC(Delta, ZSTD)。 - 自描述 vs schema 绑定:Parquet 文件离开原系统仍可被任意引擎解码(第 18 章 各引擎都读同一份湖),这是 lakehouse 「文件即真相」的前提;ClickHouse 的 CODEC 信息留在它自己的元数据里。
- 浮点编码路线不同:Gorilla 走相邻值 XOR + leading/trailing zero,Parquet 走 BYTE_STREAM_SPLIT 字节重排。目标都是「让浮点可压」,但算法无对应关系,不要混用名词。
一句话:Parquet 把编码/压缩做成可移植、自描述、自动选择的文件属性;ClickHouse 把它做成可深度手调的 schema 属性。 lakehouse 选 Parquet,本质是选「跨引擎可读」这条约束。
十一、选型(基于实测 + 规范的工程判断)
下表是结合本章实测和规范给出的默认建议,不是排名榜——具体仍要在你的数据上实测(第九节脚本可直接改用):
| 列特征 | 建议编码 | 建议压缩 | 依据 |
|---|---|---|---|
| 自增 ID / 时间戳 / 排序键 | DELTA_BINARY_PACKED | zstd(要小)/ none(要快) | 实测 seq 20.22×,解码轻 |
| 低基数维度(地区/枚举/状态) | dictionary | zstd 或不加 | 实测 lowcard 40×,压缩边际小 |
| 有序/共前缀字符串(URL/路径) | DELTA_BYTE_ARRAY | zstd | 实测有序 URL 达原始约 1/500,依赖排序 |
| 浮点信号(指标/坐标) | BYTE_STREAM_SPLIT | zstd | 实测 walk 1.31× 优于纯 zstd |
| 高基数随机(hash/UUID/密文) | PLAIN | zstd 低 level 或 none | 实测 rand ≈1.00,省 CPU 为主 |
| 通用文本(日志正文) | dictionary 失败回退 PLAIN | zstd | 重复多则 zstd,唯一则省 CPU |
通用取舍:
- 追求体积、能吃 CPU(冷数据、归档、带宽受限)→ zstd(可调高 level)。
- 追求读取/写入速度(热数据、低延迟)→ snappy 或 lz4,甚至 none + 好编码(DELTA 就属此类)。
- 优先选对编码再选压缩:实验反复证明,匹配的编码比堆压缩比更划算,且常常更省 CPU。
这些组合最终如何影响查询引擎在湖上的扫描裁剪与解码成本,见 第 18 章 查询引擎如何读湖;小文件与 compaction 时重选编码/压缩的时机,见 第 17 章。
十二、边界与小结
边界:
- 实测在单机 WSL2、3M 行、四种合成列上做;绝对数字随硬件/数据波动,相对规律稳定。生产选型须在真实数据上复跑。
- 未单独实测
DELTA_BYTE_ARRAY、BROTLI、page V1/V2 差异、字典回退阈值的精确边界——这些以规范为准,本章不给未跑过的数字。 - 不讨论加密层、列级压缩策略与对象存储传输压缩的叠加。
小结:
- Parquet 用两层缩减:专用编码降熵(字典/DELTA/BSS/RLE)+ 通用压缩压字节(zstd/snappy/lz4/gzip),顺序固定为先编码后压缩、解码逆序。
- 实测四类列的结论高度一致:编码必须匹配数据模式——等差选 DELTA(20×)、低基数选 DICT(40×)、浮点选 BSS+zstd(1.31×)、高熵随机谁都救不了(≈1×);类型感知编码常胜过纯压缩;体积与读取耗时不必同向。
- 与 ClickHouse CODEC 是同思想不同落地:Parquet 自动、自描述、可移植;ClickHouse 手动、schema 绑定、可深调。lakehouse 选 Parquet,换来的是跨引擎可读。
至此第一部分(列式文件格式,第 2–5 章)收束。下一章离开文件格式,进入对象存储的语义与代价——没有数据库进程时,ACID 的地基从这里开始。
下一篇:对象存储语义与代价
返回 系列目录
参考资料
规范
- Apache Parquet,
apache/parquet-format,Encodings.md(PLAIN、RLE_DICTIONARY、RLE/bit-packing hybrid、DELTA_BINARY_PACKED、DELTA_LENGTH_BYTE_ARRAY、DELTA_BYTE_ARRAY、BYTE_STREAM_SPLIT)。 - Apache Parquet,
apache/parquet-format,Compression.md(SNAPPY、GZIP、ZSTD、LZ4_RAW、BROTLI;LZ4 废弃说明)与PageHeader/DataPage V1/V2。 - Zstandard 官方文档(facebook/zstd,level 与压缩/解压特性)。
- LZ4 官方文档(lz4/lz4)。
- Snappy 官方文档(google/snappy,定位为高速而非最高比)。
- DEFLATE,RFC 1951(GZIP 底层算法)。
实现 / 实验
- pyarrow 24.0.0(
pyarrow.parquet.write_table的use_dictionary/column_encoding/compression参数),Python 3.14.5、numpy 2.5.0,Linux WSL2 x86_64,i9-12900K。第九节全部体积与耗时为本机真实执行(3M 行、7 轮中位数)。
系列内
- 列存引擎内核 · 压缩与编码(ClickHouse CODEC,本章对照对象)。
- 本系列 第 2 章 Parquet 格式、第 4 章 Arrow 内存格式、第 17 章 小文件与 Compaction、第 18 章 查询引擎如何读湖。
附录、工程注记
字典回退的可观测性
写入后可用
pq.ParquetFile(path).metadata.row_group(i).column(j).encodings
查看每个 column chunk 实际用了哪些
encoding。但要小心解读:如第九节实测,字典列即使没有回退,encodings
也含 PLAIN(字典页本身用 PLAIN
编码),因此单看这个集合无法判定是否回退。要确认回退需看更细的
encoding_stats(各编码的页数统计)或对比体积。调
dictionary_pagesize_limit /
use_dictionary 可控制字典行为。
data_page_size 的影响
page 太大,谓词裁剪粒度变粗、解压单元变大(第 2 章 page index 裁剪);page 太小,header 开销和压缩字典重建成本上升。本实验固定 1 MiB,仅为可比;生产值要结合查询裁剪需求实测。
压缩 level 的可调性
zstd/gzip/brotli 都有 level。本实验用 pyarrow 默认
level;要更高压缩比可传
compression_level。level 越高压缩 CPU
越高、解压基本不变(zstd 的特性),冷数据可激进调高。
写入并行与体积无关
pq.write_table
的线程数影响写入速度,不影响最终体积。比较体积时只看
encoding/compression;比较耗时要固定线程与 page
参数,并预热文件系统缓存(本实验读自内存
buffer,规避了磁盘缓存抖动)。
与 Arrow 字典的区别
第 4
章 的 Arrow dictionary
是内存逻辑类型;Parquet
的字典编码是磁盘 page 编码。从 Parquet 读到
Arrow 时,二者可能映射(Parquet 字典列可读成 Arrow
dictionary 数组以省内存),但默认未必,取决于 reader
参数(如 pyarrow 的 read_dictionary)。
nested 列的 level 也被编码
嵌套/可空列的 repetition/definition level 用 RLE/bit-packing 混合编码(第四节),并随 page 一起压缩(DataPage V1)或单独不压缩(V2)。深层嵌套或高 null 列,level 本身也是体积的一部分,别只盯着 values。
体积口径要统一
不同工具报「文件大小」口径不一:含不含 footer、含不含字典
page、按列还是整文件。本章统一用 pq.write_table
写入的总字节数,跨组合可比。对外引用 Parquet
体积时务必交代口径。
高熵列的正确做法
对随机/已压缩/加密列,最佳策略常是
PLAIN + 低 level zstd 甚至
UNCOMPRESSED:省下编码与高 level 压缩的
CPU,体积本就压不动(实测 rand_int64
≈1.00×)。在这类列上堆压缩比是纯浪费。
压缩粒度:page 独立压缩
Parquet 的压缩是 page 级的:每个 data page 的编码字节独立压缩、独立解压。好处是读取时按 page index(第 2 章)裁掉的 page 根本不用解压;坏处是每个 page 的压缩器无法跨 page 共享上下文(如 zstd 字典),page 太小会削弱压缩比。这与 ClickHouse 按压缩块(覆盖若干 granule)压缩是不同粒度。
DataPage V1 vs V2
DataPage V1 把 rep/def level 和 values 一起压缩;V2 把
level 段留作不压缩、只压缩 values,并在
header 里记
num_nulls/num_rows/num_values。V2
让读取方能先读未压缩的 level 决定是否需要解压 values,对高
null
或嵌套列的裁剪更友好。具体写哪个版本由写入器参数决定(pyarrow
data_page_version)。
与 column index / bloom filter 的关系
编码压缩决定「page 内怎么存」,而 column index(page 级 min/max)和 bloom filter(第 2 章)决定「要不要读这个 page」。两者正交但配合:min/max 裁剪靠的是写入时按列有序,而有序又恰好让 DELTA/DELTA_BYTE_ARRAY 编码更省(第九节结果五)——所以「排序写入」同时利好裁剪和编码,是 lakehouse 的常见优化。
LZ4 与 LZ4_RAW 的坑
历史上 Parquet 的 LZ4 用了 Hadoop
的帧封装,不同实现互不兼容,规范已将其废弃,改用
LZ4_RAW(裸 LZ4 块)。读到老系统写的 LZ4
文件可能在某些引擎报错。新写入避免 LZ4,用
LZ4_RAW 或 ZSTD。
BROTLI 与 LZO
BROTLI
压缩比高但压缩慢,更适合写一次读多次的冷数据;LZO
历史上在 Hadoop 生态用过,现已少见。主流选择收敛到
ZSTD(要省)和
SNAPPY/LZ4_RAW(要快),其余按特定生态需要再考虑。
FLOAT16 与新类型
较新 Parquet 规范加入了 FLOAT16(半精度)等类型,配合
BYTE_STREAM_SPLIT 用于 ML 特征/embedding
存储(与 第 21
章
相关)。可用性取决于读写两端版本,跨引擎前需确认支持。
写入端的字典参数
pyarrow write_table 的
use_dictionary 可传 bool
或列名列表(按列开关),dictionary_pagesize_limit
控制字典回退阈值。对明知高基数的列显式关字典,能省下「先建字典再回退」的无用功。
压缩比的口径陷阱
「压缩比」必须说清分母:是相对原始内存大小、相对 PLAIN
编码、还是相对另一种压缩。本章 ratio 统一以该列
PLAIN/none
体积为分母,跨组合可比。对外引用别人的压缩比时,先问清分母和数据,否则数字没有可比性。
解压在读路径的位置
读 Parquet 的 CPU 主要花在解压 + 解码,落到 第 4 章 的 Arrow buffer 后才进向量化算子。高压缩比(如高 level zstd)省 IO 但增解压 CPU;在 NVMe + 充裕 CPU 上未必更快,在带宽受限或对象存储(第 6 章)上则通常更划算。要按部署实测,别默认「压得越狠越好」。
与行存压缩的区别
行存(如 PostgreSQL 的 TOAST)压缩的是单行的大字段,目标是塞进页;列存压缩的是整列同质数据,目标是扫描带宽。列存能高压缩,根因是同列同质带来的低熵,这是行存按行存储拿不到的结构优势(与 列存引擎 一致)。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【数据湖与开放表格式】Parquet 文件格式深拆
拆 Parquet 的物理结构:file → row group → column chunk → page,footer 里的 FileMetaData(Thrift)与 PAR1 magic。讲清 PLAIN/RLE-bitpacking/字典/DELTA_BINARY_PACKED/BYTE_STREAM_SPLIT 各自压谁,Dremel 的 repetition/definition level 如何表达嵌套,column index/offset index 与 split-block bloom filter 怎样让谓词在读盘前裁掉 page。基于本机 pyarrow 24.0.0 真实 dump footer 与编码。
【数据湖与开放表格式】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。
【列存引擎内核】压缩与编码
ClickHouse 列压缩:LZ4、ZSTD、Delta、DoubleDelta、Gorilla 时序编码与列类型关系;CODEC 链顺序、LowCardinality 与 PG TOAST 对照。压缩比须本机实测,本文不编造倍数。
【数据湖与开放表格式】Apache Arrow 内存格式与零拷贝
拆解 Arrow 列式内存布局(validity bitmap + value buffer + offset buffer)、零拷贝从何而来,以及 C Data Interface、IPC、Flight 三层跨边界传递。讲清 Arrow(内存计算格式)与 Parquet(磁盘存储格式)如何分工衔接。含 pyarrow 实测 C Data Interface 同地址零拷贝。