spark-sql优化

核心优化思路

  1. 减少数据量: 尽早过滤掉不需要的数据,减少参与后续计算、Shuffle 和落盘的数据量。
  2. 减少 Shuffle: Shuffle(数据跨节点移动)是 Spark 中最昂贵、最容易成为瓶颈的操作。应尽量避免不必要的 Shuffle,或优化 Shuffle 过程。
  3. 并行度优化: 确保任务能充分利用集群资源并行执行,避免部分节点空闲而部分节点过载。
  4. 资源优化: 合理配置 Executor、CPU、内存资源,避免 OOM(内存溢出)或资源浪费。
  5. 数据倾斜处理: 解决因数据分布不均导致的个别 Task 处理数据量过大、耗时长的问题。
  6. 文件优化: 避免大量小文件带来的元数据开销和低效 I/O。

场景一:数据中存在大量空值 (NULL)

  • 问题表现:
    • JOINGROUP BYDISTINCTORDER BY 等操作时,包含 NULL 的记录可能被发送到同一个 Executor 进行处理(具体行为取决于操作类型和配置),导致该 Executor 负载过重(数据倾斜)。
    • 空值本身也占用存储和计算资源。
  • HQL 语句优化:
    1. 提前过滤: 在尽可能早的步骤(甚至在读取数据源时)过滤掉不需要的 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 的数据量和后续计算开销。
    2. 使用 COALESCEIFNULL 填充默认值: 如果业务允许,将 NULL 替换为有意义的默认值,可以避免某些操作中 NULL 的特殊处理逻辑和潜在倾斜。
      SELECT user_id, COALESCE(order_amount, 0) AS safe_amount -- 将 NULL 金额视为 0
      FROM orders;
      
      简化了后续聚合(如 SUM(safe_amount))或排序的逻辑,避免了 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 使用效果更好。
      spark-submit ... --conf spark.sql.adaptive.enabled=true \
                      --conf spark.sql.adaptive.skewJoin.enabled=true \
                      --conf spark.sql.shuffle.partitions=200 # 根据数据量和集群规模调整
      
      ** AQE 及其倾斜处理机制能自动应对运行时发现的倾斜问题,是处理包含大量 NULL 值导致倾斜的强力武器。增加分区数提供了更多处理单元来分担负载。**

场景二:数据中存在大量重复值 (数据倾斜的常见原因)

  • 问题表现:
    • GROUP BYCOUNT(DISTINCT ...)JOIN(当 Join Key 存在极高频值)时,某个或某几个 Key 对应的数据量极大,导致处理这些 Key 的 Task 运行时间远长于其他 Task。
  • HQL 语句优化:
    1. 预聚合/局部聚合: 对于大表 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) 可能因为某个城市用户量巨大导致的倾斜。第二阶段只是简单的累加,数据量小很多。
    2. 过滤高频值/单独处理: 识别出导致倾斜的少数高频 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);
      
      将“肿瘤”数据(高频值)剥离出来单独处理,防止它影响其他数据的正常聚合过程。
    3. 使用 BROADCAST JOIN 提示: 如果一张表很小,确保它被广播出去,避免 Shuffle Sort Merge Join。
      -- 告诉 Spark 尝试广播 small_table
      SELECT /*+ BROADCAST(s) */ ...
      FROM big_table b
      JOIN small_table s ON b.key = s.key;
      
      广播小表完全避免了大数据表的 Shuffle,是最高效的 Join 方式之一。但需确保小表真的足够小(能放进 Executor 内存)。
  • Spark 配置调整:
    • spark.sql.adaptive.enabled=truespark.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 语句优化:
    1. 读取前合并小文件 (Hive 表常用): 如果源表是 Hive 表,可以使用 ALTER TABLE ... CONCATENATE(仅适用于 RCFile/ORC)或运行合并小文件的 Hive/Spark 作业。Spark 读取时选择合并后的文件。
    2. 写入时控制文件数量:
      • 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 在动态分区写入时特别有用,可以控制每个动态分区内的文件数。
    3. 调整文件格式和压缩: 使用列式存储格式(Parquet, ORC)并启用合适的压缩(Snappy, Zstd)。虽然不直接减少文件数量,但能显著减小单个文件大小(即使小文件,压缩后也会更小)。
  • Spark 配置调整:
    • spark.sql.shuffle.partitions 这是控制写入文件数的核心配置! 这个值决定了 Shuffle 后(以及 repartition 后)的分区数。一个分区对应一个 Task,一个 Task 通常输出一个文件。根据最终期望的文件大小(例如 128MB - 1GB)和总数据量来设置这个值。
      spark-submit ... --conf spark.sql.shuffle.partitions=100 # 例如期望产生约 100 个文件
      
      **直接决定了 Shuffle 输出阶段的分区数(Task 数),从而直接影响生成的文件数。
    • 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
      

其他重要优化场景与手段

  1. 避免不必要的复杂操作:

    • 问题: 多层嵌套子查询、不必要的 DISTINCTORDER BY(除非最终输出需要)、过早的 COLLECT_SET/COLLECT_LIST(导致数据集中到 Driver)。
    • 优化: 简化逻辑,拆分成多个步骤的临时视图/表。只在必要时使用 DISTINCTORDER BY。慎用 COLLECT_* 聚合函数,考虑是否可以在 Executor 端完成更多处理。
      减少计算复杂度,避免过早的数据移动和收集。
  2. 谓词下推:

    • 问题: 过滤条件写在子查询外层或 JOIN 后,导致全表扫描后再过滤。
    • 优化: 确保过滤条件尽可能下推到靠近数据源的地方(写在子查询内部或 JOIN ON 条件中)。Spark 和 Parquet/ORC 通常会自动做谓词下推,但复杂的表达式或 UDF 可能阻止下推。
      让存储层(如 Parquet/ORC 的 row group / stripe)或扫描器尽早过滤掉无关数据,极大减少 I/O 和内存占用。
  3. 选择合适的 Join 策略:

    • 问题: Spark 默认选择 Sort Merge Join。如果小表没有被广播,或者大表 Join 大表存在倾斜,性能会很差。
    • 优化:
      • 确保小表被广播 (BROADCAST HINT 或 调大 autoBroadcastJoinThreshold)。
      • 对于大表 Join 大表且一个表可被过滤得很小,考虑先过滤再广播。
      • 在特定场景(如非等值 Join)可考虑 Shuffle Hash Join (需配置 spark.sql.join.preferSortMergeJoin=false 且 内存足够)。
      • 严重倾斜的大表 Join 可尝试 SMBSkewJoin (需 AQE 开启倾斜 Join)。
        正确的 Join 策略能最小化 Shuffle 代价或利用内存加速。
  4. 缓存 (Cache/Persist) 的合理使用:

    • 问题: 过度缓存或缓存不需要重用的数据,浪费内存;该缓存的没缓存,导致重复计算。
    • 优化: 仅对会被多次使用的中间数据集(尤其是在迭代算法或需要被多个后续 Action 引用的场景)进行缓存。选择合适的储级别(MEMORY_ONLY, MEMORY_AND_DISK_SER 等)。使用完后及时 unpersist()
      避免重复计算节省时间;但错误缓存会挤占宝贵内存,可能引发 GC 或 OOM。
  5. 资源与并行度配置:

    • 问题: 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 的关键。
  6. 充分利用文件格式特性:

    • 优化: 优先使用 Parquet/ORC 等列式存储格式。它们提供:
      • 谓词下推: 只读取需要的列和行组/条带。
      • 列裁剪: 只读取 SQL 查询中涉及的列。
      • 高效的压缩和编码: 节省存储空间和 I/O。
      • 统计信息: 帮助优化器做更好的决策(如 Join 策略选择)。
    • 显著减少 I/O 量和内存占用,加速查询。
  7. 监控与分析:

    • 工具: 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 的关键。

总结与最佳实践

  1. 理解原理: 深入理解 Spark 执行引擎(DAG Scheduler, Task Scheduler, Shuffle, Tungsten)、Catalyst 优化器和 Adaptive Query Execution 的工作原理。
  2. 定位瓶颈: 永远是第一步! 使用 Web UI, EXPLAIN, 日志等工具找出作业的瓶颈所在(CPU, I/O, 网络, Shuffle, 倾斜,GC)。
  3. 优化顺序:
    • 首先优化 HQL / DataFrame 代码: 这是最根本的,效果往往最显著(如过滤、减少 Shuffle、避免复杂操作)。
    • 利用文件格式特性。
    • 然后调整关键配置: 特别是 spark.sql.shuffle.partitions, spark.sql.adaptive.*, spark.executor.* 资源相关参数。
    • 考虑缓存策略。
  4. 开启 AQE: spark.sql.adaptive.enabled=true 在大多数现代 Spark 版本 (>= 3.0) 中应该是默认或强烈推荐开启的,它能解决很多运行时才能发现的优化问题(特别是数据倾斜和 Shuffle 分区大小不均)。
  5. 增量式优化: 每次修改一个地方,观察效果,逐步迭代优化。避免一次性修改太多参数难以定位效果。
  6. 测试验证: 使用代表性数据进行测试,对比优化前后的执行时间、资源消耗和结果正确性。

通过结合 HQL 语句的语义优化和 Spark 配置的针对性调整,并深刻理解每种优化手段背后的原因(减少数据量、减少 Shuffle、解决倾斜、优化并行度、避免小文件),你就能有效地解决 Spark SQL 作业中的各种性能瓶颈。记住,没有放之四海而皆准的最优配置,最佳实践需要根据你的特定数据、集群环境和业务逻辑来探索和确定。

posted @ 2025-06-04 16:02  zz_bigdata  阅读(436)  评论(0)    收藏  举报