Apache spark 通过分区将拼花地板数据附加到同一基本路径的多个spark作业
我有多个要并行执行的作业,它们使用分区将每日数据附加到同一路径中 e、 g 作业1-类别=“计费\u事件” 作业2-类别=“单击事件” 这两个作业都将在执行之前截断s3存储桶中存在的任何现有分区,然后将生成的拼花文件保存到各自的分区中 i、 e 作业1->s3://bucket/save/path/eventDate=20160101/channel=billing\u事件 作业2->s3://bucket/save/path/eventDate=20160101/channel=单击事件 我面临的问题是spark在作业执行期间创建的临时文件。它将计算文件保存到基本路径 s3://bucket/save/path/_temporary/ 因此,两个作业最终共享同一个临时文件夹并导致冲突,我注意到这可能导致一个作业删除临时文件,而另一个作业失败,s3的404表示预期的临时文件不存在 有没有人遇到过这个问题,并提出了一种策略,使作业在相同的基本路径上并行执行Apache spark 通过分区将拼花地板数据附加到同一基本路径的多个spark作业,apache-spark,parquet,Apache Spark,Parquet,我有多个要并行执行的作业,它们使用分区将每日数据附加到同一路径中 e、 g 作业1-类别=“计费\u事件” 作业2-类别=“单击事件” 这两个作业都将在执行之前截断s3存储桶中存在的任何现有分区,然后将生成的拼花文件保存到各自的分区中 i、 e 作业1->s3://bucket/save/path/eventDate=20160101/channel=billing\u事件 作业2->s3://bucket/save/path/eventDate=20160101/channel=单击事件 我面
我现在正在使用spark 1.6.0我怀疑这是因为spark 1.6中引入了对分区发现的更改。这些更改意味着,如果您指定了“basepath”选项(请参阅Spark发行说明),Spark将仅将类似于
../xxx=yyy/
的路径视为分区
因此,我认为如果您添加basepath选项,您的问题将得到解决,如下所示:
dataFrame
.write()
.partitionBy("eventDate", "category")
.option("basepath", "s3://bucket/save/path")
.mode(Append)
.parquet("s3://bucket/save/path");
(我还没有机会验证它,但希望它能起到作用:)所以在读了很多关于如何解决这个问题的书之后,我想我会在这里传授一些智慧来总结一下。多亏了好未来的评论 我还发现,直接写入s3://bucket/save/path似乎很危险,因为如果作业被终止,并且临时文件夹的清理不会在作业结束时进行,它似乎是留给下一个作业的,我注意到有时前一个被杀死的作业临时文件会放在s3://bucket/save/path中并导致重复。。。完全不可靠 此外,将临时文件夹文件重命名为其相应的s3文件需要大量时间(每个文件约1秒),因为s3仅支持复制/删除而不支持重命名。此外,只有驱动程序实例使用单个线程重命名这些文件,因此有多达1/5的具有大量文件/分区的作业仅用于等待重命名操作 出于一些原因,我排除了使用DirectOutputCommitter的可能性
这允许并发作业在保存到唯一的临时文件夹时执行,无需使用DirectOutputCommitter,因为HDFS上的重命名操作比S3更快,并且保存的数据更一致。而不是使用
分区方式
dataFrame.write().
partitionBy("eventDate", "category")
.mode(Append)
.parquet("s3://bucket/save/path");
或者,您可以将文件编写为
在作业1中,将拼花文件路径指定为:
dataFrame.write().mode(Append)
.parquet("s3://bucket/save/path/eventDate=20160101/channel=billing_events")
dataFrame.write().mode(Append)
.parquet("s3://bucket/save/path/eventDate=20160101/channel=click_events")
&在作业2中,将拼花地板文件路径指定为:
dataFrame.write().mode(Append)
.parquet("s3://bucket/save/path/eventDate=20160101/channel=billing_events")
dataFrame.write().mode(Append)
.parquet("s3://bucket/save/path/eventDate=20160101/channel=click_events")
当在
FileOutputCommitter
的cleanupJob
中删除\u-temporary
时,具有“partitionBy”的同一路径的多个写入任务将失败,例如没有这样的文件或目录
测试代码:
def batchTask[A](TASK_tag: String, taskData: TraversableOnce[A], batchSize: Int, fTask: A => Unit, fTaskId: A => String): Unit = {
var list = new scala.collection.mutable.ArrayBuffer[(String, java.util.concurrent.Future[Int])]()
val executors = java.util.concurrent.Executors.newFixedThreadPool(batchSize)
try {
taskData.foreach(d => {
val task = executors.submit(new java.util.concurrent.Callable[Int] {
override def call(): Int = {
fTask(d)
1
}
})
list += ((fTaskId(d), task))
})
var count = 0
list.foreach(r => if (!r._2.isCancelled) count += r._2.get())
} finally {
executors.shutdown()
}
}
def testWriteFail(outPath: String)(implicit spark: SparkSession, sc: SparkContext): Unit = {
println(s"try save: ${outPath}")
import org.apache.spark.sql.functions._
import spark.sqlContext.implicits._
batchTask[Int]("test", 1 to 20, 6, t => {
val df1 =
Seq((1, "First Value", java.sql.Date.valueOf("2010-01-01")), (2, "Second Value", java.sql.Date.valueOf("2010-02-01")))
.toDF("int_column", "string_column", "date_column")
.withColumn("t0", lit(t))
df1.repartition(1).write
.mode("overwrite")
.option("mapreduce.fileoutputcommitter.marksuccessfuljobs", false)
.partitionBy("t0").csv(outPath)
}, t => f"task.${t}%4d") // some Exception
println(s"fail: count=${spark.read.csv(outPath).count()}")
}
try {
testWriteFail(outPath + "/fail")
} catch {
case e: Throwable =>
}
失败
使用输出提交程序:
package org.jar.spark.util
import java.io.IOException
/*
* 用于 DataFrame 多任务写入同一个目录。
* <pre>
* 1. 基于临时目录写入
* 2. 如果【任务的输出】可能会有重叠,不要使用 overwrite 方式,以免误删除
* </pre>
* <p/>
* Created by liao on 2018-12-02.
*/
object JMultiWrite {
val JAR_Write_Cache_Flag = "jar.write.cache.flag"
val JAR_Write_Cache_TaskId = "jar.write.cache.taskId"
/** 自动删除目标目录下同名子目录 */
val JAR_Write_Cache_Overwrite = "jar.write.cache.overwrite"
implicit class ImplicitWrite[T](dw: org.apache.spark.sql.DataFrameWriter[T]) {
/**
* 输出到文件,需要在外面配置 option format mode 等
*
* @param outDir 输出目标目录
* @param taskId 此次任务ID,用于隔离各任务的输出,必须具有唯一性
* @param cacheDir 缓存目录,最好是 '_' 开头的目录,如 "_jarTaskCache"
* @param overwrite 是否删除已经存在的目录,默认 false 表示 Append模式
* <font color=red>(如果 并行任务可能有相同 子目录输出时,会冲掉,此时不要使用 overwrite)</font>
*/
def multiWrite(outDir: String, taskId: String, cacheDir: String = "_jarTaskCache", overwrite: Boolean = false): Boolean = {
val p = path(outDir, cacheDir, taskId)
dw.options(options(cacheDir, taskId))
.option(JAR_Write_Cache_Overwrite, overwrite)
.mode(org.apache.spark.sql.SaveMode.Overwrite)
.save(p)
true
}
}
def options(cacheDir: String, taskId: String): Map[String, String] = {
Map(JAR_Write_Cache_Flag -> cacheDir,
JAR_Write_Cache_TaskId -> taskId,
"mapreduce.fileoutputcommitter.marksuccessfuljobs" -> "false",
"mapreduce.job.outputformat.class" -> classOf[JarOutputFormat].getName
)
}
def path(outDir: String, cacheDir: String, taskId: String): String = {
assert(outDir != "", "need OutDir")
assert(cacheDir != "", "need CacheDir")
assert(taskId != "", "needTaskId")
outDir + "/" + cacheDir + "/" + taskId
}
/*-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-*/
class JarOutputFormat extends org.apache.hadoop.mapreduce.lib.output.TextOutputFormat {
var committer: org.apache.hadoop.mapreduce.lib.output.FileOutputCommitter = _
override def getOutputCommitter(context: org.apache.hadoop.mapreduce.TaskAttemptContext): org.apache.hadoop.mapreduce.OutputCommitter = {
if (this.committer == null) {
val output = org.apache.hadoop.mapreduce.lib.output.FileOutputFormat.getOutputPath(context)
this.committer = new JarOutputCommitter(output, context)
}
this.committer
}
}
class JarOutputCommitter(output: org.apache.hadoop.fs.Path, context: org.apache.hadoop.mapreduce.TaskAttemptContext)
extends org.apache.hadoop.mapreduce.lib.output.FileOutputCommitter(output, context) {
override def commitJob(context: org.apache.hadoop.mapreduce.JobContext): Unit = {
val finalOutput = this.output
val cacheFlag = context.getConfiguration.get(JAR_Write_Cache_Flag, "")
val myTaskId = context.getConfiguration.get(JAR_Write_Cache_TaskId, "")
val overwrite = context.getConfiguration.getBoolean(JAR_Write_Cache_Overwrite, false)
val hasCacheFlag = finalOutput.getName == myTaskId && finalOutput.getParent.getName == cacheFlag
val finalReal = if (hasCacheFlag) finalOutput.getParent.getParent else finalOutput // 确定最终目录
// 遍历输出目录
val fs = finalOutput.getFileSystem(context.getConfiguration)
val jobAttemptPath = getJobAttemptPath(context)
val arr$ = fs.listStatus(jobAttemptPath, new org.apache.hadoop.fs.PathFilter {
override def accept(path: org.apache.hadoop.fs.Path): Boolean = !"_temporary".equals(path.getName())
})
if (hasCacheFlag && overwrite) // 移除同名子目录
{
if (fs.isDirectory(finalReal)) arr$.foreach(stat =>
if (fs.isDirectory(stat.getPath)) fs.listStatus(stat.getPath).foreach(stat2 => {
val p1 = stat2.getPath
val p2 = new org.apache.hadoop.fs.Path(finalReal, p1.getName)
if (fs.isDirectory(p1) && fs.isDirectory(p2) && !fs.delete(p2, true)) throw new IOException("Failed to delete " + p2)
})
)
}
arr$.foreach(stat => {
mergePaths(fs, stat, finalReal)
})
cleanupJob(context)
if (hasCacheFlag) { // 移除缓存目录
try {
fs.delete(finalOutput, false)
val pp = finalOutput.getParent
if (fs.listStatus(pp).isEmpty)
fs.delete(pp, false)
} catch {
case e: Exception =>
}
}
// 不用输出 _SUCCESS 了
//if (context.getConfiguration.getBoolean("mapreduce.fileoutputcommitter.marksuccessfuljobs", true)) {
// val markerPath = new org.apache.hadoop.fs.Path(this.outputPath, "_SUCCESS")
// fs.create(markerPath).close()
//}
}
}
@throws[IOException]
def mergePaths(fs: org.apache.hadoop.fs.FileSystem, from: org.apache.hadoop.fs.FileStatus, to: org.apache.hadoop.fs.Path): Unit = {
if (from.isFile) {
if (fs.exists(to) && !fs.delete(to, true)) throw new IOException("Failed to delete " + to)
if (!fs.rename(from.getPath, to)) throw new IOException("Failed to rename " + from + " to " + to)
}
else if (from.isDirectory) if (fs.exists(to)) {
val toStat = fs.getFileStatus(to)
if (!toStat.isDirectory) {
if (!fs.delete(to, true)) throw new IOException("Failed to delete " + to)
if (!fs.rename(from.getPath, to)) throw new IOException("Failed to rename " + from + " to " + to)
}
else {
val arr$ = fs.listStatus(from.getPath)
for (subFrom <- arr$) {
mergePaths(fs, subFrom, new org.apache.hadoop.fs.Path(to, subFrom.getPath.getName))
}
}
}
else if (!fs.rename(from.getPath, to)) throw new IOException("Failed to rename " + from + " to " + to)
}
}
成功:
这同样适用于其他输出格式,请注意使用覆盖
火花试验2.11.8
感谢@Tal Joffe在分区本身没有任何问题,这更像是并发性问题。当作业执行时,一些内部机制会在基本路径s3://bucket/save/path/\u temporary处创建临时文件,这意味着两个作业不能有效地在同一基本路径上运行。另外,我不确定设置基本路径对于分区发现是强制性的,只有在创建分区时指向子路径而不是基本路径的情况下dataframe@vcetinick似乎我们在使用HDFS时遇到了类似的问题,我将进行更多的调查,并可能在Spark问题跟踪程序中提交一个bug。您可以使用direct output committer,因为它不使用临时文件夹,因此不会发生冲突。direct output committer将数据写入存储桶,然后作业失败,并将部分数据留在s3中,而这些数据永远不会被清除。是否存在任何风险?是的肯定由于对文件系统的提交是在任务级别上完成的(即对于每个输出文件),因此可以对部分数据进行修改。我们解决这个问题的方法是(直接)写入临时文件夹,并在Spark作业完成后将其复制到最终目标(使用S3DistCp步骤)。我寻找了一个更好的方法来解决这个问题,但我发现这是一个很好的问题-我正要着手做类似的事情,但没有考虑共享的
\u临时文件夹(顺便说一句,这是个多么糟糕的主意…)这个问题有没有干净的解决方案?我检查了vcetinick解决方案,它肯定会起作用,但我们有洛杉矶spark的解决方案吗
$ ls ok/
t0=1 t0=10 t0=11 t0=12 t0=13 t0=14 t0=15 t0=16 t0=17 t0=18 t0=19 t0=2 t0=20 t0=3 t0=4 t0=5 t0=6 t0=7 t0=8 t0=9