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

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

文章导航

分类入口
databasestorage
标签入口
#parquet#dictionary-encoding#rle#zstd#compression#byte-stream-split#delta-encoding

目录

第 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 真实执行(环境见第九节),不引用未跑过的倍数。


一、两层模型:编码降熵,压缩压字节

把一列原始值变小,有两个互补的着力点:

  1. 专用编码(encoding):利用「这列数据有什么模式」。时间戳单调递增、ID 自增、地区名只有几十种、浮点是平滑信号——这些模式能让数据用更少的比特表达。编码是类型感知的,知道这是整数还是字符串。
  2. 通用压缩(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-formatEncodings.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 写入器默认优先尝试的编码,机制分两部分:

对低基数列,这一步几乎就把数据压到熵下界附近:一列 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)就是干这个的,结构大致是:

  1. 页头:block size、每 block 的 miniblock 数、总值数、第一个值。
  2. 其余值先转差分 \(d_i = x_i - x_{i-1}\),按 block 分组。
  3. 每个 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 文件时仍可能踩兼容问题,新写入优先 ZSTDLZ4_RAW

这里不引用各库 README 的吞吐数字(不同硬件差异大),具体取舍交给第九节本机实测。要点先记住:没有免费午餐——高压缩比通常要付出更高的压缩(有时解压)CPU,zstd 的 level、gzip 的慢都体现这一点。

这些压缩器内部在做什么

它们都属 LZ 系(找重复子串)+ 可选熵编码,差别在做不做、做多狠:

理解这点就能解释第九节的现象:对已被专用编码降过熵的数据(如 DELTA 后的小差分),LZ 系还能再找重复,但熵编码的边际收益变小(数据已接近随机的小值),于是高 level 收益递减(结果六)。而对纯随机数据,LZ 找不到重复、熵已满,谁都压不动(结果三)。

page 压缩的一个细节:DataPage V1 把 level 和 values 一起压缩;DataPage V2 把 rep/def level 留作不压缩、只压缩 values 段,便于读取时先看 level 再决定是否解压。本实验未刻意切换 page 版本,体积结论以实测为准。


八、为什么顺序是「先编码后压缩」

两层不能交换顺序,原因在于它们处理的对象不同:

所以正确的流水线永远是 原值 → 编码 → 压缩 → 落盘,读取 读盘 → 解压 → 解码 → 原值。这条顺序和 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 MiBwrite_statistics=True
体积口径 pq.write_table 到内存 buffer 的总字节(含 footer),单位 MiB(\(2^{20}\)
读取口径 pq.read_table 全表 + combine_chunks(),墙钟时间(ms)
ratio 以该列 PLAIN/none 体积为基准的倍数(越大越省)

四种列(固定随机种子 42,可复现):

组合记法 编码/压缩PLAIN 直存、DICT 字典、DELTA=DELTA_BINARY_PACKEDBSS=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_f64BYTE_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 或对比体积。这一点容易误判,特此点明。

跨列结论

把六组实验放一起看,三条规律清楚:

  1. 编码必须匹配数据模式:等差整数选 DELTA、低基数选 DICT、浮点选 BSS、高熵随机谁都救不了。错配(如等差整数上字典)不仅无收益还可能变大。
  2. 类型感知编码常胜过纯压缩:低基数列字典 40 倍 vs 纯 zstd 8 倍;等差列 DELTA 15 倍 vs 纯 zstd 7 倍。压缩是兜底,不是首选。
  3. 体积和读取耗时不必同向: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 显式类型

几条值得记的差异:

一句话: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

通用取舍:

这些组合最终如何影响查询引擎在湖上的扫描裁剪与解码成本,见 第 18 章 查询引擎如何读湖;小文件与 compaction 时重选编码/压缩的时机,见 第 17 章


十二、边界与小结

边界

小结

至此第一部分(列式文件格式,第 2–5 章)收束。下一章离开文件格式,进入对象存储的语义与代价——没有数据库进程时,ACID 的地基从这里开始。


上一篇Apache Arrow 内存格式与零拷贝

下一篇对象存储语义与代价

返回 系列目录


参考资料

规范

  1. Apache Parquet, apache/parquet-formatEncodings.md(PLAIN、RLE_DICTIONARY、RLE/bit-packing hybrid、DELTA_BINARY_PACKED、DELTA_LENGTH_BYTE_ARRAY、DELTA_BYTE_ARRAY、BYTE_STREAM_SPLIT)。
  2. Apache Parquet, apache/parquet-formatCompression.md(SNAPPY、GZIP、ZSTD、LZ4_RAW、BROTLI;LZ4 废弃说明)与 PageHeader/DataPage V1/V2。
  3. Zstandard 官方文档(facebook/zstd,level 与压缩/解压特性)。
  4. LZ4 官方文档(lz4/lz4)。
  5. Snappy 官方文档(google/snappy,定位为高速而非最高比)。
  6. DEFLATE,RFC 1951(GZIP 底层算法)。

实现 / 实验

  1. pyarrow 24.0.0(pyarrow.parquet.write_tableuse_dictionary/column_encoding/compression 参数),Python 3.14.5、numpy 2.5.0,Linux WSL2 x86_64,i9-12900K。第九节全部体积与耗时为本机真实执行(3M 行、7 轮中位数)。

系列内

  1. 列存引擎内核 · 压缩与编码(ClickHouse CODEC,本章对照对象)。
  2. 本系列 第 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_RAWZSTD

BROTLI 与 LZO

BROTLI 压缩比高但压缩慢,更适合写一次读多次的冷数据;LZO 历史上在 Hadoop 生态用过,现已少见。主流选择收敛到 ZSTD(要省)和 SNAPPY/LZ4_RAW(要快),其余按特定生态需要再考虑。

FLOAT16 与新类型

较新 Parquet 规范加入了 FLOAT16(半精度)等类型,配合 BYTE_STREAM_SPLIT 用于 ML 特征/embedding 存储(与 第 21 章 相关)。可用性取决于读写两端版本,跨引擎前需确认支持。

写入端的字典参数

pyarrow write_tableuse_dictionary 可传 bool 或列名列表(按列开关),dictionary_pagesize_limit 控制字典回退阈值。对明知高基数的列显式关字典,能省下「先建字典再回退」的无用功。

压缩比的口径陷阱

「压缩比」必须说清分母:是相对原始内存大小、相对 PLAIN 编码、还是相对另一种压缩。本章 ratio 统一以该列 PLAIN/none 体积为分母,跨组合可比。对外引用别人的压缩比时,先问清分母和数据,否则数字没有可比性。

解压在读路径的位置

读 Parquet 的 CPU 主要花在解压 + 解码,落到 第 4 章 的 Arrow buffer 后才进向量化算子。高压缩比(如高 level zstd)省 IO 但增解压 CPU;在 NVMe + 充裕 CPU 上未必更快,在带宽受限或对象存储(第 6 章)上则通常更划算。要按部署实测,别默认「压得越狠越好」。

与行存压缩的区别

行存(如 PostgreSQL 的 TOAST)压缩的是单行的大字段,目标是塞进页;列存压缩的是整列同质数据,目标是扫描带宽。列存能高压缩,根因是同列同质带来的低熵,这是行存按行存储拿不到的结构优势(与 列存引擎 一致)。

同主题继续阅读

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

2026-06-30 · database / storage

【数据湖与开放表格式】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 与编码。

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-18 · database / storage

【列存引擎内核】压缩与编码

ClickHouse 列压缩:LZ4、ZSTD、Delta、DoubleDelta、Gorilla 时序编码与列类型关系;CODEC 链顺序、LowCardinality 与 PG TOAST 对照。压缩比须本机实测,本文不编造倍数。

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 同地址零拷贝。


By .