翻开源码看Spark是如何确立RDD分区数的

翻开源码看Spark如何确立rdd的分区数

这大概是个爷爷不疼奶奶不爱的问题,但是很多小伙伴还是不太清楚的。借机开始spark的源码阅读之旅。

RDD 分区确定

翻开DataSourceScanExec的源码,会发现产生rdd有两个方法:

  1. createBucketedReadRDD
  2. createNonBucketedReadRDD

这样的分类,和hive的bucket机制有比较大的渊源,bucket可以将key值相同的数值合并在一起,同时又不像partition那样为每个key值建立一个文件夹。stackOverFlow中有个不错的回答: HIVE中partition和bucket的区别

Ok,我们主要来关注一般的情况,就是Non Bucketed创建Rdd时如何分区。代码不长,直接贴进来,注释在代码中:

private def createNonBucketedReadRDD(
    readFile: (PartitionedFile) => Iterator[InternalRow],
    selectedPartitions: Array[PartitionDirectory],
    fsRelation: HadoopFsRelation): RDD[InternalRow] = {
    
    // OpenCost,是一个经验配置值,默认的是4M,啥意思呢?就是你的打开文件的需要消耗的时间成本,然后根据经验,这个时长可以读多少的数据
    val openCostInBytes = fsRelation.sparkSession.sessionState.conf.filesOpenCostInBytes
    
    // maxSplitBytes, 看这名字就很关键,后文展开看
    val maxSplitBytes =
        FilePartition.maxSplitBytes(fsRelation.sparkSession, selectedPartitions)
        logInfo(s"Planning scan with bin packing, max size: $maxSplitBytes bytes, " +
                s"open cost is considered as scanning $openCostInBytes bytes.")

	//selectedPartitions,这个是和hdfs的分区相关的,也就是表下面的分区目录个数  
    val splitFiles = selectedPartitions.flatMap { partition =>
        partition.files.flatMap { file =>
            // getPath() is very expensive so we only want to call it once in this block:
            val filePath = file.getPath
            val isSplitable = relation.fileFormat.isSplitable(
            relation.sparkSession, relation.options, filePath)
            
            // 这里是如何去切单个文件的逻辑,单独拿出来说。
            PartitionedFileUtil.splitFiles(
                sparkSession = relation.sparkSession,
                file = file,
                filePath = filePath,
                isSplitable = isSplitable,
                maxSplitBytes = maxSplitBytes,
                partitionValues = partition.values
        )
        ....

分区最大字节数确定

代码来自FilePartiion::maxSplitBytes:

def maxSplitBytes(
    sparkSession: SparkSession,
    selectedPartitions: Seq[PartitionDirectory]): Long = {
    // 最大分区字节数配置,默认128M
    val defaultMaxSplitBytes = sparkSession.sessionState.conf.filesMaxPartitionBytes
    
    // 上面已经提到过的一个经验值
    val openCostInBytes = sparkSession.sessionState.conf.filesOpenCostInBytes
    
    // 这个是默认的并发度,理论上来说是和集群的最大core size相等的
    val defaultParallelism = sparkSession.sparkContext.defaultParallelism
    
    // 文件夹下所有文件中数据量的总字节数,把每个文件的open消耗也算上
    val totalBytes = selectedPartitions.flatMap(_.files.map(_.getLen + openCostInBytes)).sum
    
    // 换算成每个core需要处理的字节数
    val bytesPerCore = totalBytes / defaultParallelism

    // 关键的来了,看仔细了
    // 1. OpenCostInBytes 和 每core处理数取最大值,也就是说最小是单个的文件打开开销,因为文件打开开销肯定是单core背锅,分摊不了。
    // 2. defaultParallelism 和 第一步的结果取最小值。
    Math.min(defaultMaxSplitBytes, Math.max(openCostInBytes, bytesPerCore))
}

在都采用默认值的情况下, open开销4M, 4 core,我们来举例看看split是如何切的(内存单位M):

文件个数 文件大小 计算公式 maxSplitBytes
10 20 min(128, max(10*(20+4) /4, 4)) 60
10 47.1 min(128, max(10*(47.1+4) /4, 4)) 127.75
10 47.2 min(128, max(10*(47.2+4) /4, 4)) 128
10 200 min(128, max(100*(20+4) /4, 4)) 128

OK,上面的三个例子应该就足以说明了。

  1. 数据总量+open开销 小于 core * defaultMaxSplitBytes时,maxSplitBytes = (数据总量+open开销) / core
  2. 数据总量+open开销 大于 core * defaultMaxSplitBytes时,maxSplitBytes = defaultMaxSplitBytes

需要的数据都准备好了,看如何划分分区

如何将文件划分为分区

处理逻辑在类FilePartition中:

def getFilePartitions(
    sparkSession: SparkSession,
    partitionedFiles: Seq[PartitionedFile],
    maxSplitBytes: Long): Seq[FilePartition] = {
    val partitions = new ArrayBuffer[FilePartition]
    val currentFiles = new ArrayBuffer[PartitionedFile]
    var currentSize = 0L

    /** Close the current partition and move to the next. */
    def closePartition(): Unit = {
        if (currentFiles.nonEmpty) {
            // Copy to a new Array.
            val newPartition = FilePartition(partitions.size, currentFiles.toArray                   partitions += newPartition
        }
        currentFiles.clear()
        currentSize = 0
    }

    val openCostInBytes = sparkSession.sessionState.conf.filesOpenCostInBytes
    // Assign files to partitions using "Next Fit Decreasing"
    // 这里的核心思想是在合并小文件,将多个小文件合并到maxSplitBytes
    partitionedFiles.foreach { file =>
        if (currentSize + file.length > maxSplitBytes) {
            closePartition()
        }
        // Add the given file to the current partition.
        currentSize += file.length + openCostInBytes
        currentFiles += file
    }
    closePartition()
    partitions
}

吐槽下,上面的代码写的并不怎么清爽,核心思想是合并小文件,大文件就直接变为partition了。一路下来会以为会切大文件,然而并不会。

加强理解

怎么理解上面的两步骚气的操作呢?总体来说:第一要充分的利用cpu,别每个小文件就一个task,资源利用率太低。回过去结合上面的举例看看分区结果:

文件个数 文件大小 maxSplitBytes 分区结果
10 20 60 (20 + 4) *3 > 60, 每3个文件合并一个分区,4分区
10 47.1 127.75 (47.1 + 4) *3 > 127.75, 每3个文件合并一个分区,4分区
10 47.2 128 (47.2 + 4) *3 > 128, 每3个文件合并一个分区,4分区
10 200 128 200 > 128,每个文件一个分区,10分区

其他

这样看下来,对defaultMaxSplitBytes 是不是有了新的认识?我们可以理解为将小文件合并到同一个分区时的最大字节数限制。但是这个限制有什么用呢?如果47.2M的文件有100个, 切分成了34个分区,而不是4分区,那又怎么样呢?

个人看来是因为task因为数据量、数据分布等因素会导致处理的速度不一样,完全按照数据量切换成和core数量相等的分区容易形成长尾。这个理由也不是很牢靠,知道确切原因的同学请留言。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章