Spark源码解读之Shuffle计算引擎剖析

Shuffle是Spark计算引擎的关键所在,是必须经历的一个阶段,在前面的文章中,我们剖析了Shuffle的原理以及Map阶段结果的输出与Reduce阶段结果如何读取。该篇文章是对前面两篇文章
【Spark源码解读之Shuffle原理剖析与源码分析】
【Spark存储机制源码剖析】
细节的深入探究。

了解Shuffle原理的读者都知道,整个Shuffle过程被划分为map和reduce阶段,在Spark Shuffle的过程中,会在map阶段将任务封装为ShuffleMapTask计算结果并且最终写入bucket中,由reduce阶段的ResultTask读取中间计算结果,在早期的版本中,shuffle过程有以下几个问题:

  1. 每个map任务会被每一个reduce任务生成一个bucket,当map与reduce数量增多的时候会生成大量的bucket,大量的磁盘IO影响系统的性能。
  2. map任务会首先写入内存,然后写入磁盘,这样容易导致内存溢出,发生OOM。

因此,在后期的版本中,对以上问题进行了优化,那么如何优化呢?以及其原理是怎样的呢?这就是该篇文章的目的。

在阅读后续的内容之前,这里先提出几个问题,我们可以带着问题去思考以及阅读源码,理解更加深刻:

map端如何优化大量的中间结果文件导致频繁的磁盘IO?
map端什么时候决定将数据spill到磁盘?
map端是否需要聚合数据,如何聚合数据?
map任务如何输出?
reduce端如何读取map阶段计算的中间结果?

对于第一个问题,在前面 的文章中介绍过,spark使用了consolidation机制,将map任务的多个partition输出的bucket合并为一个,这样就解决了bucket数量很多,导致数据刷新到磁盘的时候产生大量的磁盘IO。

除此之外,spark在后期的版本中还做了很多优化,这也是本篇文章重点介绍知识点:

  • map任务逐条输出计算结果,而不是一次性输出到内存中,并使用AppendOnlyMap缓存并且对中间结果进行聚合计算,减少中间结果占用的内存大小。
  • map任务的输出使用了SizeTrackingAppendOnlyMapSizeTrackingPairBuffer进行缓存,当大小myMemoryThreshold的大小的时候,会将数据写入磁盘,防止内存溢出。
  • reduce端对map端输出的中间结果不是一次性读入内存,而是一条条读取,在内存中聚合以及排序,减少了结果的内存占有空间。
  • reduce任务将要拉取的block按照BlockManager地址划分,然后将同一BlockManager地址中的Block累积为少量网络请求,减少网络IO。

我们了解到map阶段如何开始计算的入口为ShuffleMapTask.runTask(不了解可以参考之前的文章),然后创建了ShuffleWriter,并且调用了其write方法,它是一个接口,其实现类主要有SortShuffleWriter以及HashShuffleWriter。因此进入到SortShuffleWriter.writer()方法中,源码如下:

 /** Write a bunch of records to this task's output */
  override def write(records: Iterator[_ <: Product2[K, V]]): Unit = {
    if (dep.mapSideCombine) {
      require(dep.aggregator.isDefined, "Map-side combine without Aggregator specified!")
      sorter = new ExternalSorter[K, V, C](
        dep.aggregator, Some(dep.partitioner), dep.keyOrdering, dep.serializer)
      sorter.insertAll(records)
    } else {
      // In this case we pass neither an aggregator nor an ordering to the sorter, because we don't
      // care whether the keys get sorted in each partition; that will be done on the reduce side
      // if the operation being run is sortByKey.
      sorter = new ExternalSorter[K, V, V](
        None, Some(dep.partitioner), None, dep.serializer)
      sorter.insertAll(records)
    }

    // Don't bother including the time to open the merged output file in the shuffle write time,
    // because it just opens a single file, so is typically too fast to measure accurately
    // (see SPARK-3570).
    val outputFile = shuffleBlockManager.getDataFile(dep.shuffleId, mapId)
    val blockId = shuffleBlockManager.consolidateId(dep.shuffleId, mapId)
    val partitionLengths = sorter.writePartitionedFile(blockId, context, outputFile)
    shuffleBlockManager.writeIndexFile(dep.shuffleId, mapId, partitionLengths)

    mapStatus = MapStatus(blockManager.shuffleServerId, partitionLengths)
  }

从上述源码中可以看到,其调用了ExternalSorter.insertAll()方法,进入到方法中:

def insertAll(records: Iterator[_ <: Product2[K, V]]): Unit = {
    // TODO: stop combining if we find that the reduction factor isn't high
    val shouldCombine = aggregator.isDefined
    
    //对map端的结果写入AppendOnlyMap中并且进行聚合排序
    if (shouldCombine) {
      // Combine values in-memory first using our AppendOnlyMap
      val mergeValue = aggregator.get.mergeValue
      val createCombiner = aggregator.get.createCombiner
      var kv: Product2[K, V] = null
      val update = (hadValue: Boolean, oldValue: C) => {
        if (hadValue) mergeValue(oldValue, kv._2) else createCombiner(kv._2)
      }
      while (records.hasNext) {
        addElementsRead()
        kv = records.next()
        map.changeValue((getPartition(kv._1), kv._1), update)
        maybeSpillCollection(usingMap = true)
      }
    } else if (bypassMergeSort) {     //如果bypassMergeSort为true,那么不缓存,直接spill到磁盘文件
      // SPARK-4479: Also bypass buffering if merge sort is bypassed to avoid defensive copies
      if (records.hasNext) {
        spillToPartitionFiles(records.map { kv =>
          ((getPartition(kv._1), kv._1), kv._2.asInstanceOf[C])
        })
      }
    } else {      //如果不是以上两种情况,将map计算结果写入SizePairBuffer中,并且执行合并和排序
      // Stick values into our buffer
      while (records.hasNext) {
        addElementsRead()
        val kv = records.next()
        buffer.insert((getPartition(kv._1), kv._1), kv._2.asInstanceOf[C])
        maybeSpillCollection(usingMap = false)
      }
    }
  }

在这里,我们可以看到map端计算结果的输出有三种输出方式:

  1. map端计算结果写AppendOnlyMap中,并且进行聚合排序等操作(这种方式spark作业中必须定义了聚合函数以及排序函数)。
  2. bypassMergeSort为true的时候map端结果不缓存,也不进行聚合和排序,直接spill到磁盘。
  3. 当spark作业中没有定义聚合函数的时候,那么shouldCombine为false,则将结果输出到buffer缓存中。

在这里需要提到bypassMergeThreshold,该参数定义了map端各个partition的数据传递到reduce端进行合并(merge)的阀值,当大小小于该参数的时候,就会直接写入存储文件中,到reduce端统一聚合。

bypassMergeSort参数标记是否需要传递到reduce端再做合并排序操作,由上面可以知道,当partition大小小于bypassMergeThreshold时,该参数就会变为true,即到reduce端再做合并操作。

在深入这三种处理方式之前,有必要了解一下SizeTrackingAppendOnlyMap以及SizeTrackingPairBuffer

在spark源码的org.apache.spark.util.collection路径下,放置着spark内部封装的一系列集合类,便于spark内部使用:
Spark源码解读之Shuffle计算引擎剖析_第1张图片

SizeTrackingAppendOnlyMap它的父类为AppendOnlyMap,类似于HashMap的数据结构,它也定义了一系列的内部变量,比如负载因子、初始容量等等。
Spark源码解读之Shuffle计算引擎剖析_第2张图片
在其内部也定义了如何扩容,哈希等方法,可以类比HashMap,有兴趣的读者可以查看其源码。

在这里我们简单看一下其扩容算法:

 /** Increase table size by 1, rehashing if necessary */
  private def incrementSize() {
    curSize += 1
    if (curSize > growThreshold) {
      growTable()
    }
  }
  
 /** Double the table's size and re-hash everything */
  protected def growTable() {
    val newCapacity = capacity * 2
    if (newCapacity >= (1 << 30)) {
      // We can't make the table this big because we want an array of 2x
      // that size for our data, but array sizes are at most Int.MaxValue
      throw new Exception("Can't make capacity bigger than 2^29 elements")
    }
    val newData = new Array[AnyRef](2 * newCapacity)
    val newMask = newCapacity - 1
    // Insert all our old values into the new array. Note that because our old keys are
    // unique, there's no need to check for equality here when we insert.
    var oldPos = 0
    while (oldPos < capacity) {
      if (!data(2 * oldPos).eq(null)) {
        val key = data(2 * oldPos)
        val value = data(2 * oldPos + 1)
        var newPos = rehash(key.hashCode) & newMask
        var i = 1
        var keepGoing = true
        while (keepGoing) {
          val curKey = newData(2 * newPos)
          if (curKey.eq(null)) {
            newData(2 * newPos) = key
            newData(2 * newPos + 1) = value
            keepGoing = false
          } else {
            val delta = i
            newPos = (newPos + delta) & newMask
            i += 1
          }
        }
      }
      oldPos += 1
    }
    data = newData
    capacity = newCapacity
    mask = newMask
    growThreshold = (LOAD_FACTOR * newCapacity).toInt
  }

可以看到curSize > growThreshold时,将调用growTable方法将容量扩大一倍,然后将旧数组中的数据拷贝到新数组中。那么问题来了,对于spark这类内存计算框架,在大数据场景下,当数据量很大的时候,是否会无限制的扩容呢?这样做不会撑爆内存?答案是否定的。

实际上,spark使用了采样计算的方式,会预测估算未来AppendOnlyMap的大小,那么如何采样计算呢?SizeTrackingAppendOnlyMap实现了特质SizeTracker,在这个类中实现了抽样算法,源码如下:

/**
   * Callback to be invoked after every update.
   */
  protected def afterUpdate(): Unit = {
    numUpdates += 1
    //如果达到了nextSampleNum采样间隔
    if (nextSampleNum == numUpdates) {
      takeSample()
    }
  }

  /**
   * Take a new sample of the current collection's size.
   */
  private def takeSample(): Unit = {
    samples.enqueue(Sample(SizeEstimator.estimate(this), numUpdates))
    // Only use the last two samples to extrapolate
    //如果当前采样数量大于2时,则将sample执行一次出队操作,保证样本总数等于2
    if (samples.size > 2) {
      samples.dequeue()
    }
    //计算bytesPerUpdate,计算公式如下:
    //  (本次采集大小-上次采样大小)/(本次采集编号-上次采样编号)
    val bytesDelta = samples.toList.reverse match {
      case latest :: previous :: tail =>
        (latest.size - previous.size).toDouble / (latest.numUpdates - previous.numUpdates)
      // If fewer than 2 samples, assume no change
      case _ => 0
    }
    bytesPerUpdate = math.max(0, bytesDelta)
    //计算下次采样间隔
    nextSampleNum = math.ceil(numUpdates * SAMPLE_GROWTH_RATE).toLong
  }

SizeTrackingPairBuffer实际上是一个初始容量为64的Buffer,它也定义了一系列内部变量以及操作方法,有兴趣的读者可深入了解,这里不做过多介绍:
Spark源码解读之Shuffle计算引擎剖析_第3张图片
map端计算结果缓存聚合

这种情况下,spark作业中必须定义聚合器函数,这样就可以在map端对计算结果进行聚合和排序操作,减少了网络间大量的数据传输以及内存空间的占用。对于中间输出数据不是一次性读取,而是逐条放入AppendOnlyMap的缓存进行溢出判断,当超出myMemoryThreshold的大小时,将数据写入磁盘,防止内存溢出。

map端简单缓存,排序分组,在reduce端合并组合

这种情况是在spark作业中没有定义聚合器函数,这种方式会使用指定的排序函数对数据按照partition或者key进行排序,最后按照partition顺序合并写入同一文件,它会将多个bucket合并到一个文件,这样减少map输出的文件数量,节省了磁盘IO,提升了性能,对SizeTrackingPairBuffer的缓存进行溢出判断,当超出myMemoryThreshold大小时,将数据写入磁盘,防止内存溢出。

map端溢出分区文件,在reduce端合并组合

如果bypassMergeSort标记为true,那么就会将结果传递到reduce端再做合并与排序,这种情况不使用缓存,而是将数据按照partition写入不同的文件,最后按照partition顺序合并写入同一个文件。这种同样会将多个bucket合并到同一个文件,通过减少map输出的文件数量,节省了磁盘IO,最终提升了性能。

在了解了map阶段处理过程后,我们看看reduce端是如何处理的,实际上,通过阅读之前的文章,我们就可以了解到在reduce端会使用BlockStoreShuffleFetcher.fetch()方法去Driver的MapOutputTracker中的获取MapStatus的信息,然后去相应的BlockManager中获取相应的中间结果,最终进行计算。

那么在reduce端spark又做了哪些优化呢?

实际上,在reduce端,将中间保存在ShuffleBlockFetcherIterator中,该类中,定义了一系列成员变量,我们需要理解它们的含义,这里简单罗列一下,具体源码读者可以详细深入阅读:

  • targetRequestSize:统计Block总数。
  • totalBlocks:统计Block总数
  • numBlocksToFetch:一共需要获取的Block数量。
  • localBlocks:ArrayBuffer[BlockId]:缓存可以从本地获取的Block的blockId。
  • remoteBlocks:HashSet[BlockId]:缓存需要远程获取的Block的blockId。
  • maxBytesInFlight:单次请求数据的最大字节数。

在reduce端,为了优化程序,充分利用集群的资源,reduce端每一批请求的字节总数不能超过maxBytesInFlight,而且每个请求的字节数不能超过maxBytesInFlight的五分之一,这样做提高了请求的并发度,允许5个请求分别从5个节点获取数据,最大限度利用了资源。可以通过spark.reducer.maxMbInFlight参数来控制该大小。

以上就是今天文章介绍的内容,通过探究shuffle的计算细节,我们了解学到了以下知识点:

map端处理计算结果的几种方式。
map端进行数据的聚合,降低了网络IO,提升了系统性能。
map端以及reduce通过逐条读取数据,避免了大量数据撑爆内存。
发送请求时分批发送,限制分批发送的大小,并行发送请求以及将多个请求数据下的请求合并等优化点。

谢谢阅读,如有问题欢迎留言讨论!!!

欢迎加入大数据学习交流群:731423890

你可能感兴趣的:(Spark,Spark源码剖析与调优)