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

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 排序):

本机实测 · pyarrow 25.0.0 · 写入/读取取多次最好成绩
写法文件ts 列value 列写入
① 默认(zstd)21.79 MB7.85 MB13.77 MB0.62 s
② ts 改 DELTA_BINARY_PACKED13.54 MB0.03 MB13.35 MB0.55 s
③ ② + value 用 BYTE_STREAM_SPLIT25.15 MB ⚠0.03 MB24.95 MB ⚠0.48 s
④ ② 但完全不压缩49.84 MB0.32 MB32.98 MB0.46 s
⑤ ② + zstd 提到 15 级12.23 MB0.03 MB12.00 MB3.60 s
⑥ ② 但换 snappy20.45 MB0.04 MB19.48 MB0.49 s

从这张表能读出四个结论,逐个说。

结论一:编码的收益可以是数量级的

① → ② 只改了一个参数
7.85 MBts 列(默认字典编码)
0.03 MBts 列(DELTA_BINARY_PACKED)
−38%整个文件:21.79 → 13.54 MB

原因:每台设备的采样是严格每分钟一次,差值恒为 60000 毫秒。差分编码把一整列时间戳变成"起点 + 一堆相同的小数"。

为什么默认没这么做?因为默认走的是字典编码——而 ts 列有 21,600 个不同的时间点,字典又大又没帮上忙。写入器不会替你判断"这列是不是递增的",它只有一套默认策略。这就是你必须懂内核的地方。

结论二:字典编码会"抢跑"

结论三:BYTE_STREAM_SPLIT 在这份数据上是负收益

写法 ③ 里 value 列从 13.35 MB 涨到 24.95 MB,几乎翻倍。这不是 bug,是数据性质决定的:

BSS 的假设是"相邻浮点数很接近,因此高位字节重复"。我们的样本值是随机游走后 round(v, 3),尾数部分带着大量随机噪声——拆成字节流之后,低位那几条流是纯随机,反而破坏了 zstd 原本能找到的模式。

没有"最优编码",只有"对这一列的这份数据最优的编码"。任何编码选择都必须在你自己的数据上量一遍,而不是照抄博客。

真实的物理量传感器数据(温度、压力这类变化平缓、精度固定的量)BSS 常常是有效的。你手上的数据属于哪种,只有量了才知道——本课最后的动手环节就是量这件事。

结论四:压缩器的选择只值 5–10%,压缩级别要拿写入时间换

同为写法 ②(ts 已用 DELTA),只换压缩器
49.84 MB不压缩 · 0.46 s
20.45 MBsnappy · 0.49 s
13.54 MBzstd 默认 · 0.55 s
12.23 MBzstd 15 级 · 3.60 s

zstd 默认比 snappy 小 34%,写入只慢 12%。再往上提到 15 级,只多省 10%,写入时间却是 6.5 倍。

实用结论:

动手做 · 8 分钟 · 在你自己的数据上量一遍

下面这个脚本会把同一份数据用几种写法各写一遍,打印体积对比。把 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 字符串或高精度小数时,这三类各有专门的处理办法,本课没展开。

延伸阅读(本课推荐先读这一篇) Apache Parquet — Encodings——每种编码的算法细节与适用类型,本课那张菜单表的全部依据 · pyarrow.parquet.write_table 参数文档(use_dictionary / column_encoding / compression_level 的准确语义) · 本课程速查表:编码选择决策树