Spark系列--shuffle

668 阅读22分钟

Shuffle简介

有些运算需要将各节点上的同一类数据汇集到某一节点进行计算,把这些分布在不同节点的数据按照一定的规则汇集到一起的过程称为 Shuffle。 shuffle是spark中数据重分发的一种机制,以便于在跨分区进行数据的分组。这期间会有大量的网络I/O,磁盘I/O和数据的序列化。这使得shuffle操作十分地复杂和昂贵。 image.png

和MapReduce一样,spark中的shuffle也是各个基础算子的核心阶段,shuffle的性能一定程度上决定了一个复杂作业最终的性能,并且当数据量十分大时,shuffle的稳定性会决定当前作业能否稳定运行

这是一个spark作业的Dag图

将这个作业切换成shuffle过程,如上图所示,map阶段伴随着当前stage的shuffleMaptask进行shuffleWrite,将数据存储在底层的BlockManager上,将shuffle中间数据的元数据同步给dfiver的MapOutTrack,下一个stage通过元数据进行ShuffleRead拉取上个stage的shuffle数据

MapReduce-Shuffle

在经典的MapReduce框架中,有三个阶段,Map-> Shuffle -> Reduce,每个map阶段和reduce阶段中间必然有shuffle阶段,在shuffle阶段中,数据会读写磁盘/网络传输

Spark-shuffle

spark-shuffle组成

在 Spark 中,负责 shuffle 过程的执行、计算和处理的组件主要就是 ShuffleManager,在shuffle 过程中,前一个stage 的 ShuffleMapTask 进行 shuffle write, 把数据存储在 blockManager 上面, 并且把数据位置元信息上报到 driver 的 mapOutTrack 组件中, 下一个 stage 根据数据位置元信息, 进行 shuffle read, 拉取上个stage 的输出数据。

ShuffleManager负责 shuffle 过程的执行、计算、处理的组件。默认的ShuffleManager为 SortShuffleManager(也是现在spark中唯一的shuffle manager)。通过它来注册shuffle的操作函数,获取writer和reader等。
MapOutPutTrackerMapOutputTracker 用于跟踪Map阶段任务的输出状态,此状态便于Reduce阶段任务获取地址及中间结果。
ShuffleWriter在mapper任务中把数据记录到BlockManager中。这是一个抽象类,实现该抽象类的有:SortShuffleWriter,UnsafeShuffleWriter,BypassMergeSortShuffleWriter三个。
ShuffleReader在reduce任务中去获取来自多个mapper任务的合并记录数据。实现该接口的类只有一个:BlockStoreShuffleReader。

Hash Shuffle v1.0

相对于上面的MapReduce Shuffle,spark对shuffle性能做了改进,首先,将MapReduce在Reduce阶段的Merge sort去掉,即不在ShuffleRead阶段做Merge和sort,如果需要合并的操作的话,则会使用聚合(agggregator),即用了一个 HashMap (实际上是一个 AppendOnlyMap)来将数据进行合并。

这种情况下,ShuffleWrite的task为每个ShuffleRead task都会创建一个文件,于是整个shuffle阶段会产生M*R个中间文件,会产生两个问题:

  1. 生成大量中间文件,频繁进行磁盘IO和文件句柄创建/关闭

  2. 在ShuffleRead阶段进行聚合,将数据都放入一个HashMap中进行合并,容易OOM

Hash Shuffle v2.0

基于1.0的缺点,2.0进行改进,设计了文件组(File Consolidation 机制)

改进后:每个Executor的task会根据下游分区生成N个文件,即将所有的 shufflewrite Task 相同的分区文件合并,这样每个 Executor 上最多只生成 N 个分区文件,减少了文件数,但是当下游stage分区数N很大时,同样,如果一个executor上有K个core,还是会有K*N个文件,同样会出现上述问题

Sort Shuffle V1.0

于是spark在某个时间点又绕回了MapReduce的shuffle理念,借鉴了其中的一部分,在shuffle中加入了merge sort

  1. 每个task不会单独创建文件,按照shuffleWrite阶段的并发N创建文件数,后面所有的task都写入这N个文件中,并在这个过程中对同一个文件的数据按照partitionId和key进行排序
  2. 写文件的同时会生成另一个index索引文件,标记每个partition_id所在位置
  3. 将index文件和数据文件元数据发送给drvier

以上过程会产生2*N(一份数据+一份索引,N是shufflewrite并发度),下游stage读取文件,只需要按index文件读取对应的partation所处文件即可

这个改进同时解决了shufflewrite和shuffleread产生和读取大量文件的问题,但是也带来了map端的sort问题,有数据sort,必然伴随着数据频繁的序列化和随之产生的GC,这十分影响作业性能

Tungsten-Sort Based Shuffle / Unsafe Shuffle

在sort shuffle中,通过引入merge-sort,解决了文件数量和OOM问题,但是带来了比较严重的性能问题,尤其是数据序列化和JVM的GC开销,于是spark开始尝试直接在堆外,对二进制数据进行排序,它提供 cache-efficient sorter,使用一个 8 bytes 的指针,把排序转化成了一个指针数组的排序,极大的优化了排序性能

但是这个做法有限制:

  1. Shuffle 阶段不能有 aggregate 操作( 类似reduceByKey 这类有 aggregate 操作的算子是不能使用 Tungsten-Sort Based Shuffle,它会退化采用 Sort Shuffle)
  2. 数不能超过一定大小(2^24-1,这是可编码的最大 Parition Id)

spoddutur.github.io/spark-notes…

最终状态

从 Spark-1.6.0 开始,把 Sort Shuffle 和 Tungsten-Sort Based Shuffle 全部统一到 Sort Shuffle 中,如果检测到满足 Tungsten-Sort Based Shuffle 条件会自动采用 Tungsten-Sort Based Shuffle,否则采用 Sort Shuffle。从Spark-2.0.0开始,Spark 把 Hash Shuffle 移除,可以说目前 Spark-2.0 中只有一种 Shuffle,即为 Sort Shuffle

shuffleWrite

spark中有三种shuffleWrite的实现:BypassMergeSortShuffleWriter, SortShuffleWriter 和 UnsafeShuffleWriter

使用哪种 writer 的判断依据: 是否开启 mapSideCombine && 分区数量是否小于spark.shuffle.sort.bypassMerge;因为有些算子会在 map 端先进行一次 combine, 减少传输数据,并且 BypassMergeSortShuffleWriter 会临时输出Reducer个(分区数目)小文件,所以分区数必须要小于一个阀值,默认是小于200。

Shuffle Writer方法需要满足的条件
BypassMergeSortShuffleWriter(1) shuffle read 端(reduce)的 task 数量小于 spark.shuffle.sort.bypassMergeThreshold 参数值(默认为200)的时候;(2) 算子不会触发mapSideConbine条件(即没有使用会在map端进行聚合的算子,包括combineByKey,combineByKeyWithClassTag和reduceByKey)
UnsafeShuffleWriter(1) 序列化工具类支持对象的重定位(2) 不需要在map端进行聚合操作(同上)(3) 分区数不能大于2^24
SortShuffleWriter默认的writer方式(普通模式)

BypassMergeSortShuffleWriter

BypassMergeSortShuffleWriter和Hash Shuffle中的HashShuffleWriter实现基本一致, 唯一的区别在于map端的多个输出文件会被汇总为一个文件。 所有分区的数据会合并为同一个文件,会生成一个索引文件,是为了索引到每个分区的起始地址,可以随机 access 某个partition的所有数据。

在map端每个 task 会将数据的 key 进行 hash,然后将相同 hash 的 key 所对应的数据写入到同一个内存缓冲区,缓冲写满后会溢写到磁盘文件(不需要先排序)。

给每个分区分配一个临时文件,对每个 record的key 使用分区器(模式是hash,如果用户自定义就使用自定义的分区器)找到对应分区的输出文件句柄,直接写入文件,没有在内存中使用 buffer。 最后copyStream方法把所有的临时分区文件拷贝到最终的输出文件中,并且记录每个分区的文件起始写入位置,把这些位置数据写入索引文件中。

这种方式不宜有太多分区,因为过程中会并发打开所有分区对应的临时文件,会对文件系统造成很大的压力。

问题:

  • 该过程的磁盘写机制其实跟未经优化的HashShuffleManager是一样的,也会创建很多的临时文件
  • 需要要同时打开多个文件,如果下游stage分区数太多,map端会同时打开多个文件,产生多个IO,消耗的资源成本很高。(因为需要根据分区号将数据追加到属于不同分区的文件中去)
  • 当reduce端的 partition数量很大时,这个shuffle writer是低效率的,因为它会同时对每个partition都打开一个serializer和文件流,会对文件系统造成很大的压力。

对比:

  • 磁盘写机制不同,普通模式是将数据先写入 Map(准确的说是类似map的结构) 或者 Array 这样的内存数据结构中,而该模式是将记录直接写出到对应的内存区域。

  • 不需要进行排序,所以当reduce端分区较少时有更好的性能。

SortShuffleWriter

大数据开发面试题:如何在有限内存(1M)的情况下,对100亿数据进行排序:堆排序+merge-sort思想

假设我们的1M内存能装进1亿条数据,每次都对这 1亿条数据进行排序,排好序后输出到磁盘,总共输出100个文件。然后每个文件(有序的)都取一部分头部数据最为一个 buffer, 并且把这 100个 buffer放在一个堆里面,进行堆排序,比较方式就是对所有堆元素(buffer)的head元素进行比较大小, 然后不断的把每个堆顶的 buffer 的head 元素 pop 出来输出到最终文件中, 然后继续堆排序,继续输出。如果哪个buffer 空了,就去对应的文件中继续补充一部分数据。最终就得到一个全局有序的大文件。

这个思想就对应SortShuffleWriter中的排序:

  • 使用 PartitionedAppendOnlyMap (处理需要aggregation的算子数据)或者 PartitionedPairBuffer (非aggregation算子数据)在内存中进行排序, 排序的 K ey是(partitionId, hash(key))元组。
  • 如果超过内存限制,就spill 到文件中,这个文件中元素是按照 partitionId+hash(key)进行排序的
  • 如果需要输出全局有序的文件的时候,就需要对之前所有的输出文件 和 当前内存中的数据结构中的数据进行 merge sort, 进行全局排序,排序过程类似上面的堆排序
数据结构名称使用时机结构描述与功能实现
PartitionedAppendOnlyMap当使用聚合类shuffle算子时(比如reduceByKey)是基于Array实现的HashMap结构,并在此基础上实现的聚合,使用线性探查法处理Hash冲突。当根据key值插入数据,如果对应位置有值且等于原先key,直接进行聚合操作,更新数据。
PartitionedPairBuffer未使用聚合类shuffle类算子时(比如join)Array结构,直接将键值对依次写入到数组之中,不支持聚合。
  1. SortShuffleWriter在首先创建ExternalSorter(如果mapSideCombine为true,会传入aggregator和keyOrdering;否则aggregator和keyOrdering均为None),其包含两个数据结构,即PartitionedAppendOnlyMap和PartitionedPairBuffer。

    1. /** Write a bunch of records to this task's output */
      override def write(records: Iterator[Product2[K, V]]): Unit = {
        sorter = if (dep.mapSideCombine) {
          new ExternalSorter[K, V, C](
            context, dep.aggregator, Some(dep.partitioner), dep.keyOrdering, dep.serializer)
        } 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.
          new ExternalSorter[K, V, V](
            context, aggregator = None, Some(dep.partitioner), ordering = 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).
       //这里的mapOutputWriter是ShuffleMapOutputWriter的子类:LocalDiskShuffleMapOutputWriter
        val mapOutputWriter = shuffleExecutorComponents.createMapOutputWriter(
          dep.shuffleId, mapId, dep.partitioner.numPartitions)
        sorter.writePartitionedMapOutput(dep.shuffleId, mapId, mapOutputWriter)
        partitionLengths = mapOutputWriter.commitAllPartitions(sorter.getChecksums).getPartitionLengths
        mapStatus = MapStatus(blockManager.shuffleServerId, partitionLengths, mapId)
      }
      
    2. SortShuffleWriter根据shouldCombine (aggregator.isDefined,即有无定义aggregator操作)选择使用Map还是Buffer接收数据。
    3. PartitionedAppendOnlyMap可用于combine操作,可以边插入进行combine操作。

写内存数据结构的同时会判断数据是否要写磁盘,判断逻辑:

currentMemory=estimatedSize = map.estimateSize() 采样预估数

/**
 * Spills the current in-memory collection to disk if needed. Attempts to acquire more
 * memory before spilling.
 *
 * @param collection collection to spill to disk
 * @param currentMemory estimated size of the collection in bytes
 * @return true if `collection` was spilled to disk; false otherwise
 */
protected def maybeSpill(collection: C, currentMemory: Long): Boolean = {
  var shouldSpill = false
  if (elementsRead % 32 == 0 && currentMemory >= myMemoryThreshold) {
    // Claim up to double our current memory from the shuffle memory pool
    val amountToRequest = 2 * currentMemory - myMemoryThreshold
    val granted = acquireMemory(amountToRequest)
    myMemoryThreshold += granted
    // If we were granted too little memory to grow further (either tryToAcquire returned 0,
    // or we already had more memory than myMemoryThreshold), spill the current collection
    shouldSpill = currentMemory >= myMemoryThreshold
  }
  shouldSpill = shouldSpill || _elementsRead > numElementsForceSpillThreshold
  // Actually spill
  if (shouldSpill) {
    _spillCount += 1
    logSpillage(currentMemory)
    spill(collection)
    _elementsRead = 0
    _memoryBytesSpilled += currentMemory
    releaseMemory()
  }
  shouldSpill
}

如果每放一条记录,做一次内存的检查,看PartitionedAppendOnlyMap 到底占用了多少内存。我们假设检查一次内存1ms, 1kw 的数据,检查所需时间累积就会很长,所以 estimateSize其实是使用采样算法来做的。同时,我们也不希望mayBeSpill太耗时,所以 maybeSpill 方法里就设置了很多限制,减少耗时。

首先会判定要不要执行内部逻辑: elementsRead % 32 == 0 && currentMemory >= myMemoryThreshold

每隔32次会进行一次检查,并且要当前PartitionedAppendOnlyMap currentMemory > myMemoryThreshold 才会进一步判定是不是要spill,其中myMemoryThreshold计算方法:

// Initial threshold for the size of a collection before we start tracking its memory usage
// For testing only
private[this] val initialMemoryThreshold: Long =
  SparkEnv.get.conf.get(SHUFFLE_SPILL_INITIAL_MEM_THRESHOLD)
  
  // Threshold for this collection's size in bytes before we start tracking its memory usage
// To avoid a large number of small spills, initialize this to a value orders of magnitude > 0
@volatile private[this] var myMemoryThreshold = initialMemoryThreshold

接着会向 shuffleMemoryManager 申请 2 * currentMemory - myMemoryThreshold 的内存,shuffleMemoryManager 是被Executor 所有正在运行的Task(Core) 共享的,能够分配出去的内存是:ExecutorHeapMemeory * 0.2 * 0.8 ,可通过下面两个配置来更改:

spark.shuffle.memoryFraction=0.2
spark.shuffle.safetyFraction=0.8
// Force this collection to spill when there are this many elements in memory
// For testing only
private[this] val numElementsForceSpillThreshold: Int =
  SparkEnv.get.conf.get(SHUFFLE_SPILL_NUM_ELEMENTS_FORCE_SPILL_THRESHOLD)

最终写磁盘的条件是:当前内存中的量大于向 shuffleMemoryManager申请的内存或者读数据的条数大于强制写磁盘的阈值,就会写数据

但是这里有一个很大的问题:当前内存结构中的数据是预估值;所以有可能估的不准,也就是实际内存会远远超过预期。具体的可以看 org.apache.spark.util.collection.SizeTracker

如果内存开的比较大,其实反倒风险更高,因为estimateSize 并不是每次都去真实的算缓存。它是通过采样来完成的,而采样的周期不是固定的,而是指数增长的,比如第一次采样完后,PartitionedAppendOnlyMap 要经过1.1次的update/insert操作之后才进行第二次采样,然后经过1.1*.1.1次之后进行第三次采样,以此递推,假设你内存开的大,那PartitionedAppendOnlyMap可能要经过几十万次更新之后之后才会进行一次采样,然后才能计算出新的大小,这个时候几十万次更新带来的新的内存压力,可能已经让你的GC不堪重负了。

最终,如果shouldSpill=true,那么数据会写磁盘,同时新创建map/buffer来写数据

/**
 * Spill our in-memory collection to a sorted file that we can merge later.
 * We add this file into `spilledFiles` to find it later.
 *
 * @param collection whichever collection we're using (map or buffer)
1. 创建临时的blockId和文件
2. 针对临时文件创建DiskBlockObjectWriter
3. 循环读取内存里的数据
4. 内存里的数据数据写入文件
5. 将数据刷到磁盘
6. 创建SpilledFile然后返回
 **/
 
override protected[this] def spill(collection: WritablePartitionedPairCollection[K, C]): Unit = {
  val inMemoryIterator = collection.destructiveSortedWritablePartitionedIterator(comparator)
  val spillFile = spillMemoryIteratorToDisk(inMemoryIterator)
  spills += spillFile
}

/**
 * Spill contents of in-memory iterator to a temporary file on disk.
 */
private[this] def spillMemoryIteratorToDisk(inMemoryIterator: WritablePartitionedIterator)
    : SpilledFile = {
  // 因为这些文件在shuffle期间可能被读取,他们压缩应该被spark.shuffle.spill.compress控制而不是
  // spark.shuffle.compress,所以我们需要创建临时的shuffle block
  val (blockId, file) = diskBlockManager.createTempShuffleBlock()

  // These variables are reset after each flush
  var objectsWritten: Long = 0
  val spillMetrics: ShuffleWriteMetrics = new ShuffleWriteMetrics
  // 创建针对临时文件的writer
  val writer: DiskBlockObjectWriter =
    blockManager.getDiskWriter(blockId, file, serInstance, fileBufferSize, spillMetrics)

  // 批量写入磁盘的列表
  val batchSizes = new ArrayBuffer[Long]

  // 每一个分区有多少数据
  val elementsPerPartition = new Array[Long](numPartitions)

  // 刷新数据到磁盘
  def flush(): Unit = {
    // 每一个分区对应文件刷新到磁盘,并返回对应的FileSegment
    val segment = writer.commitAndGet()
    // 获取该FileSegment对应的文件的长度,并且更新batchSizes
    batchSizes += segment.length
    _diskBytesSpilled += segment.length
    objectsWritten = 0
  }

  var success = false
  try {
    // 循环读取内存里的数据
    while (inMemoryIterator.hasNext) {
      // 获取partitionId
      val partitionId = inMemoryIterator.nextPartition()
      require(partitionId >= 0 && partitionId < numPartitions,
        s"partition Id: ${partitionId} should be in the range [0, ${numPartitions})")
      // 内存里的数据数据写入文件
      inMemoryIterator.writeNext(writer)
      elementsPerPartition(partitionId) += 1
      objectsWritten += 1
      // 将数据刷到磁盘
      if (objectsWritten == serializerBatchSize) {
        flush()
      }
    }
    // 遍历完了之后,刷新到磁盘
    if (objectsWritten > 0) {
      flush()
    } else {
      writer.revertPartialWritesAndClose()
    }
    success = true
  } finally {
    if (success) {
      writer.close()
    } else {
      // This code path only happens if an exception was thrown above before we set success;
      // close our stuff and let the exception be thrown further
      writer.revertPartialWritesAndClose()
      if (file.exists()) {
        if (!file.delete()) {
          logWarning(s"Error deleting ${file}")
        }
      }
    }
  }
  // 创建SpilledFile然后返回
  SpilledFile(file, blockId, batchSizes.toArray, elementsPerPartition)
}`
  1. 将所有数据写入ExternalSorter,当达到内存上限之后,将PartitionedAppendOnlyMap或者PartitionedPairBuffer溢写入磁盘文件中。在溢写操作之前,对数据进行排序,排序规则根据构造ExternalSorter传入的aggregator和keyOrdering而变化。
/**
 * Write all the data added into this ExternalSorter into a map output writer that pushes bytes
 * to some arbitrary backing store. This is called by the SortShuffleWriter.
 *
 * @return array of lengths, in bytes, of each partition of the file (used by map output tracker)
 */
 
def writePartitionedMapOutput(
    shuffleId: Int,
    mapId: Long,
//这里的mapOutputWriter是ShuffleMapOutputWriter的实现类:LocalDiskShuffleMapOutputWriter
    mapOutputWriter: ShuffleMapOutputWriter): Unit = {
  var nextPartitionId = 0
  if (spills.isEmpty) {
    // 如果是空的表示只有内存数据,内存足够,不需要溢写结果到磁盘
    //将数据按照partitionId排序,否则首先按照partitionId排序,然后partition内部再按照key排序
    // 如果指定aggregator,就返回PartitionedAppendOnlyMap里的数据,否则返回
    // PartitionedPairBuffer里的数据
    val collection = if (aggregator.isDefined) map else buffer
//这里的destructiveSortedWritablePartitionedIterator方法会返回WritablePartitionedIterator对象,
//同时调用PartitionedAppendOnlyMap的partitionedDestructiveSortedIterator或者PartitionedPairBuffer的方法
//最终调用AppendOnlyMap的destructiveSortedIterator方法,在这个方法中使用new Sorter(new KVArraySortDataFormat[K, AnyRef]).sort(data, 0, newIndex, keyComparator)
//sorter里面使用的是timSort算法,最终将数据写入ShufflePartitionPairsWriter
    val it = collection.destructiveSortedWritablePartitionedIterator(comparator)
    while (it.hasNext) {
      val partitionId = it.nextPartition()
      var partitionWriter: ShufflePartitionWriter = null
      var partitionPairsWriter: ShufflePartitionPairsWriter = null
      TryUtils.tryWithSafeFinally {
        partitionWriter = mapOutputWriter.getPartitionWriter(partitionId)
        val blockId = ShuffleBlockId(shuffleId, mapId, partitionId)
        partitionPairsWriter = new ShufflePartitionPairsWriter(
          partitionWriter,
          serializerManager,
          serInstance,
          blockId,
          context.taskMetrics().shuffleWriteMetrics,
          if (partitionChecksums.nonEmpty) partitionChecksums(partitionId) else null)
        while (it.hasNext && it.nextPartition() == partitionId) {
          it.writeNext(partitionPairsWriter)
        }
      } {
        if (partitionPairsWriter != null) {
          partitionPairsWriter.close()
        }
      }
      nextPartitionId = partitionId + 1
    }
  } else {
    // 进行归并排序
    for ((id, elements) <- this.partitionedIterator) {
      val blockId = ShuffleBlockId(shuffleId, mapId, id)
      var partitionWriter: ShufflePartitionWriter = null
      var partitionPairsWriter: ShufflePartitionPairsWriter = null
      TryUtils.tryWithSafeFinally {
        partitionWriter = mapOutputWriter.getPartitionWriter(id)
        partitionPairsWriter = new ShufflePartitionPairsWriter(
          partitionWriter,
          serializerManager,
          serInstance,
          blockId,
          context.taskMetrics().shuffleWriteMetrics,
          if (partitionChecksums.nonEmpty) partitionChecksums(id) else null)
        if (elements.hasNext) {
          for (elem <- elements) {
            partitionPairsWriter.write(elem._1, elem._2)
          }
        }
      } {
        if (partitionPairsWriter != null) {
          partitionPairsWriter.close()
        }
      }
      nextPartitionId = id + 1
    }
  }

  context.taskMetrics().incMemoryBytesSpilled(memoryBytesSpilled)
  context.taskMetrics().incDiskBytesSpilled(diskBytesSpilled)
  context.taskMetrics().incPeakExecutionMemory(peakMemoryUsedBytes)
}




/**
 * Return an iterator over all the data written to this object, grouped by partition and
 * aggregated by the requested aggregator. For each partition we then have an iterator over its
 * contents, and these are expected to be accessed in order (you can't "skip ahead" to one
 * partition without reading the previous one). Guaranteed to return a key-value pair for each
 * partition, in order of partition ID.
 *
 * For now, we just merge all the spilled files in once pass, but this can be modified to
 * support hierarchical merging.
 * Exposed for testing.
 */
def partitionedIterator: Iterator[(Int, Iterator[Product2[K, C]])] = {
  val usingMap = aggregator.isDefined
  val collection: WritablePartitionedPairCollection[K, C] = if (usingMap) map else buffer
  if (spills.isEmpty) {
    // Special case: if we have only in-memory data, we don't need to merge streams, and perhaps
    // we don't even need to sort by anything other than partition ID
    if (ordering.isEmpty) {
      // The user hasn't requested sorted keys, so only sort by partition ID, not key
      groupByPartition(destructiveIterator(collection.partitionedDestructiveSortedIterator(None)))
    } else {
      // We do need to sort by both partition ID and key
      groupByPartition(destructiveIterator(
        collection.partitionedDestructiveSortedIterator(Some(keyComparator))))
    }
  } else {
    // Merge spilled and in-memory data
    merge(spills, destructiveIterator(
      collection.partitionedDestructiveSortedIterator(comparator)))
  }
}

在数据写入ShufflePartitionPairsWriter后,调用commitAllPartitions()方法输出数据,其中调用writeIndexFileAndCommit()方法写出数据和索引文件,如下:

def writeIndexFileAndCommit(
    shuffleId: Int,
    mapId: Long,
    lengths: Array[Long],
    dataTmp: File): Unit = {
  // 创建索引文件和临时索引文件
  val indexFile = getIndexFile(shuffleId, mapId)
  val indexTmp = Utils.tempFileWith(indexFile)
  try {
    // 获取shuffle data file
    val dataFile = getDataFile(shuffleId, mapId)
    // There is only one IndexShuffleBlockResolver per executor, this synchronization make sure
    // the following check and rename are atomic.
    // 对于每个executor只有一个IndexShuffleBlockResolver,确保原子性
    synchronized {
      // 检查索引是否和数据文件已经有了对应关系
      val existingLengths = checkIndexAndDataFile(indexFile, dataFile, lengths.length)
      if (existingLengths != null) {
        // Another attempt for the same task has already written our map outputs successfully,
        // so just use the existing partition lengths and delete our temporary map outputs.
        // 如果存在对应关系,说明shuffle write已经完成,删除临时索引文件
        System.arraycopy(existingLengths, 0, lengths, 0, lengths.length)
        if (dataTmp != null && dataTmp.exists()) {
          dataTmp.delete()
        }
      } else {
        // 如果不存在,创建一个BufferedOutputStream
        // This is the first successful attempt in writing the map outputs for this task,
        // so override any existing index and data files with the ones we wrote.
        val out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(indexTmp)))
        Utils.tryWithSafeFinally {
          // We take in lengths of each block, need to convert it to offsets.
          // 获取每个分区的大小,累加偏移量,写入临时索引文件
          var offset = 0L
          out.writeLong(offset)
          for (length <- lengths) {
            offset += length
            out.writeLong(offset)
          }
        } {
          out.close()
        }

        // 删除可能存在的其他索引文件
        if (indexFile.exists()) {
          indexFile.delete()
        }
        // 删除可能存在的其他数据文件
        if (dataFile.exists()) {
          dataFile.delete()
        }
        // 将临时文件重命名成正式文件
        if (!indexTmp.renameTo(indexFile)) {
          throw new IOException("fail to rename file " + indexTmp + " to " + indexFile)
        }
        if (dataTmp != null && dataTmp.exists() && !dataTmp.renameTo(dataFile)) {
          throw new IOException("fail to rename file " + dataTmp + " to " + dataFile)
        }
      }
    }
  } finally {
    if (indexTmp.exists() && !indexTmp.delete()) {
      logError(s"Failed to delete temporary index file at ${indexTmp.getAbsolutePath}")
    }
  }
}

UnsafeShuffleWriter

UnsafeShuffleWriter 是对 SortShuffleWriter 的优化, 大致上的过程与SortShuffleWriter相似,触发UnsafeShuffleWriter 需要满足三个条件:

  • map端没有聚合操作,因为传入shuffle writer的是序列化后的数据,且排序时没有反序列化操作,所以无法进行聚合

  • 序列化 方式需要支持重定位(一般使用KryoSerializer来进行序列化),这里应该是指Serializer可以对已经序列化的对象进行排序,这种排序起到的效果和先对数据排序再序列化一致

  • 下游stage的task的数量小于2^24,因为partition number是使用24bit 表示的

ShuffleExternalSorter 和ExternalSorter的差异

和SortShuffleWriter 在实现中最主要的区别就是使用了不同的外部排序,Unsafe模式使用的ShuffleExternalSorter 类,而sort模式使用的ExternalSorter类。

他们的作用都是将record插入到内存之中(管理内存的申请和释放),不同之处在于:

  • ExternalSorter支持聚合操作,ShuffleExternalSorter不支持

  • ShuffleExternalSorter直接对序列化后的数据进行排序,不需要反序列化

  • ShuffleExternalSorter使用的是Tungsten缓存

  • 溢出前排序操作:ExternalSorter是按照分区ID和key进行排序实现,ShuffleExternalSorter除了按照分区ID的排序外,也有基于基数排序(Radix Sort)的实现

过程简述

UnsafeShuffleWriter将数据序列化后写入到内存中时,在内存中的数据分为两部分,一部分是以Page(默认4KB,是内存管理中的概念)的形式存在的,存储是真正的记录。另一部分是一个存储数据指针的LongArray数组(也是序列化的)。

LongArray中每条数据都指示了真实记录的所属partition和在page中的具体位置。随后,对LongArray数组进行排序(根据Partition number), 并且将排序后的数据(真实记录)溢出写入到磁盘文件中,最后在merge阶段进行合并。

对比Sort普通模式:

  • 在Sort普通模式中存储的是键值或者值的具体类型,也就是 Java 对象,是反序列化过后的数据。Unsafe模式中序列化器支持对二进制的数据进行排序比较,不会对数据进行反序列化操作,可以减少内存的消耗和GC的开销。

  • 在Unsafe模式中,并不是利用真实数据来进行排序,而是利用LongArray数组中存储的分区和指针信息,所以排序时每条记录只有8字节。

  • UnsafeShuffleWriter 中引入额外的LongArray数组,这部分存储的开销是额外的。(但是序列化带来的收益更大)

小结

  • Spark在初始化SparkEnv的时候,会在create()方法里面初始化ShuffleManager,包含sort和tungsten-sort两种shuffle

  • ShuffleManager是一个特质(trit),核心方法有registerShuffle()、getReader()、getWriter(),

  • SortShuffleManager是ShuffleManager的唯一实现类,在registerShuffle()方法里面选择采用哪种shuffle机制,getReader()方法只会返回一种BlockStoreShuffleReader,getWriter()方法根据不同的handle选择不同的Writer,共有三种

  • BypassMergeSortShuffleWriter:如果当前shuffle依赖中没有map端的聚合操作,并且分区数小于spark.shuffle.sort.bypassMergeThreshold的值,默认为200,启用bypass机制,核心方法有:write()、writePartitionedData()(合并所有分区文件,默认采用零拷贝方式)

  • UnsafeShuffleWriter:如果serializer支持relocation并且map端没有聚合同时分区数目不大于16777215+1三个条件都满足,采用该Writer,核心方法有:write()、insertRecordIntoSorter()(将数据插入外部选择器排序)、closeAndWriteOutput()(合并并输出文件),前一个方法里核心方法有:insertRecord()(将序列化数据插入外部排序器)、growPointerArrayIfNecessary()(如果需要额外空间需要对数组扩容或溢写到磁盘)、spill()(溢写到磁盘)、writeSortedFile()(将内存中的数据进行排序并写出到磁盘文件中)、encodePageNumberAndOffset()(对当前数据的逻辑地址进行编码,转成long型),后面的方法里核心方法有:mergeSpills()(合并溢写文件),合并文件的时候有BIO和NIO两种方式

  • SortShuffleWriter:如果上面两者都不满足,采用该Writer,该Writer会使用PartitionedAppendOnlyMap或PartitionedPariBuffer在内存中进行排序,如果超过内存限制,会溢写到文件中,在全局输出有序文件的时候,对之前的所有输出文件和当前内存中的数据进行全局归并排序,对key相同的元素会使用定义的function进行聚合核心方法有:write()、insertAll()(将数据放入ExternalSorter进行排序)、maybeSpillCollection()(是否需要溢写到磁盘)、maybeSpill()、spill()、spillMemoryIteratorToDisk()(将内存中数据溢写到磁盘)、writePartitionedMapOutput()、commitAllPartitions()里面调用writeIndexFileAndCommit()方法写出数据和索引文件

shuffleRead

核心流程:

  1. 获取待拉取数据的迭代器
  2. 使用PartitionedAppendOnlyMap/ExternalAppendOnlyMap 做combine
  3. 如果需要对key排序,则使用ExternalSorter

PartitionedAppendOnlyMap[K, V]继承 SizeTrackingAppendOnlyMap[(Int, K), V] 和WritablePartitionedPairCollection

SizeTrackingAppendOnlyMap[K, V]继承AppendOnlyMap[K, V] with SizeTracker

val aggregatedIter: Iterator[Product2[K, C]] = if (dep.aggregator.isDefined) {
      if (dep.mapSideCombine) {
      // map端已经聚合过了
        // We are reading values that are already combined
        val combinedKeyValuesIterator = interruptibleIter.asInstanceOf[Iterator[(K, C)]]
        dep.aggregator.get.combineCombinersByKey(combinedKeyValuesIterator, context)
      } else {
        // We don't know the value type, but also don't care -- the dependency *should*
        // have made sure its compatible w/ this aggregator, which will convert the value
        // type to the combined type C
        //针对没有聚合的数据进行聚合
        val keyValuesIterator = interruptibleIter.asInstanceOf[Iterator[(K, Nothing)]]
        dep.aggregator.get.combineValuesByKey(keyValuesIterator, context)
      }
def combineValuesByKey(
    iter: Iterator[_ <: Product2[K, V]],
    context: TaskContext,
    partIsSorted: Int = 0)
  : Iterator[(K, C)] = {
  if (partIsSorted != 0) {
    new MultiWayAppendOnlyMapIterator[K, V, C](iter,
      createCombiner, mergeValue, mergeCombiners)
  } else {
    val combiners = new ExternalAppendOnlyMap[K, V, C](createCombiner, mergeValue, mergeCombiners)
    combiners.insertAll(iter)
    updateMetrics(context, combiners)
    combiners.iterator
  }
}
//spark1.6以后,ExternalAppendOnlyMap到底内存上限,强制写磁盘
if (!conf.getBoolean("spark.shuffle.spill", true)) {
  logWarning(
    "spark.shuffle.spill was set to false, but this configuration is ignored as of Spark 1.6+." +
      " Shuffle will continue to spill to disk when necessary.")
}

shuffleRead入口类:

org.apache.spark.rdd.ShuffledRDD
---> org.apache.spark.shuffle.sort.HashShuffleReader
   --->  org.apache.spark.util.collection.ExternalAppendOnlyMap
   --->  org.apache.spark.util.collection.ExternalSorter

所有的shuffle阶段的shuffleRead最终都会调用ShuffledRDD.compute()方法

override def compute(split: Partition, context: TaskContext): Iterator[(K, C)] = {
  // 来计算当前这个RDD的partition数据
  val dep = dependencies.head.asInstanceOf[ShuffleDependency[K, V, C]]

  val metrics = context.taskMetrics().createTempShuffleReadMetrics()
  // 获取ShuffleManager的reader去拉取ShuffleMapTask需要聚合的数据
  SparkEnv.get.shuffleManager.getReader(
    dep.shuffleHandle, split.index, split.index + 1, context, metrics)
    .read()
    .asInstanceOf[Iterator[(K, C)]]
}
  // BlockStoreShuffleReader
  /** Read the combined key-values for this reduce task */
  override def read(): Iterator[Product2[K, C]] = {
    val wrappedStreams = new ShuffleBlockFetcherIterator(
      context,
      blockManager.blockStoreClient,
      blockManager,
      blocksByAddress,
      serializerManager.wrapStream,
      // Note: we use getSizeAsMb when no suffix is provided for backwards compatibility
      SparkEnv.get.conf.get(config.REDUCER_MAX_SIZE_IN_FLIGHT) * 1024 * 1024,
      SparkEnv.get.conf.get(config.REDUCER_MAX_REQS_IN_FLIGHT),
      SparkEnv.get.conf.get(config.REDUCER_MAX_BLOCKS_IN_FLIGHT_PER_ADDRESS),
      SparkEnv.get.conf.get(config.MAX_REMOTE_BLOCK_SIZE_FETCH_TO_MEM),
      SparkEnv.get.conf.get(config.SHUFFLE_MAX_ATTEMPTS_ON_NETTY_OOM),
      SparkEnv.get.conf.get(config.SHUFFLE_DETECT_CORRUPT),
      SparkEnv.get.conf.get(config.SHUFFLE_DETECT_CORRUPT_MEMORY),
      readMetrics,
      fetchContinuousBlocksInBatch).toCompletionIterator

    val serializerInstance = dep.serializer.newInstance()

    // Create a key/value iterator for each stream
    val recordIter = wrappedStreams.flatMap { case (blockId, wrappedStream) =>
      // Note: the asKeyValueIterator below wraps a key/value iterator inside of a
      // NextIterator. The NextIterator makes sure that close() is called on the
      // underlying InputStream when all records have been read.
      serializerInstance.deserializeStream(wrappedStream).asKeyValueIterator
    }

    // Update the context task metrics for each record read.
    val metricIter = CompletionIterator[(Any, Any), Iterator[(Any, Any)]](
      recordIter.map { record =>
        readMetrics.incRecordsRead(1)
        record
      },
      context.taskMetrics().mergeShuffleReadMetrics())

    // An interruptible iterator must be used here in order to support task cancellation
    val interruptibleIter = new InterruptibleIterator[(Any, Any)](context, metricIter)
  //是否需要map端聚合
    val aggregatedIter: Iterator[Product2[K, C]] = if (dep.aggregator.isDefined) {
      if (dep.mapSideCombine) {
      // map端已经聚合过了
        // We are reading values that are already combined
        val combinedKeyValuesIterator = interruptibleIter.asInstanceOf[Iterator[(K, C)]]
        dep.aggregator.get.combineCombinersByKey(combinedKeyValuesIterator, context)
      } else {
        // We don't know the value type, but also don't care -- the dependency *should*
        // have made sure its compatible w/ this aggregator, which will convert the value
        // type to the combined type C
        //针对没有聚合的数据进行聚合
        val keyValuesIterator = interruptibleIter.asInstanceOf[Iterator[(K, Nothing)]]
        dep.aggregator.get.combineValuesByKey(keyValuesIterator, context)
      }
    } else {
      interruptibleIter.asInstanceOf[Iterator[Product2[K, C]]]
    }

  // 如果需要对key排序,则进行排序。基于sort的shuffle实现过程中,默认只是按照partitionId排序
  // 在每一个partition内部并没有排序,因此添加了keyOrdering变量,提供是否需要对分区内部的key排序
    // Sort the output if there is a sort ordering defined.
    val resultIter = dep.keyOrdering match {
      case Some(keyOrd: Ordering[K]) =>
        // Create an ExternalSorter to sort the data.
        //借用ExternalSorter进行排序,类似shufflewrite的步骤
        val sorter =
          new ExternalSorter[K, C, C](context, ordering = Some(keyOrd), serializer = dep.serializer)
        sorter.insertAll(aggregatedIter)
        context.taskMetrics().incMemoryBytesSpilled(sorter.memoryBytesSpilled)
        context.taskMetrics().incDiskBytesSpilled(sorter.diskBytesSpilled)
        context.taskMetrics().incPeakExecutionMemory(sorter.peakMemoryUsedBytes)
        // Use completion callback to stop sorter if task was finished/cancelled.
        context.addTaskCompletionListener[Unit](_ => {
          sorter.stop()
        })
        CompletionIterator[Product2[K, C], Iterator[Product2[K, C]]](sorter.iterator, sorter.stop())
      case None =>
      //不需要排序,直接返回
        aggregatedIter
    }


//计算shuffleread相关的metric指标
// Tracks shuffle wall time when doing shuffle
    val trackingIterator = new ShuffleReadMetricsTrackingIterator(resultIter, readMetrics)
    val finalIterator =
      CompletionIterator[Product2[K, C], Iterator[Product2[K, C]]](trackingIterator,
        context.taskMetrics().mergeShuffleReadMetrics())
    resultIter match {
      case _: InterruptibleIterator[Product2[K, C]] =>
        finalIterator
      case _ =>
        // Use another interruptible iterator here to support task cancellation as aggregator
        // or(and) sorter may have consumed previous interruptible iterator.
        new InterruptibleIterator[Product2[K, C]](context, finalIterator)
    }
  }
}

先使用ShuffleBlockFetcherIterator获取本地或远程节点上的block并转化为流,最终返回一小部分数据的迭代器,随后序列化、解压缩、解密流操作被放在一个迭代器中该迭代器后执行,然后添加了监控相关的迭代器、数据聚合相关的迭代器、数据排序相关的迭代器等等。这些迭代器保证了处理大量数据的高效性,在数据聚合和排序阶段,大数据量被不断溢出到磁盘中,数据最终还是以迭代器形式返回,确保了内存不会被大数据量占用,提高了数据的吞吐量和处理数据的高效性。

external shuffle service

在Spark中,Executor进程除了运行task,还要负责写shuffle 数据以及给其他Executor提供shuffle数据。当Executor进程任务过重,导致GC或者其他原因而不能为其他Executor提供shuffle数据时,会影响任务运行。同时,ESS的存在也使得,即使executor挂掉或者回收,都不影响其shuffle数据,因此只有在ESS开启情况下才能开启动态调整executor数目。

因此,spark提供了external shuffle service这个接口,常见的就是spark on yarn中的,YarnShuffleService。这样,在yarn的nodemanager中会常驻一个externalShuffleService服务进程来为所有的executor服务,默认为7337端口。

其实在spark中shuffleClient有两种,一种是blockTransferService,另一种是externalShuffleClient。如果在ESS开启,那么externalShuffleClient用来fetch shuffle数据,而blockTransferService用于获取broadCast等其他BlockManager保存的数据。

如果ESS没有开启,那么spark就只能使用自己的blockTransferService来拉取所有数据,包括shuffle数据以及broadcast数据。