云计算百科
云计算领域专业知识百科平台

第5章 Spark4.x 分区、分桶、排序与数据裁剪

第5章 Spark4.x 分区、分桶、排序与数据裁剪

章节定位:第 4 章教你读计划,本章教你让计划有东西可裁。优化器再聪明,也只能在物理布局给出的可能性里挑。分区、分桶、排序这三件事是建表时一次定死、事后改起来最贵的三个决定,所以必须放在湖仓读写(第 8-11 章)之前讲透。

本章同时是全系列与前作的一次正式对齐:ck 第 4 章那张"分区 → 主键 → 跳数索引 → 预计算"的裁剪漏斗,在 Spark + 列式文件上的对应形态是"分区 → 文件 → row-group → 行"。四层顺序不变,每层的名字和观测指标不同,判据完全一致:先问这一层裁掉了多少,再问下一层值不值得做。


学习目标

  • ✓ 分得清三种"分区"(Hive 目录分区 / Iceberg 隐藏分区 / Paimon 分区 + Bucket),能各自说出:裁剪由谁负责、谓词怎么写才命中、粒度变细的代价落在哪里
  • ✓ 拿到一条慢 SQL,能按六种典型失效写法逐条排查分区裁剪,并写出修复版;能区分"没裁"与"裁了但没用"
  • ✓ 说清列式文件的四层统计粒度(文件列统计、row-group 的 min/max、page 级索引)分别被谁使用,并知道 Spark 官方只给了前两层的开关,第三层没有官方依据
  • ✓ 能判断一张表该怎么排:线性排序、表级 WRITE ORDERED BY、还是多维 Z-Order / Hilbert,依据是查询形态而不是"听说 Z-Order 快"
  • ✓ 讲清 Paimon bucket / Iceberg bucket(n) / Hive bucket 的语义差异,以及"分桶表 join 免 shuffle"成立的前提条件
  • ✓ 在同一张表的四种布局状态下量出扫描文件数与行数的差,并且知道为什么在 20 万行的数据上量不出来
  • ✓ 面对一个新需求,能用"分区定目录、分桶定文件、排序定块"三句话写出 DDL 草案,并说明每个选择的依据

5.1 三种"分区",为什么会互相混淆

先给结论,后面都在解释这句话:

**它们不是"同一件事的三种实现",而是三种不同的物理承诺。**承诺不同,所以谓词的写法、失效的样子、变细的代价,全都不一样。

车联网这条链路上三种表格式常年共存:Flink 写的 Paimon ODS、Spark 写的 Iceberg DWD/DWS、存量数仓留下的 Hive 分区表。同一条"按天查一台车"的 SQL 打到三种表上,可能是三种结果——快、慢、或者静悄悄地返回空。第三种最危险,后面 5.2 案例 5 专门讲它。

5.1.1 一张对照表把差异钉住

表 5-1 三种分区机制对照

维度Hive 风格目录分区Iceberg 隐藏分区Paimon 分区 + Bucket
分区列是否真实存在 是,是表的一列,SELECT 能查到 否,表里只有原始列(report_date),分区字段是 transform 派生的 是(如 __dt__),且通常同时是主键的一部分
物理形态 __dt__=2026-10-15/00001.parquet 目录名即分区 data/report_date_day=2026-10-15/0000x.parquet,目录由 transform 生成,但由表格式管理,用户不该按目录名写条件 __dt__=2026-10-15/bucket-0/,两级目录,bucket 子目录可见
谁负责裁剪 Spark 自己:向 metastore 要分区列表,用谓词去匹配分区值 表格式 planner:把对原始列的谓词翻译成 transform 域上的约束,再在 manifest 里挑候选文件 Paimon scan:分区裁剪 + 对 bucket-key 的等值条件定位到桶
谓词怎么写才命中 直接对分区列写常量比较;类型、格式必须与目录里的值一致 对原始列写比较即可,无需知道分区字段;但表达式必须能落回该列 分区列等值/范围 → 裁到分区;bucket-key 等值 → 才能裁到桶
典型失效 函数包裹分区列、隐式类型转换、分区值格式不一致 过滤列与分区 transform 的输入列不是同一列;partition evolution 后新旧 spec 并存 对 bucket-key 写范围条件(>/BETWEEN)只能全桶扫
变细的代价 目录数与分区列表变长,列分区本身成为瓶颈;小文件随分区数线性增加 manifest 文件与列表膨胀,元数据读取时间上升 分区数 × bucket 数 = 文件数下界,桶数改起来最麻烦(固定桶需重刷)
事后能不能改 ADD/DROP PARTITION 改列表;改分区列等于重建表 Partition Evolution 支持换分区字段,历史数据保留旧 spec 分区列不可变;bucket 数变更需要 rescale/重刷数据

三个"所以"值得单独记:

① 所以 Iceberg 表里查不到分区列。 第 2 章建的 iceberg.demo.dws_vehicle_daily 用 PARTITIONED BY (months(report_date)),你 SELECT 里写不出 report_date_month——那是隐藏分区字段,在元数据里,不在 schema 里。想验证分区怎么切的,得去读元数据表(Spark 侧用点号访问 iceberg.demo.dws_vehicle_daily.partitions;前作硬约束:Flink 侧写 $partitions,两者不可混用)。

② 所以 Paimon 的桶不是"性能旋钮",是"定位规则"。 第 2 章那张 ODS 表 'bucket' = '4'、主键 vin,report_date,__dt__。查询里给 vin 等值 ⇒ 能定到一个桶;给 speed > 60 ⇒ 与桶无关,四个桶全开。这不是实现缺陷,是 hash 分桶的数学性质:hash 只支持等值。

③ 所以"Hive 分区数别太多"这条老经验,在湖表上换了形式但没消失。 前作在 Iceberg 系列里定下的口径是:隐藏分区遵循**“几千量级分区”**原则,分区粒度过细导致元数据膨胀。目录分区膨胀的是 metastore 与 list 调用,隐藏分区膨胀的是 manifest;两者都是"元数据先扛不住,查询才慢"。按天分区跑三五年是几千量级,安全;按小时且多维(天 × 城市 × 车型)很容易直接上万,那就要重新想。

5.1.2 三者各管一段物理布局

一张表的物理布局,三件事作用在三个层次
┌──────────────────────────────────────────────────────────────────┐
│ │
│ 分区 Partition → 决定数据落到"哪个目录 / 哪套 spec" │
│ 粒度:天、月、城市段 命中一条 ⇒ 整个目录根本不被打开 │
│ │ │
│ ├── 分桶 Bucket → 决定分区内的"文件归属规则" │
│ │ hash(key) % N 命中 ⇒ 只打开约 1/N 的文件 │
│ │ │ 同时给 join 提供"免 shuffle"的性质 │
│ │ │ │
│ │ └── 文件 File → 列式存储的最小管理单位 │
│ │ │ │
│ │ └─ 排序 Sort / Z-Order │
│ │ 决定"一个文件内部行的先后顺序" │
│ │ 命中 ⇒ 同一文件内跳过 row-group / page │
│ │ │
└──────┴───────────────────────────────────────────────────────────┘

裁剪粒度由外到内: 目录 >> 文件 >> 块(row-group) >> 行
每往里一层,单位小一到两个数量级,但能裁的条件类型也变少。

图 5-1 分区 × 分桶 × 排序在物理布局上的作用位置

这张图后面反复用。三条读法:

  • 外层裁掉的是"根本不打开",内层裁掉的是"打开了但跳过块"。 前者省的是 list + 元数据 + IO 的全部,后者省的是 IO 的一部分。所以顺序永远是:先把分区设计对,再谈排序。
  • 三层不互相替代。 有人想用 Z-Order 补分区没分好的洞——它能补一点(多维相关性),但补不了"这个文件里 90% 的行都不该被读"这种结构性浪费。反过来,分区切得再细,文件内部乱序,row-group 的 min/max 就宽到没有任何裁剪价值。
  • 分桶是唯一同时影响读和写的层。 它既裁文件,又决定 join/aggregate 需不需要 shuffle。所以它的选择空间最小:桶键必须是高频等值列,桶数一旦定下就很难改。

与 ck 第 4 章的对应关系:ClickHouse 的"分区键 → 排序键 granule → 跳数索引 → 投影",在 Spark 湖表上就是"分区 → 文件/row-group min-max → page index/bloom → 聚簇排序"。差别在于 ClickHouse 的排序键是写死在 part 内部的强约束(granule 边界靠 mark 文件精确可查),而湖表的"排序"是一次性的写入布局,写完就不再维护顺序——它靠的是统计信息,不是 mark 索引。这个差别在 5.4.4 会量化。


5.2 分区裁剪:六种失效写法

先解决"怎么知道裁没裁",再讲六种失效。第 4 章 4.2.5 埋的那个负面结果——两条查询读了同样多的文件——就是从这里开始查的。

5.2.1 三个观测点,按可信度排序

观测点怎么取回答什么局限
files read / scanned rows Spark UI 的 SQL 页,或 SparkListener 事件日志(第 17 章脚本化) 事实:到底读了多少 必须跑完才知道;跑一次可能就是全表扫
PartitionFilters EXPLAIN FORMATTED 里 FileScan 节点的这一行 意图:优化器打算用哪些条件裁分区 非空 ≠ 命中。字符串区间写错格式时它照样非空
SHOW PARTITIONS / 元数据表 SHOW PARTITIONS paimon.demo.ods_vehicle_telemetry;Iceberg 查 $.partitions 前提:分区值实际长什么样 只能人工比对,不直接说明查询命中

三个必须一起用。只看 PartitionFilters 会被案例 5 骗;只跑一次看 UI 又太贵。生产上的标准动作是:先 SHOW PARTITIONS 确认分区值形态,再 EXPLAIN 确认表达式形态,最后用 measure_scan.py(5.6)量一次真实文件数。

— 观测点 3 的固定动作:先看清分区值到底长什么样
SHOW PARTITIONS paimon.demo.ods_vehicle_telemetry;
— 输出列:partition(STRING),形如 __dt__=2026-10-15,共 30 行
— 验收:30 行;值全部为 yyyy-MM-dd 形式;min=2026-10-01,max=2026-10-30

参数说明:SHOW PARTITIONS 对 Paimon/Hive 风格的显式分区列直接列目录;Iceberg 的隐藏分区不显示在这里,要查元数据表。

输出表结构:单列 partition(STRING)。

验收标准:行数等于第 2 章记录的 30 个分区;任何一行的值形态与后面 SQL 里写的字面量不一致,就先别往下做——这是案例 5 的入口。

5.2.2 表 5-2 六种失效速查

表 5-2 分区裁剪失效速查(每一行后面都有可跑的实验)

#失效类型反例特征为什么修复方向观测症状
1 函数包裹分区列 to_date(__dt__) = date'…'、substr(__dt__,1,7)='2026-10' 裁剪拿的是分区值,不是表达式的值;函数打断"列 ↔ 常量"的比较 把函数换到常量侧,或直接对分区列写区间 PartitionFilters 为空,全分区扫
2 类型/字面量不匹配 分区列 STRING 却写 __dt__ = 20261015(漏引号) 需要一次隐式转换,比较对象不再是分区值本身 按分区列真实类型写字面量 可能"能裁但裁错",也可能不裁
3 OR 把分区谓词降级 __dt__='2026-10-15' OR city_id=310007 析取式的另一支不含分区列,整体无法给出分区边界 拆成 UNION ALL,或把非分区条件也换成分区列条件 PartitionFilters 为空
4 否定谓词(<> / NOT IN / IS NOT NULL) __dt__ <> '2026-10-15' 通常能裁,但只能排除被点名的值,剩下全读 改成显式正向枚举(IN (…)) PartitionFilters 非空但 files read 几乎没降
5 分区值格式不一致 目录是 2026-10-15,条件写 '20261015' 裁剪"成功",命中 0 个分区 写入侧统一格式 + 结果行数断言 返回空结果,不报错,速度还很快
6 分区列进 SELECT 再过滤 CTE/子查询里算出 dt_str 别名,外层 WHERE dt_str=… 外层过滤的是派生列;展开后仍是函数包裹(回到 #1) 把谓词写回原始分区列,且在 CTE 内层就过滤 PartitionFilters 为空

第 4 行是这张表里唯一"反常识"的:它不是失效,是收益归零。 很多人看到 PartitionFilters 非空就以为优化生效了,实际读了 29/30 个分区。这一行的价值在于告诉你:"裁剪生效"这个结论必须由 files read 给出,不能由 EXPLAIN 给出——这就是第 4 章 4.2 那条"Spark 的计划里只有估算,没有实测"的具体后果。

5.2.3 六个案例逐个跑

以下全部基于第 2 章的 paimon.demo.ods_vehicle_telemetry(20 万行、30 个分区)。每个案例给「反例 → 修复 → 判据」。建议把六段一起存成 pruning_fail_cases.sql,作为团队的谓词评审用例集。

案例 1:函数包裹分区列

— 反例 1A:对分区列套函数
EXPLAIN FORMATTED
SELECT vin, avg(speed) FROM paimon.demo.ods_vehicle_telemetry
WHERE to_date(__dt__) = DATE '2026-10-15'
GROUP BY vin;

— 反例 1B:对时间列套函数(这张表里 report_date 不是分区列,__dt__ 才是)
WHERE date_format(reported_at, 'yyyy-MM-dd') = '2026-10-15'

— 修复 1A:把函数从列侧挪走,直接比较分区列
WHERE __dt__ = '2026-10-15'

— 修复 1B:走真正存在的分区列,时间函数只用于选区间
WHERE __dt__ BETWEEN '2026-10-15' AND '2026-10-15'
AND reported_at >= TIMESTAMP '2026-10-15 00:00:00'
AND reported_at < TIMESTAMP '2026-10-16 00:00:00'

参数说明:1A 的失效原因最直白——裁剪是拿分区值(字符串 2026-10-15)去和谓词比较,套上 to_date() 后优化器需要"知道"函数在分区值域上的映射,Spark 不做这种反演(除少数官方列出的可下推表达式外)。1B 是生产里更高频的版本:reported_at 不是分区列,__dt__ 才是,过滤写错列 = 一定全表扫,无论有没有函数。

修复版 1B 的写法有个刻意的设计:用 >= / < 而不是 BETWEEN 包住时刻列,避免边界日的重复计数;__dt__ 的区间条件与它等价,保留是为了让"分区先裁到 1 个"这个意图在 SQL 里显式可见。

输出表结构:vin(STRING), avg(speed)(FLOAT)。

验收标准:反例的 FileScan 节点 PartitionFilters: 这一行为空 [],UI files read 覆盖 30 个分区;修复版 PartitionFilters 含 isnotnull(__dt__) 与等值/区间条件,files read 约为反例的 1/30。如果反例在你的版本里 PartitionFilters 非空,说明该版本已能对 to_date(字符串) 做常量折叠(__dt__ 是常量时可折),把这条例外记进 5.2.6 的差异表,别改判据。

案例 2:类型与字面量不匹配

— 反例:__dt__ 是 STRING,给了一个整数
EXPLAIN FORMATTED
SELECT count(*) FROM paimon.demo.ods_vehicle_telemetry
WHERE __dt__ = 20261015; — 注意:没有引号

— 修复:按分区列的真实类型写
WHERE __dt__ = '2026-10-15';

— 对照组:同一张 Iceberg 表(分区列是 DATE),写法又不同
SELECT count(*) FROM iceberg.demo.dws_vehicle_daily
WHERE report_date = DATE '2026-10-15'; — DATE 列配 DATE 字面量

参数说明:这一组的重点不是"会不会报错",而是同一语义在三种表里写法不同:Hive 风格的 __dt__ STRING 用字符串,Iceberg 的 months(report_date) 用原始 DATE 列,Paimon 的 __dt__ 也是字符串且同时是主键的一部分。跨引擎链路里最常见的事故就是"Flink 侧按 yyyyMMdd 写、Spark 侧按 yyyy-MM-dd 读"——它就是案例 5。

ANSI 相关提醒:Spark 4.0 起 spark.sql.ansi.enabled 默认 true(第 2 章已取证),字符串向数值/日期的隐式转换在 ANSI 下更容易直接抛错而不是静默给 NULL。这是好事——但别把它当保险,第 5 种失效(格式不一致)在 ANSI 下依然静默返回空。

输出表结构:count(BIGINT)。

验收标准:反例的返回值要么是 0(当成不命中)、要么整段抛类型错。两种都可接受,但必须能解释是哪一种;修复版应返回该分区行数(约 6666 行,20 万 ÷ 30,主键去重后按实际记录)。

案例 3:OR 把分区谓词降级

— 反例
EXPLAIN FORMATTED
SELECT vin, speed FROM paimon.demo.ods_vehicle_telemetry
WHERE __dt__ = '2026-10-15' OR city_id = 310007;

— 修复 A:拆 UNION ALL,每支自己可裁
SELECT vin, speed FROM ... WHERE __dt__ = '2026-10-15'
UNION ALL
SELECT vin, speed FROM ... WHERE city_id = 310007 AND __dt__ <> '2026-10-15';

— 修复 B:如果业务真实意图是"且",那就是写错了,改成 AND
WHERE __dt__ = '2026-10-15' AND city_id = 310007;

参数说明:析取式要求每一支都能给出分区边界,否则整体没有上界。修复 A 里第二支带了 __dt__ <> '2026-10-15',是为了防止两支重叠返回重复行(UNION ALL 不去重)——这个写法把代价说得很清楚:OR 的另一支如果要按非分区列查,它无论如何是全分区扫,拆不拆都一样。所以真正的决策是修复 B:先确认业务是不是要 AND。生产上 90% 的这类 OR 是笔误。

输出表结构:vin(STRING), speed(FLOAT)。

验收标准:反例 PartitionFilters 为空且 files read ≈ 全表;修复 B 的 files read ≈ 1/30。修复 A 只应在结果确实要"并集"时用——它把一次全表扫换成了"一次 1/30 扫 + 一次全表扫",比反例更慢,唯一收益是第一支的结果能早出来。这一条写在这里是为了让读者明白:"拆 OR"不是通用药,先问语义。

案例 4:否定谓词——能裁,但裁不动

— "排除一天"
SELECT count(*) FROM paimon.demo.ods_vehicle_telemetry WHERE __dt__ <> '2026-10-15';

— "最近 7 天之外"
SELECT count(*) FROM paimon.demo.ods_vehicle_telemetry WHERE __dt__ > '2026-10-23';

— 正向枚举修复
SELECT count(*) FROM paimon.demo.ods_vehicle_telemetry
WHERE __dt__ IN ('2026-10-24','2026-10-25','2026-10-26','2026-10-27',
'2026-10-28','2026-10-29','2026-10-30');

参数说明:<> 在字符串分区上能裁(排除被点名的分区值),所以 PartitionFilters 非空——但只省 1/30。反过来,> / BETWEEN / IN (枚举) 这些能给出有限集合或连续区间的谓词才是裁剪喜欢的形状。修复版和 > 版结果相同,区别在于枚举让"读 7 个分区"这件事在 SQL 里可见,而 > 依赖优化器把区间翻译成分区集合——两种都能裁,但枚举写法在跨引擎(走 metastore 列分区)时更稳。

输出表结构:count(BIGINT)。

验收标准:三个查询各自 PartitionFilters 都非空;对比 files read:<> 版 ≈ 29 个分区,> 版与 IN 版 ≈ 7 个分区。如果 <> 版读到的是全部分区而不是 29 个,说明该源的分区列表获取本身没有被谓词过滤(常见于 metastore 缓存或某些 catalog 实现),把这条记进 5.2.6。

案例 5:分区值格式不一致(本表最危险的一例)

— 目录里是 2026-10-15,条件写成紧凑格式
SELECT count(*) FROM paimon.demo.ods_vehicle_telemetry WHERE __dt__ = '20261015';
— ↑ 返回 0 行,不报错,执行得还很快

— 防御性对账:先看分区值形态,再写查询
SHOW PARTITIONS paimon.demo.ods_vehicle_telemetry;

— 上线断言(Spark SQL 里做下限校验,让"0 行"变成一次失败)
SELECT if(count(*) = 0,
raise_error('分区 2026-10-15 无数据:检查 __dt__ 格式与写入侧是否一致'),
count(*)) AS rows_checked
FROM paimon.demo.ods_vehicle_telemetry
WHERE __dt__ = '2026-10-15';

参数说明:这一例和前四例性质不同——前四例的症状是"慢",这一例的症状是**“对不上账”**。裁剪机制完全正常,它精确地命中了 0 个分区。车联网链路里它的发生条件很具体:Flink 侧作业用 yyyyMMdd 写 __DT__,Spark 批侧按 yyyy-MM-dd 读;或同一条链路上游换了一次时间格式化模式。第 2 章造数时刻意让 __dt__ 与 report_date 同源、并统一用 date_format(…, 'yyyy-MM-dd'),就是为了把这类问题在实验环境里排除掉,让六个案例的其它变量干净。

raise_error 是 Spark 内置函数,用一行 SQL 把"空结果"升级为作业失败,比事后看板发现数字为 0 便宜得多。生产上这类断言应该出现在每个批作业的开头,而不是最后。

输出表结构:rows_checked(BIGINT)。

验收标准:紧凑格式那条返回 0;断言版在正常分区上返回约 6666,把日期改成一个不存在的分区值时应抛错并终止作业。抛错这条必须真跑一次——没见过它失败的团队,等于没有这道保险。

案例 6:分区列进了 SELECT 再过滤

— 反例:在 CTE 里造派生列,外层过滤派生列
WITH t AS (
SELECT vin, speed, substr(__dt__, 1, 7) AS month_str
FROM paimon.demo.ods_vehicle_telemetry
)
SELECT count(*) FROM t WHERE month_str = '2026-10';

— 修复 A:谓词写回原始分区列,用区间
SELECT count(*) FROM paimon.demo.ods_vehicle_telemetry
WHERE __dt__ >= '2026-10-01' AND __dt__ <= '2026-10-31';

— 修复 B:CTE 内层就过滤(视图/公共 CTE 被复用时唯一可行的做法)
WITH t AS (
SELECT vin, speed FROM paimon.demo.ods_vehicle_telemetry
WHERE __dt__ >= '2026-10-01' AND __dt__ <= '2026-10-31'
)
SELECT count(*) FROM t;

参数说明:CTE 会被内联展开,所以外层 month_str = '2026-10' 展开后就是 substr(__dt__,1,7) = '2026-10'——回到了案例 1。这一例真正教的是另一件事:谓词的位置比写法更容易出问题。视图、公共 CTE、被复用的中间层,只要"分区列的原始形态"在传递过程中被丢掉(只投影了派生列),下游使用者无论怎么写都裁不动,而且他不会意识到,因为他看到的 schema 里根本没有分区列。

修复 B 是给"我改不了视图定义"的场景:与其在下游挣扎,不如给视图/CTE 加一个必传参数,或者把中间层落成带分区的小表。

输出表结构:count(BIGINT)。

验收标准:反例与修复版结果一致(约 20 万 ÷ 30 × 31 天口径内当月全部数据,本数据集为 30 个分区全在 10 月,故 ≈ 200000);files read 上反例 ≈ 30 个分区,修复版同样 30 个——本例的修复版不会少读文件,因为数据只有 10 月。这不是失败:验收看的是 PartitionFilters 是否非空(机制对不对),files read 是否下降(收益有没有)。这两个格子分开填,正是案例 4 教的那条纪律。

5.2.4 一个更靠上游的结论:分区裁剪的天花板

六个案例都在讲"怎么写谓词"。但真正的设计问题是:这张表的分区切得对不对,让裁剪有没有可能存在。

按天分区,20 万车 × 30 天,查"一台车一天"
分区裁剪 ⇒ 命中 1/30 目录 ← 有效
按天分区,查"一个城市一天"
分区裁剪 ⇒ 命中 1/30;城市仍靠列扫描 ← 一半有效,另一半要看 5.3
按天分区,查"一个城市最近 30 天"
分区裁剪 ⇒ 全部命中,等于没裁 ← 失效,但不是谓词写错

第三行是关键:查询模式与分区键不匹配时,谓词写得再正确也裁不动。 这时该改的是 DDL(加一层城市维度分区,或者用 Z-Order 把城市与日期一起编进相关性里,见 5.4),而不是继续折腾 SQL。

前作在 ClickHouse 侧得出过同构结论(ck 第 4 章 4.6:跳过排序键第一列、函数包在键列上,都是"谓词与布局不匹配"),Spark 侧的差别只在于:布局改了要重刷数据,代价更大,所以更需要事前判断。 判断依据就是 5.1.2 那张图的三层:先问这个查询能不能落在某个分区里,再问能不能落到某个桶里,最后才问文件内排序能不能帮忙。

5.2.5 湖表特有的第七件事:分区裁剪发生在"哪一层"

Iceberg 与 Paimon 都自带分区过滤能力,Spark 侧的 PartitionFilters 只是把条件下推给它们。这带来一个 Hive 表没有的行为:候选文件挑选发生在表格式的 planner 里,需要读 manifest / snapshot 元数据。

Hive 目录分区: 谓词 → metastore 分区列表匹配 → 打开目录
Iceberg: 谓词 → 读 snapshot → 读 manifest list → 按 partition 约束
筛 manifest → 按列 min/max 再筛文件 → 才轮到 row-group
Paimon: 谓词 → 读 snapshot → 分区列表 → bucket 定位 → 文件 → DataFile 统计

所以同一条"分区裁剪没生效"的结论,在三种表上的根因位置不同:Hive 是谓词表达问题,Iceberg 可能是元数据缓存问题(前作硬约束:Iceberg 元数据缓存默认 30 秒,结构变更后 Flink 侧需重启会话才立即生效——Spark 侧同样有缓存语义,跨引擎改完立刻查不到新数据要想到这一层),Paimon 可能是 bucket 定位没走等值。

Hive 这一侧还有三个官方开关,解释了"同一句 SQL 在不同环境裁得动/裁不动"的一部分差异(默认值逐字取自 configuration 页):

配置默认官方原文要点
spark.sql.hive.metastorePartitionPruning true(1.5.0 起) “some predicates will be pushed down into the Hive metastore so that unmatching partitions can be eliminated earlier”
spark.sql.hive.metastorePartitionPruningFastFallback false(3.3.0 起) 开启后改为"先取回分区名,再在客户端算过滤表达式";含 TimeZoneAwareExpression 的谓词不支持这条路
spark.sql.hive.manageFilesourcePartitions 见配置页 官方原文把两者连起来:“use the metastore to prune partitions during query planning when spark.sql.hive.metastorePartitionPruning is set to true”

读这三行的正确方式:"把谓词下推进 metastore 让它早淘汰"和"先把分区名全捞回来再自己筛"是两条路径,前者省的是网络与分区数,后者省的是兼容性问题(复杂谓词、MetaException 退路)。当你的 Hive 分区表突然变慢、或换了 metastore 版本后裁剪行为变了,FastFallback 是第一个该看的地方。另外要破除一个名字:社区文章里流传的 spark.sql.hive.metastore.partition.prune.enabled 不是官方配置名,官方名是上面这个。

这条差异的工程价值:遇到"我明明裁了但没变快",先问这张表的裁剪发生在哪一层,再决定是改 SQL 还是查元数据。

5.2.6 把观察结果落成版本差异表

六个案例跑完,把与预期不符的条目记下来。这张表是本系列留给读者的填空,也是团队 wiki 上最有用的一页:

案例预期实测(填自己的版本与结果)处置
1A to_date(__dt__) 不裁 若裁了:注明 Spark 版本 + catalog 版本,评审时不再依赖"必然失效"
2 STRING 分区 = 整数 报错或 0 行 统一改为显式字符串字面量,无论哪种症状
3 OR 混杂 不裁 若部分裁:仍按"不可依赖"处理,hint 不算设计
4 <> 裁 1/30 若全读:该源的分区列表获取未被谓词过滤,改用正向枚举
5 格式不一致 0 行不报错 一律加 raise_error 断言,与实测无关
6 CTE 派生列 不裁 一律要求中间层暴露原始分区列

5.3 文件级与列级裁剪:分区之后的三层

分区裁掉的是"整个目录"。目录里剩下的,就要靠列式文件自己的统计信息来裁。这一层的能力全部建立在"数据是按某种顺序写的"这个前提上——所以它和 5.4 的排序是一对:5.3 讲尺子,5.4 讲怎么把数据摆到尺子上。

5.3.1 一个 Parquet 文件里有四层可以被裁的东西

demo.parquet
├── row-group #1 ← Iceberg 默认 write.parquet.row-group-size-bytes = 128MB
│ ├── column chunk (vin) 带 statistics:min / max / null_count / value_count
│ ├── column chunk (report_date) 带 statistics
│ └── column chunk (speed)
│ └── page … ← 默认 write.parquet.page-size-bytes = 1MB
│ └── page 级统计(column index / page index)
├── row-group #2
└── footer ← schema + 每个 row-group 每列的统计入口

表 5-3 四层统计、使用方与开关(默认值逐字取自官方页面)

粒度存什么谁能用它裁相关配置(默认值 / 起始版本)
分区(目录/spec) 分区值 表格式 planner、metastore 见 5.1、5.2
文件 每列的 lower_bounds / upper_bounds / value_counts / null_value_counts / nan_value_counts / column_sizes Iceberg planner:先按分区裁,再按列统计裁文件 write.metadata.metrics.default = truncate(16)
row-group / column chunk 该块内该列的 min/max/null count Parquet reader(谓词下推后逐块判定可否跳过) spark.sql.parquet.filterPushdown = true(1.2.0 起);spark.sql.orc.filterPushdown = true
page page 级 min/max 与位置索引 Spark 侧未找到任何官方开关(见下) 无对应配置项

第三行到第四行之间有个必须写清楚的事实:

本次在 Spark 官方文档(sql-data-sources-parquet.html、configuration.html、sql-performance-tuning.html 三页全文)没有找到任何 page index / column index / page-level 的字样,也没有 spark.sql.parquet.columnIndexPageSize 这类配置项。 Parquet 格式本身定义了页级索引,Iceberg 写侧也能生成,但**"Spark 读取时会不会用它裁 page"这件事没有官方依据**,本系列不把它写成事实。需要它时,用 5.3.5 的实测法量给自己看。

同样值得记录的一条取证结论:社区文章里常写的 spark.sql.parquet.recordColumnFilter 不是官方配置名,官方存在的是 spark.sql.parquet.recordLevelFilter.enabled(默认 false,2.3.0 起)。它的一句话描述正好暴露了一个真实取舍:

“If true, enables Parquet’s native record-level filtering using the pushed down filters. This configuration only has an effect when spark.sql.parquet.filterPushdown is enabled and the vectorized reader is not used.”

5.3.2 向量化读取与 record-level filter 互斥,这不是细节

上面那句官方原文里有"and the vectorized reader is not used"。把它和 spark.sql.parquet.enableVectorizedReader = true(2.0.0 起,“Enables vectorized parquet decoding.”)放在一起读,结论是:

默认状态:向量化 reader 开 → record-level filtering 开了也不生效(官方明说)
关掉向量化:能启用 record-level filtering,但退到非向量化解码路径
⇒ 这不是"多开一个开关多一层裁剪",是一次路径替换

怎么确认自己走的是哪条路:EXPLAIN FORMATTED 里 FileScan parquet 节点有一行 Batched: true/false(第 4 章 4.2 的四个必看标记之一)。Batched: false 就是掉到了非向量化路径——常见诱因是 schema 里有向量化 reader 不支持的类型组合(完整清单本次未逐字取证,按 4.1 Parquet 页核对;车联网表里最容易触发的是 DECIMAL 高精度列与 TIMESTAMP_NTZ 混用)。

判据:同一谓词,分别在 enableVectorizedReader=true(默认)与 false 两种设置下跑,比较 UI 的 scanned rows 与 bytes read。只有当 scanned rows 明显下降时才值得换路径,否则就是拿解码速度换裁剪精度。

5.3.3 字符串列的 min/max 只记前 16 个字符——这对 VIN 意味着什么

Iceberg 官方 write.metadata.metrics.default = truncate(16)(可选 none / counts / truncate(length) / full)。这条默认值直接决定字符串列能不能裁文件:

本数据集的 VIN 形态:LSGKA12P7MA + 6 位数字(第 2 章造数)
前 11 位 = 车型固定前缀,第 12 位起才有区分度
truncate(16) ⇒ 参与统计的是"前缀 + 前 5 位数字" → 区分度足够,能裁

但如果换成:
• 前缀很长的编码(如统一 17 位里只有后 2 位变化) → min/max 区间几乎覆盖全部值 ⇒ 裁不动
• 或者对 URL / 报文文本这类长字符串做前缀过滤 → 同理失效
⇒ 高价值列显式设 full,代价是元数据变大

这条的实用价值在于它能提前解释一种"排序做了但没变快"的现象:你把 city_id(INT)排好了,裁剪生效;但查询走的是 vin LIKE 'LSGKA%' 这种前缀条件,而统计被截断——排序形成的顺序性和统计能表达的边界是两回事。

5.3.4 小文件慢在哪:三个互不相干的代价

很多人把"小文件慢"归因成"文件多",这只对三分之一。三个代价各自有独立的处置方法:

① 打开成本(每文件一次 IO 突发,与文件大小无关)
官方对分片的估算是把"打开成本"折成字节参与装箱:
spark.sql.files.openCostInBytes = 4194304(4 MB,2.0.0 起)
"The estimated cost to open a file, measured by the number of bytes
that could be scanned in the same time…"
spark.sql.files.maxPartitionBytes = 134217728(128 MB)
⇒ 一个 1KB 的文件在装箱时被当作约 4MB 计 ⇒ 每个 task 仍要打开几十个文件,
每个文件一次 footer 读取(footer 在文件末尾,另一次随机 IO)

② 计划成本(表格式的 planning 时间随文件数线性增长)
Iceberg:manifest 里每个数据文件一行记录 + 若干列统计(表 5-3 第二行那份清单)
⇒ 10 万文件 = 10 万条 entry,planning 与元数据缓存都被推大
⇒ 这正是前作"几千量级分区"经验在文件维度的另一半:分区数管目录,文件数管 manifest

③ 解码成本(向量化 reader 按批解码)
向量化路径的收益来自"一次处理一批行";小文件往往凑不满一批,
批的边界与文件边界反复对齐,退化明显
⇒ 具体批大小配置名本次未逐字取证,判据看实测:bytes read 相近但耗时差一个量级,
基本就是这一项

治理入口只有一个,参数有三个(Iceberg 1.12 官方默认值,逐字):

参数官方默认作用
target-file-size-bytes 536870912(512 MB) 合并目标大小
min-file-size-bytes 75% of target file size 小于此值才被视为"该被重写"
max-file-size-bytes 180% of target file size 大于此值也可能被重写(拆分/合并)
min-input-files 5 一组至少几个文件才启动重写
rewrite-all false 默认不重写大文件

这张表解释了生产上最常见的困惑:“我跑了 rewrite_data_files,文件数没变”。默认参数下它只处理 小于 384MB(512×75%)且同组不少于 5 个 的文件——10 万个小文件当然会被合并,但 3 个 600MB 的文件不会被动。要改行为就显式传参,不要指望默认值。 第 10 章会给完整的维护作业编排。

5.3.5 观测列级裁剪:三件套加一次对照实验

— 主实验:同一张表,两个谓词分别打在"参与排序的列"与"不参与任何布局的列"上
— 前置:本实验用的表在 5.4.3 建,先跑 5.4 再回来做这一项也可以(顺序不影响结论)
EXPLAIN FORMATTED
SELECT vin, speed FROM iceberg.demo.dwd_vehicle_telemetry
WHERE city_id = 310007; — (a) 参与排序/统计的列

EXPLAIN FORMATTED
SELECT vin, speed FROM iceberg.demo.dwd_vehicle_telemetry
WHERE battery_pct = 42; — (b) 不参与排序的列(基数低、散布全表)

参数说明:两个谓词都是等值、都只引用单列,因此都能被下推(PushedFilters 都会非空)。差别只在统计边界能不能给出"这一块一定不匹配"的结论。(a) 若数据按 city_id 排过,块级 min/max 很窄;(b) battery_pct 在每个块里都取遍 10~99,min/max 永远覆盖 42,一个块都跳不掉。这正是"谓词下推生效 ≠ 数据被裁掉"的成因,也是第 4 章五问模板第 2 问要看得比 PushedFilters 更细的原因。

输出表结构:vin(STRING), speed(FLOAT)。

验收标准:两条 SQL 的 PushedFilters 都非空;差别必须体现在运行后的数字上——(a) 的 scanned rows 应显著低于 (b)。如果 (a) 也没降,回到 5.4 检查排序是否真的落到了文件上(历史数据不会因为改了一条 ALTER … WRITE ORDERED BY 而重排)。

另外两个官方"能减少扫描"的开关,务必和文件级裁剪区分开:

开关(逐字默认值)官方描述要点减的是什么
spark.sql.optimizer.dynamicPartitionPruning.enabled = true(3.0.0 起) “When true, we will generate predicate for partition column when it’s used as join key” 生成对分区列的谓词 ⇒ 减扫描(5.2.5)
spark.sql.optimizer.runtime.bloomFilter.enabled = true(3.3.0 起) “if one side of a shuffle join has a selective predicate … we attempt to insert a bloom filter in the other side to reduce shuffle data” 官方措辞是减 shuffle 数据量,不是减打开的文件数

把 runtime bloom filter 当成"文件裁剪"来期待,是社区文章里流传最广的误读。官方原文写的是 reduce shuffle data。要验证它对扫描有没有帮助,只看 files read 有没有变——默认它不会变。


5.4 排序、聚簇与 Z-Order:把数据摆到尺子上

5.3 讲的是尺子(统计边界)。这一节讲怎么让数据摆得"有边界"。先破一个语法幻觉,再讲选型,最后给可跑的三段 SQL。

5.4.1 Spark 没有"写入时排序"的 SQL 写法

本次取证里最反直觉的一条:

官方 INSERT 语法页(sql-ref-syntax-dml-insert-into.html)的 Syntax 全文:
INSERT INTO [ TABLE ] table_identifier [ partition_spec ] [ ( column_list ) ]
{ VALUES ( … ) | query }
→ 整页 0 次出现 ORDER BY / SORT BY / CLUSTER BY / DISTRIBUTE BY

官方 SELECT 页对 ORDER BY 的原文只有一句定位:
"Specifies an ordering of the rows of the complete result set of the query.
The output rows are ordered across the partitions."
→ 它描述的是查询输出,不是落盘布局

那"排序写入"到底由谁决定?官方把它放在 DataSource V2 的接口层,两条原文说完了整件事:

“A Write implementation can also implement RequiresDistributionAndOrdering to tell Spark how input data must be distributed and sorted before writing. Spark will insert shuffle and sort nodes as needed to satisfy these requirements.”

“Requested distribution and ordering — Write connectors can request that Spark repartition and sort incoming data before writing, enabling optimized data layouts such as clustering or Z-ordering.”

结论说得直白一点:写入布局的排序能力是 connector 向 Spark 申请的,不是用户在 SQL 里写 ORDER BY 换来的。 所以本章后面所有"怎么排"的语法都来自表格式自己的扩展,而不是 Spark SQL:

  • Iceberg 提供 SQL 扩展模块(官方原文:Iceberg 0.11.0 起为 Spark 增加扩展命令,如 CALL 与 ALTER TABLE … WRITE ORDERED BY),有 WRITE ORDERED BY / WRITE LOCALLY ORDERED BY / WRITE UNORDERED / WRITE DISTRIBUTED BY PARTITION 四条。
  • Paimon 把布局交给维护过程:append 表的 incremental clustering,以及 CALL sys.compact(… order_strategy => 'zorder', order_by => …)。
  • Hive 风格表用 DDL 子句 CLUSTERED BY … SORTED BY … INTO n BUCKETS(官方原文:“Bucketing is an optimization technique that uses buckets (and bucketing columns) to determine data partitioning and avoid data shuffle.”),见 5.5。

表 5-4 五种"排序"能力对照(官方定位逐字摘)

能力属于哪一侧官方原文要点改变什么影响下游查询
SELECT … ORDER BY Spark 查询 “ordered across the partitions”,与 SORT BY/CLUSTER BY/DISTRIBUTE BY 互斥 结果输出顺序 否
SELECT … CLUSTER BY Spark 查询 “only ensures that the resultant rows are sorted within each partition and does not guarantee a total order of output” 结果分区内顺序 否
RequiresDistributionAndOrdering DSv2 写接口 connector 声明需求,“Spark will insert shuffle and sort nodes as needed” 写入布局 是
WRITE ORDERED BY / LOCALLY ORDERED BY Iceberg 扩展 “configured with a sort order that is used to automatically sort data that is written to the table in some engines. For example, MERGE INTO in Spark will use the table ordering” 表默认布局 + 新写入 是(只对新写入和 MERGE 生效)
CLUSTERED BY … SORTED BY … INTO n BUCKETS Spark DDL “determine data partitioning and avoid data shuffle” 桶归属 + 桶内序 是,且带来免 shuffle
Paimon clustering.strategy / Sort Compact Paimon 维护过程 “improves the data layout of append tables by sorting selected files on frequently filtered columns”;“does not replace SQL ORDER BY” 存量文件布局 是

最后一列那句话值得单独抄进评审清单:WRITE ORDERED BY 改的是"以后怎么写",不是"已经写好的怎么摆"。 存量数据只有 rewrite 一条路。生产上最常见的错法是"给表加了排序,然后发现查询没变快"——顺序全对,只是没人重写历史文件。

5.4.2 线性 / Z-Order / Hilbert:按查询形态选,不按名气选

两个维度(横轴 city_id、纵轴 report_date)的两种摆法:

线性排序 sort(city_id, report_date)
┌─────────────────────────────────┐ city 先分组,组内按日期连续
│ ██│▓▓│░░│──│── │ · 查 city=? ⇒ 1 段连续块,极好
│ ██│▓▓│░░│──│── │ · 查 date=? ⇒ 每段各沾一点,几乎全读
│ ██│▓▓│░░│──│── │ · 查 city=? AND date=? ⇒ 1 块内定位,极好
└─────────────────────────────────┘ · 优点:单维选择性最强,代价最小
██=city1 ▓▓=city2 ░░=city3

Z-Order(city 与 date 的比特位交错)
┌─────────────────────────────────┐ 空间上相邻的格子在编码上也相邻
│ A│B│A│C│B│A│C│B │ · 查 city=? ⇒ 若干块,能裁
│ ─┼─┼─┼─┼─┼─┼─┼─ │ · 查 date=? ⇒ 若干块,能裁
│ D│B│D│C│B│D│C│A │ · 查 city=? AND date=? ⇒ 1~2 块,好
└─────────────────────────────────┘ · 代价:没有任何一维拿到"连续整段"
同一字母 = 编码上相邻的块 多维各自都只拿到"部分收益"

Hilbert:与 Z-Order 同族,局部性保持更好,维度多时优势显现

表 5-5 三种布局对不同查询形态的表现(笔者归纳,判据见 5.6 实测)

查询形态线性 sort(a,b)Z-Order(a,b)Hilbert(a,b)
a = 常量 最优(连续区间) 好(若干块) 好
b = 常量 差(散布全部块) 好 好
a = 常量 AND b = 常量 最优 好 好
a BETWEEN … AND b BETWEEN … 只对第一列友好 中 较好
维度数 ≥ 5 — 交错位数膨胀,收益下降 官方建议在此切换

官方给出的选型规则(比任何口诀都可信):Paimon 的 clustering.strategy 默认 auto,官方逐字说明是 “Automatic selection uses order for one column, zorder for two to four, and hilbert for five or more.” —— 1 列线性、2~4 列 Z-Order、5 列以上 Hilbert。Iceberg 侧则在 rewrite_data_files 的 sort_order 里同时提供 zorder(c1,c2,c3) 与 hilbert(c1,c2,c3) 两种写法。超过四列还在往 Z-Order 里塞,就已经越出官方建议区间了。

落到车联网这条链路上,两个真实需求给出两个相反的答案:

需求 A:诊断/客服"查一台车某一天的轨迹"
查询形状:vin = ? AND report_date = ? (两列都是等值,且几乎总是同时出现)
选择:线性 WRITE ORDERED BY (vin, report_date)
理由:线性对"复合等值 + 组内范围"是最优形状;换 Z-Order 会让同一 vin 的数据
不再连续,反而牺牲了"按车整段导出"这类作业

需求 B:城市运营看板"任意城市 × 任意日期区间"
查询形状:city_id 可能有、date 范围几乎总有,且 city 常常单独出现
选择:分区裁日期 + Z-Order(city_id, report_date) 整理块内相关性
理由:city 不在分区键里,线性排序对"只给 city"零收益;Z-Order 是唯一能同时
照顾两维的手段

需求 C:把两者都想要的表
答案:不存在一个布局能同时最优。这时该拆表(DWD 明细按 vin 排、DWS 城市汇总
按 city 排),不是继续调排序参数。这是本章对"过度优化"最重要的一条劝阻。

两条必须一起说出来的代价,否则读者会以为 Z-Order 免费:

  • Z-Order 的紧凑性会被新数据稀释。 之后追加写入的数据不参与既有的交错编码,块边界越来越宽——必须周期性 rewrite。这就把它从"一次 DDL 决定"变成了"一项长期维护成本",归属第 10 章。
  • 它会伤害有序性本身。 交错编码之后,按 vin 顺序流式导出、按 vin 做 window(第 13 章行程段切分)这类作业,从"数据已有序"退化为"必须重排"。第 4 章 4.5.2 说过 Sort 是最常被白白付掉的开销——这里它不是白白付,是被布局逼着付。
  • 5.4.3 实操:从建表到重写历史数据

    — 文件:zorder_layout.sql
    — ① 建 DWD 明细表:分区与排序分开写,两件事各管一段
    CREATE TABLE IF NOT EXISTS iceberg.demo.dwd_vehicle_telemetry (
    vin STRING,
    city_id INT,
    report_date DATE,
    reported_at TIMESTAMP,
    speed FLOAT,
    battery INT,
    battery_pct TINYINT,
    vehicle_status STRING,
    seq BIGINT,
    __dt__ STRING
    ) USING iceberg
    PARTITIONED BY (days(report_date))
    TBLPROPERTIES (
    'format-version' = '2',
    'write.target-file-size-bytes' = '33554432', — 32MB:实验规模必须切小,理由见下
    'write.parquet.row-group-size-bytes' = '4194304' — 4MB:让 row-group 级裁剪可观测
    );

    — ② 表级全局排序(对"新写入"与"MERGE INTO"生效)
    ALTER TABLE iceberg.demo.dwd_vehicle_telemetry
    WRITE ORDERED BY city_id, report_date, vin;

    — ③ 或者:task 内局部排序,省掉一次全局 shuffle(②③互斥,实际只保留一条)
    ALTER TABLE iceberg.demo.dwd_vehicle_telemetry
    WRITE LOCALLY ORDERED BY city_id, vin;

    — ④ 取消表级排序
    ALTER TABLE iceberg.demo.dwd_vehicle_telemetry WRITE UNORDERED;

    — ⑤ 写入历史数据(第 2 章数据集整段搬入,保持可复现)
    INSERT INTO iceberg.demo.dwd_vehicle_telemetry
    SELECT vin, city_id, report_date, reported_at, speed, battery, battery_pct,
    vehicle_status, seq, __dt__
    FROM paimon.demo.ods_vehicle_telemetry
    WHERE __dt__ BETWEEN '2026-10-01' AND '2026-10-30';

    参数说明

    项取值为什么这么写
    PARTITIONED BY (days(report_date)) 隐藏分区,按天 用户查询写 report_date,不写分区字段(5.1 的核心差别)
    write.target-file-size-bytes 32MB(官方默认 512MB) 本实验总数据只有 20 万行、约 15~25MB。用默认值只会产出 1 个文件,1 个文件上任何排序与统计都测不出裁剪
    write.parquet.row-group-size-bytes 4MB(官方默认 128MB) 同上:让"同一文件内跳过 row-group"这件事在小数据上仍然发生。生产方向是相反的:合并、放大
    WRITE ORDERED BY city_id, report_date, vin 三列线性 列序即收益序:最粗的选择性放前面。Z-Order 版本见下一步的 rewrite
    WRITE LOCALLY ORDERED BY 对照组 官方明确"to order within each task, not across tasks"——它不做全局 shuffle,块内相关性差,但写入便宜得多

    输出表结构:与源列一致(10 列)。report_date 是 DATE,隐藏分区字段 report_date_day 不出现在 schema 里。

    验收标准

    SELECT count(*) AS rows_total, count(DISTINCT vin) AS vin_cnt,
    min(report_date) AS dmin, max(report_date) AS dmax
    FROM iceberg.demo.dwd_vehicle_telemetry;
    — 期望:200000 / 200000 / 2026-10-01 / 2026-10-30,与第 2 章 ODS 完全一致

    SELECT file_format, record_count, file_size_in_bytes
    FROM iceberg.demo.dwd_vehicle_telemetry.files
    ORDER BY file_size_in_bytes DESC;
    — Spark 侧元数据表用点号 .files(前作硬约束:Flink 侧要写 $files,两者不可混用)
    — 期望:文件数 ≥ 2;每个文件 record_count 与 file_size_in_bytes 相互匹配(无极端长尾文件)

    历史数据的布局只能重写。rewrite_data_files 在 Iceberg 1.12.0 的参数与旧版差别很大,这是本章最需要"按版本核对"的一段:

    — ⑥ 对历史数据做 Z-Order 重排(1.12 的参数名)
    CALL iceberg.system.rewrite_data_files(
    table => 'demo.dwd_vehicle_telemetry',
    strategy => 'sort',
    sort_order => 'zorder(city_id, report_date)',
    where => "report_date >= '2026-10-01'",
    options => map('target-file-size-bytes', '8388608',
    'max-concurrent-file-group-rewrites', '5')
    );

    — ⑦ 只合并小文件,不动顺序
    CALL iceberg.system.rewrite_data_files(
    table => 'demo.dwd_vehicle_telemetry', strategy => 'binpack');

    — ⑧ 看结果:文件数与每文件行数
    SELECT count(*) AS file_cnt, sum(record_count) AS rows_total,
    avg(file_size_in_bytes) AS avg_bytes
    FROM iceberg.demo.dwd_vehicle_telemetry.files;
    — 验收:rows_total 必须与⑤前后一致(rewrite 不删行);file_cnt 变化符合 target 设定

    表 5-6 rewrite_data_files 参数与官方默认值(Iceberg 1.12.0,逐字)

    参数必填官方默认原文要点
    table ✔ — 表名
    strategy binpack “Name of the strategy – binpack or sort. Defaults to binpack strategy”
    sort_order 表的 sort order “For Zorder use a comma separated list of columns within zorder(). Example: zorder(c1,c2,c3). For Hilbert use … hilbert(c1,c2,c3). Else, Comma separated sort orders in the format (ColumnName SortDirection NullOrder)”
    where 未设 “predicate as a string used for filtering the files. Note that all files that may contain data matching the filter will be selected for rewriting”
    options 未设 map,传给底层动作的写选项
    target-file-size-bytes 536870912(512MB) 目标大小
    min-file-size-bytes / max-file-size-bytes 75% / 180% of target 决定"哪些文件算小/大"
    min-input-files 5 一组至少几个文件才重写
    rewrite-all false 默认不动"已经合格"的文件
    max-concurrent-file-group-rewrites 5 并发组数
    max-file-group-size-bytes 107374182400(100GB) 单组上限
    use-starting-sequence-number true 序列号语义,与 MoR 删除文件相关
    partial-progress.enabled / max-commits false / 10 分批提交
    output-spec-id 当前 spec 跨 partition evolution 时必须显式指定

    这张表里最容易写错的是参数名。 旧版教程里常见的 sort_by / zorder_by / distribute_by / sort_buffer_size,在本次 1.12.0 的 rewrite_data_files 文档里出现次数为 0(sort_by 只存在于 rewrite_manifests,语义是"按分区变换名 cluster manifest")。照旧版参数名写,会直接报找不到参数——这不是兼容性问题,是版本基线不同。 第 10 章的维护作业一律按这张表写。

    where 那句 “all files that may contain data matching” 也要单独记住:它是候选文件筛选,不是行过滤。rewrite 不会减少任何行,谁要是以为 where 能"清理旧数据",就会在跑完之后发现数据还在——而真正的清理是 DELETE + expire(第 10 章)。

    5.4.4 与 ClickHouse 稀疏索引对照:裁剪粒度差多少

    前作 ck 第 3/4 章取证过 ClickHouse 的机制:ORDER BY 排序键即主键前缀,part 内严格有序,mark 每 index_granularity(默认 8192)行一条,二分定位 granule。对照湖表:

    表 5-7 ClickHouse 稀疏索引 vs 湖表排序裁剪

    对比项ClickHouse ORDER BY + markSpark 湖表排序 + min/max 统计
    顺序的承诺强度 强:part 不可变,merge 后仍严格有序 弱:只在写入/rewrite 那一刻形成,追加即稀释
    定位方式 mark 文件二分(精确行区间) 统计边界比较(只能判"可能/不可能")
    最小裁剪单位 1 granule ≈ 8192 行(默认) 1 row-group(默认 128MB)或 1 文件(默认 512MB)
    想让粒度变细 调小 index_granularity,代价小 只能把文件与 row-group 切小,代价是文件数与 planning
    失效的表现 跳过排序键第一列、函数包键列 统计边界过宽(前缀截断、维度不相关、追加稀释)
    观测手段 EXPLAIN indexes = 1 的 Parts/Granules A/B files read、scanned rows、元数据表 files 的 bounds

    一句话概括这个对照(笔者归纳,非官方措辞):

    ClickHouse 的排序"就是"索引;湖表的排序"喂给"统计信息。前者省的是行区间,后者省的是文件与块。 所以同一句 sort(city_id, vin),在 CK 上你能指望 8192 行的精度,在湖表上你的精度单位是"一个几百 MB 的文件里有几段窄边界"——除非你把文件切小到几十 MB,而那就等于亲手制造小文件问题。

    这条对照的直接工程后果,是本章给湖表定的一个务实起点:

    湖表不要追求 granule 级细粒度。
    目标 = 每个文件/块内的 city、日期高度同质,且文件总数可控。
    常见落点:文件 32~128MB、row-group 用默认或折半、分区按天、
    排序列不超过 3~4 个(超出就回到官方 auto 规则:2~4 用 zorder,≥5 用 hilbert)
    反例:为了让"查一台车"更快,把 target 设成 2MB ⇒ 文件数 ×250,
    manifest 与 planning 先炸,第 10 章来收拾你


    5.5 Bucket:三个引擎同一个词,三种语义

    "分桶"是全系列最容易让人把不同东西当成同一件事的词。第 4 章讲过 Spark 的 join 策略选择,前作在 Doris 侧定过 Colocate Join 的硬约束(同分桶列、同桶数、同 colocate group),第 2 章又给 Paimon 表写了 'bucket' = '4'。三个"桶"的目的完全不同,把它们当同一个东西,就会在错误的场景期待错误的收益。

    5.5.1 三套 bucket 的语义对照

    表 5-8 三种 bucket 对照

    维度Hive 风格 bucketIceberg bucket(n) transformPaimon bucket
    它本质是什么 表的一个物理属性:hash(桶列) % n 决定行落到哪个桶文件 一种分区变换:把高基数列离散成有限个"分区值" 主键表里"主键 → 文件"的定位单位
    设计目的 ① join/聚合免 shuffle ② 桶内预排序免 sort ③ 抽样 给高基数列做裁剪(时间列之外的第二维度) 控制并发写入与 LSM 层级规模;保证同键数据同桶
    写侧形态 CLUSTERED BY (vin) SORTED BY (vin, reported_at) INTO 8 BUCKETS PARTITIONED BY (bucket(16, vin)) bucket > 0 固定桶;官方默认 -1 动态桶;-2 为 postpone 模式。bucket-key 不给时"用主键"(Paimon 官方:“For primary-key tables, bucket-key defaults to the primary key excluding partition columns.”)
    定位算法 hash(桶列) % n bucket[N]:“Hash of value, mod N”(Iceberg spec) 官方逐字:“Paimon calculates the bucket using Math.abs(key_hashcode % numBuckets)”
    读侧收益条件 两侧桶列语义相同 + 桶数相同 ⇒ 可省 shuffle;单表按桶列等值也可只读部分桶 对 vin 写等值/IN ⇒ 只扫对应桶分区;范围条件 ⇒ 全桶扫 对 bucket-key/主键写等值 ⇒ 定位到 1 个桶;范围 ⇒ 全桶扫
    桶数能不能改 改桶数需重写整表 属于 partition spec 变更,走 partition evolution,老数据仍按旧 spec(官方:“Partition evolution is a metadata operation and does not eagerly rewrite files.”) 官方三模式的规划约束逐字:固定桶 “Predictable layout; resizing requires an explicit rescale operation”;动态桶 “Index memory and single-writer constraints”;postpone “Batch/compaction workflow and per-partition sizing”
    最常见的误用 桶数与 shuffle.partitions 无关系却被当成并行度设置 桶数取太小(如 4),"桶"变成粗分区,裁剪粒度反而变差 把桶数按"全表"而不是"按分区"算,导致单分区文件数失控

    三条必须说透的差异:

    ① Iceberg 的 bucket 不是"桶",是"分区"。 bucket(16, vin) 出现在 partition spec 里,产出的 vin_bucket=7/ 与 report_date_day=2026-10-15/ 是同一层级的东西。所以它的代价也按分区算:16 个桶 × 30 天 = 480 个分区目录。这就是为什么 Iceberg 侧选桶数要拿"几千量级分区"这把尺子量,而不是拿"并行度"这把。

    ② Paimon 的桶是"正确性约束",不只是性能选项。 官方那句话把因果讲完了:“For primary-key tables, bucket-key defaults to the primary key excluding partition columns.” —— 默认桶键就是"主键去掉分区列"。把它和第 2 章那条硬约束(Paimon 主键必须包含全部分区字段)放在一起读,结论很清楚:分区字段负责把数据切到目录,剩下的主键部分负责定桶,两者拼起来才是完整主键。谁手动指定了一个与主键不一致的 bucket-key,谁就在制造"同键不同桶"的风险。

    还有一个读者一定会撞上的差异:Paimon 官方默认是动态桶(bucket = -1),而第 2 章的 ODS 表显式写了 'bucket' = '4'(固定桶)。 这是刻意安排的——实验要的是可预期的目录形态。生产上选固定桶意味着接受官方那句 “resizing requires an explicit rescale operation”;选动态桶意味着接受 “Index memory and single-writer constraints”(索引占内存、单写者约束)。这两个代价必须在建表评审时写下来,不能等到改桶数那天才发现。

    ③ Hive bucket 是唯一能带来"免 shuffle"的,但它有前置门槛。 Spark 侧要认出这个性质,需要:分桶信息在 catalog 里可见,且 spark.sql.sources.bucketing.enabled 处于开启(官方默认 true,2.0.0 起;原文措辞很有画面感——“When false, we will treat bucketed table as normal table”),另有上限 spark.sql.sources.bucketing.maxBuckets = 100000(2.4.0 起)。少任何一条,收益静默归零——计划里 Exchange 一个不少,而且没有任何警告。

    5.5.2 免 shuffle 这件事:先验证,再期待

    — 前置检查 1:分桶优化开关在不在(官方默认 true)
    SET spark.sql.sources.bucketing.enabled;
    — 输出列:key(STRING), value(STRING)
    — 若被某处配置成 false,官方行为是"把分桶表当普通表"——收益直接归零且不报错

    — 前置检查 2:桶上限
    SET spark.sql.sources.bucketing.maxBuckets; — 官方默认 100000

    — 前置检查 3:当前 catalog 能否持久化分桶元数据
    SET spark.sql.catalogImplementation;
    — 说明:官方 CREATE TABLE 的两个页面都接受 CLUSTERED BY 子句——
    — Hive 风格:sql-ref-syntax-ddl-create-table-hiveformat.html
    — DataSource 风格:sql-ref-syntax-ddl-create-table-datasource.html
    — 官方原文示例逐字为:CREATE TABLE student (id INT, name STRING, age INT)
    — USING CSV PARTITIONED BY (age) CLUSTERED BY (Id) INTO 4 buckets;
    — 两种写法都能建,差别在"桶信息写进哪份元数据、能不能被下次会话读回来"。
    — 建完立刻 DESCRIBE EXTENDED 看一眼,比背语法可靠。

    — 文件:partition_design.sql 的第 3 段
    — 官方两种 DDL 形态都接受 CLUSTERED BY;生产上推荐落进能持久化桶信息的 catalog(接 HMS 的 spark_catalog)
    CREATE TABLE IF NOT EXISTS spark_catalog.demo.bkt_telemetry (
    vin STRING,
    report_date DATE,
    reported_at TIMESTAMP,
    city_id INT,
    speed FLOAT,
    battery INT,
    vehicle_status STRING,
    seq BIGINT
    )
    USING parquet
    CLUSTERED BY (vin) — 桶键:join 键,必须与下游查询的等值列一致
    SORTED BY (vin, reported_at) — 桶内排序:让"按车取时序"免一次 sort
    INTO 8 BUCKETS — 8 = 2 的幂,便于与并行度对齐
    PARTITIONED BY (report_date);

    — 灌数:直接演示"同桶键同桶数"的第二张表(聚合结果表,用于 join)
    CREATE TABLE IF NOT EXISTS spark_catalog.demo.bkt_vehicle_daily (
    vin STRING,
    report_date DATE,
    trip_cnt BIGINT,
    avg_speed DECIMAL(10,3)
    )
    USING parquet
    CLUSTERED BY (vin) INTO 8 BUCKETS
    PARTITIONED BY (report_date);

    参数说明(只讲容易误解的两处)

    项取值为什么
    CLUSTERED BY (vin) 单列,等于 join 键 多列桶键要求 join 条件覆盖全部桶列才对齐,车联网场景里 vin 单列最实用
    SORTED BY (vin, reported_at) 桶内二级排序 免 shuffle 之外再免一次 sort;代价是写入端多付一次排序,写放大换读加速
    INTO 8 BUCKETS 8 桶数一旦定下不可轻易改。选择依据见下
    PARTITIONED BY (report_date) 与分桶同时存在 分区管"哪些天的文件",桶管"每个分区内的文件归属",两层独立(图 5-1)

    桶数怎么定(口径要分清)

    Hive / Paimon 侧:按"单桶数据量"反推
    桶数 ≈ 单个分区的字节量 ÷ 目标单桶大小
    Paimon 官方口径(逐字):"For MOR workloads, 200 MB–1 GB per bucket is
    an initial sizing guide, not a limit. Measure skew, throughput, and read
    plans to select the actual count."
    ⇒ 一个分区 4GB、目标 512MB ⇒ 8 桶
    ⇒ 注意官方紧接着的那句:"A batch scan can split one bucket into
    independent key ranges, so bucket count is not a hard read-parallelism cap."
    这句直接否掉了"桶数 = 读并行度"的常见误解(见 5.5.4)

    Iceberg 侧:按"总分区数"反推
    总分区数 ≈ 时间分区数 × bucket(n) 的 n
    受"几千量级分区"约束 ⇒ n 的上界由时间分区数决定
    ⇒ 30 天 × n ≤ 几千 ⇒ n 可以很大;但 1000 天 × n 就必须把 n 压到个位数

    "几千量级分区"这条尺子要说清来源:它是前作 Iceberg 系列定下的工程口径,本次在 Iceberg 1.12.0 的 partitioning 文档全文里没有检索到对应措辞(官方页面只讲 transform 列表、分区演进与"plan 只读匹配分区"的收益)。所以它的性质是工程经验,不是官方规范——用于评审提问很有用,用于"引用官方"不行。

    输出表结构:bkt_telemetry 为分桶 + 分区表,物理上 report_date=2026-10-15/000000_0.parquet … 每分区 8 个桶文件(Spark 写 Hive 风格分桶表时,文件名里的编号承载桶号;具体形态随版本而异,验收不看文件名)。

    验收标准

    — 关键验收:join 的 Exchange 有没有少
    EXPLAIN FORMATTED
    SELECT t.vin, t.report_date, t.speed, d.avg_speed
    FROM spark_catalog.demo.bkt_telemetry t
    JOIN spark_catalog.demo.bkt_vehicle_daily d
    ON t.vin = d.vin AND t.report_date = d.report_date;
    — 判定表:
    — | 观察 | 结论 |
    — | join 两侧均无 Exchange hashpartitioning(vin, N) | 分桶对齐生效,免 shuffle |
    — | 两侧各多一个 Exchange hashpartitioning(vin, 200) | 收益归零:桶数不同 / 开关未开 / 桶信息未被识别 |

    为什么"归零"要写成一条纪律:免 shuffle 失效时 Spark 不报错、不打警告,计划退化得无声无息。唯一的发现手段是对比 Exchange 数量——所以这条 SQL 应该在改桶数、升级 Spark、换 catalog 之后各跑一次,把结果存档。

    5.5.3 与 Doris Colocate Join 的不等价性(回扣前作)

    前作硬约束:Colocate Join 要求同分桶列、同桶数,并且要配置 Colocate Group。Spark 侧的分桶 join 表面像,实质差三点:

    对比项Doris Colocate JoinSpark bucketed join
    "同桶"保证的是什么 数据在同一 BE 节点上物理共置(tablet 分布一致) 两侧的 hash 分区表达式一致,从而省掉一次重分布
    省掉的代价 跨节点网络传输 + 重分布 shuffle write + shuffle read(仍是网络 IO,只是不再重排)
    不满足时的退路 不能 colocate 就走 Broadcast/SHJ,需要显式建 group 静默退回普通 SMJ,没有任何提示
    桶的载体 建表时 DISTRIBUTED BY HASH(k) BUCKETS n + PROPERTIES("colocate_with"=…) 建表时 CLUSTERED BY (k) INTO n BUCKETS(Hive 风格表)

    最关键的一句:Spark 的 bucket 只保证"不需要重排",不保证"数据在本地"。 所以拿 Doris 的 colocate 直觉去设计 Spark 湖表,会期待一个不存在的收益。

    而且——湖表这一侧确实有一个官方机制,但它的名字和适用范围与多数人的想象不同:

    Storage Partition Join(SPJ),官方逐字:“Storage Partition Join (SPJ) is an optimization technique in Spark SQL that makes use of the existing storage layout to avoid the shuffle phase. This is a generalization of the concept of Bucket Joins, which is only applicable for bucketed tables, to tables partitioned by functions registered in FunctionCatalog. Storage Partition Joins are currently supported for compatible V2 DataSources.”

    对应开关:spark.sql.sources.v2.bucketing.enabled = true(3.3.0 起),官方描述 “When true, try to eliminate shuffle by using the partitioning reported by a compatible V2 data source.”

    它还在被修:Spark 4.1.0 发布说明里有 [SPARK-53074] Avoid partial clustering in SPJ to meet a child's required distribution。

    这段原文纠正了一个流传很广的说法——"只有 Hive 分桶表才能免 shuffle"已经不成立:官方明说 SPJ 是 Bucket Join 的泛化,适用到"按注册在 FunctionCatalog 里的函数分区"的表,而这正是 Iceberg 隐藏分区(days(…)、bucket(N, col))的形态。

    但它同时给了两个限制条件,两个都很容易被人跳过:

    ① "compatible V2 DataSources" ——兼容是连接器的事,不是 Spark 的承诺
    ② "partitioning reported by" ——连接器必须真的把分区信息上报给 Spark
    ⇒ 所以 Iceberg / Paimon 在你这套版本组合上到底能不能吃到 SPJ,
    是一个"实测项",不是一个"背下来的结论"。本次未取证连接器侧的上报实现。

    实测只有一条判据(就是 5.5.2 验收里那条,这里再强调一次它的普适性):把 join 两侧都换成湖表,EXPLAIN FORMATTED 里数 Exchange。消失了就是生效了,没消失就是没生效——不要引用任何人的博客代替这一步,包括本系列的。

    因此生产上的可行组合仍然是三条,只是第①条的措辞变了:

    ① 热点大表之间的高频 join,且能接受重刷代价
    → 首选 Hive 风格分桶表(两侧同桶键同桶数),这是官方明确承诺的路径;
    湖表则先实测 SPJ 是否生效,生效才写进设计,不生效不要当既成事实
    ② 大表 × 小表
    → 不要指望分桶。走广播(第 4 章 BHJ),或把小表做成 Broadcast 变量/位图
    ③ 大表 × 大表且都是湖表,且 SPJ 实测不生效
    → 接受一次 shuffle,用 AQE 控并行度 + 两阶段聚合缓解倾斜(4.5.3)

    车联网链路里真实出现的形态是"轨迹大表 × 车辆日汇总大表",两者都是 Iceberg。这条链路上,分桶收益在未实测前一律按"没有"来设计——把 SPJ 实测列进 DDL 评审清单,比留一个"理论上能免 shuffle"的期待有用得多。

    5.5.4 一个必须说的反例:桶数与并行度不是一回事

    误解:8 个桶 ⇒ 查询并行度 8
    事实:读侧 task 数由文件分片决定(spark.sql.files.maxPartitionBytes、
    openCostInBytes、AQE 合并),桶只是"文件归属规则"
    后果:桶数设成 8 但每桶文件很小 ⇒ 实际可能 1 个 task;
    桶数设成 8、数据 100GB ⇒ 每个桶一个 12GB 文件 ⇒ 单 task 长尾,AQE 治不了读侧(4.3 边界)

    前作在 SeaTunnel 侧有一条同构经验(JDBC 切分列必须是索引列,否则并行 SQL 反而更慢)——"能并行"和"并行得有效"是两件事。桶数、分区数、shuffle.partitions 是三个独立旋钮,只有 5.5.2 那种"计划里数 Exchange"的实测能把它们的关系确认下来。


    5.6 四层裁剪漏斗的 Spark 版本

    到这里可以把四层重新排一遍,并给每一层一个能填数字的格子。这张图就是 ck 第 4 章那张漏斗的 Spark 对应物——形状一样,名字不同。

    你的 SQL 的 WHERE 条件
    │
    ▼
    ┌────────────────────────────────────────────────────────────────┐
    │ 第 1 层 分区裁剪(目录 / partition spec) │
    │ 谁来裁 Spark + 表格式 planner + metastore │
    │ 拿什么裁 分区值(Hive/Paimon)或 transform 域约束(Iceberg) │
    │ 观测 partitions read / EXPLAIN 的 PartitionFilters 非空 │
    │ 省掉 整个目录的 list + 元数据 + IO ★收益最大 │
    └──────────────────────────┬─────────────────────────────────────┘
    ▼ 剩下的候选文件
    ┌────────────────────────────────────────────────────────────────┐
    │ 第 2 层 文件裁剪(列统计 lower/upper bounds、value/null counts)│
    │ 谁来裁 Iceberg planner(读 manifest 时就裁)/ Paimon scan │
    │ 拿什么裁 每文件每列的统计(字符串默认只记前 16 字符) │
    │ 观测 files read:候选文件数 vs 实际打开数 │
    │ 省掉 不打开文件(连 footer 都不读) │
    └──────────────────────────┬─────────────────────────────────────┘
    ▼ 打开的文件
    ┌────────────────────────────────────────────────────────────────┐
    │ 第 3 层 row-group / 块裁剪(Parquet column chunk 的 min/max) │
    │ 谁来裁 Parquet reader(谓词下推) │
    │ 开关 spark.sql.parquet.filterPushdown = true(官方默认) │
    │ 观测 row groups read:Selected / Missing / Total │
    │ 省掉 块级 IO;列只读需要的(ReadSchema 列裁剪) │
    │ ★page 级:Spark 官方无对应开关,本页不写成事实(见 5.3.1) │
    └──────────────────────────┬─────────────────────────────────────┘
    ▼ 读进内存的行
    ┌────────────────────────────────────────────────────────────────┐
    │ 第 4 层 行级过滤 + 列裁剪(真正的 WHERE 求值) │
    │ 谁来裁 Spark(向量化批内 / 非向量化 record-level filter) │
    │ 观测 scanned rows → output rows;bytes read │
    │ 到这一层才"精确",但前三层决定了你要付多少钱走到这里 │
    └────────────────────────────────────────────────────────────────┘

    图 5-2 四层裁剪漏斗与每层的观测指标

    表 5-9 每层的名字、开关与"不合格"的样子

    层Spark 侧叫什么主要开关/前提不合格的典型症状
    1 分区 PartitionFilters、partitions read 谓词直接命中分区列(5.2);Iceberg 走 transform PartitionFilters: []
    2 文件 files read、metadata tasks 列统计有效(排序 + metrics 模式);文件别太碎 files read ≈ 该查询逻辑不该碰的文件数
    3 块 row groups read: Selected/Missing/Total filterPushdown=true;row-group 大小与数据相关性 Missing: 0(一个块都没跳过)
    4 行 scanned rows / output rows 无(必然发生) scanned rows / output rows > 100 ⇒ 前三层全空转

    5.6.1 四态实验:同一份数据摆成四种布局

    这一步是整个系列的"裁剪天花板"实验,也是本章唯一需要你自己填数字的地方。 之所以不给现成结论,是因为文件数、块数强依赖版本与连接器实现——填进去的每一格都必须是你在自己环境里量出来的,这也是本章反复强调的纪律。

    — 文件:partition_design.sql 的第 4 段(四态对照表)
    — 四张表都从第 2 章 ODS 派生,数据完全相同,只有物理布局不同

    — S0:裸表(无分区、无排序、默认 512MB 目标)
    CREATE TABLE IF NOT EXISTS iceberg.demo.t_layout_raw (
    vin STRING, city_id INT, report_date DATE, reported_at TIMESTAMP,
    speed FLOAT, battery INT, battery_pct TINYINT, vehicle_status STRING, seq BIGINT
    ) USING iceberg TBLPROPERTIES ('format-version'='2');

    — S1:加分区
    CREATE TABLE IF NOT EXISTS iceberg.demo.t_layout_part (
    vin STRING, city_id INT, report_date DATE, reported_at TIMESTAMP,
    speed FLOAT, battery INT, battery_pct TINYINT, vehicle_status STRING, seq BIGINT
    ) USING iceberg PARTITIONED BY (days(report_date))
    TBLPROPERTIES ('format-version'='2',
    'write.target-file-size-bytes'='33554432', 'write.parquet.row-group-size-bytes'='4194304');

    — S2:加分区 + 表级排序(与 S1 只差一条 ALTER 的时机)
    CREATE TABLE IF NOT EXISTS iceberg.demo.t_layout_sort (
    vin STRING, city_id INT, report_date DATE, reported_at TIMESTAMP,
    speed FLOAT, battery INT, battery_pct TINYINT, vehicle_status STRING, seq BIGINT
    ) USING iceberg PARTITIONED BY (days(report_date))
    TBLPROPERTIES ('format-version'='2',
    'write.target-file-size-bytes'='33554432', 'write.parquet.row-group-size-bytes'='4194304');
    ALTER TABLE iceberg.demo.t_layout_sort WRITE ORDERED BY city_id, report_date, vin;

    — S3:与 S1 相同布局,但事后做 Z-Order 重写(先灌数再 rewrite)
    CREATE TABLE IF NOT EXISTS iceberg.demo.t_layout_zorder (
    vin STRING, city_id INT, report_date DATE, reported_at TIMESTAMP,
    speed FLOAT, battery INT, battery_pct TINYINT, vehicle_status STRING, seq BIGINT
    ) USING iceberg PARTITIONED BY (days(report_date))
    TBLPROPERTIES ('format-version'='2',
    'write.target-file-size-bytes'='33554432', 'write.parquet.row-group-size-bytes'='4194304');

    — 灌数(四张表同一段 SELECT,字段列表显式写全,避免列序串位)
    INSERT INTO iceberg.demo.t_layout_raw
    SELECT vin, city_id, report_date, reported_at, speed, battery, battery_pct, vehicle_status, seq
    FROM paimon.demo.ods_vehicle_telemetry;
    — 其余三张表重复同一段,只在 t_layout_zorder 灌完后执行:
    CALL iceberg.system.rewrite_data_files(
    table => 'demo.t_layout_zorder', strategy => 'sort',
    sort_order => 'zorder(city_id, report_date)');

    参数说明:为什么 S0 不设小 target、S1~S3 设成 32MB/4MB?因为对照实验必须让"布局"成为唯一变量。S0 一个 512MB 目标下只有 1 个文件,它的 files read 恒为 1——这本身就是最有说服力的一格:文件都只有一个,谈不上文件级裁剪。

    输出表结构:四张表列结构完全一致(9 列),行数完全一致(200000)。

    验收标准(三条必须全过,否则四态对比无效)

    SELECT 'raw' AS st, count(*) AS c FROM iceberg.demo.t_layout_raw
    UNION ALL SELECT 'part', count(*) FROM iceberg.demo.t_layout_part
    UNION ALL SELECT 'sort', count(*) FROM iceberg.demo.t_layout_sort
    UNION ALL SELECT 'zorder', count(*) FROM iceberg.demo.t_layout_zorder;
    — 四行必须完全相等
    SELECT 'zorder' AS st, count(*) AS f, sum(record_count) AS r FROM iceberg.demo.t_layout_zorder.files;
    — r 必须等于 200000(rewrite 不删行)

    两个探针查询,分别代表两类真实需求:

    — Q1:需求 A 形态(时间 + 城市都有)——分区就该帮忙
    SELECT count(*) FROM <state_table> WHERE report_date = DATE '2026-10-15' AND city_id = 310007;
    — Q2:需求 B 形态(只给城市,跨全月)——分区无能为力,只能靠第 2/3 层
    SELECT count(*) FROM <state_table> WHERE city_id = 310007;

    5.6.2 量出来:measure_scan.py

    Spark 的 EXPLAIN 不给实测(第 4 章 4.2 那条不对称),实测只能从事件日志拿。第 2 章已经把 spark.eventLog.enabled 打开了,这里直接用它——这就是为什么事件日志要在搭环境的第一个小时就开,而不是等到第一次排障。

    # 文件:measure_scan.py
    # 用法:spark.eventLog.enabled=true 跑完查询后,对生成的事件日志执行
    # python measure_scan.py /data/spark/eventlog/local-xxx.log
    # 依赖:仅标准库
    import json, sys, re

    def collect(path):
    """从事件日志里抓每个 SQL execution 的累加器指标。"""
    accs, sqls = {}, []
    for line in open(path, encoding="utf-8"):
    try:
    e = json.loads(line)
    except json.JSONDecodeError:
    continue
    t = e.get("Event")
    if t == "SparkListenerSQLExecutionStart":
    sqls.append((e["executionId"], e.get("description", "")))
    elif t == "SparkListenerSQLAccumulator":
    m = e.get("metric", {}) or {}
    acc = e.get("accumualtorUpdate") or e.get("accumulatorUpdate") or {}
    name = m.get("name") or ""
    typ = m.get("type") or ""
    val = m.get("value")
    if val is None:
    val = acc.get(e.get("accumualtorId", ""), None)
    key = (e.get("sqlExecutionId"), name, typ)
    accs[key] = accs.get(key, 0) + (val if isinstance(val, (int, float)) else 0)
    return sqls, accs

    # 关心的指标名(不同版本命名略有差异:先跑一次看它到底叫什么)
    PATTERN = re.compile(r"(file|row group|page|scan|record|bytes|partitions)", re.I)

    sqls, accs = collect(sys.argv[1])
    for eid, desc in sqls:
    print(f"\\n=== SQL {eid} :: {desc[:110]}")
    for (seid, name, typ), v in sorted(accs.items()):
    if seid == eid and PATTERN.search(name or ""):
    print(f" {name:<38} {typ:<12} {v}")

    参数说明:事件日志里 SQL 指标的字段名在各版本间确有差异(accumualtorUpdate 这个拼写是官方事件日志里历史遗留的笔误,脚本对新旧两种写法都做了兼容尝试——这也是"日志格式不要靠猜"的一个具体例子)。所以脚本的策略是先把你环境里所有名字里带 file/row group/page/scan/record/bytes 的指标全打出来,再人工认哪一个是"文件数"。这是有意的:背指标名不如认出指标名。

    输出结构:每条 SQL 一段,逐行 指标名 / 类型 / 累加值。

    验收标准:能稳定打出四个数字——文件数、row-group 数(Selected/Total 或等价项)、scanned rows、bytes read。四个都拿不到就先去第 17 章(可观测落地),别用估算数字代替实测。

    5.6.3 记录表:把它填成团队的第一份基线

    表 5-10 四态实测记录(本系列不代填数字)

    状态查询partitions readfiles readrow groups Selected/Totalscanned rowsbytes read结论
    S0 裸表 Q1 基线:全扫
    S1 +分区 Q1 预期 ≈ S0 的 1/30
    S2 +分区+排序 Q1 第 2/3 层开始有 Missing
    S3 +分区+Z-Order Q1 与 S2 接近或略差(时间已单独命中)
    S0 裸表 Q2 全扫,且文件少 ⇒ 块很宽
    S1 +分区 Q2 分区帮不上(无日期条件)
    S2 +分区+排序 Q2 排序首列是 city ⇒ 应显著下降
    S3 +分区+Z-Order Q2 Z-Order 的主场

    填完后你会得到三个结论,它们比任何一段说明都有用:

    ① S1 对 Q1 有效、对 Q2 无效 ⇒ 分区只裁它切的那个维度,别指望它顺手裁别的
    ② S2 与 S1 的差,就是"排序"的净值 ⇒ 净值为 0 时,说明文件太宽或统计太糙(回 5.3.3/5.3.4)
    ③ S3 与 S2 的相对高低决定选型 ⇒ 你的真实查询里"只给 city"占多少?
    占比低就留线性,别为了 Q2 牺牲 Q1 和流式导出

    实验规模不足时的现象要提前知道:20 万行数据即使切成 32MB 也只有 1~2 个文件,S2/S3 的差别可能量不出来。这不是实验失败,是你的数据没到需要裁剪的规模。 想看到差别,把第 2 章的 sequence(1, 200000) 改成 sequence(1, 20000000) 重灌一遍(造数原则不变:全部从 v 派生,禁止 rand()),或者把 target 进一步降到 4MB。记住方向:实验时把文件切小是为了看清机制;生产时把文件合大才是省钱。


    工程要点

  • 分区、分桶、排序是三件事,分别决定"哪些目录"“哪些文件”“文件内的块”。 混谈它们的后果是:用排序补分区的洞,或用桶数当并行度调。三层的收益不可互替。

  • Hive 目录分区由 Spark 裁,Iceberg 隐藏分区由表格式 planner 裁,Paimon 由 scan 按分区 + 桶两级定位。 谓词写法必须跟着"谁来裁"变——给 Iceberg 表写分区目录名,给 Paimon 表对 bucket-key 写范围条件,都是白写。

  • PartitionFilters 非空不等于少读数据。 排除式谓词(<>、NOT IN)通常"能裁但裁不动"。"裁了多少"只由 files read 说话。

  • 六个失效里唯一不表现为慢的是"分区值格式不一致",它表现为空结果。 跨引擎写 __DT__ 时这是首要风险,处置是固定格式 + raise_error 行数断言,与版本无关、与实测无关,一律加。

  • 谓词下推生效 ≠ 数据被裁掉。 下推只保证条件进了 reader;能不能跳块取决于统计边界窄不窄。判据看 scanned rows / output rows 的比值,>100 就是前三层全空转。

  • 字符串列的 min/max 默认只记前 16 字符(Iceberg write.metadata.metrics.default = truncate(16))。 VIN、报文、URL 这类"前缀长、变化短"的列,排序做得再好也裁不动——高价值列显式设 full,代价是元数据变大。

  • 向量化 reader 与 Parquet record-level filtering 是互斥路径(官方原文:后者"只在向量读取器未使用时才有效")。别把它们当两个叠加的开关;换路径必须实测,判据是 Batched: true/false 与 scanned rows。

  • 小文件有三笔独立的账:打开成本、planning 成本、解码成本。 只盯"文件多"会漏掉后两笔。Iceberg rewrite_data_files 默认只处理 <75% target 且同组 ≥5 个的文件——"跑了没效果"多半是默认参数,不是 bug。

  • Spark 官方 INSERT 语法里没有"写入时排序"。 排序写入是 connector 通过 RequiresDistributionAndOrdering 申请的。语法要背的是 ALTER TABLE … WRITE ORDERED BY / WRITE LOCALLY ORDERED BY / WRITE UNORDERED 和 rewrite_data_files,不是 INSERT … ORDER BY。

  • WRITE ORDERED BY 只管新写入和 MERGE INTO,历史文件一行都不会重排。 加了排序发现没变快,第一件事是查有没有 rewrite 过。

  • 排序列数按官方区间选:1 列线性、2~4 列 Z-Order、5 列以上 Hilbert。 超过四列继续往 Z-Order 塞已经越出建议范围;两个维度都想最优的表不存在,该拆表就拆表。

  • Z-Order 有两笔隐性代价:会被追加写入稀释(变成第 10 章的长期维护项),并且会破坏按原键的有序性(流式导出、窗口作业被迫重排)。 收益要拿实测换,别拿直觉换。

  • Iceberg 1.12 的 rewrite_data_files 只有 strategy / sort_order / where / options。 旧参数名 sort_by / zorder_by / distribute_by / sort_buffer_size 在这一版文档里出现次数为 0,照旧写会直接报错;sort_by 只存在于 rewrite_manifests,语义完全不同。

  • "只有 Hive 分桶表能免 shuffle"已经不成立,但也不等于湖表能免。 官方 SPJ 是 Bucket Join 的泛化、面向"兼容的 V2 数据源",兼容与上报都是连接器的责任。唯一判据是 join 两侧 Exchange 有没有消失,实测一次,存档一次。

  • Paimon 官方一句话否掉了最常见的误解:“bucket count is not a hard read-parallelism cap.” 桶数按"单桶 200MB–1GB"起步(官方明说这是 guide 不是 limit),读并行度由文件分片与 AQE 决定。


  • 本章小结

    这一章把"少读数据"落成了三段可执行的话:分区定目录、分桶定文件、排序定块。

    • 5.1 用表 5-1 钉住三种"分区"的差别,核心不是语法,而是谁负责裁剪——Hive 是 Spark,Iceberg 是表格式 planner,Paimon 是分区 + 桶两级定位。图 5-1 给出三件事各管哪一段物理布局。
    • 5.2 给了六个失效案例和一张速查表。两个必须带走的习惯:结论由 files read 给,不由 EXPLAIN 给;格式不一致会返回空而不是返回慢,所以断言要无条件加。
    • 5.3 数清了四层统计,也数清了 Spark 官方真正给了开关的只有三层——page 级在官方文档里找不到任何配置项,本章不把它写成事实。附带一条硬事实:向量化读取与 record-level filtering 互斥。
    • 5.4 破掉"写入时排序"的语法幻觉,把选型规则交给官方区间(1 列线性 / 2~4 列 Z-Order / 5 列以上 Hilbert),并给了一次真实对照:ClickHouse 的排序是索引本身,湖表的排序只是喂给统计的信息,精度单位的差别决定了不要拿 granule 的思路去切湖表文件。
    • 5.5 三个"桶"三种语义。Iceberg 的桶是分区、Paimon 的桶是正确性约束、Hive 的桶才是 shuffle 优化;SPJ 让湖表有了理论上的可能,但那是实测项。
    • 5.6 四层漏斗收口成一张填数表。本章的结论必须由读者的实测数字来补完——这不是留白,这是这一章真正教的东西:优化器的能力上限是物理布局给的,而布局的效果只有量过才知道。

    下章预告:布局能裁掉数据,但前提是类型选对——第 6 章《数据类型、VARIANT 与 ANSI 语义》。Spark 4 起 spark.sql.ansi.enabled 默认 true 带来的行为清单,除零/溢出/字符串转数值失败从"静默给 NULL"变成"抛异常";Timestamp / TimestampNTZ / Date 的时区语义(回扣前作"Doris DATETIME 不得带毫秒"与"Doris 4.1 Arrow DATE 有时区问题");collect_time 13 位毫秒要除 1000 这个坑在 Spark 侧的对应写法;以及 4.1 GA 的 VARIANT——车端那种"字段数量和内容都不定的信号包",展开成宽表、存 String 加 JSON 函数、存 VARIANT,三种方案的存储与查询代价差多少。


    官方参考资料

    主题链接
    Spark CREATE TABLE(Hive 格式,CLUSTERED BY/SORTED BY/INTO n BUCKETS 原文与 NOTE) https://spark.apache.org/docs/latest/sql-ref-syntax-ddl-create-table-hiveformat.html
    Spark CREATE TABLE(Data Source 格式,含 USING CSV … CLUSTERED BY (Id) INTO 4 buckets 示例) https://spark.apache.org/docs/latest/sql-ref-syntax-ddl-create-table-datasource.html
    Spark SQL Performance Tuning(Storage Partition Join、filterPushdown、files 分片参数) https://spark.apache.org/docs/latest/sql-performance-tuning.html
    Spark Configuration(metastorePartitionPruning、DPP、runtime bloom filter、sources.bucketing.*) https://spark.apache.org/docs/latest/configuration.html
    Spark Parquet 数据源页(向量化读取、record-level filtering) https://spark.apache.org/docs/latest/sql-data-sources-parquet.html
    Spark DataSource V2(RequiresDistributionAndOrdering、requested distribution & ordering) https://spark.apache.org/docs/latest/sql-data-sources-v2.html
    Spark SELECT 语法(ORDER BY / CLUSTER BY 的官方定位与互斥关系) https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select.html
    Spark 4.1.0 Release Notes(SPARK-53074 SPJ、SPARK-51831 DSv2 列裁剪、SPARK-52187 join pushdown) https://spark.apache.org/releases/spark-release-4.1.0.html
    Iceberg Spark DDL(WRITE ORDERED BY / WRITE LOCALLY ORDERED BY / WRITE UNORDERED、partition transforms 列表) https://iceberg.apache.org/docs/latest/spark-ddl/
    Iceberg Spark 写入与配置(spark.sql.iceberg.distribution-mode、write.distribution-mode) https://iceberg.apache.org/docs/latest/spark-writes/
    Iceberg 表属性(write.target-file-size-bytes、row-group/page 大小、write.metadata.metrics.default) https://iceberg.apache.org/docs/latest/configuration/
    Iceberg 存储过程(rewrite_data_files 的 strategy/sort_order/where/options 与默认值) https://iceberg.apache.org/docs/latest/spark-procedures/
    Iceberg 规范:partition transforms 与文件统计字段(108~128) https://iceberg.apache.org/spec/
    Iceberg 隐藏分区与分区演进 https://iceberg.apache.org/docs/latest/partitioning/ 与 …/evolution/
    Iceberg 性能页:data filtering(planner 两步裁剪) https://iceberg.apache.org/docs/latest/performance/
    Paimon 数据分布(固定/动态/postpone 三桶模式、bucket sizing 官方口径) https://paimon.apache.org/docs/master/primary-key-table/data-distribution/
    Paimon 增量聚簇(clustering.strategy 的 auto/order/zorder/hilbert 规则) https://paimon.apache.org/docs/master/append-table/incremental-clustering/
    Paimon 专属压缩与 Sort Compact https://paimon.apache.org/docs/master/maintenance/dedicated-compaction/
    Paimon 分区管理(partition.expiration-time 等) https://paimon.apache.org/docs/master/maintenance/manage-partitions/
    前作对齐点:ClickHouse 主键、稀疏索引与查询裁剪(granule、EXPLAIN indexes) 本仓库 tech/ck/第4章 主键、稀疏索引与查询裁剪.md

    版本取证说明

    本章取证日期:2026-11-03。抓取路径说明:https://spark.apache.org/docs/latest/* 当前渲染为 4.3.0 文档,因此涉及 Spark 侧的每一条都另行比对了 4.1.4 同名页面;Iceberg 按 docs/latest(面包屑标注 Latest = 1.12.0)与仓库 docs/docs/*.md 源文件取证;Paimon 官方站点为客户端渲染、抓取不稳定,逐字原文取自 Apache Paimon 仓库 master 分支文档源,1.4.2 的措辞未逐字验证,凡引用处均标注为 master。

    内容档位说明
    spark.sql.hive.metastorePartitionPruning 默认 true,“some predicates will be pushed down into the Hive metastore so that unmatching partitions can be eliminated earlier”(1.5.0 起) 官方原文 configuration 页。注意:细化大纲里预写的 spark.sql.hive.metastore.partition.prune.enabled 这个配置名在官方文档中不存在,正文已按官方名改写
    spark.sql.hive.metastorePartitionPruningFastFallback 默认 false(3.3.0 起);开启后改为"先取分区名再在客户端算过滤表达式",含 TimeZoneAwareExpression 的谓词不支持 官方原文 configuration 页
    spark.sql.optimizer.dynamicPartitionPruning.enabled 默认 true(3.0.0 起),“we will generate predicate for partition column when it’s used as join key” 官方原文 configuration 页。注意:performance-tuning 页(4.3.0 与 4.1.4)正文没有"Dynamic Partition Pruning"小节,只有 configuration 页有该配置
    spark.sql.optimizer.runtime.bloomFilter.enabled 默认 true(3.3.0 起),措辞为"insert a bloom filter in the other side to reduce shuffle data";numBits=8388608、expectedNumItems=1000000、maxNumBits=67108864、maxNumItems=4000000、creationSideThreshold=10MB、applicationSideScanSizeThreshold=10GB 官方原文 configuration 页。旧帖常写的 applicationFrequencyThreshold / numberOfBits / numberOfApplications 在当前页面均无匹配
    spark.sql.parquet.filterPushdown 默认 true(1.2.0 起);官方描述仅一句,不存在"仅引用单列的谓词才下推"的措辞 官方原文 parquet 页 + configuration 页两处一致
    spark.sql.parquet.recordLevelFilter.enabled 默认 false(2.3.0 起);原文含"only has an effect when filterPushdown is enabled and the vectorized reader is not used" 官方原文 本章 5.3.2 的结论直接由此推出。社区文章常用的 spark.sql.parquet.recordColumnFilter 不是官方配置名
    spark.sql.parquet.enableVectorizedReader / spark.sql.orc.enableVectorizedReader 默认 true;spark.sql.orc.filterPushdown 默认 true 官方原文 parquet / configuration 页
    Parquet page index / column index:Spark 侧配置项 官方无此开关 parquet、configuration、performance-tuning 三页全文无 page index / column index / page-level 字样,也无 columnIndexPageSize;正文按"没有官方依据"处理,未写成事实
    spark.sql.files.maxPartitionBytes 默认 134217728(128 MB);performance 页写 134217728 (128 MB)、configuration 页写 128MB,两处表述不同 官方原文 同页还注明"effective only when using file-based sources such as Parquet, JSON and ORC"
    spark.sql.files.openCostInBytes 默认 4194304(4 MB);ignoreCorruptFiles 默认 false 官方原文 performance-tuning 页
    spark.sql.sources.bucketing.enabled 默认 true(2.0.0 起,“When false, we will treat bucketed table as normal table”);maxBuckets 默认 100000(2.4.0 起) 官方原文 configuration 页
    SPJ 定位原文(“generalization of the concept of Bucket Joins … compatible V2 DataSources”)与 spark.sql.sources.v2.bucketing.enabled 默认 true(3.3.0 起) 官方原文 performance-tuning 页 + configuration 页
    官方 INSERT 语法页不含 ORDER BY / SORT BY / CLUSTER BY / DISTRIBUTE BY 子句 官方原文(负面结论) 全页 0 次匹配,5.4.1 的结论由此来
    SELECT 的 ORDER BY 定位原文、与 SORT/CLUSTER/DISTRIBUTE 互斥;CLUSTER BY “does not guarantee a total order of output” 官方原文 sql-ref-syntax-qry-select 系列页
    DSv2 RequiresDistributionAndOrdering 原文,含"enabling optimized data layouts such as clustering or Z-ordering" 官方原文 sql-data-sources-v2 页
    Spark 4.1.0 中与本章相关的条目:SPARK-53074(SPJ partial clustering)、SPARK-51831(DSv2 existsJoin 列裁剪)、SPARK-52187(DSv2 join pushdown) 官方原文 4.1.0 发布说明。该页无 bucketing / sorted write / data skipping 类条目,正文未虚构 4.1 相关新特性
    spark.sql.optimizer.canChangeCachedPlanOutputPartitioning、dynamicPartitionPruning.reuseBroadcastOnly 不存在于当前官方页 4.3.0 与 4.1.4 全文无匹配,正文未引用
    Iceberg ALTER TABLE … WRITE ORDERED BY / WRITE LOCALLY ORDERED BY / WRITE UNORDERED / WRITE DISTRIBUTED BY PARTITION;“MERGE INTO in Spark will use the table ordering”;扩展模块自 0.11.0 官方原文 spark-ddl 页。注意:CREATE TABLE … WRITE ORDER BY 这种写法在官方文档中不存在,本章一律用 ALTER 形式
    Iceberg partition transforms 清单(year/month/day(=dateint)/hour/bucket(N,col)/truncate(L,col))与 spec 中 bucket[N]=“Hash of value, mod N” 官方原文 spark-ddl + spec 页;旧式复数写法 days() 等官方注明"also supported for compatibility"
    Iceberg rewrite_data_files 参数集(strategy 默认 binpack、sort_order 支持 zorder(…)/hilbert(…)、where、options)与全部通用选项默认值 官方原文 spark-procedures 页。旧参数名 sort_by/zorder_by/distribute_by/sort_buffer_size 在该页出现次数为 0;sort_by 仅存在于 rewrite_manifests
    Iceberg write.target-file-size-bytes 默认 512MB、write.parquet.row-group-size-bytes 默认 128MB、write.parquet.page-size-bytes 默认 1MB、write.parquet.dict-size-bytes 默认 2MB、write.metadata.delete-after-commit.enabled 默认 false 官方原文 configuration 页逐项
    Iceberg write.metadata.metrics.default 默认 truncate(16),可选 none/counts/truncate(length)/full 官方原文 configuration 页。按列覆盖的配置名本次未逐字取证,正文写为"可按列覆盖,名称需按官方页核对"
    Iceberg planner 两步裁剪原文(先 partition 谓词、再 value counts/null counts/lower/upper bounds 消文件)与 spec 字段 108/109/110/137/125/128 官方原文 performance 页 + spec 页
    Iceberg “Partition evolution is a metadata operation and does not eagerly rewrite files”;老数据保留旧 spec(split planning) 官方原文 evolution 页
    "分区数量控制在几千量级"这一措辞 官方未找到 Iceberg partitioning 文档全文对 thousand 匹配数为 0。正文标注为前作工程口径,不作为官方规范
    Paimon 三桶模式表(固定 / 动态=默认 -1 / postpone -2)与各自 planning 约束原文;Math.abs(key_hashcode % numBuckets);bucket-key 默认取"主键去掉分区列" 官方原文(master) data-distribution + 生成的 core_configuration。1.4.2 措辞未逐字验证
    Paimon bucket sizing:“For MOR workloads, 200 MB–1 GB per bucket is an initial sizing guide, not a limit”;“bucket count is not a hard read-parallelism cap” 官方原文(master) data-distribution#bucket-sizing。大纲原记的"单桶 200MB 左右"不够准确,已按官方完整措辞改写并补上后半句
    Paimon 分区过期:partition.expiration-time 默认 (none)、expiration-strategy=values-time、check-interval=1h、expiration-retain-latest=false、dynamic-bucket.target-row-num=2000000 官方原文(master) manage-partitions 页,第 9/10 章继续引用
    Paimon 支持排序与 Z-Order:clustering.strategy 可选 auto/order/zorder/hilbert,auto 规则为"1 列 order、2~4 列 zorder、5 列以上 hilbert";Sort Compact CALL sys.compact(order_strategy => 'zorder', order_by => 'a,b'),官方限定"currently supports only bucket=-1 and batch mode" 官方原文(master) incremental-clustering + dedicated-compaction 页。取证前正文曾准备写"Paimon 无排序能力",该结论已被推翻并改写
    Paimon Spark 侧 ORDER BY / DISTRIBUTE BY / CLUSTER BY 写入支持 官方文档未找到 spark/sql-write 与 sql-query 两页对这些关键字匹配数为 0;正文未给出任何 Paimon 侧的写入排序语法
    Paimon Spark 默认 static 覆盖,需 spark.sql.sources.partitionOverwriteMode=dynamic 官方原文(master) sql-write 页,第 9 章继续用
    向量化 reader 不支持的类型清单、批大小配置名 未逐字取证 正文只给"看 Batched 判断路径"的实测方法,未列具体类型与数值
    Spark 对 Iceberg/Paimon 的 SPJ 实际生效性 待实测 官方只承诺"compatible V2 DataSources";正文一律要求"数 Exchange"自证
    EXPLAIN 输出示例中的 files read、row groups read: Selected/Missing/Total 等指标名与事件日志字段拼写 笔者示意 + 实测项 形态取自真实 UI/事件日志,名称随版本微调;measure_scan.py 的策略是"先全打出来再认"
    表 5-5、图 5-2 的层级顺序、5.4.4 的"排序是索引 vs 排序喂统计"结论、5.6.3 三条读表结论 笔者归纳 各层机制本身有官方原文,分层与性价比结论是本书组织方式,非官方分类
    四态实验(S0~S3)的实测数字 本系列不代填 文件数与块数强依赖版本/连接器;填数表要求读者在自身环境量出来

    实操清单

    前置:第 2 章环境(4.1.4 主线,Paimon 相关步骤如遇不兼容改在 4.0.5 跑)、paimon.demo.ods_vehicle_telemetry 20 万行、iceberg.demo.dws_vehicle_daily 与 iceberg.demo.dim_vehicle_info、事件日志已开。本章新建 iceberg.demo.dwd_vehicle_telemetry 与四张 t_layout_*。

    #操作命令 / 位置验收标准
    1 看清分区值形态 SHOW PARTITIONS paimon.demo.ods_vehicle_telemetry 30 行、值全为 yyyy-MM-dd;不符就先修数据,别往下做
    2 复现失效案例 1 to_date(__dt__)='2026-10-15' + EXPLAIN FORMATTED PartitionFilters: [];用 _hist_ 指出谓词在哪一步被判不可下推
    3 复现失效案例 2 __dt__ = 20261015 记录是"报错"还是"0 行",并把结论写进 5.2.6 差异表
    4 复现失效案例 3 OR 版与 AND 版各跑一次 OR 版 files read ≈ 30 分区;AND 版 ≈ 1 分区
    5 复现失效案例 4 <> / > / IN 三种写法 三者 PartitionFilters 都非空,但 files read 分别 ≈29 / 7 / 7
    6 复现失效案例 5 紧凑格式查询 + raise_error 断言 前者返回 0 不报错;后者改成不存在的分区值时必须让作业失败
    7 复现失效案例 6 CTE 派生列版 vs 谓词下推版 派生列版 PartitionFilters 为空;两版结果行数一致
    8 观察裁剪发生在哪一层 同一条件分别打到 Paimon / Iceberg / Hive 表 能说出三者候选文件挑选分别由谁完成
    9 确认读取路径 EXPLAIN FORMATTED 看 Batched 能判断当前走的是向量化还是非向量化路径
    10 验证 record-level filter 取舍 enableVectorizedReader=false + recordLevelFilter.enabled=true 对比 scanned rows 与耗时;记录"是否值得换路径"的结论
    11 建 DWD 表并设排序 5.4.3 步骤①②⑤ rows_total=200000、.files 里文件数 ≥2
    12 Z-Order 重写历史数据 5.4.3 步骤⑥ 重写前后 sum(record_count) 完全相等;file_cnt 符合 target 设定
    13 验证"排序只管新写入" 只执行②不执行⑥,跑 Q2 并记 files read 与做过⑥的结果对比,差值即"历史未重排"的代价
    14 建 Hive 风格分桶表 5.5.2 两段 DDL DESCRIBE EXTENDED 能看到桶信息;否则该环境不用于本节实验
    15 实测 SPJ 是否生效 5.5.2 验收 SQL,两侧换成湖表 Exchange 消失=生效;未消失=按无收益设计,两种结果都要存档
    16 四态裁剪实验 5.6.1 建表灌数 + Q1/Q2 表 5-10 至少填满 files read 与 scanned rows 两列;四表 count(*) 完全一致
    17 打通实测链路 5.6.2 measure_scan.py 能从事件日志稳定打出文件数、row-group 数、scanned rows、bytes read 四个数字

    第 6、12、13、15 项是本章核心。 第 13 项证明的是本章最容易被忽略的一条因果(排序不回溯历史),第 15 项决定你后面所有湖表 join 的设计前提——这两项没做过,本章的其他内容都只是读过的道理。


    思考题

  • 表 5-1 说 Iceberg 的谓词"对原始列写即可"。那么查询 WHERE report_date >= '2026-10-01' 打在 PARTITIONED BY (days(report_date)) 的表上,裁剪发生在第几层(用图 5-2 的四层回答)、由谁执行、以及为什么 WHERE speed > 60 同样下推了却几乎裁不掉任何文件?

  • 案例 5 的症状是"返回空、不报错、而且很快"。请设计一组上线前断言,至少包含分区存在性、结果行数下限、files read 上限三项,并说明哪一项应该告警、哪一项应该直接失败、哪一项应该只在灰度期观察。给出理由,不要只给 SQL。

  • 若上游把 VIN 从"11 位前缀 + 6 位数字"改成"20 位、仅末 2 位变化"的自定义编码,在 write.metadata.metrics.default = truncate(16) 下,按 vin 等值查询的文件级裁剪会发生什么?给出两种处置(改 metrics 模式 / 改排序键)并各自说明代价落在哪一层。

  • 5.4.2 说 Z-Order 会伤害"按 vin 顺序流式导出"。现在两个需求都要满足,且不允许拆成两张物理表。请给出你的方案(可考虑 Iceberg 的 branch、不同 spec、或读写分离的作业编排),并说明它引入了哪些新的维护成本,归属第 10 章哪一节。

  • 官方两句话放在一起:Paimon 的"bucket count is not a hard read-parallelism cap",与第 4 章的"AQE 只拿 shuffle 之后的统计、看不见读侧"。在"20 万台车 × 一天"的查询上,这两条合起来对你的并行度设计意味着什么?给出方案与验证步骤(必须包含一个能证伪你自己方案的指标)。


  • 读完这一章,你应该已经能把"这张表慢"翻译成一句具体得多的话:它的 WHERE 条件在哪一层落空了。下一章不再谈位置,谈内容本身——类型选错,存储、压缩、裁剪、正确性四头一起赔。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 第5章 Spark4.x 分区、分桶、排序与数据裁剪
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!