本节介绍其中一种ShuffleWriter:BypassMergeSortShuffleWriter的实现原理。
实现步骤
这种ShuffleWriter会按以下处理流程来处理输入的数据:
1)为每个分区创建一个临时文件。
2)遍历数据,把数据写入到对应的分区临时文件中。
3)把所有临时文件进行合并,形成一个大文件。
4)创建一个索引文件,记录每个分区在这个大文件中的偏移量等信息,这样就可以根据索引文件找到不同分区的数据块。
这个过程如下图1所示:
图1 BypassMergeSortShuffleWriter写shuffle数据逻辑图
要注意的是:这种方式会同时为每个分区分别打开一个序列化流和一个文件写入流,所以,当Shuffle具有大量reduce分区时,这种写入方式效率会比较低。
何时使用
这种ShuffleWriter有一定适用场景,当同时满足以下三个条件时才启用这种shuffleWriter:
-
任务中没有排序操作(Ordering)
-
任务中没有指定聚合器(Aggregator)
-
分区数量少于参数:spark.shuffle.sort.bypassMergeThreshold的值,默认是:200。
实现分析
该类实现了ShuffleWrite接口的write函数,主要的操作流程都在write中实现,会着重介绍write的具体实现。
重要类成员
该类中用到了很多其他模块的类,为了更好的理解它的实现原理,这里对该类的重要成员进行说明。
write()函数
函数原型如下:
public void write(Iterator<Product2<K, V>> records)
该函数的实现流程如下:
-
判断写入记录是否已经遍历到最后一条,若是则:
-
若记录还没有遍历到最后一条,则创建一个序列化工具SerializerInstance类对象:serInstance,该对象用来对数据进行序列化操作。
-
创建一个DiskBlockObjectWriter数组,后面会为每个分区创建一个DiskBlockObjectWriter对象,并把对象保存到该数组中。该对象负责把对象写入磁盘。
// 创建一个长度为分区数的DiskBlockObjectWriter对象的数组
partitionWriters = new DiskBlockObjectWriter[numPartitions];
- 创建FileSegment对象数组,数组长度为分区数。该对象用来记录每个分区文件的长度,file对象,偏移量offset的值。
// 创建一个长度为分区数的FileSegment对象的数组
partitionWriterSegments = new FileSegment[numPartitions];
- 有了以上的信息,现在就可以开始处理分区数据了。这一步会遍历所有分区,创建一个blockId和File文件对象的对应关系的元组。通过这两个对象来创建DiskBlockObjectWriter对象,从而为数组partitionWriters赋值。
// 为当前分区创建一个DiskBlockObjectWriter对象,保存到partitionWriters数组对应位置中
partitionWriters[i] =
blockManager.getDiskWriter(blockId, file, serInstance, fileBufferSize, writeMetrics);
注意:这里balockId是一个uuid,保证每个分区的shuffle文件名不重复。
- 把分区数据按key-value的方式写入文件流中:
partitionWriters[partitioner.getPartition(key)].write(key, record._2());
- 让输出流缓冲区的数据全部刷到磁盘文件中,并返回一个FileSegment对象,保存到partitionWriterSegments数组的对应位置。
partitionWriterSegments[i] = writer.commitAndGet();
- 合并以上的每个分区的临时文件。
先创建一个结果文件对象output,再创建一个tmp文件对象,把每个分区对应的临时文件数据都合并到tmp文件中,再把tmp文件rename成output的文件名。另外,为合并文件中每个分区的数据块创建索引信息,并把这些信息保存到一个索引文件中。
File output = shuffleBlockResolver.getDataFile(shuffleId, mapId);
File tmp = Utils.tempFileWith(output);
try {
// 把每份分区对应的临时文件合并成一个单独的文件,返回每个分区的长度的数组
partitionLengths = writePartitionedFile(tmp);
// 把数据持久化到磁盘上,然后把tmp文件rename成output文件
shuffleBlockResolver.writeIndexFileAndCommit(shuffleId, mapId, partitionLengths, tmp);
} finally {
if (tmp.exists() && !tmp.delete()) {
logger.error("Error while deleting temp file {}", tmp.getAbsolutePath());
}
}
- 最后删除为每个分区创建的临时文件。
writePartitionedFile函数
该函数把每个分区生成的文件,合并成一个单个文件。
-
根据所给的参数创建一个文件输出流对象:out
-
遍历分区,获取在写入分区数据时生成的FileSegment对象中的文件对象,并生成文件输入流用来读取该分区文件的数据。
-
通过一个Utils.copyStream函数把输入流的数据复制到输出流中,若设置了参数:spark.file.transferTo,该函数会使用NIO的方式来复制流数据。
-
完成以上操作后,删除为每个分区创建的临时文件。
实际使用
我们知道启用BypassMergeSortShuffleWriter需要同时满足三个条件,下面我们来演示一下Spark是如何选择该ShuffleWriter的:
scala> val rdd = sc.parallelize(0 to 8).groupBy(_ % 3)
rdd: org.apache.spark.rdd.RDD[(Int, Iterable[Int])] = ShuffledRDD[2] at groupBy at <console>:24
scala> rdd.dependencies
res0: Seq[org.apache.spark.Dependency[_]] = List(org.apache.spark.ShuffleDependency@3a7be6e6)
// 获取shuffleHandle的类名
scala> import org.apache.spark.ShuffleDependency
scala> val shuffleDep = rdd.dependencies(0).asInstanceOf[ShuffleDependency[Int, Int, Int]]
scala> shuffleDep.mapSideCombine // 查看map端是否有聚合操作
res2: Boolean = false
scala> shuffleDep.aggregator // 查看是否有聚合器
res3: Option[org.apache.spark.Aggregator[Int,Int,Int]] = Some(Aggregator(<function1>,<function2>,<function2>))
scala> shuffleDep.partitioner.numPartitions // 查看分区数
res4: Int = 8
scala> shuffleDep.shuffleHandle // 查看ShuffleHandle的值
res5: org.apache.spark.shuffle.ShuffleHandle = org.apache.spark.shuffle.sort.BypassMergeSortShuffleHandle@214738c1
从以上代码可以看出,第一步的运算满足了使用BypassMergeSortShuffleWriter的条件,所以ShuffleHandle是BypassMergeSortShuffleHandle,当调用SortShuffleManager.getWriter时就会选择对应的BypassMergeSortShuffleWriter对象。
小结
本文分析了BypassMergeSortShuffleWriter的实现原理。从分析中可以看出,该ShuffleWriter并没有对数据进行排序,而是直接通过DiskBlockObjectWriter来把数据写出到文件中。
另外,和其他的ShuffleWriter实现方式类似,该ShuffleWriter也是先把数据写出到临时文件中,然后再对临时文件进行合并,这样一个map task就会对应一个输出文件,并为该文件创建一个索引文件,这样查找某个分区或map的数据块就很方便了。