2. 分区裁剪基础:分区表的概念、静态分区裁剪、动态分区裁剪、分区裁剪的执行计划分析

好,咱们正式开始聊分区裁剪。很多同学觉得分区表就是建个表、指定个分区字段就完事了。其实不然。分区裁剪(Partition Pruning)才是分区表真正的灵魂。没有裁剪,分区表就是个摆设,甚至比普通表还慢。

我个人习惯把分区裁剪比作「查字典」。你想查「猫」字,不会从第一页翻到最后一页吧?你直接去「犭」部首那几页找就行。分区裁剪干的也是这个事——跳过那些肯定不包含目标数据的分区。

2.1 分区表的核心概念

先过一遍基础。分区表,说白了就是把一张大表物理上拆成多个小表(分区)。每个分区对应一个独立的存储目录或文件。

举个例子,一张订单表按日期分区:

CREATE TABLE orders (
    order_id BIGINT,
    user_id STRING,
    amount DOUBLE,
    order_date STRING
)
PARTITIONED BY (dt STRING);

这里 dt 就是分区字段。注意,分区字段不是表里的普通字段,它独立于表结构之外。查询时你可以把它当普通字段用,但底层存储是按它来分目录的。

分区表的好处,我总结为三点:

  • 减少扫描量:只读需要的分区,IO 大幅降低
  • 方便管理:可以单独删除、归档某个分区
  • 提升并发:不同分区可以并行处理
我的经验:分区字段的选择很关键。选错了,分区表反而会成为性能瓶颈。我见过有人用「用户ID」做分区字段,结果每个分区数据量极不均匀,有的分区几亿行,有的分区只有几行。分区裁剪直接失效。

2.2 静态分区裁剪

静态分区裁剪,是最简单、最直接的一种裁剪方式。它在 SQL 编译阶段就能确定要扫描哪些分区。

看个例子:

SELECT * FROM orders WHERE dt = '2024-01-15';

这个查询,优化器在解析 SQL 时就知道——只需要读 dt='2024-01-15' 这一个分区。其他分区碰都不碰。

静态分区裁剪的条件通常是这样:

  • 分区字段上使用等值条件(=)
  • 分区字段上使用 IN 子句
  • 分区字段上使用确定性的范围条件(>、<、BETWEEN)

比如:

-- 等值条件
SELECT * FROM orders WHERE dt = '2024-01-15';

-- IN 子句
SELECT * FROM orders WHERE dt IN ('2024-01-15', '2024-01-16');

-- 范围条件
SELECT * FROM orders WHERE dt >= '2024-01-01' AND dt < '2024-02-01';
关键点:静态分区裁剪发生在 SQL 编译阶段。执行计划生成时,需要扫描的分区列表就已经确定了。这意味着执行过程中不会再有额外的分区发现开销。

2.3 动态分区裁剪

动态分区裁剪就高级一些了。它发生在 SQL 执行阶段,而不是编译阶段。

为什么会需要动态裁剪?你想想看,有些查询的分区条件不是直接写在 WHERE 子句里的,而是通过 JOIN 或子查询间接推导出来的。

比如:

SELECT o.*
FROM orders o
JOIN daily_sales ds ON o.dt = ds.sale_date
WHERE ds.total_amount > 1000000;

这个查询,优化器在编译时不知道 ds.sale_date 具体是哪些值。它得先执行子查询或 JOIN 的另一端,拿到结果集之后,才能确定要扫描 orders 表的哪些分区。

动态分区裁剪的执行流程大致是这样:

  1. 先执行「驱动侧」查询(这里是 daily_sales 表)
  2. 从结果中提取分区字段的值集合
  3. 用这个值集合去过滤「被驱动侧」表(orders)的分区
  4. 只扫描匹配的分区
我曾经踩过的坑:动态分区裁剪在 Spark 3.0 之前默认是关闭的。有一次我优化一个慢查询,怎么调都调不好,执行计划显示全表扫描。查了半天才发现是 spark.sql.optimizer.dynamicPartitionPruning.enabled 没开。开了之后,查询从 20 分钟降到了 3 分钟。

2.4 分区裁剪的执行计划分析

光说不练假把式。咱们来看看执行计划里分区裁剪到底长什么样。

以 Spark SQL 为例,执行计划可以用 EXPLAIN 查看:

EXPLAIN SELECT * FROM orders WHERE dt = '2024-01-15';

输出中你会看到类似这样的信息:

== Physical Plan ==
*(1) FileScan parquet default.orders[order_id#0L,user_id#1,amount#2,dt#3]
    PartitionFilters: [isnotnull(dt#3), (dt#3 = 2024-01-15)]
    PushedFilters: []
    ReadSchema: struct<order_id:bigint,user_id:string,amount:double>

注意看 PartitionFilters 这一行。它列出了实际生效的分区过滤条件。如果这里显示的是 PartitionFilters: [],说明分区裁剪没生效,那就是全表扫描。

对于动态分区裁剪,执行计划会更复杂一些:

== Physical Plan ==
*(5) BroadcastHashJoin [dt#3], [sale_date#10], Inner, BuildRight
:- *(5) FileScan parquet default.orders
:     PartitionFilters: [dynamicpruning(condition#15)]
:     ReadSchema: ...
+- BroadcastExchange HashedRelationBroadcastMode(...)
   +- *(4) Filter (ds.total_amount > 1000000)
      +- FileScan parquet default.daily_sales

看到 dynamicpruning(condition#15) 了吗?这就是动态分区裁剪的标志。它会在运行时根据广播过来的值动态过滤分区。

怎么看执行计划:我个人习惯先看 PartitionFilters,再看 PushedFilters。如果 PartitionFilters 是空的,那就要警惕了——要么分区字段没用好,要么优化器没识别出来。

下面这张图总结了分区裁剪的完整知识体系:

分区裁剪知识体系 分区裁剪 静态分区裁剪 动态分区裁剪 编译阶段确定 等值/IN/范围 执行阶段确定 JOIN/子查询推导 执行计划分析 PartitionFilters 非空 dynamicpruning 标识 扫描分区数 < 总分区分 核心原则:分区裁剪生效的前提是分区字段参与过滤条件 且优化器能识别出分区字段上的过滤谓词

2.5 分区裁剪的常见误区

最后,我总结几个实际项目中经常遇到的问题:

误区 错误写法 正确做法
分区字段参与计算 WHERE YEAR(dt) = 2024 WHERE dt >= '2024-01-01' AND dt < '2025-01-01'
分区字段类型不匹配 WHERE dt = 20240115 WHERE dt = '2024-01-15'
子查询中分区字段被包裹 WHERE CONCAT(dt, '00') = ... 直接使用分区字段做过滤
动态裁剪未开启 依赖默认配置 显式开启 spark.sql.optimizer.dynamicPartitionPruning.enabled=true
避坑指南:我曾经接手过一个数据管道,每天跑 2 小时。一看执行计划,PartitionFilters 全是空的。排查发现,开发人员在 WHERE 条件里写了 WHERE TO_DATE(dt) = '2024-01-15'。分区字段被函数包裹了,优化器认不出来。改成 WHERE dt = '2024-01-15' 之后,查询时间降到了 15 分钟。