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 大幅降低
- 方便管理:可以单独删除、归档某个分区
- 提升并发:不同分区可以并行处理
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';
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 表的哪些分区。
动态分区裁剪的执行流程大致是这样:
- 先执行「驱动侧」查询(这里是
daily_sales表) - 从结果中提取分区字段的值集合
- 用这个值集合去过滤「被驱动侧」表(
orders)的分区 - 只扫描匹配的分区
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 是空的,那就要警惕了——要么分区字段没用好,要么优化器没识别出来。
下面这张图总结了分区裁剪的完整知识体系:
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 |
PartitionFilters 全是空的。排查发现,开发人员在 WHERE 条件里写了 WHERE TO_DATE(dt) = '2024-01-15'。分区字段被函数包裹了,优化器认不出来。改成 WHERE dt = '2024-01-15' 之后,查询时间降到了 15 分钟。