Apache spark 通过分区将拼花地板数据附加到同一基本路径的多个spark作业

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=单击事件 我面

我有多个要并行执行的作业,它们使用分区将每日数据附加到同一路径中

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表示预期的临时文件不存在

有没有人遇到过这个问题,并提出了一种策略,使作业在相同的基本路径上并行执行


我现在正在使用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的可能性

  • 当与推测模式结合使用时,会导致重复()
  • 任务失败会留下杂乱的东西,以后不可能找到并移除/清理
  • Spark 2.0已完全取消对此的支持,并且不存在升级路径。()
  • 执行这些作业的唯一安全、高效且一致的方法是首先将它们保存到hdfs中唯一的临时文件夹(通过applicationId或时间戳唯一)。并在作业完成时复制到S3


    这允许并发作业在保存到唯一的临时文件夹时执行,无需使用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")
    
  • 这两个作业将在各自的文件夹下创建单独的临时目录,从而解决并发问题
  • 对于通道列,当eventDate=20160101时也会发生分区发现
  • 缺点-即使数据中不存在channel=click\u事件,也会创建channel=click\u事件的拼花文件

  • 当在
    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