Lesson 04 · 速度
为什么这么快:四层跳过与排序键
读 1 个 row group 还是 44 个,差别 5 倍。决定权在你写文件那一刻。
Parquet 没有 B-tree 索引。它变快靠的是不读——一层层地判断"这块数据肯定不含我要的行,跳过"。这一课把四层跳过讲清楚,然后落到一个结论:排序键是写入侧最重要的一个决定,它决定了前面三层能不能生效。
第一层:列裁剪(永远生效)
最基础也最有效的一层。查询要哪几列,就只读那几块 column chunk:
3.5 倍差距,而且这份数据只有 7 列。真实宽表往往几十列,差距会更大。
所以第一条纪律很朴素:SELECT * 在 Parquet 上是有明确代价的。在 PostgreSQL 里 SELECT * 和 SELECT a, b 差别不大(反正整行都在一个页里),在列存里完全不是这么回事。
第二层:row group 统计(最重要的一层)
footer 里为每个 row group 的每一列记了 min / max / null 数。读取器拿查询条件跟这些统计一比,就知道整个 row group 能不能跳:
查询:WHERE ts >= '2026-01-08 03:00' AND ts < '2026-01-08 04:00'
row group 0 ts ∈ [01-01 00:00, 01-01 08:19] → 无交集,跳过 ✗
row group 1 ts ∈ [01-01 08:20, 01-01 16:39] → 无交集,跳过 ✗
…
row group 19 ts ∈ [01-07 14:20, 01-07 22:39] → 无交集,跳过 ✗
row group 20 ts ∈ [01-07 22:40, 01-08 06:59] → 有交集,读 ✓
row group 21 ts ∈ [01-08 07:00, 01-08 15:19] → 无交集,跳过 ✗
…
只读了 1/44 个 row group
(以上 min/max 全部取自本课样本文件的 footer 实测值)
但这一层只在数据有序时才有用。如果数据是按到达顺序(也就是乱序)写的,每个 row group 的 ts 范围都是"01-01 到 01-15 全覆盖",一个都跳不掉。
实测:同一份数据,三种排序
把 4,320,000 行分别按三种方式排序后写出(row group 统一设成 100,000 行,共 44 个):
| 写入时的排序 | 查询 A:某一小时的时间窗 | 查询 B:某台设备全时段 | ||
|---|---|---|---|---|
| 需读 rg | 耗时 | 需读 rg | 耗时 | |
| 未排序(按到达顺序) | 44 / 44 | 14.2 ms | 44 / 44 | 9.7 ms |
按 ts, device_id | 1 / 44 | 2.8 ms | 44 / 44 | 14.7 ms |
按 device_id, ts | 44 / 44 | 14.1 ms | 2 / 44 | 2.8 ms |
这张表是本课的核心,它说了三件事:
- 排序对了,跳过率 97%,快 5 倍(14.2 → 2.8 ms)。
- 排序错了,等于没排——按
ts排的文件做设备点查,44 个 row group 一个没跳,比未排序的还慢一点。 - 你只能选一个主排序键。二者不可兼得,必须按查询模式取舍。
怎么写"排好序的 Parquet"
# 排序后再写,并把排序信息记进 footer(读取器可据此做更激进的优化) sorted_tbl = table.sort_by([("device_id", "ascending"), ("ts", "ascending")]) pq.write_table( sorted_tbl, "out.parquet", compression="zstd", row_group_size=200_000, sorting_columns=pq.SortingColumn.from_ordering( sorted_tbl.schema, [("device_id", "ascending"), ("ts", "ascending")]), )
COPY (SELECT * FROM src ORDER BY device_id, ts) TO 'out.parquet' (FORMAT parquet, COMPRESSION zstd, ROW_GROUP_SIZE 200000);
第三层:Page Index(页级跳过)
row group 跳完之后,剩下那个 row group 里还有几十上百个 page。Page Index 就是把每一页的 min/max 也集中收进 footer,让读取器再筛一层:
- ColumnIndex:每页的 min/max/null 数——按值找页。列有序时可以直接二分查找。
- OffsetIndex:每页的字节偏移和起始行号——按行号找页。用来把"第 12 页"翻译成"从文件第 N 字节读 M 字节",也用来在跳过某列的行之后,让其他列跳到对应位置。
官方说得很直白:有了它,单行查询在每个列上只需要读一个 data page。
第四层:Bloom filter(高基数点查的补丁)
前三层都靠 min/max,它们对范围查询有效。但如果列是乱序的高基数值(比如 UUID、订单号),min/max 覆盖全域,等于没有。Bloom filter 补的就是这个洞:一个紧凑的位图,能确定地回答"这块里肯定没有 X",代价是偶尔误报"可能有"。
Parquet 用的是 Split Block Bloom Filter(SBBF),哈希函数是 XXH64。写入时可以指定预估基数(NDV)和期望误判率(FPP):
pq.write_table(t, "out.parquet", compression="zstd", bloom_filter_options={"device_id": {"ndv": 200, "fpp": 0.01}})
在已按 device_id 排序的文件上,min/max 已经能精准跳过,Bloom 无事可做。
所以 Bloom filter 的适用判据很窄,记住这三个条件同时满足才开:
- 列是高基数(字典编码不划算的那种);
- 查询是等值点查(
=/IN,不是范围); - 该列不是排序键(是排序键的话 min/max 已经够了)。
典型例子:按时间排序的日志文件里查某个 trace_id。
四层合起来
SELECT avg(value) FROM t WHERE device_id='AHU-05-013' AND ts >= …
① 列裁剪 7 列 → 只读 device_id / ts / value 三块 ← 永远生效
② rg 统计 44 个 row group → 2 个 ← 要求排序键选对
③ Page Index 该 rg 内 100 页 → 3 页 ← 要求 write_page_index=True
④ Bloom 高基数乱序列的等值查询才用得上 ← 按需开
↓
真正从磁盘读的字节:原文件的千分之几
这个脚本会算出"这个查询需要读几个 row group",是判断文件写得好不好的硬指标:
import duckdb con = duckdb.connect() FILE, COL = "data/iot_sorted.parquet", "ts" LO, HI = "2026-01-08 03:00:00", "2026-01-08 04:00:00" print(con.sql(f""" SELECT count(*) FILTER ( NOT (stats_max_value < '{LO}' OR stats_min_value > '{HI}') ) AS 需读, count(*) AS 总数 FROM parquet_metadata('{FILE}') WHERE path_in_schema = '{COL}'"""))
怎么判读:需读 / 总数 接近 1 说明这个查询完全没享受到跳过——要么排序键选错了,要么 row group 太大。把 FILE 换成按不同键排序的版本对比,你会看到 1/44 与 44/44 的差别。
顺手对比一下第 1 课的两个文件:iot_unsorted.parquet(未排序)和 iot_sorted.parquet(按 device 排序),用 COL='device_id' 各跑一次。
Parquet 靠四层跳过变快:列裁剪(永远生效)→ row group 统计(最重要)→ Page Index(要显式开)→ Bloom filter(窄场景补丁)。中间两层的效果完全取决于写入时的排序键和 row group 大小:同一份数据、同一个查询,排序对了读 1 个 row group(2.8 ms),排序错了读 44 个(14.2 ms)。你只能选一个主排序键,所以要按查询模式取舍——通常的组合拳是"分区管一个维度,排序管另一个维度"。另外记住:SELECT * 在列存上是有真实代价的。
一个按 ts 排序的 Parquet,被用来查"某台设备的全部历史",结果 44 个 row group 一个都没跳过。最该改的是什么?
随时问我:把你真实的查询语句(哪怕是 PG 上的)发给我,我帮你反推该选什么排序键、该不该开 Page Index。如果你的查询里有 JOIN 或 GROUP BY 高基数列,那是另一套考量,问我。