Spark SQL 性能优化:从定位问题到解决数据倾斜
Spark SQL 性能优化:从定位问题到解决数据倾斜
一份面向数据工程师的实战指南,覆盖 Spark SQL 任务优化的完整链路。
目录
1. 理解 Spark SQL 执行过程
优化之前,先搞清楚一段 SQL 在 Spark 中到底经历了什么。
1.1 从 SQL 到 RDD 的五步走
SQL 文本
→ 解析(Parser)→ AST 语法树
→ 分析(Analyzer)→ 解析后的逻辑计划(绑定表名、列名、类型)
→ 优化(Optimizer)→ 优化后的逻辑计划(谓词下推、列裁剪、常量折叠)
→ 物理计划(Planner)→ 多个物理执行计划
→ 代价模型(CBO)→ 选出最优物理计划,生成 RDD DAG
关键点:Spark 的 Catalyst 优化器会自动做很多事,但前提是你的 SQL 写法给了它优化空间。
1.2 两个核心概念
| 概念 | 说明 | 代价 |
|---|---|---|
| 窄依赖 | 子 RDD 的每个分区只依赖父 RDD 的一个分区(map、filter、union) | 无 Shuffle,快 |
| 宽依赖 | 子 RDD 的一个分区依赖父 RDD 的多个分区(groupBy、join、distinct) | 触发 Shuffle,慢 |
优化的一条主线就是:减少 Shuffle 次数和数据量。
1.3 什么操作会触发 Shuffle
GROUP BY → Shuffle
JOIN (非广播) → Shuffle
COUNT(DISTINCT) → Shuffle(且往往需要两次)
WINDOW FUNCTION → 部分场景 Shuffle
UNION / UNION ALL → 无 Shuffle(仅合并)
1.4 Exchange 类型
在 Spark 执行计划中看到 Exchange 节点,说明发生了数据重新分区:
| Exchange 类型 | 分区方式 | 典型触发操作 |
|---|---|---|
HashPartitioning |
hash(key) % numPartitions |
GROUP BY、JOIN |
RoundRobinPartitioning |
轮询分配 | DISTRIBUTE BY random() |
RangePartitioning |
按范围分配 | ORDER BY、WINDOW RANGE |
BroadcastExchange |
广播到所有节点 | mapjoin / broadcast hint |
2. 如何定位性能瓶颈
2.1 Spark UI —— 第一入口
打开 Spark UI(默认 driver:4040),按以下顺序排查:
Stage 页面(最重要)
┌─────────────────────────────────────────────────────────┐
│ Stage 3 (Shuffle stage) │
│ Duration: 12min ← 这个 Stage 为什么这么久? │
│ Input: 200GB ← 输入量合理吗? │
│ Shuffle Read: 80GB ← Shuffle 数据量对吗? │
│ Tasks: 200 total, 1 still running ← 有长尾任务! │
└─────────────────────────────────────────────────────────┘
关键指标:
| 指标 | 怎么看 | 问题信号 |
|---|---|---|
| Duration | Stage 总耗时 | 占总 Job 时间的比重 |
| Tasks | 总任务数 / 完成数 | 少量任务长时间不完成 → 数据倾斜 |
| Shuffle Read/Write | 数据量 | 过大说明聚合不够或分区不合理 |
| Spill | 溢写磁盘量 | Spill 大 → 内存不足或 key 太多 |
| GC Time | 垃圾回收时间 | > 10% → 内存压力大 |
如何判断数据倾斜(Stage 页面)
展开 Stage → 看 Summary Metrics 表的 Duration 列:
Task ID Duration Shuffle Read
0 15s 2MB
1 14s 2.1MB
...
198 12min 1.8GB ← 倾斜!比其他 task 大 1000 倍
199 13s 2.3MB
特征:Max / Median 比值很大(> 3~5 倍就是倾斜信号,> 10 倍基本确认)。
SQL / DataFrame 页面
看每个算子耗时:
- 找到最耗时的 operator
- 看 Exchange(Shuffle)节点前后的数据量变化
COUNT(DISTINCT)会展开为groupBy+groupBy
Executor 页面
┌────────────────────────────────────────────┐
│ Executor 1: Tasks 50, GC Time 35% │ ← GC 过高
│ Executor 2: Tasks 50, GC Time 8% │
│ Executor 3: Tasks 3, GC Time 2% │ ← 调度不均
│ Executor 4: Failed Tasks 5 │ ← 有失败重试
└────────────────────────────────────────────┘
问题信号:
- 各 Executor 任务数严重不均 → 调度问题或数据倾斜
- 个别 Executor GC 时间极高 → 该节点数据过大
- Failed Tasks 多 → 内存不足或数据问题
2.2 执行计划 —— EXPLAIN
EXPLAIN EXTENDED
SELECT ... FROM ... WHERE ... GROUP BY ...
读执行计划的关键是找 Exchange 节点,以及它前后的数据量变化。
常见问题模式:
| 计划特征 | 问题 | 解决方向 |
|---|---|---|
HashAggregate → Exchange → HashAggregate |
COUNT(DISTINCT) 展开了两次聚合 | 改用 bitmap/approx |
Exchange on 大表 JOIN 小表 |
小表没广播 | hint /*+ BROADCAST(small) */ |
| 多个连续 Exchange | 多次 Shuffle | 合并聚合,减少中间步骤 |
SortMergeJoin on 大表×大表 |
两张大表都需 Shuffle | 考虑分桶或预聚合 |
BroadcastHashJoin |
小表广播,最优 | 确认广播阈值够大 |
2.3 日志与 Metrics
# 找到倾斜的 partition 对应的 key
# 方法:抽样统计 key 分布
SELECT key_col, COUNT(*) AS cnt
FROM source_table
WHERE dt = '2026-05-01'
GROUP BY key_col
ORDER BY cnt DESC
LIMIT 20;
2.4 常见问题 - 定位速查表
| 现象 | 根因 | 判断方法 |
|---|---|---|
| 个别 Task 特别慢 | 数据倾斜 | Stage 页看 Duration max/median |
| 所有 Task 都慢 | 数据量大 / 资源不足 | 看 Input Size、并行度 |
| Shuffle Write 巨大 | 聚合不够早 | 看 Exchange 前的数据量 |
| OOM / 大量 Spill | 内存不足 / key 太多 | Executor 页看 GC%、Spill |
| Stage 间有长空闲 | 调度延迟 | 看时间线中的 gap |
| Task 反复重试 | 数据或资源问题 | Executor 页 Failed Tasks |
3. 通用优化策略
3.1 减少数据扫描
原则:读最少的数据,尽早过滤。
-- ❌ 先 JOIN 后过滤
SELECT ... FROM big_table a
JOIN big_table b ON a.id = b.id
WHERE a.dt = '2026-05-01';
-- ✅ 先过滤后 JOIN(谓词下推,但显式写更可靠)
WITH filtered AS (
SELECT * FROM big_table WHERE dt = '2026-05-01'
)
SELECT ... FROM filtered a JOIN filtered b ON a.id = b.id;
关键技巧:
- 分区表一定在 WHERE 中指定分区字段
- 只 SELECT 需要的列(列裁剪),不要
SELECT * - 能提前过滤的 WHERE 条件不要放到 HAVING 或 JOIN 之后
3.2 减少 Shuffle 次数
-- ❌ 多次独立聚合,触发多次 Shuffle
,agg1 AS (SELECT brand, SUM(gmv) AS total_gmv FROM t GROUP BY brand)
,agg2 AS (SELECT brand, SUM(dot) AS total_dot FROM t GROUP BY brand)
-- ✅ 一次聚合完成(或使用窗口函数)
,agg AS (
SELECT brand,
SUM(gmv) AS total_gmv,
SUM(dot) AS total_dot,
-- 大粒度的汇总用窗口函数,不额外 Shuffle
SUM(SUM(gmv)) OVER (PARTITION BY category) AS category_gmv
FROM t
GROUP BY brand, category
)
窗口函数 vs 独立聚合的选择:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 同粒度多指标 | 一次 GROUP BY | 一次 Shuffle |
| 父级汇总 | 窗口函数 | 复用同一 Shuffle 结果 |
| COUNT(DISTINCT) 汇总 | 独立聚合 | 窗口 SUM(distinct) 无意义 |
| 多层不同粒度 | 视情况,优先合并 | 每层 GROUP BY 都是一个 Shuffle |
3.3 JOIN 优化
-- 1. 小表 JOIN 大表 → Broadcast Hint
SELECT /*+ BROADCAST(small_table) */ ...
FROM small_table a JOIN big_table b ON a.key = b.key;
-- 2. 大表 JOIN 大表 → 考虑分桶(Bucketing)
-- 建表时按 JOIN key 分桶,后续 JOIN 可避免 Shuffle
CREATE TABLE orders (id BIGINT, ...)
CLUSTERED BY (user_id) INTO 256 BUCKETS;
-- 3. 避免笛卡尔积
-- JOIN 条件中必须包含等值条件
JOIN 类型自动选择逻辑:
| 条件 | 自动选择的 JOIN 类型 |
|---|---|
小表 < spark.sql.autoBroadcastJoinThreshold(默认 10MB) |
BroadcastHashJoin |
| 两表都大,JOIN key 可排序 | SortMergeJoin |
| 两表都大,无合适 key | ShuffledHashJoin(需一方小于 spark.sql.autoBroadcastJoinThreshold × 3) |
3.4 避免 COUNT(DISTINCT) 陷阱
-- ❌ 多个 COUNT(DISTINCT) — 每个都会展开为独立的 groupBy + groupBy
SELECT
COUNT(DISTINCT col1),
COUNT(DISTINCT col2),
COUNT(DISTINCT col3)
FROM t;
-- ✅ 改写:使用条件聚合替代部分 COUNT(DISTINCT)
SELECT
COUNT(DISTINCT col1),
COUNT(DISTINCT IF(condition, col2, NULL)) -- 合并进同一个 DISTINCT
FROM t;
-- ✅ 或使用 approx_count_distinct(误差 ~2%)
SELECT approx_count_distinct(col1) FROM t;
为什么 COUNT(DISTINCT) 慢:
- Spark 将
COUNT(DISTINCT col)展开为:先(col) GROUP BY,再COUNT(*) - 多个
COUNT(DISTINCT)不能共享 Shuffle - distinct 的 key 往往膨胀(高基数),Shuffle 数据量大
3.5 替代 collect_set + array_contains 笛卡尔积
这是一个容易被忽视的性能杀手。
-- ❌ 笛卡尔积方式(常见于计算「该品类下各品牌用户数」)
WITH user_brands AS (
SELECT user_id, COLLECT_SET(brand) AS brand_set
FROM t GROUP BY user_id
),
all_brands AS (
SELECT DISTINCT brand FROM t
)
SELECT b.brand, SUM(CASE WHEN ARRAY_CONTAINS(brand_set, b.brand) THEN 1 ELSE 0 END)
FROM user_brands u JOIN all_brands b -- 笛卡尔积!
GROUP BY b.brand;
-- ✅ 等价于直接 COUNT(DISTINCT user_id) GROUP BY brand
SELECT brand, COUNT(DISTINCT user_id)
FROM t
GROUP BY brand;
本质:COLLECT_SET + ARRAY_CONTAINS 的 INNER JOIN 就是 COUNT(DISTINCT) GROUP BY。 改写后从 O(n×m) 降到 O(n),且避免了 collect_set 的 OOM 风险。
4. 数据倾斜:识别与处理
4.1 什么是数据倾斜
Spark 按 key 的 hash 值将数据分配到不同分区。当某些 key 的数据量远大于其他 key 时:
Partition 0: ████████████████████████████████ 200MB (Task 耗时 15min)
Partition 1: ██ 5MB (Task 耗时 20s)
Partition 2: ██ 6MB (Task 耗时 22s)
Partition 3: ███ 8MB (Task 耗时 30s)
...
Partition 199: ████ 10MB (Task 耗时 35s)
倾斜的 partition 所在的 Task(有时称为「长尾任务」)拖慢整个 Stage。
4.2 倾斜的根因
| 原因 | 典型场景 | 例子 |
|---|---|---|
| 热点 key | 少量 key 有海量数据 | brandname_full = '全部' 作为汇总行 |
| NULL 值聚集 | JOIN 或 GROUP BY key 大量为 NULL | 未关联上的数据全落到同一分区 |
| 数据分布天然不均 | 业务规律 | 头部品牌占 80% 交易,尾部品牌数据极少 |
| JOIN key 基数极低 | 只有几个值 | data_type = '全部',item_cate = '全部' |
4.3 解决方案矩阵
按场景选择合适方案:
方案 1:过滤倾斜 key(适用:倾斜 key 对业务无意义)
-- 如果 NULL 或 '全部' 不需要参与计算,直接过滤
WHERE brandname_full <> '全部'
AND user_id IS NOT NULL
方案 2:两阶段聚合(适用:GROUP BY 场景的倾斜)
-- ❌ 直接聚合,hot key 全部进一个 Task
SELECT key, SUM(val) FROM t GROUP BY key;
-- ✅ 两阶段聚合:加随机前缀 → 预聚合 → 去前缀再聚合
WITH salted AS (
SELECT CONCAT(key, '_', CAST(RAND() * 100 AS INT)) AS salted_key, val
FROM t
),
pre_agg AS (
SELECT salted_key, SUM(val) AS partial_sum
FROM salted
GROUP BY salted_key
)
SELECT
SUBSTRING_INDEX(salted_key, '_', 1) AS key,
SUM(partial_sum) AS total
FROM pre_agg
GROUP BY SUBSTRING_INDEX(salted_key, '_', 1);
原理: 第一层聚合将 hot key 打散到 100 个分区各自局部聚合,第二层聚合的数据量已经缩小 100 倍。
适用条件: 聚合函数可拆分(SUM、COUNT 可以;AVG 需要拆成 SUM/COUNT 两个阶段;COUNT(DISTINCT) 不能直接用此方案)。
方案 3:倾斜侧加盐(适用:JOIN 场景,一侧有倾斜)
-- ❌ 直接 JOIN,hot key 集中在一个 Task
SELECT a.*, b.val
FROM big_table a
JOIN lookup_table b ON a.key = b.key;
-- ✅ 大表加随机盐,小表复制 N 份
WITH big_salted AS (
SELECT
CONCAT(key, '_', CAST(RAND() * 10 AS INT)) AS salted_key,
*,
CAST(RAND() * 10 AS INT) AS salt
FROM big_table
),
small_expanded AS (
SELECT
CONCAT(key, '_', s.salt) AS salted_key,
l.*
FROM lookup_table l
CROSS JOIN (SELECT EXPLODE(ARRAY(0,1,2,3,4,5,6,7,8,9)) AS salt) s
)
SELECT a.*, b.val
FROM big_salted a
JOIN small_expanded b ON a.salted_key = b.salted_key;
适用条件: 一侧表较小(可接受 N 倍膨胀),另一侧有大 key 倾斜。
方案 4:广播 JOIN(适用:小表 JOIN 大表)
SELECT /*+ BROADCAST(small_table) */ ...
FROM big_table JOIN small_table ON big_table.key = small_table.key;
广播 JOIN 没有 Shuffle,从根本上避免了倾斜。调整阈值:
SET spark.sql.autoBroadcastJoinThreshold = 104857600; -- 100MB
方案 5:分桶(Bucketing)(适用:高频 JOIN 的大表×大表)
-- 建表时定义分桶
CREATE TABLE orders
CLUSTERED BY (user_id) INTO 256 BUCKETS;
CREATE TABLE users
CLUSTERED BY (user_id) INTO 256 BUCKETS;
-- JOIN 时同 key 同桶数,无需 Shuffle
SELECT /*+ BROADCAST(a) */ ... FROM orders a JOIN users b ON a.user_id = b.user_id;
注意: 分桶只在两表桶数相同(或成倍数)且 JOIN key = 分桶 key 时才生效。写入时需保证 spark.sql.shuffle.partitions 与桶数一致。
方案 6:分离处理(适用:倾斜 key 是少量的特定值)
-- 把倾斜 key 单独处理,非倾斜 key 正常处理
WITH
skewed_data AS (
SELECT * FROM t WHERE key IN ('hot_key_1', 'hot_key_2')
),
normal_data AS (
SELECT * FROM t WHERE key NOT IN ('hot_key_1', 'hot_key_2')
),
-- 倾斜数据用加盐处理
skewed_agg AS ( /* 方案 2 两阶段聚合 */ ),
-- 正常数据直接聚合
normal_agg AS ( /* 直接 GROUP BY */ )
SELECT * FROM skewed_agg UNION ALL SELECT * FROM normal_agg;
方案 7:调整分区策略(适用:key 分布不均但无极端热点)
-- 默认 Hash Partitioning → 改为 Range Partitioning
SELECT /*+ REPARTITION(200, key_col) */ ...;
-- 或使用 DISTRIBUTE BY 控制分区
SELECT ... FROM t DISTRIBUTE BY key_col;
方案 8:开启 AQE(Adaptive Query Execution)
Spark 3.0+ 的 AQE 可以在运行时自动处理部分倾斜:
SET spark.sql.adaptive.enabled = true;
SET spark.sql.adaptive.skewJoin.enabled = true;
SET spark.sql.adaptive.skewJoin.skewedPartitionFactor = 5;
SET spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes = 256MB;
SET spark.sql.adaptive.coalescePartitions.enabled = true;
AQE 能自动做什么:
- 运行时合并小分区(减少 Task 数量)
- 检测并拆分倾斜的分区
- 动态调整 JOIN 策略
但不能完全依赖 AQE: 极端倾斜、COUNT(DISTINCT) 场景、自定义 UDAF 等场景下 AQE 效果有限,仍需要前面的人工优化手段。
4.4 方案选择决策树
遇到数据倾斜
├─ 倾斜 key 可以过滤掉?
│ └─ Yes → 方案 1: 直接过滤(最简单)
│
├─ 是 JOIN 操作?
│ ├─ 小表 < 100MB?
│ │ └─ Yes → 方案 4: 广播 JOIN
│ │
│ ├─ 倾斜只在少数 key?
│ │ └─ Yes → 方案 6: 分离处理
│ │
│ └─ 大表 × 大表?
│ ├─ 频繁 JOIN → 方案 5: 分桶
│ └─ 临时查询 → 方案 3: 加盐 JOIN
│
├─ 是 GROUP BY?
│ └─ 方案 2: 两阶段聚合
│
└─ Spark 3.0+
└─ 开启 AQE → 方案 8: 作为兜底
5. 配置调优指南
5.1 常见配置误区及修正
| 参数 | 常见误区 | 建议值 | 原因 |
|---|---|---|---|
spark.sql.shuffle.partitions |
设 2000-3000 | 200-800 | 分区太多 → Task 调度开销大、小文件多 |
mapred.max.split.size |
16MB | 128MB-256MB | 切片太小 → Task 太多、每个 Task 处理的元数据开销占比大 |
spark.dynamicAllocation.maxExecutors |
1000 | 200-400 | 过多 Executor 增加调度延迟和资源争抢 |
spark.executor.cores |
8-16 | 4-8 | 核数过多 → 并发 I/O 竞争、GC 压力 |
spark.executor.memory |
4-8G | 8-16G | 视数据量而定,过大浪费 |
spark.sql.autoBroadcastJoinThreshold |
10MB(默认) | 50MB-100MB | 提高广播阈值,减少 Shuffle JOIN |
spark.sql.adaptive.enabled |
false(Spark 3.0 前) | true | AQE 自动优化 |
5.2 根据数据量估算并行度
总输入数据量 / 128MB ≈ 分区数下限
总输入数据量 / 256MB ≈ 分区数上限
例如:输入 100GB 数据
100GB / 128MB ≈ 800 分区(上限)
100GB / 256MB ≈ 400 分区(下限)
推荐: spark.sql.shuffle.partitions = 400~600
每个 Task 处理 128MB ~ 256MB 数据比较理想。 太小 → 调度开销大,太大 → GC 压力大。
5.3 内存分配
单个 Executor 内存分配原则:
spark.executor.memory = 16G
spark.executor.memoryOverhead = max(384MB, 0.1 × 16G) = 1.6G
spark.executor.cores = 4
每个 Task 可用内存 = (16G - storage memory) / 4 ≈ 3~4G
如果 Spill 严重:
- 增大
spark.executor.memory - 或减小
spark.executor.cores(减少并发,增加单 Task 内存)
5.4 生产配置模板
-- Spark 3.x 生产环境推荐配置
SET spark.sql.adaptive.enabled = true;
SET spark.sql.adaptive.coalescePartitions.enabled = true;
SET spark.sql.adaptive.skewJoin.enabled = true;
SET spark.sql.adaptive.skewJoin.skewedPartitionFactor = 5;
SET spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes = 256MB;
SET spark.sql.shuffle.partitions = 400;
SET spark.sql.autoBroadcastJoinThreshold = 104857600; -- 100MB
SET spark.sql.hive.mergeFiles = true; -- 合并小文件
SET spark.sql.parser.quotedRegexColumnNames = false; -- 避免正则列名歧义
SET spark.dynamicAllocation.maxExecutors = 400;
SET spark.dynamicAllocation.minExecutors = 20;
SET spark.executor.cores = 4;
SET spark.executor.memory = 16G;
SET spark.driver.memory = 16G;
6. 实战案例复盘
以「药品品牌竞品分析 SQL」为例,演示一个完整的优化过程。
6.1 原始 SQL 的问题
-- 原始代码结构
WITH ord1 AS (SELECT ... FROM source WHERE ...), -- 扫描一次
ord1_stats AS (SELECT ... FROM ord1 GROUP BY ...), -- 聚合 1,Shuffle 1
ord1_stats_without_brand AS (... FROM ord1 GROUP BY ...), -- 聚合 2,Shuffle 2 (重复扫 ord1)
ord1_usr_brand_set AS (SELECT collect_set(...) ...), -- 聚合 3,Shuffle 3
ord1_all_brands AS (...), -- 聚合 4,Shuffle 4
ord1_usr_brand_set_fg AS (collect_set + WHERE ...), -- 聚合 5,Shuffle 5
ord1_all_brands_fg AS (...), -- 聚合 6,Shuffle 6
ord1_usr_stats AS (JOIN + array_contains ...), -- 笛卡尔积!
ord1_fg_usr_stats AS (同上), -- 又一个笛卡尔积!
ord1_final AS (JOIN × 4), -- 4 个 LEFT JOIN
-- ord2 完全重复上面整套逻辑
问题清单:
| 问题 | 严重程度 | 影响 |
|---|---|---|
| 同一张源表扫了 2 次(ord1_stats + ord1_stats_without_brand 各自扫 ord1) | 中 | 双倍扫描 I/O |
collect_set + array_contains 笛卡尔积计算用户数 |
致命 | O(n×m) 复杂度,大量 Shuffle |
| ord1 整套逻辑在 ord2 完全重复 | 中 | 双倍计算资源 |
| 8 个 CTE 各触发独立聚合 | 高 | 至少 6 次 Shuffle |
spark.sql.shuffle.partitions = 3000 |
高 | 3000 个小分区,调度开销大 |
6.2 优化后的结构
WITH
ord1_base AS (SELECT ... FROM source WHERE ...), -- 扫描 1 次
ord1_stats AS ( -- 一次聚合完成品牌+品类
SELECT brand, ...,
SUM(SUM(gmv)) OVER (PARTITION BY drug) total_gmv, -- 窗口函数替代独立聚合
SUM(SUM(dot)) OVER (PARTITION BY drug) total_dot,
...
FROM ord1_base
GROUP BY brand, drug, category
),
single_brand_users AS ( -- 找出只买一个品牌的用户
SELECT user, drug, MAX(brand) sole_brand,
MAX(parent_ord_cnt) max_ord
FROM ord1_base
GROUP BY user, drug
HAVING COUNT(DISTINCT brand) = 1
),
brand_exclusive_users AS ( -- 每个品牌的独占用户数
SELECT drug, sole_brand brand,
COUNT(DISTINCT user) exclusive_cnt
FROM single_brand_users
GROUP BY drug, sole_brand
),
ord1_final AS ( -- 一次 JOIN 产出所有指标
SELECT ...
FROM ord1_stats s
LEFT JOIN drug_level_users d ON ...
LEFT JOIN brand_exclusive_users e ON ...
)
-- ord2 同上(复用结构)
6.3 优化效果对比
| 维度 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| Shuffle 次数 | ~12 次 | ~4 次 | -67% |
| 源表扫描 | 2 次 | 1 次 | -50% |
| CTE 数量 | 20 个 | 12 个 | 更清晰的 DAG |
| 笛卡尔积 | 2 处 | 0 处 | 消除 |
| 竞品用户数口径 | bug(= 本品用户数) | 修复 | 正确 |
| shuffle.partitions | 3000 | 400 | 调度效率 +7x |
6.4 还有一个容易忽略的点:collect_set 的 OOM 风险
-- ❌ 如果某个用户的 brand_set 包含数百个品牌:
-- collect_set 的单个 key 巨大 → OOM
collect_set(brandname_full) AS brand_set
-- ✅ 等价改写为 COUNT(DISTINCT) 后,不再有 collect 操作
-- Spark 内部用哈希聚合,数据分散在各节点内存中
COUNT(DISTINCT user_log_acct) GROUP BY brandname_full
7. 优化检查清单
写 SQL 或 Review SQL 时,逐项检查:
数据读取
Shuffle 与 聚合
JOIN
倾斜
配置
验证
附:快速参考卡片
┌────────────────────────────────────────────────────────────┐
│ Spark SQL 优化速查 │
├────────────────────────────────────────────────────────────┤
│ Shuffle 次数: 越少越好 │
│ 分区数: 400~800(每 Task 128-256MB) │
│ Broadcast: 小表 < 100MB 就 broadcast │
│ 倾斜信号: max/median Duration > 5x │
│ 倾斜处理: 过滤 → 两阶段聚合 → 加盐 → AQE │
│ COUNT DISTINCT: 多个同字段合并,或用 approx │
│ 窗口函数: 同粒度指标用窗口函数避免二次聚合 │
│ 定位入口: Spark UI → Stage → Summary Metrics │
└────────────────────────────────────────────────────────────┘
优化不在一蹴而就,而在于每次写 SQL 时多问一句:「这一步会 Shuffle 吗?能省吗?」
浙公网安备 33010602011771号