Spark 3.x 动态分区裁剪:为什么它能自动跳过 90% 的数据扫描
一、从一个真实场景说起
大家好,我是朱大喜。先问大家一个问题:你们在 Spark 里做两张大表 JOIN 的时候,有没有遇到过一个尴尬的场景——明明事实表按日期分了 365 个分区,你只查最近 7 天的数据,但 Spark 硬是把 365 个分区的文件列表全都扫了一遍?
这不是 bug,而是 Spark 2.x 时代的设计局限。在 Spark 3.0 之前,静态分区裁剪只能在查询编译阶段,根据 WHERE 条件中直接写在 SQL 里的过滤条件来跳过分区。一旦过滤条件隐藏在另一张小表里(比如 JOIN 之后的 ON 条件),优化器就没办法在扫描阶段做裁剪了。
打个生活化的比方:你家里有 365 个抽屉,每个抽屉标着日期。你要找"最近 7 天老婆买过的所有东西"。老婆买过什么记录在一个小本子上(维度表),具体买了啥记在一堆购物小票里(事实表)。聪明人会先翻小本子找出最近 7 天她确实买了东西的那几天,然后只开那几个抽屉。但 Spark 2.x 就像个耿直的机器人,它会老老实实把 365 个抽屉全打开看一眼,然后再跟小本子对日期——白白开了 350 多个没用的抽屉。
二、动态分区裁剪的魔法在哪里
Spark 3.0 引入的动态分区裁剪(Dynamic Partition Pruning,简称 DPP),核心思路非常朴素:在运行时,把维度表的过滤结果作为广播变量,下推到事实表的扫描阶段,让事实表在读取文件时就跳过不相干的分区。
graph LR A[维度表<br/>dim_date] -->|过滤: 最近7天| B[过滤结果: 7个日期] B -->|Broadcast 广播| C{事实表扫描阶段} D[事实表<br/>fact_orders<br/>365个分区] --> C C -->|只扫描7个分区| E[JOIN 结果] style B fill:#4CAF50,color:#fff style C fill:#FF9800,color:#fff这里的关键词是**"运行时"和"下推"**。静态裁剪发生在编译期,只能处理常量过滤条件;而动态裁剪发生在执行期,维度的过滤结果已经算出来了,可以直接拿来用。而且它是下推到文件扫描层面的,不是在 JOIN 之后才过滤,这就从根源上减少了数据读取量。
实际效果有多明显呢?我们团队有个订单分析场景:事实表 365 天分区 × 每天 2 亿行,维度表是一个几十 MB 的日期配置表。老版本 Spark 每次查询都要扫描全表再过滤,耗时 40 多分钟。升级到 Spark 3.x 并确认 DPP 生效后,同样的查询扫描数据量降到原来的 8%,耗时缩短到 3 分钟以内。
三、DPP 的触发条件,不是你想用就能用
DPP 虽然美好,但不是所有 JOIN 都能享受这个福利。它有严格的触发条件,理解这些条件才能写出"能被优化"的 SQL。
条件一:必须是等值 JOIN。ON 条件必须是=而不能是>、<或LIKE。因为只有等值才能建立分区键之间的精确映射关系。
条件二:维度表必须足够小,能被广播。DPP 的前提是把维度表的过滤结果广播到所有 Executor。Spark 默认spark.sql.autoBroadcastJoinThreshold是 10MB,如果你的维度表超过这个大小,Spark 会退化为普通的 SortMergeJoin,不再触发 DPP。
条件三:事实表的分区键必须参与 JOIN 的 ON 条件。如果你的事实表按dt分区,但 JOIN 的 ON 条件是a.user_id = b.user_id,那 DPP 跟分区没关系,不会生效。只有 JOIN 键包含了分区键时,动态裁剪才有意义。
-- ✅ 会触发 DPP:dim_date.date_id 过滤后广播,下推到 fact_orders -- 的扫描阶段,只读取 dt 在最近7天内的分区 SELECT /*+ BROADCAST(d) */ f.* FROM fact_orders f JOIN dim_date d ON f.dt = d.date_id WHERE d.date_type = 'workday' AND d.date_id >= '2026-07-08'; -- ❌ 不会触发 DPP:JOIN 键是 order_id,跟分区键 dt 没关系 SELECT /*+ BROADCAST(d) */ f.* FROM fact_orders f JOIN dim_product p ON f.order_id = p.order_id -- 不是分区键 WHERE f.dt >= '2026-07-08'; -- 这里只能走静态裁剪还有一个容易踩坑的地方:DPP 和 Bucket Join 是互斥的。如果你的表做了分桶(Bucket),而 Spark 选择了 Bucket Join 策略,DPP 就不会生效。这时需要在性能上做取舍——是分桶带来的 Shuffle 减少更香,还是 DPP 的数据量削减更猛。
四、如何验证 DPP 真的生效了
写完了 SQL,跑得也挺快,但你心里犯嘀咕:它到底走了 DPP 没有?Spark UI 里的信息说实话不太好读,我来教你怎么快速确认。
最直接的方法是在 Spark SQL 里用EXPLAIN看执行计划。如果你在扫描阶段看到dynamicpruning字样,恭喜你,DPP 起作用了。
# 在 PySpark 中验证 DPP 是否生效 spark.sql(""" EXPLAIN EXTENDED SELECT f.* FROM fact_orders f JOIN dim_date d ON f.dt = d.date_id WHERE d.date_type = 'workday' """).show(truncate=False) # 关键输出标志(看到这些就说明DPP生效了): # +- *(1) Project [order_id#123L, dt#124, ...] # +- *(1) BroadcastHashJoin [dt#124], [date_id#200], # :- *(1) Filter dynamicpruningexpression(dt#124 IN dynamicpruning#300) # | +- *(1) FileScan parquet fact_orders[dt#124,...] # | : +- DynamicPruningExpression # +- BroadcastExchange HashedRelationBroadcastMode(...) # +- *(2) Filter (date_type#202 = workday)另外可以通过 Spark UI 的 SQL 页签来看。如果 DPP 生效了,你会看到 Scan 阶段的number of files read远小于总文件数。没有 DPP 时,365 个分区的文件可能全被列出来;有了 DPP,可能只读到了 7 个分区的文件。
经验之谈:如果 DPP 没生效,排查顺序是 1) 看维度表大小是否超过广播阈值 2) 看 JOIN 键是否就是分区键 3) 看有没有显式或隐式地禁用了 AQE(Adaptive Query Execution),因为 DPP 是 AQE 框架下的一个子功能。
五、总结
动态分区裁剪是 Spark 3.x 里投入产出比最高的优化之一。你不需要改代码,不需要调参数,只要你的 SQL 满足条件(等值 JOIN + 小维度表 + JOIN 键是分区键),Spark 就会自动帮你省掉大量无用数据扫描。
但别因为它自动就完全不管。写 SQL 时心里要有这条线:我的事实表分区键是哪个?维度表能不能再缩小一点让它进广播阈值?JOIN 条件能不能精确地包含分区键?这三个问题想清楚了,DPP 一般不会让你失望。
回到抽屉的比喻:Spark 2.x 是把所有抽屉打开再核对小本子,Spark 3.x 是先翻小本子确定是哪几个抽屉,然后只开那几个。这一前一后的差异,就是 40 分钟和 3 分钟的距离。