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

本章系统阐述Spark 4.x在湖仓场景下分区、分桶、排序与数据裁剪的协同机制,聚焦四层裁剪漏斗(目录/文件/row-group/行)的物理实现与观测方法。重点解析三种分区语义差异、六种分区裁剪失效模式、列式文件(Parquet/Iceberg/Paimon)的统计粒度与开关控制、Z-Order/Hilbert排序选型依据,以及Bucket在不同引擎中的语义鸿沟与免shuffle验证方法。强调所有优化效果必须通过事件日志实测验证,而非依赖执行计划估算。

第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 rowsSpark UI 的 SQL 页,或 SparkListener 事件日志(第 17 章脚本化)事实:到底读了多少必须跑完才知道;跑一次可能就是全表扫
PartitionFiltersEXPLAIN 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(漏引号)需要一次隐式转换,比较对象不再是分区值本身按分区列真实类型写字面量可能"能裁但裁错",也可能不裁
3OR 把分区谓词降级__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.metastorePartitionPruningtrue(1.5.0 起)“some predicates will be pushed down into the Hive metastore so that unmatching partitions can be eliminated earlier”
spark.sql.hive.metastorePartitionPruningFastFallbackfalse(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_sizesIceberg planner:先按分区裁,再按列统计裁文件write.metadata.metrics.default = truncate(16)
row-group / column chunk该块内该列的 min/max/null countParquet reader(谓词下推后逐块判定可否跳过)spark.sql.parquet.filterPushdown = true(1.2.0 起);spark.sql.orc.filterPushdown = true
pagepage 级 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-bytes536870912(512 MB)合并目标大小
min-file-size-bytes75% of target file size小于此值才被视为"该被重写"
max-file-size-bytes180% of target file size大于此值也可能被重写(拆分/合并)
min-input-files5一组至少几个文件才启动重写
rewrite-allfalse默认不重写大文件

这张表解释了生产上最常见的困惑:“我跑了 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 BYSpark 查询“ordered across the partitions”,与 SORT BY/CLUSTER BY/DISTRIBUTE BY 互斥结果输出顺序否
SELECT ... CLUSTER BYSpark 查询“only ensures that the resultant rows are sorted within each partition and does not guarantee a total order of output”结果分区内顺序否
RequiresDistributionAndOrderingDSv2 写接口connector 声明需求,“Spark will insert shuffle and sort nodes as needed”写入布局是
WRITE ORDERED BY / LOCALLY ORDERED BYIceberg 扩展“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 BUCKETSSpark DDL“determine data partitioning and avoid data shuffle”桶归属 + 桶内序是,且带来免 shuffle
Paimon clustering.strategy / Sort CompactPaimon 维护过程“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 免费:

  1. Z-Order 的紧凑性会被新数据稀释。 之后追加写入的数据不参与既有的交错编码,块边界越来越宽——必须周期性 rewrite。这就把它从"一次 DDL 决定"变成了"一项长期维护成本",归属第 10 章。
  2. 它会伤害有序性本身。 交错编码之后,按 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-bytes32MB(官方默认 512MB)本实验总数据只有 20 万行、约 15~25MB。用默认值只会产出 1 个文件,1 个文件上任何排序与统计都测不出裁剪
write.parquet.row-group-size-bytes4MB(官方默认 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✔—表名
strategybinpack“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-bytes536870912(512MB)目标大小
min-file-size-bytes / max-file-size-bytes75% / 180% of target决定"哪些文件算小/大"
min-input-files5一组至少几个文件才重写
rewrite-allfalse默认不动"已经合格"的文件
max-concurrent-file-group-rewrites5并发组数
max-file-group-size-bytes107374182400(100GB)单组上限
use-starting-sequence-numbertrue序列号语义,与 MoR 删除文件相关
partial-progress.enabled / max-commitsfalse / 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/Bfiles 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 BUCKETSPARTITIONED 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(桶列) % nbucket[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 BUCKETS8桶数一旦定下不可轻易改。选择依据见下
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 走 transformPartitionFilters: []
2 文件files read、metadata tasks列统计有效(排序 + metrics 模式);文件别太碎files read ≈ 该查询逻辑不该碰的文件数
3 块row groups read: Selected/Missing/TotalfilterPushdown=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-OrderQ1与 S2 接近或略差(时间已单独命中)
S0 裸表Q2全扫,且文件少 ⇒ 块很宽
S1 +分区Q2分区帮不上(无日期条件)
S2 +分区+排序Q2排序首列是 city ⇒ 应显著下降
S3 +分区+Z-OrderQ2Z-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。记住方向:实验时把文件切小是为了看清机制;生产时把文件合大才是省钱。


工程要点

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

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

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

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

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

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

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

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

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

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

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

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

  13. 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,语义完全不同。

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

  15. 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 Compacthttps://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_telemetry30 行、值全为 yyyy-MM-dd;不符就先修数据,别往下做
2复现失效案例 1to_date(__dt__)='2026-10-15' + EXPLAIN FORMATTEDPartitionFilters: [];用 _hist_ 指出谓词在哪一步被判不可下推
3复现失效案例 2__dt__ = 20261015记录是"报错"还是"0 行",并把结论写进 5.2.6 差异表
4复现失效案例 3OR 版与 AND 版各跑一次OR 版 files read ≈ 30 分区;AND 版 ≈ 1 分区
5复现失效案例 4<> / > / IN 三种写法三者 PartitionFilters 都非空,但 files read 分别 ≈29 / 7 / 7
6复现失效案例 5紧凑格式查询 + raise_error 断言前者返回 0 不报错;后者改成不存在的分区值时必须让作业失败
7复现失效案例 6CTE 派生列版 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
12Z-Order 重写历史数据5.4.3 步骤⑥重写前后 sum(record_count) 完全相等;file_cnt 符合 target 设定
13验证"排序只管新写入"只执行②不执行⑥,跑 Q2 并记 files read与做过⑥的结果对比,差值即"历史未重排"的代价
14建 Hive 风格分桶表5.5.2 两段 DDLDESCRIBE 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 的设计前提——这两项没做过,本章的其他内容都只是读过的道理。


思考题

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

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

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

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

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


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

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

俊哥大数据

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值