一条
SELECT ... WHERE event_ts = DATE '2026-06-04'
打在 Iceberg 表上,引擎不会把对象存储里几千个 Parquet
文件都拉下来再过滤。它先读元数据:从 snapshot 找到
manifest,用 manifest
里的分区值和列统计裁掉不相关的文件,再在选中的 Parquet
文件里用 column index 跳过 row group 和
page,最后才解压真正需要的列块。
这条「下推链路」决定了湖上查询是扫 1 个文件还是扫 1000 个。本文按四层拆开:
- partition pruning:用 manifest 里的分区数据裁掉整批文件。
- file pruning:用 manifest 里的列级 min/max、null count 裁掉单个数据文件。
- row-group / page pruning:进到 Parquet 文件内部,用 column index 跳过 row group 和 page。
- 字典过滤:用 page 的 dictionary 做等值/IN 过滤,连解码都省掉。
然后对照 Trino、Spark、DuckDB、DataFusion、ClickHouse 在这条链路上的能力差异,最后讲 planning 在哪一层完成、stats 从哪来。
实测环境:本机 12th Gen Intel Core i9-12900K(24 逻辑核)、32 GB 内存、Arch Linux on WSL2(内核 6.6.87.2)。组件版本:Python 3.14.5、DuckDB 1.5.4、PyArrow 24.0.0、PyIceberg 0.11.1。本机未安装 Trino / Spark / Flink / ClickHouse(无 JVM)。涉及 Trino/Spark/ClickHouse 的行为均标注为引用官方文档,不伪造其计划输出;DuckDB 与 PyIceberg 的部分为本机真实执行。
一、下推链路总览
把一次读湖看成一个漏斗:每一层都拿到上一层的输出,用更细的元数据进一步裁剪,直到只剩真正要解压的 page。
flowchart TB
SQL["SQL + WHERE 谓词"] --> META["读表元数据指针 (catalog)"]
META --> SNAP["snapshot / manifest list"]
SNAP --> L1["① partition pruning<br/>manifest 分区值"]
L1 --> L2["② file pruning<br/>manifest 列 min/max/nullcount"]
L2 --> FILES["选中的 data files"]
FILES --> L3["③ row-group / page pruning<br/>Parquet column index"]
L3 --> L4["④ 字典过滤<br/>page dictionary"]
L4 --> DEC["解压 + 向量化执行"]
四层的元数据来源不同,裁剪粒度也不同:
| 层 | 裁剪粒度 | 元数据来源 | 在哪读 |
|---|---|---|---|
| ① partition pruning | 一组文件(一个分区) | manifest entry 的 partition 字段 |
元数据层,不开数据文件 |
| ② file pruning | 单个数据文件 | manifest entry 的
lower_bounds/upper_bounds/null_value_counts |
元数据层,不开数据文件 |
| ③ row-group / page pruning | 文件内 row group 与 page | Parquet footer 的 Statistics + column index
/ offset index |
打开数据文件读 footer |
| ④ 字典过滤 | page 内的值 | Parquet dictionary page | 读 dictionary page |
前两层都在「不打开数据文件」的前提下完成,这是开放表格式相对
Hive 目录式分区表的关键优势:Hive 要 LIST
目录、读每个文件的 footer 才知道能不能跳;Iceberg/Delta
把这些统计提前收进 manifest / 事务日志,planning
只读元数据。
这条链路在第 8 篇 Iceberg 元数据树 与第 2 篇 Parquet 格式 里已经分别拆过元数据和文件结构;本篇把它们串成「引擎视角」的一条读路径。
二、partition pruning(manifest 层)
2.1 分区值存在 manifest entry 里,不在路径里
Hive
表的分区是目录名:/event_date=2026-06-04/,引擎靠
LIST 目录拿分区值。Iceberg
不靠目录,每个数据文件在 manifest 里有一条 entry,entry
携带该文件的 partition 结构体(按 partition
spec 求值后的分区元组)。引擎把 WHERE
里的分区谓词转成对 manifest entry 的过滤,扫 manifest
就能裁掉整批文件,完全不碰目录结构。
Iceberg 的分区是「隐藏分区」:用户写
WHERE event_ts >= TIMESTAMP '2026-06-04 00:00:00' AND event_ts < ...,即使表按
day(event_ts)
分区,引擎也能把时间戳谓词转换成对 day
transform 结果的约束去裁分区。Trino
官方博客把这个过程描述为「constant folding +
把谓词改写成对常量/常量区间的比较,从而把过滤下推到 Iceberg
元数据层」,结果是「partition pruning
发生在表的元数据层,而不是在数据之上过滤,显著减少实际扫描的数据文件数」(Trino
blog, Just the right time date predicates with
Iceberg, 2023-04-11)。
2.2 隐藏分区让用户少写错谓词
Hive 上常见的事故是查询写了
WHERE ts >= '...' 却没写分区列
WHERE dt = '...',导致全表扫。Iceberg 的
transform(day/month/bucket[N]/truncate[W])把分区表达式记在
spec
里,引擎自动从原始列谓词推导分区约束,避免「分区裂脑」(见第
9
篇 隐藏分区)。这是 partition pruning
在工程上真正省心的地方:裁剪不依赖用户记得分区列名。
2.3 边界:分区谓词不可推导时退化
如果谓词作用在分区列上但无法折叠成常量区间(例如
WHERE day(event_ts) = day(now()) 里
now() 非确定性,或对分区列套了引擎不认识的
UDF),partition pruning 退化为不裁剪,进入 file pruning
兜底。这也是为什么生产上对大表常配
iceberg.query-partition-filter-required(Trino),强制查询带分区过滤,否则拒绝执行,防止误触发全表扫(Trino
481 docs, Iceberg connector)。
三、file pruning(manifest stats)
3.1 manifest 里的列级统计
partition pruning
裁到分区级别后,同一分区里可能仍有很多文件。第二层靠
manifest entry 里的列级统计继续裁单个文件。Iceberg manifest
的每条 data_file entry 带这些字段(Iceberg
table spec, manifest 部分):
lower_bounds/upper_bounds:每列的最小/最大值(按 field ID 映射)。null_value_counts/nan_value_counts:每列 null / NaN 计数。value_counts:每列值计数。record_count/file_size_in_bytes:行数与文件字节数。
引擎把 WHERE col BETWEEN a AND b
这类范围谓词,与每个文件的
[lower_bounds[col], upper_bounds[col]]
求交:不相交则整文件跳过。等值谓词 col = v 检查
v 是否落在
[lower, upper];col IS NULL 检查
null_value_counts[col] > 0。
3.2 file pruning 依赖数据布局
file pruning
的效果取决于「同一列的值在文件之间是否聚集」。如果数据按某列排序写入,各文件的
[lower, upper]
区间互不重叠,范围谓词能精确命中少数文件;如果该列在每个文件里都是全范围分布,min/max
区间几乎都覆盖谓词,裁剪失效。这正是第 17 篇 小文件与
Compaction 里 sort / z-order rewrite
要解决的问题:通过重排数据让 min/max 更「紧」,提升 file
pruning 命中率。
下面第八节的实测里,user_id
单调递增写入,各文件 user_id
区间不重叠,WHERE user_id IN [450000,470000)
能从 10 个文件裁到 1 个;而 country
每个文件都同时含
CN/US,WHERE country = 'CN'
一个文件都裁不掉。
3.3 stats 的「新鲜度」问题
manifest 里的 stats 是写入时落的。如果用
add_files 把外部 Parquet 直接挂进 Iceberg
表,或某些写入路径没收集完整 column stats,manifest 的
bounds 可能缺失或不精确,file pruning 退化。Delta
侧类似:_delta_log 的 add action
带 stats(min/max/nullCount),但默认只对前 N
列(dataSkippingNumIndexedCols,默认
32)收集统计,超出的列不参与 data
skipping。这点在第二十篇的「stats
是否新鲜」陷阱里会再展开。
四、row-group / page pruning(Parquet column index)
裁到具体数据文件后,引擎打开 Parquet 文件,进入文件内部的第三层。
4.1 Parquet 的两级统计
一个 Parquet 文件切成多个 row group,每个 row group
的每个 column chunk 在
footer(FileMetaData)里带
Statistics(min/max/null_count/distinct_count)。这让引擎能在
row group 级别跳过。Parquet 还有更细的 column
index 与 offset
index(PageIndex),把每个 page 的 min/max
单独存在文件尾部一块连续区域,引擎可以只读 PageIndex
就定位到 row group 内哪些 page 命中谓词,做 page
级跳过(apache/parquet-format, PageIndex 规范;见第 2
篇)。
行级删除场景里,这一层还要叠加 delete file / deletion vector 的合并(第 10 篇 行级删除与 MoR):row group 选中后,position delete / DV 标记的行在读出后被过滤。
4.2 引擎如何用它
引擎读 Parquet 的典型顺序:
flowchart LR
OPEN["打开文件读 footer"] --> RGS["遍历 row group 统计"]
RGS --> SKIP1["min/max 不相交 -> 跳过 row group"]
RGS --> PI["读 PageIndex"]
PI --> SKIP2["page min/max 不相交 -> 跳过 page"]
SKIP2 --> DICT["读 dictionary page"]
DICT --> DEC["解压选中 page"]
DuckDB 的 delta 扩展文档明确列出它复用
Parquet scan 的能力:「data skipping/filter pushdown:基于
Parquet metadata 跳过文件内的 row group;基于 Delta
分区信息跳过整个文件;projection pushdown」(DuckDB docs,
Delta extension, Features)。Trino 的 Hive/Iceberg
连接器同样「把 dynamic filter 下推进 ORC 和 Parquet reader
做 stripe 或 row-group pruning」(Trino 481 docs, Dynamic
filtering)。
4.3 row group 大小是个权衡
row group 越小,min/max 越紧、跳得越准,但 footer 元数据越多、每个 page 的压缩率越低;row group 越大,IO 越连续但裁剪粒度越粗。第八节实测里用 100000 行一个 row group,500 万行的文件切成 100 个 row group,一个窄区间谓词在排好序的文件上只需读约 1 个 row group。
五、字典过滤与谓词重写
5.1 字典过滤
Parquet 列若用字典编码(dictionary encoding),page 前面有一个 dictionary page 存去重后的值集合,数据 page 存字典下标。对等值/IN 谓词,引擎可以先读 dictionary page,检查目标值是否在字典里:不在则整 page 跳过,连下标都不用解码;在则把谓词转成「下标等于某几个值」,在编码域上直接过滤,省掉大量解码。这是「字典过滤」,是 row group/page pruning 之后最后一层省 CPU 的裁剪。
字典过滤对低基数列(如
country、status、枚举)特别有效,但对高基数列字典会退化(超过
page 字典大小上限后转回 PLAIN
编码),此时这层失效,落到普通谓词求值。
5.2 谓词重写:让裁剪能用上
很多裁剪能不能生效,取决于谓词能不能被改写成「列 op
常量」的形式。Trino 的做法是 constant folding 与
desugaring:把
WHERE event_ts >= date_trunc('day', ...)
这类表达式先在优化期算成常量区间,再下推(Trino blog,
2023-04-11)。如果谓词里把列包在函数里(WHERE cast(event_ts as date) = ...、WHERE lower(country) = 'cn'),而引擎不能反推出对原始列的约束,min/max
裁剪就用不上——这是写查询时最常见的「裁剪失效」原因。
5.3 投影下推(projection pushdown)
谓词下推裁的是「行」,投影下推裁的是「列」。SELECT a, b FROM t
只读 a、b 两个 column
chunk,其余列的字节根本不从对象存储拉取。对宽表这是数量级的
IO 节省。Trino 的
iceberg.projection-pushdown-enabled、DuckDB 的
Parquet/Delta/Iceberg scan
默认都支持投影下推与下推到嵌套字段的子列。列式格式 +
投影下推,是湖上分析查询比行存便宜的根本原因(对照第 4 篇
Arrow 与列存系列)。
六、planning 在哪一层完成、stats 从哪来
6.1 两阶段:metadata planning 与 data scan
读湖的 planning 分两段:
- metadata planning:读 catalog 指针 →
snapshot/事务日志 → manifest/
addactions,应用 partition pruning 与 file pruning,产出「要扫的文件清单 + 每个文件要应用的残余谓词 + 关联的 delete 文件」。这一段只读元数据。 - data scan:对清单里每个文件打开 footer,做 row-group/page pruning 与字典过滤,解压列块,进向量化执行。这一段才碰数据。
Iceberg 的 metadata planning 由
TableScan/planFiles 完成,可以在
driver/coordinator 端集中做,也可以下放。PyIceberg 的
table.scan(row_filter=...).plan_files()
就是这一段的纯元数据实现——第八节用它直接数「裁剪后还剩几个文件」。
flowchart TB
subgraph MP["metadata planning(只读元数据)"]
C["catalog 指针"] --> S["snapshot / manifest list"]
S --> M["manifest entries"]
M --> PP["partition pruning"]
PP --> FP["file pruning (min/max)"]
FP --> TASKS["FileScanTask 清单"]
end
subgraph DS["data scan(打开数据文件)"]
TASKS --> RG["row-group/page pruning"]
RG --> EX["解压 + 执行"]
end
6.2 stats 从哪来
| stats 类型 | 存放位置 | 谁写的 | 用途 |
|---|---|---|---|
| 分区值 | manifest entry partition |
写入时按 spec 求值 | partition pruning |
| 列 min/max/nullcount | manifest entry bounds(Iceberg)/
add.stats(Delta) |
写入或 compaction 时 | file pruning |
| row group / page min/max | Parquet footer + PageIndex | Parquet writer | row-group/page pruning |
| NDV / 直方图 | Iceberg Puffin(如 Theta sketch)/ Delta
ANALYZE |
单独的 stats 收集 | CBO(join 顺序、聚合策略) |
注意区分两类 stats:裁剪用的 min/max
随数据写入产生,几乎总是有;CBO 用的
NDV/直方图 通常要单独收集(Iceberg 的 Puffin
文件、Trino 的 ANALYZE、Spark 的
ANALYZE TABLE),缺了不影响正确性但影响
join/聚合计划质量。Trino 的
iceberg.table-statistics-enabled 控制是否把
Iceberg 统计喂给 CBO(Trino 481 docs;LakeOps, Trino+Iceberg
优化指南,B 级)。Puffin 与 NDV sketch 的细节见第 17 篇。
七、引擎对照:Trino / Spark / DuckDB / DataFusion / ClickHouse
各引擎读 Iceberg/Delta 的「能不能读、读到什么程度、planning 在哪做」差异很大。下表按 A 级官方文档归纳能力(截至各引擎 2026 年文档),口径为「开源自托管、读路径」,不含云托管特性。
| 引擎 | Iceberg | Delta | 行级 delete / DV | planning 位置 | 备注 |
|---|---|---|---|---|---|
| Trino | 连接器读写,分区/文件/row-group 裁剪 + 动态过滤 | 连接器读写 | 支持 position/equality delete、DV | coordinator 分布式 planning,split 级分发 | query-partition-filter-required
可强制分区过滤 |
| Spark | 官方 runtime 读写,全功能 | 原生(Delta 主场) | 支持 | driver planning + executor 扫描 | 写入/维护 procedure 主力引擎 |
| DuckDB | iceberg 扩展(原生实现,无第三方 Iceberg
库依赖);iceberg_scan 直读,REST catalog
ATTACH 后可写 |
delta 扩展(基于 delta-kernel-rs),读 +
有限 blind insert |
Delta DV 读支持;Iceberg v3 部分读 | 单进程内 planning | 适合单机/嵌入式分析 |
| DataFusion | 通过 iceberg-rust(社区 crate)读 |
通过 delta-rs/deltalake
读 |
取决于 crate 版本 | 单进程,Arrow 原生 | 作为库被嵌入(如 Comet、各类引擎底座) |
| ClickHouse | iceberg 表函数/引擎读 |
deltaLake 表函数/引擎读 |
以版本文档为准 | 单实例/分布式 | 读外部表,非主存储 |
几个关键差异点:
- planning 是否分布式:Trino/Spark 在 coordinator/driver 做 metadata planning,再把文件切成 split 分发给 worker,适合大表大集群;DuckDB/DataFusion 单进程内完成 planning 与扫描,适合单机或嵌入式。
- 动态过滤(dynamic filtering):Trino 在 join 时从 build 侧(小维表)收集 join key,运行时下推成 probe 侧(大事实表)的谓词,进而触发 probe 侧的分区/row-group 裁剪(Trino 481 docs, Dynamic filtering;episode 11)。这对「大事实表 join 小维表、原查询没有显式过滤事实表」的场景能裁掉大部分文件。DuckDB 等单机引擎也有类似的 join 过滤下推,但形态不同。
- 写入与维护:Spark 是 Iceberg 写入与维护 procedure 的主力(migrate / rewrite_data_files / expire_snapshots,见第二十篇);DuckDB 的 Iceberg 写入需 attach REST catalog;ClickHouse/DataFusion 偏读。
- 实现独立性:DuckDB 的
iceberg扩展是原生实现、不依赖 Java Iceberg 库;delta扩展用 delta-kernel-rs。这让它们在没有 JVM 的环境也能读湖(DuckDB docs, Lakehouse formats)。
选型不是「谁更强」,而是「planning 模型与你的部署匹配吗」:要分布式扫 PB 级、要动态过滤、要在湖上做写入维护,Trino/Spark;要单机/嵌入式/CLI 快速查湖,DuckDB;要把读湖能力嵌进自己的 Rust 引擎,DataFusion + iceberg-rust。
7.1 Trino
Trino 把 metadata planning 放在 coordinator:解析 SQL 后由 Iceberg 连接器读 snapshot/manifest,应用 partition pruning 与 file pruning,产出 split(一个 split 通常对应一个数据文件或文件的一段),分发给 worker。worker 打开 Parquet/ORC 做 row-group/page 裁剪与解压。
Trino 在这条链路上的特色能力:
- 动态过滤:join 时从 build 侧收集
key,运行时下推到 probe 侧扫描,触发分区/row-group
裁剪(
iceberg.dynamic-filtering.wait-timeout控制 split 生成等待时长)。 - 强制分区过滤:
iceberg.query-partition-filter-required+...-schemas对指定 schema 强制查询带分区谓词,否则拒绝,防止误全表扫。 - CBO 接入 Iceberg
统计:
iceberg.table-statistics-enabled,配合 Iceberg Puffin 里的 NDV sketch 做 join 排序。 optimize过程:ALTER TABLE ... EXECUTE optimize在 Trino 内做 compaction,分区表按分区独立合并(Trino 481 docs)。
适合:多租户、大集群、交互式 BI、联邦查询(一条 SQL 跨 Iceberg + 其它连接器)。
7.2 Spark
Spark + Iceberg runtime 是 Iceberg
功能最全的引擎,既能读也能写,还承载维护
procedure(migrate/rewrite_data_files/expire_snapshots
等,见第二十篇)。读路径上 driver 做 metadata
planning,executor 扫描。Spark 的
PushedFilters/PartitionFilters 在
explain() 里可见,用来确认谓词是否下推到
scan。
Spark 是 Delta 的主场(Delta 由 Databricks 主推、生态围绕 Spark)。如果团队已有 Spark 批处理栈,读写一体、维护 procedure 齐全是它的最大优势;代价是单查询启动开销和资源调度比 Trino/DuckDB 重,不适合低延迟交互。
7.3 DuckDB
DuckDB 的 iceberg 扩展是原生实现,不依赖
Java Iceberg 库;delta 扩展基于
delta-kernel-rs。两者都在第一次使用时自动加载。读法:
-- 直读单表(只读,无需 catalog)
SELECT count(*) FROM iceberg_scan('s3://bucket/db/events/metadata/v5.metadata.json')
WHERE event_ts >= DATE '2026-06-04';
-- 看 Iceberg 元数据
SELECT * FROM iceberg_metadata('.../v5.metadata.json');
-- attach REST catalog 后可写(INSERT/UPDATE/DELETE/MERGE INTO)
ATTACH '' AS lake (TYPE iceberg, ...);DuckDB 的读路径在单进程内完成 planning 与扫描,复用其
Parquet scan 的 data skipping(跳 row
group/file)、projection
pushdown、多线程扫描。适合:单机分析、CLI
临时查湖、嵌入式(应用进程内直接读湖)、在没有 JVM 的环境读
Iceberg/Delta。delta 扩展当前为读 + 有限 blind
insert(INSERT INTO),不是完整写引擎。
7.4 DataFusion
DataFusion 是 Rust 写的查询引擎库(Arrow 原生),读
Iceberg 走社区 iceberg-rust crate,读 Delta 走
delta-rs/deltalake。它更多是「被嵌入」而非独立部署:很多新引擎(如
Spark 的向量化加速 Comet、各类自研 OLAP)以 DataFusion
为执行底座。能力随所用 crate 版本变化,选用前要核对 crate 对
spec 版本(V2/V3、DV、equality
delete)的支持程度。适合:自研引擎、需要把读湖能力嵌进 Rust
服务的场景。
7.5 ClickHouse
ClickHouse 通过 iceberg /
deltaLake
表函数和对应表引擎读外部湖表,定位是「把湖表当外部数据源读进
ClickHouse 做分析」,而非把湖当主存储(主存储仍是
MergeTree,见 列存引擎系列)。行级
delete/DV、写入支持以具体版本文档为准。适合:已有 ClickHouse
栈、想直接查对象存储上的 Iceberg/Delta
数据,而不想引入额外引擎。
八、读路径的代价模型
把裁剪的收益写成一个简单模型,能解释「为什么数据布局比引擎更决定性能」。
设表共 \(F\) 个数据文件,谓词在分区列上的选择性使 partition pruning 保留比例为 \(p_{\text{part}} \in (0,1]\),在排序/聚簇列上 file pruning 保留比例为 \(p_{\text{file}} \in (0,1]\),文件内 row-group pruning 保留比例为 \(p_{\text{rg}} \in (0,1]\)。则真正需要解压的数据量近似为:
\[ \text{scanned} \approx F \cdot p_{\text{part}} \cdot p_{\text{file}} \cdot p_{\text{rg}} \cdot \bar{s}_{\text{rg}} \]
其中 \(\bar{s}_{\text{rg}}\) 是单个 row group 的平均字节。三个比例相乘,意味着任意一层失效(比例接近 1)都会拖垮整体:分区设计不当 \(p_{\text{part}}\to 1\)、数据未按过滤列排序 \(p_{\text{file}}\to 1\)、row group 过大 \(p_{\text{rg}}\) 难压低。
file pruning 的保留比例与数据布局直接相关。若某列在文件间「区间重叠度」为 \(o\)(\(o=0\) 完全不重叠、\(o=1\) 完全重叠),一个选择性为 \(\sigma\) 的范围谓词命中的文件比例近似:
\[ p_{\text{file}} \approx \sigma + o \cdot (1 - \sigma) \]
\(o=0\)(理想排序)时
\(p_{\text{file}}\approx\sigma\),裁剪逼近选择性上限;\(o=1\)(完全打乱,如第八节的
country)时 \(p_{\text{file}}\approx
1\),一个文件都裁不掉。这就是第 17 篇
sort/z-order rewrite 把 \(o\) 往 0
压的意义:它不改变引擎,只改变 \(p_{\text{file}}\)。
实测对照:第八节实验一里 user_id
单调写入(\(o\approx
0\)),user_id ∈ [450000,470000) 选择性
\(\sigma=0.02\),实际从 10
个文件裁到 1 个(\(p_{\text{file}}=0.1\),受文件粒度离散化限制,已逼近
\(\sigma\)
对应的下限);country='CN' 因 \(o=1\),\(p_{\text{file}}=1\),10
个全扫。
九、实验:manifest 裁剪与 row-group 裁剪实测
本节两个实验都在上文声明的本机环境真实执行。实验一用
PyIceberg 直接数「metadata planning
裁剪后剩几个文件」(确定性结果);实验二用 DuckDB
测「row-group pruning 对扫描耗时的影响」(5
轮取中位数)。本机无 Trino/Spark,故不做跨引擎对比,改为用
PyIceberg 的 plan_files() 暴露 Iceberg 自身的
metadata planning 结果,再用 DuckDB 展示 Parquet
文件内裁剪——两者合起来覆盖前四层中的①②③。
9.1 实验一:Iceberg manifest 层裁剪(partition + file pruning)
建一张按 day(event_ts) 隐藏分区的 Iceberg
表(SQLite catalog + 本地 warehouse),分 10 天各 append
一次,每天 10
万行,得到每天一个数据文件。user_id
全局单调递增(各文件区间不重叠),country
每个文件都含
CN/US。建表与写入核心代码:
spec = PartitionSpec(
PartitionField(source_id=1, field_id=1000, transform=DayTransform(), name="event_day")
)
tbl = catalog.create_table("db.events", schema=schema, partition_spec=spec)
# 10 天,每天 append 10 万行;user_id 跨天单调递增
for d in range(10):
tbl.append(batch_for_day(d))用 plan_files()
数不同谓词裁剪后剩余的数据文件数(纯 metadata
planning,不打开数据文件):
def count_files(scan):
return len(list(scan.plan_files()))
total = count_files(tbl.scan()) # 全表
day_range = count_files(tbl.scan(row_filter=And( # 分区裁剪
GreaterThanOrEqual("event_ts", "2026-06-04T00:00:00"),
LessThan("event_ts", "2026-06-05T00:00:00"))))
uid_filter = count_files(tbl.scan(row_filter=And( # 文件 min/max 裁剪
GreaterThanOrEqual("user_id", 450_000),
LessThan("user_id", 470_000))))
country = count_files(tbl.scan(row_filter=EqualTo("country", "CN"))) # 无法裁本机真实输出(确定性,不随轮次变化):
| 谓词 | 裁剪类型 | 扫描数据文件数 |
|---|---|---|
| 无(全表) | — | 10 |
event_ts 落在 2026-06-04 当天 |
partition pruning | 1 |
user_id ∈ [450000, 470000) |
file pruning(min/max) | 1 |
country = 'CN' |
无法裁剪 | 10 |
结论印证前文:分区谓词在 manifest 分区值上裁到 1
个文件;user_id 因各文件区间不重叠,靠 manifest
的 lower_bounds/upper_bounds
也裁到 1 个文件;country 每文件都有
CN,min/max
区间都覆盖谓词,一个都裁不掉——这不是 bug,而是数据布局决定了
file pruning 的上限。
9.2 实验二:Parquet row-group pruning(DuckDB)
写两个内容相同、布局不同的 Parquet
文件:sorted.parquet 按 k
升序、shuffled.parquet 把 k
打乱,各 500 万行、row_group_size=100000(即
100 个 row group)。对同一个窄区间谓词
k BETWEEN 2500000 AND 2509999(命中 1 万行)跑
5 轮,比较耗时:
Q = "SELECT count(*), sum(v) FROM read_parquet('{p}') WHERE k BETWEEN 2500000 AND 2509999"
# 每轮 disable_object_cache 后计时,跑 5 轮本机真实结果:
| 文件布局 | row group 数 | 5 轮耗时(ms) | 中位数(ms) |
|---|---|---|---|
| sorted(按 k 排序) | 100 | 3.4 / 2.5 / 2.7 / 2.6 / 2.5 | 2.6 |
| shuffled(k 打乱) | 100 | 14.8 / 6.6 / 6.1 / 6.3 / 6.1 | 6.3 |
两个文件的 Parquet scan 算子在
EXPLAIN ANALYZE 里都输出 10000
行(过滤后命中行数相同),但 sorted
的中位数耗时约为 shuffled 的 0.41
倍。差异来源是 row-group 跳过:sorted
文件里目标区间只落在约 1 个 row group,DuckDB 用 Parquet
footer 的 min/max 跳过其余约 99 个;shuffled
文件里 1 万个命中行散布在全部 100 个 row
group,一个都跳不掉,必须解压全部。
EXPLAIN ANALYZE 中
sorted.parquet
的扫描算子片段(本机执行,经删减):
TABLE_SCAN
Function: READ_PARQUET
Filters: k>=2500000 AND k<=2509999
Total Files Read: 1
...
10,000 rows
9.3 实验的边界
- 这两个实验覆盖下推链路的①②③层;第④层(字典过滤)对低基数列才显著,未单独构造。
- 实验二的绝对耗时受本机 OS page cache 影响(文件已在内存),主要反映 CPU 解压差异 而非冷读 IO;对象存储上冷读的绝对差距会更大,但相对趋势一致。
- 未做 Trino vs DuckDB 跨引擎扫描文件数对比(本机无
Trino/JVM)。可复现的跨引擎步骤:用本节实验一生成的 Iceberg
表,分别在 Trino(
icebergcatalog)跑EXPLAIN ANALYZE看input rows/input size,在 DuckDB(iceberg_scan)跑同一查询看Total Files Read,对比两者裁剪后的文件数与扫描量。
完整脚本与依赖见本机
/tmp/lake_exp/(PyIceberg + PyArrow +
DuckDB,venv 安装)。
实验一覆盖①②层、实验二覆盖③层,对应第八节代价模型里的 \(p_{\text{part}}\)、\(p_{\text{file}}\)、\(p_{\text{rg}}\) 三个因子。
十、小结
读湖的下推链路是四层漏斗:partition pruning(manifest 分区值)→ file pruning(manifest 列 min/max)→ row-group/page pruning(Parquet column index)→ 字典过滤。前两层只读元数据、不开数据文件,是开放表格式相对 Hive 的核心优势;后两层进 Parquet 文件内部,靠 footer 与 PageIndex 跳块。
裁剪效果不只取决于引擎,更取决于数据布局(决定 min/max 紧不紧)和谓词写法(能否折叠成对原始列的约束)。本机实测里,分区谓词与单调列谓词都能裁到 1/10 的文件,而每文件全分布的列一个都裁不掉;Parquet 排序文件的 row-group 跳过让同一查询快 2.4 倍。
引擎选型看 planning 模型与部署匹配:Trino/Spark 分布式 planning + 动态过滤,适合大集群;DuckDB/DataFusion 单进程,适合单机与嵌入式。下一篇进入写入侧——流式写入与 CDC 如何稳定入湖,以及它和这条读路径在小文件上的拉扯。
上一篇:小文件与 Compaction
下一篇:流式写入与 CDC 入湖
返回 系列目录
附录、工程注记
A. 下推能力自检清单
排查「为什么我的湖查询还是扫了全表」时,按顺序核对:
- 谓词是否作用在分区列或其 transform 上?是否被函数包裹导致无法折叠?
- 表是否按谓词列排序/聚簇过?没有的话 file pruning 命中率天然低(去做 sort rewrite,见第 17 篇)。
- manifest/Delta log 的列 stats
是否存在?
add_files挂入的文件可能缺 stats。 - Delta 的
dataSkippingNumIndexedCols(默认 32)是否覆盖了谓词列? - Parquet 是否写了 PageIndex?老 writer 可能只有 row group 级 stats。
- 是否启用了 CBO stats(Trino
ANALYZE/ Iceberg Puffin)?缺了不影响裁剪但影响 join 计划。
B. EXPLAIN 看裁剪的入口
| 引擎 | 看裁剪 | 关注字段 |
|---|---|---|
| Trino | EXPLAIN ANALYZE |
input rows /
input size、dynamicFilter、partitionConstraint |
| Spark | df.explain() / SQL UI |
PushedFilters、PartitionFilters、scan
的 files read |
| DuckDB | EXPLAIN ANALYZE |
Filters、Total Files Read、scan
算子输出行数 |
| DataFusion | EXPLAIN |
ParquetExec 的
predicate、pruning |
裁剪是否生效,看「扫描算子的输入文件数/行数」是否远小于全表,而不是看最终结果行数。
C. 动态过滤适用与不适用
动态过滤(Trino)对「大事实表 join
小维表、原查询无显式事实表过滤」最有效:维表值在 build
阶段收集后下推为 probe 侧谓词,触发分区/row-group
裁剪。不适用或收益低的情况:维表很大(build
侧值集合过大,过滤选择性差)、join key
在事实表上无聚集(min/max 裁不动)、broadcast join
才生效的局部动态过滤场景。配
iceberg.dynamic-filtering.wait-timeout 控制
split 生成等待动态过滤完成的最长时间(Trino 481 docs)。
D. projection pushdown 与嵌套列
投影下推不仅裁顶层列,现代引擎还能下推到 struct
的子字段(只读 user.id 不读
user.profile)。宽表/嵌套表上这是主要 IO
节省来源。写 SELECT *
会放弃这层优化——分析查询应显式列出需要的列。
E. 字典过滤的失效边界
字典编码在列基数超过 page 字典上限后退回 PLAIN 编码,字典过滤随之失效。高基数列(UUID、随机串、连续 ID)通常不享受字典过滤;低基数列(枚举、国家码、状态)享受。这也是建模时把过滤常用的低基数列留作字典编码的理由之一。
F. 读 Delta 与读 Iceberg 的元数据差异
Iceberg 的 metadata planning 走 snapshot → manifest list
→ manifest(树状,可并行展开 manifest);Delta 走
_delta_log 的有序 commit + 周期
checkpoint(顺序重放 + checkpoint 加速)。裁剪信息:Iceberg
在 manifest entry 的 bounds;Delta 在 add
action 的 stats。引擎层抽象相似,但 planning 的
IO 模式不同——大量历史 commit 的 Delta 表依赖 checkpoint
才能快速重建当前文件集(见第 12 篇 Delta
事务日志)。
G. 为什么 file pruning 比 partition pruning「脆」
partition pruning 基于精确的分区值匹配,确定性强;file pruning 基于 min/max 区间,是「可能命中就保留」的保守裁剪,区间一宽就裁不动,且对数据布局敏感。工程上:用分区承担粗粒度裁剪(时间、租户),用排序/聚簇 + file pruning 承担细粒度裁剪(高频过滤列),两者配合,别指望单靠 min/max 解决一切。
H. 与列存引擎读路径的对照
ClickHouse MergeTree 的读路径是「稀疏主键索引定 granule → 跳数索引缩范围 → 读 mark 对应压缩块」(见 列存引擎查询读取路径)。湖上读路径是「manifest 裁文件 → Parquet footer/PageIndex 裁 row group/page」。两者都是「稀疏统计 + 范围裁剪 + 列式解压」,区别在于:MergeTree 统计在引擎自管的 part 内,湖上统计分布在表格式元数据与 Parquet 文件两层,且 planning 可跨进程/跨引擎共享同一份元数据。
I. 可复现的跨引擎对比步骤(未在本机执行)
- 用第八节实验一生成 Iceberg 表(pyiceberg + SQLite catalog),或用 Spark 建一张分区表。
- Trino:配置
icebergcatalog 指向同一 warehouse,EXPLAIN ANALYZE SELECT ... WHERE <分区谓词>,记录input size。 - DuckDB:
SELECT * FROM iceberg_scan('.../metadata/vN.metadata.json') WHERE ...,EXPLAIN ANALYZE看Total Files Read。 - 对同一谓词比较两者裁剪后的文件数/扫描量;预期分区/单调列谓词两边都裁到少数文件,全分布列两边都裁不动。
- 每个查询冷热各跑 ≥3 轮,记录环境(CPU/内存/版本/数据集规模),区分冷读 IO 与热缓存。
J. 谓词类型与可裁剪性
不同谓词对四层裁剪的友好度不同:
| 谓词 | partition | file min/max | page index | 字典过滤 |
|---|---|---|---|---|
col = v |
强(若 col 是分区列) | 强 | 强 | 强(低基数) |
col BETWEEN a AND b |
强 | 强 | 强 | 弱 |
col IN (...) |
中 | 中(多区间) | 中 | 强(低基数) |
col IS NULL |
弱 | 强(null_count) | 中 | — |
f(col) = v(函数包裹) |
弱(除非可折叠) | 弱 | 弱 | 弱 |
col LIKE 'x%' |
弱 | 中(前缀转范围) | 中 | 弱 |
经验:把过滤条件写成「裸列 op 常量」,别在列上套
cast/函数;LIKE 'x%'
前缀有时能被引擎转成范围谓词从而用上
min/max,LIKE '%x' 不能。
K. NULL 的裁剪语义
manifest 的 null_value_counts 让
IS NULL / IS NOT NULL 可裁:某文件
null_count = 0 则 IS NULL
整文件跳过;null_count = record_count 则
IS NOT NULL 整文件跳过。但 min/max 默认不含
null,范围谓词遇到含 null
的列要小心三值逻辑:col > 5 不会命中 null
行,引擎据此可安全裁剪,但若谓词是
col > 5 OR col IS NULL 则需保留含 null
的文件。
L. 时间戳与时区的裁剪陷阱
按 day(event_ts)
分区时,分区裁剪用的是写入时按某时区(通常 UTC)求值的
day。如果查询端按本地时区写
WHERE event_ts >= '2026-06-04',跨时区会让分区边界对不齐,导致多扫一天或漏裁。生产上统一以
UTC
存储时间戳、查询端显式转换,避免分区裁剪因时区错位失效。Trino
的 date predicate 博客专门讨论了这类时间谓词的折叠(Trino
blog, 2023-04-11)。
M. Parquet Bloom filter
除 min/max 外,Parquet 还可选写 split-block Bloom filter(每个 column chunk 一个),用于高基数列的等值谓词:min/max 对高基数列裁不动(区间太宽),Bloom filter 能在 row group 级判断「这个值肯定不在」从而跳过。代价是写入时间和文件体积。引擎是否读 Bloom filter 取决于实现与开关;高基数等值过滤频繁的列值得开(见第 2 篇)。
N. MoR 读放大与 delete 合并
读 merge-on-read 表时,第三层选中 row group 后还要应用
delete file(position/equality delete)或 deletion
vector:position delete 按 (file, pos)
标记删除行,读出后过滤;equality delete
按列值匹配,代价更高(要对每行比对 delete 列)。delete
文件多会显著拖慢读——这正是第 19 篇
CDC upsert 与第 17 篇
compaction 要平衡的:写得快(多 delete)vs 读得快(少
delete)。
O. equality delete 为何拖累 file pruning
equality delete 文件本身也有作用范围(哪些 data 文件可能被它影响)。引擎做 planning 时要把可能匹配的 equality delete 关联到 data 文件上,这一步可能放大要读的元数据与 delete 文件数。设计 upsert 表时控制 delete 文件数量、及时 compaction,是保住读性能的关键。
P. manifest 缓存与 planning 延迟
metadata planning 的延迟取决于读多少
manifest。表历史长、manifest 多时,planning
本身就慢。缓解:rewrite_manifests 合并
manifest、按分区组织 manifest 让分区裁剪能跳过整批
manifest(manifest list 里也带分区 summary,可在 manifest
级先裁)。REST catalog 还能把部分 planning
下推到服务端。planning
慢通常表现为「查询还没开始扫数据就卡住」。
Q. split 大小与并行度
Trino/Spark 把选中文件切成 split 分发。split 太大则并行度不足、长尾明显;太小则调度开销和元数据开销上升。理想 split 对齐 row group 边界。文件大小不均(小文件多)会让 split 分布倾斜,这也是 compaction 的另一个动机:不仅减少文件数,还让 split 更均匀。
R. ORC 与 Parquet 在裁剪上的异同
ORC 的 stripe 类比 Parquet 的 row group,row index(默认每 10000 行)类比 page index,同样支持 stripe/row-group 级 min/max 与 Bloom filter(见第 3 篇 ORC)。Trino 的动态过滤可下推进 ORC 与 Parquet 两种 reader 做 stripe/row-group pruning。选 Parquet 还是 ORC 在裁剪能力上差别不大,更多看生态(Parquet 通用、ORC 在 Hive 系更深)。
S. 向量化解码与 late materialization
裁剪决定「读哪些 page」,向量化执行决定「读出来多快算完」。late materialization(延迟物化)进一步优化:先只读过滤列、算出命中行的位置,再去读其余投影列对应位置的数据,避免为最终被过滤掉的行解码所有列。这与 ClickHouse 的 PREWHERE 思想一致(见 列存引擎读路径)。
T. schema evolution 对裁剪的影响
Iceberg 按 field ID 而非列位置/名字解析 schema。改列名、重排列不影响老文件的 stats 关联(仍按 field ID 找 bounds)。但新增列在老文件里没有 stats,对新列的谓词在老文件上裁不动(保守保留)。理解这点能解释「为什么加了个列、对它过滤却还是扫了全部老文件」——老文件根本没有这列的 min/max。
U. 为什么避免 LIST 是关键收益
Hive 表 planning 要 LIST
分区目录、逐个读文件 footer,对象存储上 LIST 是 \(O(\text{prefix 下对象数})\)
且按次计费、延迟高(见第 6 篇
对象存储语义)。开放表格式把文件清单和 stats 预先收进
manifest/事务日志,planning
变成「读几个元数据对象」而非「LIST
成千上万次」。这是湖仓在对象存储上能快起来的结构性原因,也是
partition/file pruning 能「不打开数据文件」的前提。
V. 一次完整读路径走查
以一张按 day(event_ts) 分区、按
user_id 排序写入的 Iceberg 表为例,跑:
SELECT user_id, sum(amount)
FROM events
WHERE event_ts >= TIMESTAMP '2026-06-04 00:00:00'
AND event_ts < TIMESTAMP '2026-06-05 00:00:00'
AND user_id BETWEEN 450000 AND 470000
GROUP BY user_id;引擎从上到下经历:
- catalog:拿到
events当前 metadata.json 的位置,读到当前 snapshot 与 manifest list。 - manifest list 级:manifest list 里每个
manifest 带分区 summary,先按
event_ts的 day 约束跳过不含 2026-06-04 的 manifest。 - partition pruning:在剩下的 manifest
entry 里,按
event_day = 2026-06-04裁掉其它天的文件——只剩当天的文件。 - file pruning:对当天文件,用
user_id的lower_bounds/upper_bounds与[450000,470000]求交,因数据按user_id排序,只命中少数文件。 - 投影下推:只读
user_id、amount、event_ts三列的 column chunk(event_ts用于残余谓词),其余列不拉取。 - row-group/page
pruning:打开选中文件,用 footer + PageIndex 跳过
user_id不在区间的 row group/page。 - 解压 + 执行:解压命中的
page,应用残余谓词,做
GROUP BY user_id聚合。
整条链路里,第 1-4 步只读元数据;只有第 5 步起才从对象存储拉数据列的字节。一个设计良好的表,这条查询最终可能只拉取「1 天 × 少数文件 × 少数 row group × 3 列」的数据,相对全表是数量级的节省。
W. 残余谓词(residual predicate)
planning
把谓词分成两部分:能被分区/文件裁剪「完全消化」的部分,和必须在数据行上逐行求值的「残余谓词」。例如分区裁剪消化了
event_ts 的 day 约束后,event_ts
在具体到「当天内某小时」的精度上仍需作为残余谓词在行上过滤。理解残余谓词能解释:为什么裁到了正确的文件,引擎还要读
event_ts
列——它要在行级完成分区粒度之下的精确过滤。
X. 统计缺失时的保守behavior
裁剪是「保守正确」的:拿不准就保留。stats
缺失(老文件、add_files
挂入、未索引列)时,引擎不会错误地跳过可能命中的文件,而是把它纳入扫描。结果是「正确但慢」。排查慢查询时,把「扫描文件数
≫ 预期」当成 stats
缺失或布局不佳的信号,而不是怀疑结果错误。
Y. 多引擎共享一份元数据的代价对齐
同一张 Iceberg 表被 Trino 写、被 DuckDB 读、被 Spark 维护时,四层裁剪用的是同一份 manifest/Parquet 元数据。这带来一致性红利(谁写的 stats 谁读都能用),也带来对齐要求:写入引擎要正确收集 column stats,否则所有读引擎的 file pruning 一起退化。选型时确认写入路径产生的 stats 完整,比纠结读引擎谁快更重要。
Z. 把裁剪当成可观测指标
不要把「查询快不快」当唯一指标,要监控「扫描放大比」= 实际扫描字节 / 理论命中字节。这个比值长期偏大说明布局或 stats 出了问题(\(o\) 偏大、stats 缺失、分区设计不当),是触发 sort rewrite / 重新分区 / 补 stats 的运维信号。具体阈值与监控见第 20 篇运维清单。
参考资料
- Apache Iceberg, Table Spec(manifest entry 的 bounds/null_value_counts、partition 字段),spec V2/V3。
- Apache Parquet,
parquet-format(
FileMetaData、Statistics、PageIndex / column index / offset index)。 - Trino Documentation 481, Iceberg
connector(
query-partition-filter-required、projection-pushdown-enabled、dynamic-filtering.wait-timeout、table-statistics-enabled、optimize)。 - Trino Documentation 481, Dynamic filtering(dynamic partition pruning、下推进 ORC/Parquet reader 做 row-group pruning)。
- Trino Blog, Just the right time date predicates with Iceberg, 2023-04-11(constant folding、谓词 desugaring、metadata 层 partition pruning)。B 级(官方博客)。
- DuckDB Documentation, Lakehouse Formats /
Iceberg Extension / Delta
Extension(
iceberg_scan、原生实现、delta-kernel-rs、data skipping 跳 row group/file、projection pushdown、DV 读)。 - 本机实验:PyIceberg 0.11.1 + PyArrow 24.0.0 + DuckDB
1.5.4,Python 3.14.5,i9-12900K / 32 GB / Arch Linux WSL2
内核 6.6.87.2;脚本见
/tmp/lake_exp/exp18.py。 - 本系列:第 2、8、9、10、12、17 篇。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【列存引擎内核】ClickHouse 与 DuckDB 源码级拆解
主选 ClickHouse 拆解 MergeTree 存储格式、向量化执行与分布式协调;DuckDB 作为嵌入式 OLAP 对照。覆盖列存文件布局、merge 机制、跳数索引与生产故障模式,面向数据平台工程师与从 PG/MySQL 转 OLAP 的 DBA。
【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式
Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。
【数据湖与开放表格式】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 与编码。
【数据湖与开放表格式】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。