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

Lesson 04 · 速度

为什么这么快:四层跳过与排序键

读 1 个 row group 还是 44 个,差别 5 倍。决定权在你写文件那一刻。

Parquet 没有 B-tree 索引。它变快靠的是不读——一层层地判断"这块数据肯定不含我要的行,跳过"。这一课把四层跳过讲清楚,然后落到一个结论:排序键是写入侧最重要的一个决定,它决定了前面三层能不能生效。

第一层:列裁剪(永远生效)

最基础也最有效的一层。查询要哪几列,就只读那几块 column chunk:

本机实测 · DuckDB 1.5.5 · 4,320,000 行 · 取 5 次最好成绩
12.2 ms只碰 value 一列
42.1 ms碰全部 7 列
0.2 mscount(*):一列都不碰

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 个):

本机实测 · 「需读 row group 数」由 footer 统计与查询条件比对算出
写入时的排序查询 A:某一小时的时间窗查询 B:某台设备全时段
需读 rg耗时需读 rg耗时
未排序(按到达顺序)44 / 4414.2 ms44 / 449.7 ms
按 ts, device_id1 / 442.8 ms44 / 4414.7 ms
按 device_id, ts44 / 4414.1 ms2 / 442.8 ms

这张表是本课的核心,它说了三件事:

  1. 排序对了,跳过率 97%,快 5 倍(14.2 → 2.8 ms)。
  2. 排序错了,等于没排——按 ts 排的文件做设备点查,44 个 row group 一个没跳,比未排序的还慢一点。
  3. 你只能选一个主排序键。二者不可兼得,必须按查询模式取舍。

怎么写"排好序的 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,让读取器再筛一层:

官方说得很直白:有了它,单行查询在每个列上只需要读一个 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}})
本机实测 · 诚实的负面结果
1.1 ms不开 Bloom 的设备点查
1.2 ms开了 Bloom 的同一查询
47 字节Bloom filter 本身的大小

在已按 device_id 排序的文件上,min/max 已经能精准跳过,Bloom 无事可做。

所以 Bloom filter 的适用判据很窄,记住这三个条件同时满足才开:

  1. 列是高基数(字典编码不划算的那种);
  2. 查询是等值点查(= / IN,不是范围);
  3. 该列不是排序键(是排序键的话 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       高基数乱序列的等值查询才用得上                  ← 按需开
                          ↓
                 真正从磁盘读的字节:原文件的千分之几
动手做 · 8 分钟 · 亲手量跳过率

这个脚本会算出"这个查询需要读几个 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 高基数列,那是另一套考量,问我。

延伸阅读(本课推荐先读这一篇) Apache Parquet — Page Index——ColumnIndex / OffsetIndex 的设计文档,讲清了"单行查询只需读一个 page"是怎么做到的 · Bloom Filter 规范(SBBF 算法与适用判据) · DuckDB — Parquet Tips