4. 分区裁剪优化策略:倾斜键重分布、盐化分区键、复合分区设计、动态重分区、自适应分区裁剪
分区裁剪(Partition Pruning)是 Spark/Hive 等引擎最有效的优化手段之一。但一旦碰上数据倾斜,裁剪效果就会大打折扣——甚至比没分区还慢。我这些年踩过的坑,大多都跟「倾斜键 + 错误的分区策略」有关。
说白了,分区裁剪的核心就一句话:让引擎在扫描阶段就跳过无关分区。但倾斜数据会导致某些分区过大,裁剪了也白裁。下面这五种策略,是我在实际项目中反复验证过的。
4.1 倾斜键重分布(Skew Key Redistribution)
先看最常见的情况。订单表按 order_date 分区,但某一天的订单量是其他天的 100 倍。这时候分区裁剪虽然能跳过其他日期,但扫描那个大分区本身就要跑半小时。
我的做法是:把倾斜键拆成多个子键。比如把 order_date 拆成 year + month + day 三级分区。这样大促那天的数据会被分散到 2024/11/11 这个叶子分区,但引擎在裁剪时依然能精确命中。
-- 改造前:单键分区,倾斜严重
CREATE TABLE orders (
order_id STRING,
order_date STRING,
amount DOUBLE
) PARTITIONED BY (order_date);
-- 改造后:三级分区,分散倾斜
CREATE TABLE orders_v2 (
order_id STRING,
order_date STRING,
amount DOUBLE
) PARTITIONED BY (year STRING, month STRING, day STRING);
-- 查询时依然能裁剪
SELECT * FROM orders_v2
WHERE year = '2024' AND month = '11' AND day = '11';
4.2 盐化分区键(Salting the Partition Key)
盐化(Salting)是我个人最喜欢的手法。原理很简单:给分区键加一个随机后缀,把一个大分区拆成 N 个小分区。
举个例子。用户表按 city_id 分区,但北京的用户占了 60%。这时候可以给 city_id 加一个 0~9 的随机数后缀:
-- 盐化分区键
CREATE TABLE users_salted (
user_id STRING,
city_id STRING,
salt STRING
) PARTITIONED BY (city_salted STRING);
-- 写入时生成盐值
INSERT INTO users_salted PARTITION (city_salted)
SELECT
user_id,
city_id,
CONCAT(city_id, '_', FLOOR(RAND() * 10)) AS city_salted
FROM raw_users;
-- 查询时也要带盐值(或者用 IN 条件)
SELECT * FROM users_salted
WHERE city_salted IN ('beijing_0', 'beijing_1', ..., 'beijing_9');
我曾经在一个日活 5000 万的项目里用盐化把倾斜分区从 1 个拆成 20 个,查询时间从 8 分钟降到了 40 秒。代价是元数据膨胀了 20 倍,但 Hive Metastore 扛得住。
4.3 复合分区设计(Composite Partitioning)
复合分区就是「先按 A 分区,再按 B 子分区」。这招在 Hive 和 Spark 里都支持,但很多人用错了。
正确的姿势是:把倾斜度高的键放在第一级,把倾斜度低的键放在第二级。为什么?因为第一级分区决定了裁剪的粒度。
| 分区层级 | 推荐键类型 | 示例 | 裁剪效果 |
|---|---|---|---|
| 第一级 | 高基数、低倾斜 | date |
快速过滤大部分数据 |
| 第二级 | 低基数、高倾斜 | city_id |
在已裁剪范围内精细过滤 |
-- 复合分区设计示例
CREATE TABLE events_composite (
event_id STRING,
event_time TIMESTAMP,
city_id STRING,
event_type STRING
) PARTITIONED BY (event_date STRING, city_id STRING);
-- 查询时两层裁剪
SELECT * FROM events_composite
WHERE event_date = '2024-11-11'
AND city_id IN ('beijing', 'shanghai');
4.4 动态重分区(Dynamic Repartitioning)
动态重分区不是建表时的策略,而是运行时的优化。说白了,就是在执行计划里动态判断哪些分区倾斜,然后自动拆开。
Spark 3.0+ 的 Adaptive Query Execution (AQE) 就支持这个能力。它会根据 Shuffle 输出的统计信息,自动把倾斜的分区拆成多个小分区。
-- 开启 AQE 动态重分区
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
-- 倾斜阈值配置(默认 5 倍)
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256MB")
我个人习惯把 skewedPartitionFactor 设成 3 而不是默认的 5。因为 5 倍太宽松了,很多中等倾斜的场景检测不到。设成 3 倍虽然会多拆一些分区,但整体稳定性更好。
4.5 自适应分区裁剪(Adaptive Partition Pruning)
这是最「智能」的一种策略。它不是在建表时固定分区方案,而是根据查询模式动态调整裁剪逻辑。
举个例子。你的表按 user_id 哈希分区成 100 个分区。但某个查询只涉及 3 个用户,引擎如果能提前知道这 3 个用户落在哪几个分区,就能精准裁剪。
Spark 的 Dynamic Partition Pruning (DPP) 就是干这个的。它会在运行时先执行子查询,拿到过滤条件,再用这些条件去裁剪主表的分区。
-- 开启 DPP
spark.conf.set("spark.sql.optimizer.dynamicPartitionPruning.enabled", "true")
-- 示例:子查询结果用于裁剪主表分区
SELECT /*+ BROADCAST(s) */ o.*
FROM orders o
JOIN small_users s ON o.user_id = s.user_id
WHERE s.city = 'beijing';
-- DPP 会自动将 s.city = 'beijing' 转化为 o 表的分区过滤条件
IN 子查询代替 Join。
策略对比与选型指南
这五种策略不是互斥的,实际项目中经常组合使用。下面这张表是我自己总结的选型参考:
| 策略 | 适用场景 | 改造成本 | 运行时开销 | 推荐指数 |
|---|---|---|---|---|
| 倾斜键重分布 | 少数分区极大 | 中(需改分区键) | 低 | ★★★★☆ |
| 盐化分区键 | 单键严重倾斜 | 高(需改写入+查询) | 低 | ★★★★★ |
| 复合分区设计 | 多维度过滤 | 中(需重新建表) | 低 | ★★★★☆ |
| 动态重分区 | Shuffle 倾斜 | 低(仅配置) | 中(需 AQE 开销) | ★★★☆☆ |
| 自适应分区裁剪 | 大表 Join 小表 | 低(仅配置) | 中(子查询开销) | ★★★★☆ |
最后说一句:没有银弹。我见过有人把所有策略都试了一遍,最后发现只是数据质量有问题——空值太多导致分区倾斜。所以遇到倾斜问题,先查数据,再调策略,顺序别搞反了。