为什么小文件多,task也多?
Spark SQL通过这个类读取Hive表 org.apache.spark.sql.hive.TableReader$HadoopTableReader
HadoopTableReader继承自TableReader类,而该基类仅有两个方法:
- makeRDDForTable(hiveTable: HiveTable): RDD[InternalRow]
- makeRDDForPartitionedTable(partitions: Seq[HivePartition]): RDD[InternalRow]
因此关注HadoopTableReader类对这两个方法的实现即可。
makeRDDForTable实现更简单清晰些,因此从它开始看:
def makeRDDForTable(
hiveTable: HiveTable,
deserializerClass: Class[_ <: Deserializer],
filterOpt: Option[PathFilter]): RDD[InternalRow] = {
// ...校验、配置、表的路径等...
// 这里创建出RDD,分区是由该方法定义的
// makeRDDForPartitionedTable中创建RDD也是通过这个方法
val hadoopRDD = createHadoopRdd(localTableDesc, inputPathStr, ifc)
// ...
}
因此跟进createHadoopRdd:
private def createHadoopRdd(
tableDesc: TableDesc,
path: String,
inputFormatClass: Class[InputFormat[Writable, Writable]]): RDD[Writable] = {
val initializeJobConfFunc = HadoopTableReader.initializeLocalJobConfFunc(path, tableDesc) _
val rdd = new HadoopRDD(
sparkSession.sparkContext,
_broadcastedHadoopConf.asInstanceOf[Broadcast[SerializableConfiguration]],
Some(initializeJobConfFunc),
inputFormatClass,
classOf[Writable],
classOf[Writable],
// 单从变量名即可识别出,对RDD切分的最小切片数
_minSplitsPerRDD)
// Only take the value (skip the key) because Hive works only with values.
rdd.map(_._2)
}
查看_minSplitsPerRDD是如何定义的:
// Hadoop honors "mapreduce.job.maps" as hint,
// but will ignore when mapreduce.jobtracker.address is "local".
// https://hadoop.apache.org/docs/r2.6.5/hadoop-mapreduce-client/hadoop-mapreduce-client-core/
// mapred-default.xml
//
// In order keep consistency with Hive, we will let it be 0 in local mode also.
private val _minSplitsPerRDD = if (sparkSession.sparkContext.isLocal) {
0 // will splitted based on block by default.
} else {
// 比对配置和defaultMinPartitions,取其中最大值
math.max(hadoopConf.getInt("mapreduce.job.maps", 1),
sparkSession.sparkContext.defaultMinPartitions)
}
我们查看默认的最小分区数是如何定义的:
// 可以看到,Spark是与最小并行度进行比对,取最小值
def defaultMinPartitions: Int = math.min(defaultParallelism, 2)
当我们进一步跟踪defaultParallelism,可以发现它是由Spark调度器定义的,代码中有4个相关的实现类,这些实现类中对于该值的定义也有不同的实现类,为了限定范围,我们按照企业级常用的集群模式的调度来查看:
// org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend
override def defaultParallelism(): Int = {
// totoalCoreCount是集群内core的总数,对该变量有兴趣可以跟进到receiveAndReply方法下的case RegisterExecutor
conf.getInt("spark.default.parallelism", math.max(totalCoreCount.get(), 2))
}
在企业级生产环境下,defaultParallelism一般由Executor的资源数定义,一般都大于2,但defaultMinPartitions的值是min(defaultParallelism, 2),因此一般情况下,defaultMinPartitions为2,也由此得到_minSplitsPerRDD一般为2。
费了老大功夫,只确认了一个参数的大致取值为2
接着查看HadoopRDD类中是如何分片的:
override def getPartitions: Array[Partition] = {
val jobConf = getJobConf()
// add the credentials here as this can be called before SparkContext initialized
SparkHadoopUtil.get.addCredentials(jobConf)
// 先看完整个方法定义,可以看到该方法返回了分区数组,而分区数组元素的数量实质是由下面这个变量决定的
// 因此,需要跟进这里的getSplits方法
val allInputSplits = getInputFormat(jobConf).getSplits(jobConf, minPartitions) // minPartitions即上述确认的2
val inputSplits = if (ignoreEmptySplits) {
allInputSplits.filter(_.getLength > 0)
} else {
allInputSplits
}
val array = new Array[Partition](inputSplits.size)
for (i <- 0 until inputSplits.size) {
array(i) = new HadoopPartition(id, i, inputSplits(i))
}
array
}
getSplits实现方法众多,一个个查看确认是org.apache.hadoop.mapred.FileInputFormat实现的:
public InputSplit[] getSplits(JobConf job, int numSplits)
throws IOException {
// 获取目录下所有文件
FileStatus[] files = listStatus(job);
long totalSize = 0; // compute total size
for (FileStatus file: files) { // check we have valid files
if (file.isDirectory()) {
throw new IOException("Not a file: "+ file.getPath());
}
// 计算出目录下所有文件的大小总和
totalSize += file.getLen();
}
long goalSize = totalSize / (numSplits == 0 ? 1 : numSplits);
// 分片的最小大小,最小1
long minSize = Math.max(job.getLong(org.apache.hadoop.mapreduce.lib.input.
FileInputFormat.SPLIT_MINSIZE, 1), minSplitSize);
// generate splits
ArrayList<FileSplit> splits = new ArrayList<FileSplit>(numSplits);
for (FileStatus file: files) {
Path path = file.getPath();
long length = file.getLen();
if (length != 0) {
// 文件可分片
if (isSplitable(fs, path)) {
// 文件系统的块大小
long blockSize = file.getBlockSize();
// 该方法的实现即Math.max(minSize, Math.min(goalSize, blockSize))
// 计算出应当分割的阈值
long splitSize = computeSplitSize(goalSize, minSize, blockSize);
long bytesRemaining = length;
// SPLIT_SLOP为1.1
while (((double) bytesRemaining)/splitSize > SPLIT_SLOP) {
// ...分片并将分片添加到splits
bytesRemaining -= splitSize;
}
// ...分片剩余部分
} else {
// 不可分片的文件,就只创建一个分片
}
} else {
// 空文件就分一个为空的分片
}
}
return splits.toArray(new FileSplit[splits.size()]);
}
可以看到,当输入的都是小文件时,splitSize = max(minSize, Math.min(goalSize, blockSize)),
- 假设goalSize比每个文件都大,取到的splitSize也都会比每个文件大,基本一个文件对应一个切片;
- 假设goalSize比部分文件小,取到splitSize有可能比每个文件小,一个文件可能对应多个切片。
此时我们再回到HadoopRDD#getPartitions方法,上述的切片数量对应着RDD的分区数,在Spark中,RDD分区和task一一对应,因此小文件多,task也就多。即使通过repartition,也无法改变读取时RDD分区多的事实!因此一定要避免用Spark处理过多的小文件问题。