web-dev-qa-db-fra.com

Plusieurs travaux d'étincelle ajoutant des données de parquet au même chemin de base avec partitionnement

Je souhaite exécuter plusieurs tâches en parallèle, qui ajoutent des données quotidiennes dans le même chemin à l'aide du partitionnement. 

par exemple. 

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

Job 1 - category = "billing_events" Job 2 - category = "click_events"

Ces deux tâches tronqueront les partitions existantes présentes dans le compartiment s3 avant l'exécution, puis enregistreront les fichiers de parquet résultants dans leurs partitions respectives.

c'est à dire. 

job 1 -> s3: // bucket/save/path/eventDate = 20160101/channel = billing_events

job 2 -> s3: // bucket/save/path/eventDate = 20160101/channel = click_events

Le problème auquel nous sommes confrontés concerne les fichiers temporaires créés lors de l'exécution du travail par spark. Il enregistre les fichiers de travail dans le chemin de base

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

de sorte que les deux travaux finissent par partager le même dossier temporaire et provoquent un conflit, ce qui a été remarqué peut amener un travail à supprimer des fichiers temporaires, et l'autre travail échoue avec un 404 de s3 indiquant qu'un fichier temporaire attendu n'existe pas. 

Quelqu'un at-il fait face à ce problème et a mis au point une stratégie visant à exécuter en parallèle des tâches dans le même chemin de base?

im utilisant spark 1.6.0 pour l'instant

13
vcetinick

Donc, après avoir beaucoup lu sur la façon de traiter ce problème, j'ai pensé que je devais transférer une certaine sagesse ici pour conclure. Merci surtout aux commentaires de Tal.

J'ai également constaté qu'écrire directement dans le seau/répertoire/chemin/s3: // semblait dangereux, car si un travail est supprimé et que le nettoyage du dossier temporaire ne se produit pas à la fin du travail, il semble qu'il en reste là pour le travail suivant et j’ai parfois remarqué que les fichiers temporaires des travaux précédemment tués se retrouvent dans le compartiment s3: ///sus/save/path et sont à l’origine de la duplication ... Totalement peu fiable ...

De plus, l'opération de changement de nom des fichiers de dossiers _temporary en leurs fichiers s3 appropriés prend un temps démesuré (environ 1 seconde par fichier), car S3 ne prend en charge que les fonctions copier/supprimer et non renommer. De plus, seule l'instance de pilote renomme ces fichiers en utilisant un seul thread, de sorte que jusqu'à 1/5 de certains travaux comportant un grand nombre de fichiers/partitions sont perdus en attente d'opérations de changement de nom.

J'ai exclu l'utilisation de DirectOutputCommitter pour un certain nombre de raisons. 

  1. Utilisé en conjonction avec le mode spéculation, il en résulte une duplication ( https://issues.Apache.org/jira/browse/SPARK-9899 )
  2. Les échecs de tâches laisseront un fouillis qu'il serait impossible de trouver et de supprimer/nettoyer ultérieurement.
  3. Spark 2.0 a complètement supprimé la prise en charge de cette fonctionnalité et aucun chemin de mise à niveau n'existe. ( https://issues.Apache.org/jira/browse/SPARK-10063 )

Le seul moyen sûr, performant et cohérent d'exécuter ces travaux consiste à les enregistrer d'abord dans un dossier temporaire unique (unique par applicationId ou timestamp). Et copier sur S3 à la fin du travail. 

Cela permet aux tâches simultanées de s'exécuter car elles sont enregistrées dans des dossiers temporaires uniques. Il n'est donc pas nécessaire d'utiliser DirectOutputCommitter, car l'opération de changement de nom sur HDFS est plus rapide que S3 et les données enregistrées plus cohérentes.

11
vcetinick

Au lieu d'utiliser partitionBy DataFrame.write (). PartitionBy ("eventDate", "category") .Mode (Append) .Parquet ("s3 : // bucket/save/path ");

Sinon, vous pouvez écrire les fichiers en tant que 

Dans la tâche 1, spécifiez le chemin du fichier du parquet comme suit: DataFrame.write (). Mode (Ajout) .parquet ("s3: // bucket/save/path/eventDate = 20160101/channel = billing_events")

& dans job-2, spécifiez le chemin du fichier parquet comme suit: dataFrame.write (). mode (ajout) .parquet ("s3: // bucket/save/path/eventDate = 20160101/channel = click_events")

  1. Les deux travaux créeront un répertoire _temporary séparé sous le dossier respectif afin de résoudre le problème de concurrence.
  2. Et la découverte de la partition se produira également comme eventDate = 20160101 et pour la colonne channel.
  3. Inconvénient - même si channel = click_events n’existe pas dans les données, un fichier parquet pour le channel = click_events sera créé.
2
parthiv

Je suppose que cela est dû aux modifications de la découverte de partition introduites dans Spark 1.6. Les modifications signifient que Spark ne traitera les chemins d'accès comme .../xxx=yyy/ comme des partitions que si vous avez spécifié l'option "basepath" (voir Notes de publication de Spark ici ).

Je pense donc que votre problème sera résolu si vous ajoutez l'option basepath, comme ceci:

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

(Je n'ai pas eu l'occasion de le vérifier, mais j'espère que ça fera l'affaire :))

1

Plusieurs tâches d'écriture pour le même chemin avec "partitionBy", willA ÉCHOUÉlorsque _temporary a été supprimé dans cleanupJob de FileOutputCommitter, comme No such file or directory.

CODE D'ESSAI :

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

Echec

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

Et alors:

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

Succès:

$  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

Il en va de même pour les autres formats de sortie, faites attention à l'utilisation de overwrite.

Essai à l'étincelle 2.11.8.

Merci pourTal Joffe

0
jason.liao