两个版本(各有优劣)
-
mapreduce.fileoutputcommitter.algorithm.version = 1
性能方面:v1在task结束后只是将输出文件拷到临时目录,然后在job结束后才由Driver把这些文件再拷到输出目录。如果文件数量很多,Driver就需要不断的和NameNode做交互,而且这个过程是单线程的,因此势必会增加耗时。如果我们碰到有spark任务所有task结束了但是任务还没结束,很可能就是Driver还在不断的拷文件;数据一致性方面:v1在Job结束后才批量拷文件,其实就是两阶段提交,它可以保证数据要么全部展示给用户,要么都没展示(当然,在拷贝过程中也无法保证完全的数据一致性,但是这个时间一般来说不会太长)。如果任务失败,也可以直接删了_temporary目录,可以较好的保证数据一致性。
-
mapreduce.fileoutputcommitter.algorithm.version = 2
性能方面:v2在task结束后立马将输出文件拷贝到输出目录,后面Job结束后Driver就不用再去拷贝了数据一致性方面: v2在task结束后就拷文件,就会造成spark任务还未完成就让用户看到一部分输出,这样就完全没办法保证数据一致性了。另外,如果任务在输出过程中失败,就会有一部分数据成功输出,一部分没输出的情况。
-
总结
在性能方面,v2完胜v1
在数据一致性方面,v1完胜v2
原理解析
Spark任务输出文件的总过程
- Job启动时创建一个目录: ${output.dir}/_temporary/${appAttemptId} 作为本次运行的输出临时目录
- 当有task开始运行后,会创建 ${output.dir}/_temporary/${appAttemptId}/_temporary/${taskAttemptId}/${fileName} 文件,后面这个task的所有输出都会被写到这个文件中
- 当task运行完后,需要检查是否要commit,如果需要commit,会调用OutputCommitter#commitTask()方法
- 等整个Job执行完就调用OutputCommitter#commitJob()方法
ps: 上述路径解释
output.dir表示用户指定的输出目录,appAttemptId表示任务的attemptId,一般从0开始一直递增。taskAttemptId表示task的attemptId,比如taskId是0,第一次运行,这个id就是0.0。taskAttemptId的输出格式是这样的(toString方法实现)1、
当task的type是map时,格式为attempt_{时间表达字符串}{stageId}m{sparkPartitionId}{TaskId},比如attempt目录是attempt_20190801032358_0020_m_000093_7683,则表示stageId为20,type是m,对应map
task(reduce
task则为r),sparkPartitionId表示该task在stage中的id是93,最后,它对应的TaskId是7683。2、
当task的type是reduce时,格式为attempt_{时间表达字符串}{rddId}r{sparkPartitionId}{attemptNumber},比如attempt目录是attempt_20190801042010_1478_r_000970_1,则表示对应的rddId是1478,r表示type是reduce
task,970表示partiton的Id,最后的1表示这是该task的第二次运行(第一次运行是0)——
一个stage中可能会有多个rdd,所以stageId并不等于rddId
mapreduce.fileoutputcommitter.algorithm.version 控制 commitTask和commitJob的具体方式
OutputCommitter 只是一个抽象类,spark运行时会从配置中获取指定的实现类,如果配置中没指定,spark默认会使用 org.apache.hadoop.mapred.FileOutputCommitter 的实现。
public void commitTask(TaskAttemptContext context, Path taskAttemptPath)
throws IOException {
........
if (taskAttemptDirStatus != null) {
if (algorithmVersion == 1) {
Path committedTaskPath = getCommittedTaskPath(context);
if (fs.exists(committedTaskPath)) {
if (!fs.delete(committedTaskPath, true)) {
throw new IOException("Could not delete " + committedTaskPath);
}
}
if (!fs.rename(taskAttemptPath, committedTaskPath)) {
throw new IOException("Could not rename " + taskAttemptPath + " to "
+ committedTaskPath);
}
LOG.info("Saved output of task '" + attemptId + "' to " +
committedTaskPath);
} else {
// directly merge everything from taskAttemptPath to output directory
mergePaths(fs, taskAttemptDirStatus, outputPath);
LOG.info("Saved output of task '" + attemptId + "' to " +
outputPath);
}
} else {
LOG.warn("No Output found for " + attemptId);
}
} else {
LOG.warn("Output Path is null in commitTask()");
}
}
public void commitJob(JobContext context) throws IOException {
........
jobCommitNotFinished = false;
........
}
protected void commitJobInternal(JobContext context) throws IOException {
........
if (algorithmVersion == 1) {
for (FileStatus stat: getAllCommittedTaskPaths(context)) {
mergePaths(fs, stat, finalOutput);
}
}
........
}
- 设置成v1时,task完成commitTask时会将临时生成的数据移动到task对应目录下,当所有task都执行完成进行commitJob的时候,会由driver按照单线程模式将所有task目录下的数据文件移动到最终的作业输出目录
v1:commitTask的操作是将 ${output.dir}/_temporary/${appAttemptId}/_temporary/${taskAttemptId} 重命名为 ${output.dir}/_temporary/${appAttemptId}/${taskId})
- 设置成v2时,task完成就会直接将临时生成的数据移动到Job最终输出目录,commitJob的时候就不需要再次移动数据
v2: commitTask的操作是将 ${output.dir}/_temporary/${appAttemptId}/_temporary/${taskAttemptId} 下的文件移动到 ${output.dir} 目录下 (也就是最终的输出目录)
设置方法
-
spark任务可以通过设置spark配置 spark.hadoop.mapreduce.fileoutputcommitter.algorithm.version=2来开启版本2的commit逻辑
-
在hadoop 2.7.0之前,FileOutputCommitter的实现没有区分版本,统一都是使用version=1的commit逻辑。因此如果spark的hadoop依赖包版本如果低于2.7.0,设置mapreduce.fileoutputcommitter.algorithm.version=2是没有用的
-
spark运行时会从配置中获取指定的实现类,如果配置中没指定,spark默认会使用 org.apache.hadoop.mapred.FileOutputCommitter 的实现。