Lesson 03 · 体积
为什么这么小:编码与压缩是两层
一个参数把时间戳列从 7.85 MB 压到 0.03 MB。前提是你知道它们是两层。
大多数人对 Parquet 的调优认知停在"用 snappy 还是 zstd"。这一课要说的是:压缩只是第二层,第一层是编码,而编码的收益经常比压缩大一个数量级。分不清这两层,你就只会在 5% 的空间里做选择题。
两层分工
原始值 编码(encoding) 压缩(compression) 落盘
────────── ───────────────────── ──────────────────── ──────
[AHU-05-013, 字典:[AHU-05-013,…] zstd / snappy / gzip 字节
AHU-05-013, → 下标:[0,0,0,1,1,…] → 对编码后的字节做 →
AHU-05-013, 再 RLE:[(0,×3),(1,×2)] 通用无损压缩
AHU-05-014…]
↑ 懂"这是什么数据" ↑ 不懂数据是什么,
按语义压 只找字节重复模式
关键区别:编码知道你的数据是什么——它知道这一列是递增的整数、是重复的字符串、是浮点数;而压缩器只看到一串字节。所以编码能做压缩做不到的事,比如把等间隔时间戳变成一串常数差值。
Parquet 的编码菜单
官方定义了这些编码(Encodings 规范页),实际写入时你会用到的是这几个:
| 编码 | 原理 | 什么时候赢 |
|---|---|---|
PLAIN |
原样按小端存 | 兜底。数据毫无规律时反而最优 |
RLE_DICTIONARY(字典 + 游程) |
建一张"值 → 下标"表,数据页只存下标,下标再用 RLE / bit-packing 压 | 低基数列:租户号、楼栋号、设备号、指标名、枚举、状态位。Parquet 写入器默认就开 |
DELTA_BINARY_PACKED |
只存相邻值的差,差值再按最小位宽打包 | 递增整数:时间戳、自增 ID、序号。时序数据的头号武器 |
DELTA_BYTE_ARRAY(前缀压缩) |
存"与上一个值共享多长前缀 + 剩下的后缀" | 有共同前缀的有序字符串:路径、URL、排序后的编码 |
BYTE_STREAM_SPLIT |
把每个 float/double 的第 1、2、3… 字节各自归成一条流 | 物理量浮点数:相邻值接近时,高位字节流几乎全同。但见下面的实测反例 |
实测:六个写法,同一份数据
全部基于第 1 课生成的 iot_sorted.parquet(4,320,000 行,已按 device_id, ts 排序):
| 写法 | 文件 | ts 列 | value 列 | 写入 |
|---|---|---|---|---|
| ① 默认(zstd) | 21.79 MB | 7.85 MB | 13.77 MB | 0.62 s |
② ts 改 DELTA_BINARY_PACKED | 13.54 MB | 0.03 MB | 13.35 MB | 0.55 s |
③ ② + value 用 BYTE_STREAM_SPLIT | 25.15 MB ⚠ | 0.03 MB | 24.95 MB ⚠ | 0.48 s |
| ④ ② 但完全不压缩 | 49.84 MB | 0.32 MB | 32.98 MB | 0.46 s |
⑤ ② + zstd 提到 15 级 | 12.23 MB | 0.03 MB | 12.00 MB | 3.60 s |
⑥ ② 但换 snappy | 20.45 MB | 0.04 MB | 19.48 MB | 0.49 s |
从这张表能读出四个结论,逐个说。
结论一:编码的收益可以是数量级的
原因:每台设备的采样是严格每分钟一次,差值恒为 60000 毫秒。差分编码把一整列时间戳变成"起点 + 一堆相同的小数"。
为什么默认没这么做?因为默认走的是字典编码——而 ts 列有 21,600 个不同的时间点,字典又大又没帮上忙。写入器不会替你判断"这列是不是递增的",它只有一套默认策略。这就是你必须懂内核的地方。
结论二:字典编码会"抢跑"
结论三:BYTE_STREAM_SPLIT 在这份数据上是负收益
写法 ③ 里 value 列从 13.35 MB 涨到 24.95 MB,几乎翻倍。这不是 bug,是数据性质决定的:
BSS 的假设是"相邻浮点数很接近,因此高位字节重复"。我们的样本值是随机游走后 round(v, 3),尾数部分带着大量随机噪声——拆成字节流之后,低位那几条流是纯随机,反而破坏了 zstd 原本能找到的模式。
没有"最优编码",只有"对这一列的这份数据最优的编码"。任何编码选择都必须在你自己的数据上量一遍,而不是照抄博客。
真实的物理量传感器数据(温度、压力这类变化平缓、精度固定的量)BSS 常常是有效的。你手上的数据属于哪种,只有量了才知道——本课最后的动手环节就是量这件事。
结论四:压缩器的选择只值 5–10%,压缩级别要拿写入时间换
zstd 默认比 snappy 小 34%,写入只慢 12%。再往上提到 15 级,只多省 10%,写入时间却是 6.5 倍。
实用结论:
- 默认用 zstd,级别就用库的默认值(不要手写
compression_level)。snappy 的历史地位来自 Hadoop 时代 CPU 紧张的假设,今天在大多数场景下已经不是更优解。 - 不要轻易调高压缩级别,除非这份数据写一次读一万次(真正的归档冷层),而且你实测过读取端没变慢。
- 不压缩几乎没有使用场景——④ 那一行里
quality列从 0.10 MB 涨到 16.49 MB,压缩器在这种"编码后仍高度重复"的数据上便宜又高效。
下面这个脚本会把同一份数据用几种写法各写一遍,打印体积对比。把 SRC 换成你自己的表就能用:
import os, time import duckdb, pyarrow.parquet as pq SRC = "data/iot_sorted.parquet" DICT = ["tenant_id", "building_code", "device_id", "metric"] # 你的低基数列 TIME = "ts" # 你的递增整数/时间列 NUM = "value" # 你的浮点列 t = pq.read_table(SRC) con = duckdb.connect() variants = { "1_default": dict(compression="zstd"), "2_delta": dict(compression="zstd", use_dictionary=DICT, column_encoding={TIME: "DELTA_BINARY_PACKED"}), "3_delta_bss":dict(compression="zstd", use_dictionary=DICT, column_encoding={TIME: "DELTA_BINARY_PACKED"}, use_byte_stream_split=[NUM]), "4_snappy": dict(compression="snappy", use_dictionary=DICT, column_encoding={TIME: "DELTA_BINARY_PACKED"}), } for name, kw in variants.items(): p = f"data/enc_{name}.parquet" s = time.perf_counter(); pq.write_table(t, p, **kw); w = time.perf_counter() - s mb = os.path.getsize(p) / 1024 / 1024 print(f"{name:14s} {mb:7.2f} MB 写入 {w:.2f}s") print(con.sql(f""" SELECT path_in_schema col, any_value(encodings) enc, round(sum(total_compressed_size)/1024.0/1024, 2) mb FROM parquet_metadata('{p}') GROUP BY 1 ORDER BY mb DESC LIMIT 3"""))
预期结果:1_default 21.79 MB、2_delta 13.54 MB、3_delta_bss 25.15 MB、4_snappy 20.45 MB。看 enc 栏是关键——确认 ts 那行真的变成了 DELTA_BINARY_PACKED,而不是还在 RLE_DICTIONARY。
编码和压缩是两层,编码懂语义、收益常是数量级(ts 列 7.85 → 0.03 MB),压缩只找字节模式、收益是常数倍。默认配置对时序列是次优的:写入器不会替你发现"这列是递增的"。三条可直接用的规则——① 递增整数/时间列显式指定 DELTA_BINARY_PACKED;② 指定任何非字典编码时必须同时收窄 use_dictionary,否则字典抢跑;③ 压缩器选 zstd 默认级别,别调高级别去换那 10%。所有编码选择都要在你自己的数据上实测,BSS 就是个现成的反例。
你给 ts 列指定了 column_encoding={"ts": "DELTA_BINARY_PACKED"},写完发现文件大小完全没变。最可能的原因是什么?
随时问我:跑完上面的 bench 把输出贴给我,我帮你判断每一列该用什么编码——尤其是当你的数据里有 UUID 主键、JSON 字符串或高精度小数时,这三类各有专门的处理办法,本课没展开。
use_dictionary / column_encoding / compression_level 的准确语义) ·
本课程速查表:编码选择决策树