web-dev-qa-db-ja.com

複数のsparkジョブは、パーティショニングを使用して寄木細工のデータを同じベースパスに追加します

パーティション分割を使用して同じパスに毎日のデータを追加する複数のジョブを並行して実行したい。

例えば.

dataFrame.write().
         partitionBy("eventDate", "category")
            .mode(Append)
            .parquet("s3://bucket/save/path");

ジョブ1-カテゴリ= "billing_events"ジョブ2-カテゴリ= "click_events"

これらのジョブはどちらも、実行前にs3バケットに存在する既存のパーティションを切り捨て、結果の寄木細工ファイルをそれぞれのパーティションに保存します。

つまり.

ジョブ1-> s3:// bucket/save/path/eventDate = 20160101/channel = billing_events

ジョブ2-> s3:// bucket/save/path/eventDate = 20160101/channel = click_events

直面している問題は、sparkによるジョブの実行中に作成される一時ファイルです。ワークアウトファイルをベースパスに保存します

s3:// bucket/save/path/_temporary/...

したがって、両方のジョブが同じ一時フォルダを共有し、競合が発生します。これにより、一方のジョブが一時ファイルを削除し、もう一方のジョブが失敗して、予期される一時ファイルが存在しないとs3の404が表示されます。

誰かがこの問題に直面し、同じ基本パスでジョブを並行して実行する戦略を考え出しましたか?

私はspark 1.6.0を今のところ使用しています

22
vcetinick

したがって、この問題への取り組み方について多くの記事を読んだ後、私はidをここに戻し、物事を締めくくると考えました。主にタルのコメントに感謝します。

さらに、s3:// bucket/save/pathに直接書き込むと危険なように見えることがわかりました。ジョブが終了し、一時フォルダーのクリーンアップがジョブの最後に行われない場合は、ジョブが残っているようです。次のジョブと私は時々前に殺されたジョブの一時ファイルがs3:// bucket/save/pathに着陸して重複を引き起こすことに気づきました...完全に信頼できません...

さらに、S3は名前の変更ではなくコピー/削除のみをサポートしているため、_temporaryフォルダーファイルの適切なs3ファイルへの名前変更操作には、非常に長い時間がかかります(ファイルあたり約1秒)。さらに、ドライバーインスタンスのみが単一のスレッドを使用してこれらのファイルの名前を変更するため、多数のファイル/パーティションを含む一部のジョブの1/5は、名前変更操作を待つだけで費やされます。

DirectOutputCommitterの使用を除外した理由はいくつかあります。

  1. 投機モードと組み合わせて使用​​すると、重複が発生します( https://issues.Apache.org/jira/browse/SPARK-9899
  2. タスクの失敗は混乱を招き、後で見つけて削除/削除することは不可能です。
  3. Spark 2.0はこれに対するサポートを完全に削除し、アップグレードパスは存在しません。( https://issues.Apache.org/jira/browse/SPARK-1006

これらのジョブを実行するための唯一の安全で、パフォーマンスが高く、一貫した方法は、最初にhdfs内の一意の一時フォルダー(applicationIdまたはタイムスタンプで一意)に保存することです。そして、ジョブの完了時にS3にコピーします。

これにより、同時ジョブを一意の一時フォルダーに保存するときに実行できます。HDFSでの名前変更操作はS3よりも速く、保存されたデータの一貫性が高いため、DirectOutputCommitterを使用する必要はありません。

17
vcetinick

partitionByを使用する代わりに

dataFrame.write().
         partitionBy("eventDate", "category")
            .mode(Append)
            .parquet("s3://bucket/save/path");

または、ファイルを次のように書き込むこともできます

Job-1で、寄木細工のファイルのパスを次のように指定します。

dataFrame.write().mode(Append)            
.parquet("s3://bucket/save/path/eventDate=20160101/channel=billing_events")

&ジョブ2で、寄木細工のファイルのパスを次のように指定します。

dataFrame.write().mode(Append)            
.parquet("s3://bucket/save/path/eventDate=20160101/channel=click_events")
  1. 両方のジョブで、それぞれのフォルダーの下に別の_temporaryディレクトリーが作成されるため、同時実行の問題が解決されます。
  2. また、パーティションの検出は、eventDate = 20160101として、およびチャネル列に対しても行われます。
  3. 欠点-データにchannel = click_eventsが存在しない場合でも、channel = click_eventsのパーケットファイルが作成されます。
3
parthiv

これは、Spark 1.6で導入されたパーティション検出の変更によるものだと思います。この変更は、Sparkが.../xxx=yyy/「ベースパス」オプションを指定した場合はパーティションとして(Sparkリリースノート こちら を参照)。

したがって、次のようにbasepath-optionを追加すると、問題は解決すると思います。

dataFrame
  .write()
  .partitionBy("eventDate", "category")
  .option("basepath", "s3://bucket/save/path")
  .mode(Append)
  .parquet("s3://bucket/save/path");

(私はそれを確認する機会がありませんでしたが、うまくいけばそれがうまくいくでしょう:))

「partitionBy」を使用した同じパスの複数の書き込みタスクは、cleanupJob of FileOutputCommitter_temporaryが削除されると[〜#〜] failed [〜#〜]になります] _、No such file or directoryなど。

テストコード

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 =>
}

失敗

OutputCommitterを使用:

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)
  }
}

その後:

def testWriteOk(outPath: String)(implicit spark: SparkSession, sc: SparkContext): Unit = {
  println(s"try save: ${outPath}")
  import org.Apache.spark.sql.functions._
  import org.jar.spark.util.JMultiWrite.ImplicitWrite // 导入工具
  import spark.sqlContext.implicits._
  batchTask[Int]("test.ok", 1 to 20, 6, t => {
    val taskId = t.toString
    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(taskId))
    df1.repartition(1).write
      .partitionBy("t0")
      .format("csv")
      .multiWrite(outPath, taskId, overwrite = true) // 这里使用了 overwrite ,如果分区有重叠,请不要使用 overwrite
  }, t => f"task.${t}%4d")
  println(s"ok: count=${spark.read.csv(outPath).count()}") // 40
}
try {
  testWriteOk(outPath + "/ok")
} catch {
  case e: Throwable =>
}

成功:

$  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

同じことが他の出力フォーマットにも当てはまります。overwriteの使用に注意してください。

spark 2.11.8。

@Tal Joffeに感謝

0
jason.liao