Spark AQE 导致的 Driver OOM问题

背景

最近在做Spark 3.1 升级 Spark 3.5的过程中,遇到了一批SQL在运行的过程中 Driver OOM的情况,排查到是AQE开启导致的问题,再次分析记录一下,顺便了解一下Spark中指标的事件处理情况

结论

SQLAppStatusListener 类在内存中存放着 一个整个SQL查询链的所有stage以及stage的指标信息,在AQE中 一个job会被拆分成很多job,甚至几百上千的job,这个时候 stageMetrics的数据就会成百上倍的被存储在内存中,从而导致Driver OOM
解决方法:

  1. 关闭AQE spark.sql.adaptive.enabled false
  2. 合并对应的PR-SPARK-45439

分析

背景知识:对于一个完整链接的sql语句来说(比如说从 读取数据源,到 数据处理操作,再到插入hive表),这可以称其为一个最小的SQL执行单元,这最小的数据执行单元在Spark内部是可以跟踪的,也就是用executionId来进行跟踪的。
对于一个sql,举例来说 :

insert into  TableA select * from TableB;

在生成 物理计划的过程中会调用 QueryExecution.assertOptimized 方法,该方法会触发eagerlyExecuteCommands调用,最终会到SQLExecution.withNewExecutionId方法:

  def assertOptimized(): Unit = optimizedPlan

  ...
  lazy val commandExecuted: LogicalPlan = mode match {
    case CommandExecutionMode.NON_ROOT => analyzed.mapChildren(eagerlyExecuteCommands)
    case CommandExecutionMode.ALL => eagerlyExecuteCommands(analyzed)
    case CommandExecutionMode.SKIP => analyzed
  }
  ...
  lazy val optimizedPlan: LogicalPlan = {
  // We need to materialize the commandExecuted here because optimizedPlan is also tracked under
  // the optimizing phase
  assertCommandExecuted()
  executePhase(QueryPlanningTracker.OPTIMIZATION) {
    // clone the plan to avoid sharing the plan instance between different stages like analyzing,
    // optimizing and planning.
    val plan =
      sparkSession.sessionState.optimizer.executeAndTrack(withCachedData.clone(), tracker)
    // We do not want optimized plans to be re-analyzed as literals that have been constant
    // folded and such can cause issues during analysis. While `clone` should maintain the
    // `analyzed` state of the LogicalPlan, we set the plan as analyzed here as well out of
    // paranoia.
    plan.setAnalyzed()
    plan
  }
  
  def assertCommandExecuted(): Unit = commandExecuted
  ...
  private def eagerlyExecuteCommands(p: LogicalPlan) = p transformDown {
    case c: Command =>
      // Since Command execution will eagerly take place here,
      // and in most cases be the bulk of time and effort,
      // with the rest of processing of the root plan being just outputting command results,
      // for eagerly executed commands we mark this place as beginning of execution.
      tracker.setReadyForExecution()
      val qe = sparkSession.sessionState.executePlan(c, CommandExecutionMode.NON_ROOT)
      val name = commandExecutionName(c)
      val result = QueryExecution.withInternalError(s"Eagerly executed $name failed.") {
        SQLExecution.withNewExecutionId(qe, Some(name)) {
          qe.executedPlan.executeCollect()
        }
      }  

SQLExecution.withNewExecutionId主要的作用是设置当前计划的所属的executionId:

    val executionId = SQLExecution.nextExecutionId
    sc.setLocalProperty(EXECUTION_ID_KEY, executionId.toString)

EXECUTION_ID_KEY的值会在JobStart的时候传递给Event,以便记录跟踪整个执行过程中的指标信息。
同时我们在方法中eagerlyExecuteCommands看到qe.executedPlan.executeCollect()这是具体的执行方法,针对于insert into 操作来说,物理计划就是
InsertIntoHadoopFsRelationCommand,这里的run方法最终会流转到DAGScheduler.submitJob方法:

    eventProcessLoop.post(JobSubmitted(
      jobId, rdd, func2, partitions.toArray, callSite, waiter,
      JobArtifactSet.getActiveOrDefault(sc),
      Utils.cloneProperties(properties)))

最终会被DAGScheduler.handleJobSubmitted处理,其中会发送SparkListenerJobStart事件:

    listenerBus.post(
      SparkListenerJobStart(job.jobId, jobSubmissionTime, stageInfos,
        Utils.cloneProperties(properties)))

该事件会被SQLAppStatusListener捕获,从而转到onJobStart处理,这里有会涉及到指标信息的存储,这里我们截图出dump的内存占用情况:
在这里插入图片描述

可以看到 SQLAppStatusListener 的 LiveStageMetrics 占用很大,也就是 accumIdsToMetricType占用很大

那在AQE中是怎么回事呢?
我们知道再AQE中,任务会从source节点按照shuffle进行分割,从而形成单独的job,从而生成对应的shuffle指标,具体的分割以及执行代码在AdaptiveSparkPlanExec.getFinalPhysicalPlan中,如下:

      var result = createQueryStages(currentPhysicalPlan)
      val events = new LinkedBlockingQueue[StageMaterializationEvent]()
      val errors = new mutable.ArrayBuffer[Throwable]()
      var stagesToReplace = Seq.empty[QueryStageExec]
      while (!result.allChildStagesMaterialized) {
        currentPhysicalPlan = result.newPlan
        if (result.newStages.nonEmpty) {
          stagesToReplace = result.newStages ++ stagesToReplace
          executionId.foreach(onUpdatePlan(_, result.newStages.map(_.plan)))

          // SPARK-33933: we should submit tasks of broadcast stages first, to avoid waiting
          // for tasks to be scheduled and leading to broadcast timeout.
          // This partial fix only guarantees the start of materialization for BroadcastQueryStage
          // is prior to others, but because the submission of collect job for broadcasting is
          // running in another thread, the issue is not completely resolved.
          val reorderedNewStages = result.newStages
            .sortWith {
              case (_: BroadcastQueryStageExec, _: BroadcastQueryStageExec) => false
              case (_: BroadcastQueryStageExec, _) => true
              case _ => false
            }

          // Start materialization of all new stages and fail fast if any stages failed eagerly
          reorderedNewStages.foreach { stage =>
            try {
              stage.materialize().onComplete { res =>
                if (res.isSuccess) {
                  events.offer(StageSuccess(stage, res.get))
                } else {
                  events.offer(StageFailure(stage, res.failed.get))
                }
                // explicitly clean up the resources in this stage
                stage.cleanupResources()
              }(AdaptiveSparkPlanExec.executionContext)

这里就是得看stage.materialize()这个方法,这两个stage只有两类:BroadcastQueryStageExec 和 ShuffleQueryStageExec
这两个物理计划稍微分析一下如下:

  • BroadcastQueryStageExec
    数据流如下:
    broadcast.submitBroadcastJob
          ||
          \/
    promise.future
          ||
          \/
    relationFuture
          ||
          \/
    child.executeCollectIterator()
    
    
    其中 promise的设置在relationFuture方法中,而relationFuture 会被doPrepare调用,而submitBroadcastJob会调用executeQuery,从而调用doPrepare,executeCollectIterator()最终也会发送JobSubmitted事件,分析和上面的一样
  • ShuffleQueryStageExec
     shuffle.submitShuffleJob
          ||
          \/
     sparkContext.submitMapStage(shuffleDependency)
          ||
          \/
     dagScheduler.submitMapStage
    
    

submitMapStage会发送MapStageSubmitted事件:

    eventProcessLoop.post(MapStageSubmitted(
      jobId, dependency, callSite, waiter, JobArtifactSet.getActiveOrDefault(sc),
      Utils.cloneProperties(properties)))

最终会被DAGScheduler.handleMapStageSubmitted处理,其中会发送SparkListenerJobStart事件:

    listenerBus.post(
      SparkListenerJobStart(job.jobId, jobSubmissionTime, stageInfos,
        Utils.cloneProperties(properties)))

该事件会被SQLAppStatusListener捕获,从而转到onJobStart处理:

  private val liveExecutions = new ConcurrentHashMap[Long, LiveExecutionData]()
  private val stageMetrics = new ConcurrentHashMap[Int, LiveStageMetrics]()
   ...
 
  override def onJobStart(event: SparkListenerJobStart): Unit = {
    val executionIdString = event.properties.getProperty(SQLExecution.EXECUTION_ID_KEY)
    if (executionIdString == null) {
      // This is not a job created by SQL
      return
    }

    val executionId = executionIdString.toLong
    val jobId = event.jobId
    val exec = Option(liveExecutions.get(executionId))

该方法会获取事件中的executionId,在AQE中,同一个执行单元的executionId是一样的,所以stageMetrics内存占用会越来越大。
而这里指标的更新是在AdaptiveSparkPlanExec.onUpdatePlan等方法中。

这样整个事件的数据流以及问题的产生原因就应该很清楚了。

其他

为啥AQE以后多个Job还是共享一个executionId呢?因为原则上来说,如果没有开启AQE之前,一个SQL执行单元的是属于同一个Job的,开启了AQE之后,因为AQE的原因,一个Job被拆成了了多个Job,但是从逻辑上来说,还是属于同一个SQL处理单元的所以还是得归属到一次执行中。

<think>嗯,用户问的是关于在Spark中处理40亿数据量的优化方法。首先,我需要回忆一下处理大数据时的常见优化策略,并结合Spark的特性来思考。40亿条数据,这个量级确实很大,单机处理肯定不行,必须用分布式系统,而Spark作为常用的处理框架,优化点应该围绕资源管理、数据分区、缓存、算法选择等方面。 首先,资源调优是关键。用户需要合理配置executor的数量、内存和核心。比如,executor的内存不能太大,否则会导致GC时间过长,但也不能太小,否则容易OOM。可能要考虑堆外内存的使用,以及动态资源分配,这样可以根据任务需求调整资源,避免浪费。 然后是数据分区。40亿数据如果分区不当,会导致数据倾斜,某些task处理的数据量远大于其他,拖慢整个作业。可能需要调整分区数,比如默认是200,但数据量增大时可能需要增加到几千甚至更多,确保每个分区的数据量适中,比如每个分区128MB左右。同时,处理数据倾斜的方法,比如加盐、两阶段聚合,可能需要详细说明。 接着是持久化与缓存。对于需要多次使用的RDD或DataFrame,使用cache或persist可以避免重复计算。但要注意存储级别的选择,比如MEMORY_AND_DISK,当内存不足时溢写到磁盘。此外,检查点机制可以切断血缘关系,减少恢复时间。 代码优化方面,避免使用低效的算子,比如groupByKey,改用reduceByKey或aggregateByKey,因为后者会在map端进行combine,减少shuffle的数据量。同时,mapPartitions可能比map更高效,特别是在需要创建连接时,每个分区只创建一次连接。 广播变量和累加器的使用也很重要。大表join小表时,可以用广播变量把小表分发到各个节点,避免shuffle。累加器适合做全局统计,比如计数或求和,减少driver和executor之间的通信开销。 数据格式的选择,比如Parquet或ORC,列式存储有更好的压缩率和查询性能。同时,数据压缩可以减少IO和网络传输,比如使用snappy或lz4,但要注意压缩和解压的开销是否值得。 Spark SQL的优化,比如启用自适应查询执行(AQE),自动调整执行计划,动态合并shuffle分区,处理倾斜join。还有Catalyst优化器的利用,比如谓词下推和列剪枝,减少处理的数据量。 硬件层面的优化可能需要增加节点数量,使用SSD提升IO性能,网络升级到万兆。监控工具如Spark UI和Ganglia,帮助定位瓶颈,调整配置。 另外,数据倾斜的具体处理办法可能需要更详细的例子,比如在join时遇到倾斜key,可以拆分处理或者加随机前缀。还有shuffle的优化,调整缓冲区大小,压缩算法等。 最后,可能需要结合具体的业务场景,比如是ETL、机器学习还是实时处理,不同的场景优化重点可能不同。用户可能有实际案例中的痛点,比如某个stage特别慢,需要针对性解决。 总之,需要从资源配置、数据分区、代码优化、Shuffle管理、数据格式、硬件和监控等多个方面综合考虑,逐步调整和测试,找到最适合当前集群和作业的优化策略。</think>在 Spark 中处理 40 亿级别数据量时,需通过多维度优化策略提升性能。以下是分步骤的优化方案(附关键代码示例): 一、资源调优 ```bash # 提交任务时典型参数配置示例 spark-submit \ --executor-memory 16G \ # 每个Executor内存 --executor-cores 4 \ # 每个Executor核心数 --num-executors 50 \ # Executor总数 --conf spark.dynamicAllocation.enabled=true \ # 启用动态资源分配 --conf spark.memory.fraction=0.8 \ # JVM堆内存分配给Spark的比例 ``` 二、数据分区优化 ```scala // 调整分区数(假设原始数据400GB) val df = spark.read.parquet("s3://input/") .repartition(4000) // 按4000分区(每个分区约100MB) // 处理数据倾斜示例(加盐扩容) val skewedDF = df.withColumn("salt", (rand()*100).cast("int")) val aggregated = skewedDF.groupBy("key", "salt") .agg(sum("value").as("partial_sum")) .groupBy("key") .agg(sum("partial_sum").as("total_sum")) ``` 三、Shuffle优化 ```scala // 调整Shuffle参数 spark.conf.set("spark.sql.shuffle.partitions", "4000") // 默认200改为4000 spark.conf.set("spark.shuffle.file.buffer", "1MB") // Shuffle写缓冲区 spark.conf.set("spark.reducer.maxSizeInFlight", "128MB")// 读取缓冲区 ``` 四、执行计划优化 ```scala // 启用AQESpark 3.0+) 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) // 广播Join优化(小表<10MB时自动生效) val smallDF = spark.read.parquet("s3://small_table/") val bigDF = spark.read.parquet("s3://big_table/") bigDF.join(broadcast(smallDF), "key") // 强制广播 ``` 五、数据存储优化 ```scala // 写入优化(使用Z-Ordering) df.write .option("maxRecordsPerFile", 1000000) // 控制文件大小 .bucketBy(32, "key") // 分桶存储 .sortBy("timestamp") // 排序写入 .format("parquet") .save("s3://optimized-output/") ``` 六、代码优化技巧 ```scala // 使用高效算子替代低效操作 // 错误示例(全量shuffle) df.groupBy("key").agg(collect_list("value")) // 优化方案(预聚合+控制输出) import org.apache.spark.sql.expressions.Window val winSpec = Window.partitionBy("key").orderBy("timestamp") df.withColumn("rank", row_number().over(winSpec)) .filter($"rank" <= 1000) // 每个key保留前1000条 .groupBy("key").agg(collect_list("value")) ``` 七、监控与诊断 1. 查看Stage执行情况: ```bash # 通过Spark UI分析: # - 各Stage耗时 # - Shuffle读写量 # - Task数据倾斜情况(75th/median差异) ``` 2. 检查执行计划: ```scala df.explain("formatted") // 查看物理计划 ``` 典型优化效果对比表: | 优化项 | 优化前耗时 | 优化后耗时 | 资源消耗下降 | |----------------------|------------|------------|--------------| | 默认分区(200) | 6小时 | - | - | | 调整到4000分区 | - | 4.2小时 | 30% | | 开启AQE | 4.2小时 | 3.1小时 | 25% | | 广播Join优化 | 3.1小时 | 2.4小时 | 40% | | Z-Order存储 | 2.4小时 | 1.8小时 | 35% | 注意事项: 1. 每次修改配置后需进行小规模验证(可采样1%数据测试) 2. 使用Spark 3.x版本可获得更好的AQE优化效果 3. 对于超大数据量,建议采用增量处理模式: ```scala // 按时间范围分批处理 val dailyDF = spark.read.parquet("s3://data/") .filter($"event_date".between(startDate, endDate)) ``` 遇到数据倾斜时的应急方案: ```scala // 极端倾斜处理(分离倾斜key) val skewKeys = Seq("key1", "key2") // 已知倾斜key val commonDF = df.filter(!$"key".isin(skewKeys:_*)) val skewDF = df.filter($"key".isin(skewKeys:_*)) // 分别处理 val resultCommon = commonDF.groupBy("key").agg(...) val resultSkew = skewDF.repartition(100, $"key") // 单独扩大分区 .groupBy("key").agg(...) // 合并结果 resultCommon.union(resultSkew) ``` 这些优化策略需要根据实际集群配置和数据特征进行调整,建议通过以下步骤实施: 1. 使用Spark UI识别瓶颈阶段 2. 针对性调整相关参数(每次只改一个变量) 3. 记录基准测试结果 4. 对ETL流水线进行分阶段优化 5. 最终实施组合优化方案
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值