广播哈希联接-迭代

时间:2018-12-14 17:21:19

标签: apache-spark pyspark apache-spark-sql

当我们有一个足够小以适合内存的数据帧时,我们在Spark中使用广播哈希联接。当小数据框的大小小于spark.sql.autoBroadcastJoinThreshold时 我对此几乎没有疑问。

我们暗示广播的小数据帧的生命周期是多少?它会在内存中保留多长时间?我们如何控制它?

例如,如果我使用广播哈希连接两次将一个大数据框与一个小数据框连接在一起。第一次执行联接时,它将把小数据帧广播到工作节点并执行联接,同时避免大数据帧数据的混洗。

我的问题是,执行者将保留广播数据帧的副本多长时间?它会保留在内存中直到会话结束吗?否则,一旦我们采取任何措施,它将被清除。我们可以控制还是清除它?或者我只是在错误的方向上思考...

3 个答案:

答案 0 :(得分:3)

至少在Spark 2.4.0中,您的问题的答案是,数据帧将保留在驱动程序进程的内存中,直到SparkContext完成,即直到您的应用程序结束为止。

事实上,广播联接是使用广播变量实现的,但是使用DataFrame API时,您无法访问基础广播变量。在内部使用它后,Spark本身不会销毁此变量,因此它始终存在。

具体来说,如果您查看BroadcastExchangeExec(https://github.com/apache/spark/blob/master/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/BroadcastExchangeExec.scala)的代码,则可以看到它创建了一个私有变量relationFuture,该变量保存了Broadcast变量。此私有变量仅在此类中使用。作为用户,您无法访问它以对其调用destroy,而curretn实现中的任何地方都不会为您调用Spark。

答案 1 :(得分:1)

这里的想法是在连接之前创建广播变量以轻松控制它。没有它,您将无法控制这些变量-spark为您完成。

示例:

from pyspark.sql.functions import broadcast

sdf2_bd = broadcast(sdf2)
sdf1.join(sdf2_bd, sdf1.id == sdf2_bd.id)

此规则应用于所有广播变量(通过联接自动创建或手动创建):

  1. 广播数据仅发送到包含需要执行者的节点。
  2. 广播数据存储在内存中。如果没有足够的可用内存,则使用磁盘。
  3. 完成广播变量后,应destroy释放内存。

答案 2 :(得分:1)

在我对广播选项进行了一些研究之后,还有一些其他发现。

让我们考虑下一个示例:

import org.apache.spark.sql.functions.{lit, broadcast}

val data = Seq(
(2010, 5, 10, 1520, 1),
(2010, 5, 1, 1520, 1),
(2011, 11, 25, 1200, 2),
(2011, 11, 25, 1200, 1),
(2012, 6, 10, 500, 2),
(2011, 11, 5, 1200, 1),
(2012, 6, 1, 500, 2),
(2011, 11, 2, 200, 2))

val bigDF = data
            .toDF("Year", "Month", "Day", "SalesAmount", "StoreNumber")
            .select("Year", "Month", "Day", "SalesAmount")

val smallDF = data
            .toDF("Year", "Month", "Day", "SalesAmount", "StoreNumber")
            .where($"Year" === lit(2011))
            .select("Year", "Month", "Day", "StoreNumber")

val partitionKey = Seq("Year", "Month", "Day")
val broadcastedDF = broadcast(smallDF)
val joinedDF = bigDF.join(broadcastedDF, partitionKey)

与预期的一样,joinedDF的执行计划应为下一个:

== Physical Plan ==
*(1) Project [Year#107, Month#108, Day#109, SalesAmount#110, StoreNumber#136]
+- *(1) BroadcastHashJoin [Year#107, Month#108, Day#109], [Year#132, Month#133, Day#134], Inner, BuildRight, false
   :- LocalTableScan [Year#107, Month#108, Day#109, SalesAmount#110]
   +- BroadcastExchange HashedRelationBroadcastMode(ArrayBuffer(input[0, int, false], input[1, int, false], input[2, int, false]))
      +- LocalTableScan [Year#132, Month#133, Day#134, StoreNumber#136]

如果不使用显式广播,那可能也是一样的,因为smallDF非常小,并且适合默认的广播大小(10MB)。

现在,我希望我能够从joinedDF的依赖关系访问广播的数据帧,因此我尝试通过打印出rdd.id来获取joindDF和广播的DF的所有依赖关系来访问广播的df辅助功能:

import org.apache.spark.rdd._

def printDependency(rdd : RDD[_], indentation: String = "") : Unit = {
      if (rdd == null)
        return;

      println(s"$indentation Partition Id: ${rdd.id} ")
      rdd.dependencies.foreach { d => printDependency(d.rdd, s"$indentation ")}
}

println(s"Broadcasted id: ${broadcastedDF.rdd.id}")
printDependency(joinedDF.rdd)

//Output
//
// Broadcasted id: 164
//
// Partition Id: 169 
//   Partition Id: 168 
//    Partition Id: 167 
//     Partition Id: 166 
//      Partition Id: 165

令人惊讶的是,我意识到广播的数据帧不包含/考虑为joinDF的DAG的一部分,这很有意义,因为一旦我们广播了smallDF的实例,我们就不再想跟踪其更改了,当然Spark意识到了这一点。

释放广播数据集的一种方法是使用unpersist,如下所示:

val broadcastedDF = smallDF.hint("broadcast")
val joinedDF = bigDF.join(broadcastedDF, partitionKey)

broadcastedDF.unpersist()

第二种方法是直接使用sparkContext API,如下所示:

val broadcastedDF = spark.sparkContext.broadcast(smallDF)
val joinedDF = bigDF.join(broadcastedDF.value, partitionKey)
broadcastedDF.destroy() // or unpersist for async

尽管这将删除广播实例本身,而不是底层的smallDF。最后一个将被标记为删除,并且不会立即删除,这取决于是否有其他引用。这将与ContextCleaner类结合使用,更具体地说,将由keepCleaning方法控制,该方法尝试删除在程序执行期间或在程序执行时不再需要的RDD,累加器,随机播放和检查点上下文结束(如前所述)。

删除不再使用的joinDF依赖项的第二种方法(我认为更安全)是通过方法 df.persist(),df.checkpoint(),rdd.persist()和rdd.checkpoint()。所有提到的方法最终都将调用ContextCleaner类的registerRDDForCleanup或registerForCleanup方法,以清理其父依赖项。

一个明显的问题是使用哪个,有什么区别?有两个主要区别,首先是checkpoint(),您可以通过从同一检查点目录加载数据来在第二个作业中重用输出数据。其次,dataframe API将在保存数据时应用其他优化,RDD API中没有此类功能。

因此,最后的结论是,您可以通过调用df.persist(), df.checkpoint, rdd.persist() and rdd.checkpoint()之一来修剪RDD祖先的数据。 修剪将在作业执行过程中发生,而不仅仅是在上下文终止时发生。最后但并非最不重要的一点是,您不要忘记以前的所有方法都会被懒惰地评估,因此仅在之后执行动作。

更新:

似乎立即强制为数据帧/ RDD释放内存的最有效方法是调用unpersist,如here所述。然后,代码将稍微更改为:

val broadcastedDF = smallDF.hint("broadcast")
val joinedDF = bigDF.join(broadcastedDF, partitionKey)
broadcastedDF.unpersist()