spark-sql优化
核心优化思路
- 减少数据量: 尽早过滤掉不需要的数据,减少参与后续计算、Shuffle 和落盘的数据量。
- 减少 Shuffle: Shuffle(数据跨节点移动)是 Spark 中最昂贵、最容易成为瓶颈的操作。应尽量避免不必要的 Shuffle,或优化 Shuffle 过程。
- 并行度优化: 确保任务能充分利用集群资源并行执行,避免部分节点空闲而部分节点过载。
- 资源优化: 合理配置 Executor、CPU、内存资源,避免 OOM(内存溢出)或资源浪费。
- 数据倾斜处理: 解决因数据分布不均导致的个别 Task 处理数据量过大、耗时长的问题。
- 文件优化: 避免大量小文件带来的元数据开销和低效 I/O。
场景一:数据中存在大量空值 (NULL)
- 问题表现:
- 在
JOIN、GROUP BY、DISTINCT、ORDER BY等操作时,包含 NULL 的记录可能被发送到同一个 Executor 进行处理(具体行为取决于操作类型和配置),导致该 Executor 负载过重(数据倾斜)。 - 空值本身也占用存储和计算资源。
- 在
- HQL 语句优化:
- 提前过滤: 在尽可能早的步骤(甚至在读取数据源时)过滤掉不需要的 NULL 值。
大大减少了参与-- 优化前: WHERE 在 JOIN 之后 SELECT a.id, b.name FROM table_a a JOIN table_b b ON a.key = b.key WHERE a.value IS NOT NULL AND b.status IS NOT NULL; -- 优化后: 尽早过滤,减少 JOIN 的数据量 SELECT a.id, b.name FROM (SELECT * FROM table_a WHERE value IS NOT NULL) a -- 子查询提前过滤 JOIN (SELECT * FROM table_b WHERE status IS NOT NULL) b -- 子查询提前过滤 ON a.key = b.key;JOIN操作的数据行数,降低了 Shuffle 的数据量和后续计算开销。 - 使用
COALESCE或IFNULL填充默认值: 如果业务允许,将 NULL 替换为有意义的默认值,可以避免某些操作中 NULL 的特殊处理逻辑和潜在倾斜。
简化了后续聚合(如SELECT user_id, COALESCE(order_amount, 0) AS safe_amount -- 将 NULL 金额视为 0 FROM orders;SUM(safe_amount))或排序的逻辑,避免了 NULL 值的特殊分组行为。
- 提前过滤: 在尽可能早的步骤(甚至在读取数据源时)过滤掉不需要的 NULL 值。
- Spark 配置调整:
spark.sql.adaptive.enabled=true(强烈推荐开启): 自适应查询执行 (AQE) 能在运行时检测到数据倾斜(例如某个 Task 处理的数据量远大于其他 Task),并自动进行优化,如将倾斜的分区分裂成更小的部分处理。spark.sql.adaptive.skewJoin.enabled=true(依赖 AQE): 专门针对 Join 倾斜进行优化。当检测到 Join 一侧的某个分区过大时,会将该分区动态拆分,再与另一侧的对应分区进行 Join。spark.sql.shuffle.partitions: 如果数据倾斜严重且无法通过过滤/填充解决,适当增加 Shuffle 分区数 可能 缓解(但不治本),让倾斜的数据分布到更多 Task 上。需要结合 AQE 使用效果更好。
** AQE 及其倾斜处理机制能自动应对运行时发现的倾斜问题,是处理包含大量 NULL 值导致倾斜的强力武器。增加分区数提供了更多处理单元来分担负载。**spark-submit ... --conf spark.sql.adaptive.enabled=true \ --conf spark.sql.adaptive.skewJoin.enabled=true \ --conf spark.sql.shuffle.partitions=200 # 根据数据量和集群规模调整
场景二:数据中存在大量重复值 (数据倾斜的常见原因)
- 问题表现:
- 在
GROUP BY、COUNT(DISTINCT ...)、JOIN(当 Join Key 存在极高频值)时,某个或某几个 Key 对应的数据量极大,导致处理这些 Key 的 Task 运行时间远长于其他 Task。
- 在
- HQL 语句优化:
- 预聚合/局部聚合: 对于大表 GROUP BY,尝试先进行局部聚合减少数据量,再进行全局聚合。
第一阶段的-- 优化前: 直接在大表上做全局 DISTINCT COUNT 可能倾斜 SELECT city, COUNT(DISTINCT user_id) AS user_cnt FROM huge_log_table GROUP BY city; -- 优化后: 两阶段聚合 SELECT city, SUM(partial_cnt) AS user_cnt FROM ( SELECT city, user_id, COUNT(*) AS partial_cnt -- 或 1 作为标记 FROM huge_log_table GROUP BY city, user_id -- 第一阶段:按 city+user_id 聚合,去重计数已完成 ) tmp GROUP BY city; -- 第二阶段:只需按 city 累加即可GROUP BY city, user_id将去重计数的工作分散到更细的粒度(每个城市下的每个用户),避免了直接COUNT(DISTINCT user_id)可能因为某个城市用户量巨大导致的倾斜。第二阶段只是简单的累加,数据量小很多。 - 过滤高频值/单独处理: 识别出导致倾斜的少数高频 Key,将它们单独拿出来处理。
将“肿瘤”数据(高频值)剥离出来单独处理,防止它影响其他数据的正常聚合过程。-- 假设 'unknown' 是一个高频且会导致倾斜的 city 值 -- 1. 单独处理高频值 'unknown' (SELECT 'unknown' AS city, COUNT(DISTINCT user_id) AS user_cnt FROM huge_log_table WHERE city = 'unknown') UNION ALL -- 2. 处理非高频值 (SELECT city, COUNT(DISTINCT user_id) AS user_cnt FROM huge_log_table WHERE city <> 'unknown' GROUP BY city); - 使用
BROADCAST JOIN提示: 如果一张表很小,确保它被广播出去,避免 Shuffle Sort Merge Join。
广播小表完全避免了大数据表的 Shuffle,是最高效的 Join 方式之一。但需确保小表真的足够小(能放进 Executor 内存)。-- 告诉 Spark 尝试广播 small_table SELECT /*+ BROADCAST(s) */ ... FROM big_table b JOIN small_table s ON b.key = s.key;
- 预聚合/局部聚合: 对于大表 GROUP BY,尝试先进行局部聚合减少数据量,再进行全局聚合。
- Spark 配置调整:
spark.sql.adaptive.enabled=true和spark.sql.adaptive.skewJoin.enabled=true: 同样关键,能自动检测并拆分处理倾斜的分区(对于 Join 和 Aggregation 都有效)。spark.sql.autoBroadcastJoinThreshold: 设置一个合理的值(例如10485760即 10MB),让 Spark 自动将小于此阈值的表进行广播 Join。如果确定某些表应该广播但没被广播,可以调大此值或使用 HINT。spark.sql.shuffle.partitions: 增加分区数有助于分散倾斜 Key 的处理负载(需配合 AQE 使用效果更佳)。spark.sql.adaptive.coalescePartitions.enabled=true(依赖 AQE): AQE 在 Shuffle 后可能自动合并过小的分区,避免启动过多小 Task 的开销。spark-submit ... --conf spark.sql.adaptive.enabled=true \ --conf spark.sql.adaptive.skewJoin.enabled=true \ --conf spark.sql.adaptive.coalescePartitions.enabled=true \ --conf spark.sql.autoBroadcastJoinThreshold=20971520 \ # 20MB --conf spark.sql.shuffle.partitions=400
场景三:小文件问题
- 问题表现:
- 读取时: HDFS 或对象存储上的大量小文件导致 NameNode/元数据服务压力巨大,任务启动慢(需要列出大量文件)。Spark 每个文件至少对应一个 Partition/Task,导致 Task 数量爆炸,调度开销极大,大量小 Task 不能充分利用资源。
- 写入时: 每个 Task 或每个 Partition 可能输出一个文件,如果 Task 数很多或 Partition 数很多,就会产生大量小文件,给下游作业带来同样的问题。
- HQL 语句优化:
- 读取前合并小文件 (Hive 表常用): 如果源表是 Hive 表,可以使用
ALTER TABLE ... CONCATENATE(仅适用于 RCFile/ORC)或运行合并小文件的 Hive/Spark 作业。Spark 读取时选择合并后的文件。 - 写入时控制文件数量:
coalesce: 在写入前主动减少分区数(会减少 Task 数)。适用于最终分区数远小于当前 RDD/DataFrame 分区数的情况。 注意:coalesce可能导致数据倾斜,因为它只是合并分区而不进行 Shuffle。INSERT OVERWRITE TABLE target_table SELECT * FROM source_table DISTRIBUTE BY key -- 可选,按某个 Key 重新分布数据 CLUSTER BY key -- 可选,按 Key 重新分布并排序 -- 在最终写入前,使用 coalesce 减少分区数 -- 例如: 将数据合并到 10 个分区写入 -- 注意: coalesce 是 Spark 的 transformation,在 SQL 中通常通过 DataFrame API 或在子查询后使用 hint (较新版本支持) 或配置实现。 -- 常见做法是在 DataFrame.write 时指定 .coalesce(10).parquet(...)repartition: 显式地按照指定的列或数量进行重新分区(会触发 Shuffle)。更通用,可以指定列名或分区数。 通常比coalesce更安全,因为它能更好地平衡数据。INSERT OVERWRITE TABLE target_table SELECT /*+ REPARTITION(128) */ * FROM source_table; -- 使用 Hint 指定重分区到 128 个分区 -- 或者按列重分区 (常用于动态分区写入前) INSERT OVERWRITE TABLE target_table SELECT /*+ REPARTITION(partition_col1, partition_col2) */ * FROM source_table;DISTRIBUTE BY/CLUSTER BY: 在 Hive 语法中常用,效果类似repartition。INSERT OVERWRITE TABLE target_table PARTITION (dt) SELECT ..., dt FROM source_table DISTRIBUTE BY dt, some_key; -- 按 dt 和 some_key 分发数据,控制每个分区下的文件数量 -- CLUSTER BY dt, some_key 还会在分区内排序
coalesce/repartition/DISTRIBUTE BY直接控制了最终写入数据的 Task 数量(每个 Task 通常写一个文件),从而控制了生成的文件数量。DISTRIBUTE BY在动态分区写入时特别有用,可以控制每个动态分区内的文件数。 - 调整文件格式和压缩: 使用列式存储格式(Parquet, ORC)并启用合适的压缩(Snappy, Zstd)。虽然不直接减少文件数量,但能显著减小单个文件大小(即使小文件,压缩后也会更小)。
- 读取前合并小文件 (Hive 表常用): 如果源表是 Hive 表,可以使用
- Spark 配置调整:
spark.sql.shuffle.partitions: 这是控制写入文件数的核心配置! 这个值决定了 Shuffle 后(以及repartition后)的分区数。一个分区对应一个 Task,一个 Task 通常输出一个文件。根据最终期望的文件大小(例如 128MB - 1GB)和总数据量来设置这个值。
**直接决定了 Shuffle 输出阶段的分区数(Task 数),从而直接影响生成的文件数。spark-submit ... --conf spark.sql.shuffle.partitions=100 # 例如期望产生约 100 个文件spark.sql.files.maxRecordsPerFile: 控制每个输出文件最多包含多少条记录。达到这个限制后会写入新文件。可以和spark.sql.shuffle.partitions结合使用。spark.sql.adaptive.enabled=true: AQE 在 Shuffle 后可能会自动合并过小的分区(spark.sql.adaptive.coalescePartitions.enabled=true),这也会减少最终写入的文件数。spark.sql.sources.partitionOverwriteMode: 设置为dynamic对于按分区覆盖写入非常重要,避免意外覆盖整个表。spark.hadoop.mapreduce.fileoutputcommitter.algorithm.version: 设置为2(或使用 S3 的DirectOutputCommitter) 可以显著提高写入文件系统(尤其是 S3)的性能和一致性。spark-submit ... --conf spark.sql.shuffle.partitions=200 \ # 核心控制文件数 --conf spark.sql.adaptive.enabled=true \ --conf spark.sql.adaptive.coalescePartitions.enabled=true \ --conf spark.sql.files.maxRecordsPerFile=1000000 \ # 可选,控制文件大小 --conf spark.sql.sources.partitionOverwriteMode=dynamic \ --conf spark.hadoop.mapreduce.fileoutputcommitter.algorithm.version=2
其他重要优化场景与手段
-
避免不必要的复杂操作:
- 问题: 多层嵌套子查询、不必要的
DISTINCT、ORDER BY(除非最终输出需要)、过早的COLLECT_SET/COLLECT_LIST(导致数据集中到 Driver)。 - 优化: 简化逻辑,拆分成多个步骤的临时视图/表。只在必要时使用
DISTINCT和ORDER BY。慎用COLLECT_*聚合函数,考虑是否可以在 Executor 端完成更多处理。
减少计算复杂度,避免过早的数据移动和收集。
- 问题: 多层嵌套子查询、不必要的
-
谓词下推:
- 问题: 过滤条件写在子查询外层或 JOIN 后,导致全表扫描后再过滤。
- 优化: 确保过滤条件尽可能下推到靠近数据源的地方(写在子查询内部或 JOIN ON 条件中)。Spark 和 Parquet/ORC 通常会自动做谓词下推,但复杂的表达式或 UDF 可能阻止下推。
让存储层(如 Parquet/ORC 的 row group / stripe)或扫描器尽早过滤掉无关数据,极大减少 I/O 和内存占用。
-
选择合适的 Join 策略:
- 问题: Spark 默认选择 Sort Merge Join。如果小表没有被广播,或者大表 Join 大表存在倾斜,性能会很差。
- 优化:
- 确保小表被广播 (
BROADCASTHINT 或 调大autoBroadcastJoinThreshold)。 - 对于大表 Join 大表且一个表可被过滤得很小,考虑先过滤再广播。
- 在特定场景(如非等值 Join)可考虑
Shuffle Hash Join(需配置spark.sql.join.preferSortMergeJoin=false且 内存足够)。 - 严重倾斜的大表 Join 可尝试
SMBSkewJoin(需 AQE 开启倾斜 Join)。
正确的 Join 策略能最小化 Shuffle 代价或利用内存加速。
- 确保小表被广播 (
-
缓存 (Cache/Persist) 的合理使用:
- 问题: 过度缓存或缓存不需要重用的数据,浪费内存;该缓存的没缓存,导致重复计算。
- 优化: 仅对会被多次使用的中间数据集(尤其是在迭代算法或需要被多个后续 Action 引用的场景)进行缓存。选择合适的储级别(
MEMORY_ONLY,MEMORY_AND_DISK_SER等)。使用完后及时unpersist()。
避免重复计算节省时间;但错误缓存会挤占宝贵内存,可能引发 GC 或 OOM。
-
资源与并行度配置:
- 问题: Executor 数量、CPU 核数、内存大小配置不当。Executor 内存结构 (JVM Heap vs Off-Heap) 配置不当。
- 优化: (需根据集群资源和作业负载调整)
spark.executor.instances: Executor 数量。spark.executor.cores: 每个 Executor 的 CPU 核心数 (影响 Task 并行度)。spark.executor.memory/spark.executor.memoryOverhead: Executor JVM Heap 内存和堆外内存。合理分配比例,防止 OOM。spark.memory.fraction/spark.memory.storageFraction控制 Execution 和 Storage 内存池比例。spark.default.parallelism: 默认并行度,影响 RDD 操作的初始分区数。spark.sql.shuffle.partitions: (再次强调) 控制 Shuffle 后的分区数,直接影响 Shuffle 阶段的并行度和输出文件数。spark.task.cpus: 通常为 1,表示一个 Task 需要一个 CPU Core。如果 Task 有特殊资源需求(如 GPU)才调整。- 使用动态资源分配 (
spark.dynamicAllocation.enabled=true): 让 Spark 根据负载自动增减 Executor。
让集群资源得到充分利用,避免资源不足导致慢或资源空闲导致浪费。合理的内存配置是避免 OOM 的关键。
-
充分利用文件格式特性:
- 优化: 优先使用 Parquet/ORC 等列式存储格式。它们提供:
- 谓词下推: 只读取需要的列和行组/条带。
- 列裁剪: 只读取 SQL 查询中涉及的列。
- 高效的压缩和编码: 节省存储空间和 I/O。
- 统计信息: 帮助优化器做更好的决策(如 Join 策略选择)。
- 显著减少 I/O 量和内存占用,加速查询。
- 优化: 优先使用 Parquet/ORC 等列式存储格式。它们提供:
-
监控与分析:
- 工具: Spark Web UI (Stages, Storage, SQL/DataFrame tabs), Spark History Server,
EXPLAIN/EXPLAIN EXTENDED(查看物理执行计划),日志分析。 - 关注点: Task 执行时间分布、Shuffle 读写量、GC 时间、数据倾斜情况、Stage 依赖关系、执行计划中的瓶颈操作 (如
BroadcastNestedLoopJoin,SortMergeJoin,Exchange/ShuffleExchange等)。
精准定位性能瓶颈是有效优化的前提。执行计划 (EXPLAIN) 是理解 Spark 如何执行你的 SQL 的关键。
- 工具: Spark Web UI (Stages, Storage, SQL/DataFrame tabs), Spark History Server,
总结与最佳实践
- 理解原理: 深入理解 Spark 执行引擎(DAG Scheduler, Task Scheduler, Shuffle, Tungsten)、Catalyst 优化器和 Adaptive Query Execution 的工作原理。
- 定位瓶颈: 永远是第一步! 使用 Web UI,
EXPLAIN, 日志等工具找出作业的瓶颈所在(CPU, I/O, 网络, Shuffle, 倾斜,GC)。 - 优化顺序:
- 首先优化 HQL / DataFrame 代码: 这是最根本的,效果往往最显著(如过滤、减少 Shuffle、避免复杂操作)。
- 利用文件格式特性。
- 然后调整关键配置: 特别是
spark.sql.shuffle.partitions,spark.sql.adaptive.*,spark.executor.*资源相关参数。 - 考虑缓存策略。
- 开启 AQE:
spark.sql.adaptive.enabled=true在大多数现代 Spark 版本 (>= 3.0) 中应该是默认或强烈推荐开启的,它能解决很多运行时才能发现的优化问题(特别是数据倾斜和 Shuffle 分区大小不均)。 - 增量式优化: 每次修改一个地方,观察效果,逐步迭代优化。避免一次性修改太多参数难以定位效果。
- 测试验证: 使用代表性数据进行测试,对比优化前后的执行时间、资源消耗和结果正确性。
通过结合 HQL 语句的语义优化和 Spark 配置的针对性调整,并深刻理解每种优化手段背后的原因(减少数据量、减少 Shuffle、解决倾斜、优化并行度、避免小文件),你就能有效地解决 Spark SQL 作业中的各种性能瓶颈。记住,没有放之四海而皆准的最优配置,最佳实践需要根据你的特定数据、集群环境和业务逻辑来探索和确定。

浙公网安备 33010602011771号