翻开源码看Spark如何确立rdd的分区数
这大概是个爷爷不疼奶奶不爱的问题,但是很多小伙伴还是不太清楚的。借机开始spark的源码阅读之旅。
RDD 分区确定
翻开DataSourceScanExec的源码,会发现产生rdd有两个方法:
- createBucketedReadRDD
- 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,上面的三个例子应该就足以说明了。
- 数据总量+open开销 小于 core * defaultMaxSplitBytes时,maxSplitBytes = (数据总量+open开销) / core
- 数据总量+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数量相等的分区容易形成长尾。这个理由也不是很牢靠,知道确切原因的同学请留言。