private def takeSample(): Unit = {
samples.enqueue(Sample(SizeEstimator.estimate(this), numUpdates))
// Only use the last two samples to extrapolate
if (samples.size > 2) {
samples.dequeue()
}
// 估计每次跟新的变化量
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
}
ShuffleMapTask的结果(ShuffleMapStage中FinalRDD的数据)都将写入磁盘,以供后续Stage拉取,即整个Shuffle包括前Stage的Shuffle Write和后Stage的Shuffle Read,由于内容较多,本文先解析Shuffle Write。
概述:
入口
执行一个ShuffleMapTask最终的执行逻辑是调用了ShuffleMapTask类 的runTask()方法:
其中的finalRDD和dependency是在Driver端DAGScheluer中提交Stage的时候加入广播变量的。
接着通过SparkEnv获取shuffleManager,默认使用的是sort(对应的是org.apache.spark.shuffle.sort.SortShuffleManager),可通过spark.shuffle.manager设置。
然后manager.getWriter返回的是SortShuffleWriter,我们直接看writer.write发生了什么:
先细看sorter.inster是怎么写到内存,并spill到磁盘文件的:
需要聚合的情况,遍历records拿到record的KV,通过map的changeValue方法并根据update函数来对相同K的V进行聚合,这里的map是PartitionedAppendOnlyMap类型,只能添加数据不能删除数据,底层实现是一个数组,数组中存KV键值对的方式是[K1,V1,K2,V2...],每一次操作后都会判断是否要spill到磁盘。
不需要聚合的情况,直接将record放入buffer,然后判断是否要溢写到磁盘。
先看map.changeValue方法到底是怎么通过map实现对数据combine的:
super.changeValue的实现:
根据K的hashCode再哈希与上掩码 得到 pos,2 pos 为 k 应该所在的位置,2 pos + 1 为 k 对应的 v 所在的位置,获取k应该所在位置的原来的key:
跟新curSize,若当前大小超过了阈值growThreshold(growThreshold是当前容量capacity的0.7倍),则通过growTable()来扩容:
这里重新创建了一个两倍capacity 的数组来存放数据,将原来数组中的数据通过重新计算位置放到新数组里,将data替换为新的数组,并跟新一些变量。
此时聚合已经完成,回到changeValue方面里面,接下来会执行super.afterUpdate()方法来对map的大小进行采样:
若每遍历跟新一条record,都来对map进行采样估计大小,假设采样一次需要1ms,100w次采样就会花上16.7分钟,性能大大降低。所以这里只有当update次数达到nextSampleNum 的时候才通过takeSample()采样一次:
这里估计每次跟新的变化量的逻辑是:(当前map大小-上次采样的时候的大小) / (当前update的次数 - 上次采样的时候的update次数)。
接着计算下次需要采样的update次数,该次数是指数级增长的,基数是1.1,第一次采样后,要1.1次进行第二次采样,第1.1*1.1次后进行第三次采样,以此类推,开始增长慢,后面增长跨度会非常大。
这里采样完成后回到insetAll方法,接着通过maybeSpillCollection方法判断是否需要spill:
通过集合的estimateSize方法估计map的大小,若需要spill则将集合中的数据spill到磁盘文件,并且为集合创建一个新的对象放数据。先看看估计大小的方法estimateSize:
以上次采样完更新的bytePerUpdate 作为最近平均每次跟新的大小,估计当前占用内存:(当前update次数-上次采样时的update次数) * 每次跟新大小 + 上次采样记录的大小。
获取到当前集合的大小后调用maybeSpill判断是否需要spill:
这里有两种情况都可导致spill:
若需要spill,则跟新spill次数,调用spill(collection)方法进行溢写磁盘,并释放内存。 跟进spill方法看看其具体实现:
继续跟进看看spillMemoryIteratorToDisk的实现:
通过diskBlockManager创建临时文件和blockID,临时文件名格式为是 "tempshuffle" + id,遍历内存数据迭代器,并调用Writer(DiskBlockObjectWriter)的write方法,当写的次数达到序列化大小则flush到磁盘文件,并重新打开writer,及跟新batchSizes等信息。
最后返回一个SpilledFile对象,该对象包含了溢写的临时文件File,blockId,每次flush的到磁盘的大小,每个partition对应的数据条数。
spill完成,并且insertAll方法也执行完成,回到开始的SortShuffleWriter的write方法:
获取最后的输出文件名及blockId,文件格式:
接着通过sorter.writePartitionedFile方法来写文件,其中包括内存及所有spill文件的merge操作,看看起具体实现:
接下来看看通过this.partitionedIterator方法是怎么将内存及spill文件的数据进行merge-sort的:
这里在有spill文件的情况下会执行下面的merge方法,传入的是spill文件数组和内存中的数据进过partitionId和key排序后的数据迭代器,看看merge:
merge方法将属于同一个reduce端的partition的内存数据和spill文件数据合并起来,再进行聚合排序(有需要的话),最后返回(reduce对应的partitionId,该分区数据迭代器)
将数据merge-sort后写入最终的文件后,需要将每个partition的偏移量持久化到文件以供后续每个reduce根据偏移量获取自己的数据,写偏移量的逻辑很简单,就是根据前面得到的partition长度的数组将偏移量写到index文件中,对应的文件名为:
最后创建一个MapStatus实例返回,包含了reduce端每个partition对应的偏移量。
该对象将返回到Driver端的DAGScheluer处理,被添加到对应stage的OutputLoc里,当该stage的所有task完成的时候会将这些结果注册到MapOutputTrackerMaster,以便下一个stage的task就可以通过它来获取shuffle的结果的元数据信息。
至此Shuffle Write完成!