翻開源碼看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數量相等的分區容易形成長尾。這個理由也不是很牢靠,知道確切原因的同學請留言。

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