Spark Core - 高效的使用 RDD join

Spark 作爲分佈式的計算框架,最爲影響其執行效率的地方就是頻繁的網絡傳輸。所以一般的,在不存在數據傾斜的情況下,想要提高 Spark job 的執行效率,就儘量減少 job 的 shuffle 過程(減少 job 的 stage),或者退而減小 shuffle 帶來的影響,join 操作也不例外。

所以,針對 spark RDD 的 join 操作的使用,提供一下幾條建議:

  1. 儘量減少參與 join 的 RDD 的數據量。
  2. 儘量避免參與 join 的 RDD 都具有重複的key。
  3. 儘量避免或者減少 shuffle 過程。
  4. 條件允許的情況下,使用 map-join 完成 join。

我們來舉個例子,現在一共有兩個 RDD,一個是元素爲 (String, Double) 類型的 userScoresRDD, 其 key 代表用戶 id,其 value 代表用戶遊戲的歷史分數,id 與分數爲一對多的關係。另一個爲元素爲 (String, String) 的 userMobileRDD, 其 key 代表用戶的id,value 代表用戶的手機號碼。我們現在需要得到每個用戶的最高分以及其手機號,使得可以使用短信的方式向每個用戶告知其最高的遊戲記錄(發短信有些浪費了)。

儘量減少參與 join 的 RDD 的數據量

按照套路,先舉個反例,如下:

代碼 1

  def joinGetUserBestScoreWithMobile1(userScoresRDD: RDD[(String, Double)],
                                     userMobileRDD: RDD[(String, String)])
                                        : RDD[(String, (Double, String))] = {
    val userScoreAndMobile = userScoresRDD.join(userMobileRDD)
    userScoreAndMobile.reduceByKey((x, y) => if (x._1 > y._1) x else y)
  }

在上面的例子中,先進行的 join 操作,在用戶的每條遊戲記錄上都添加了一枚手機號,然後在帶着手機號的 RDD 上通過 reduceByKey 得到每個用戶最高分已經手機號。

這樣做明顯會影響效率,我們明顯可以先算出每個用戶的最高分,然後在去得到他的手機號:

代碼 2

  def joinGetUserBestScoreWithMobile2(userScoresRDD: RDD[(String, Double)],
                                      userMobilesRDD: RDD[(String, String)])
                                        : RDD[(String, (Double, String))] = {
    val userBestScore = userScoresRDD.reduceByKey((x, y) => if (x > y) x else y)
    userBestScore.join(userMobilesRDD)
  }

兩種都使用的reduceByKey,但後者會明顯減少參與 join 操作的數據量,即減少了shuffle 的時間,又減少了計算的時間,增加效率,降低了數據的冗餘。

儘量避免參與 join 的 RDD 都具有重複的key

此條建議是爲了避免發生兩個RDD full join 而笛卡爾積的情況。

在我們的例子中,假如每個用戶都擁有多個手機號,爲了避免 full join 而使數據暴增,我們可以在代碼2的基礎上,先對 userMobilesRDD 使用 combileByKey 進行處理,減少重複的 key。

儘量避免或者減少 shuffle 過程

Join 怎麼才能避免或減少 shuffle 操作呢? 我們知道只有父子RDD的依賴關係爲寬依賴的時候,纔會發生shuffle,所以關鍵就是控制父子RDD的依賴關係。join 操作有兩個父RDD(即被join的RDD),一個子RDD(join後的結果),首先需要了解一下join操作時依賴的判斷過程。下面即爲過程源代碼:

Spark 源代碼 org.apache.spark.rdd.CoGroupedRDD

  override def getDependencies: Seq[Dependency[_]] = {
    rdds.map { rdd: RDD[_] =>
      if (rdd.partitioner == Some(part)) {
        logDebug("Adding one-to-one dependency with " + rdd)
        new OneToOneDependency(rdd)
      } else {
        logDebug("Adding shuffle dependency with " + rdd)
        new ShuffleDependency[K, Any, CoGroupCombiner](
          rdd.asInstanceOf[RDD[_ <: Product2[K, _]]], part, serializer)
      }
    }
  }

其中 part 爲 join 所使用的分區器,rdds 爲參加 join 的RDD。通過代碼,我們就可以瞭解到,當父RDD與 join操作 使用相同的分區器的時候,父子RDD纔會建立窄依賴(OneToOneDependency)關係,否則就使用寬依賴關係,並且 shuffle 使用join操作的分區器來進行分區。

所以最差情況下,如下圖一,兩個父RDD的分區器與 join 使用的分區都不相同(一般是父RDD的分區器都爲 None),兩個父RDD到子RDD,都會進行shuffle操作:

好一點的情況,如下圖二,即只有一個父RDD的分區器與 join操作 所使用的相同。這樣只會在一個RDD上發生 shuffle。

最後就是最完美的情況,兩個父RDD的分區器都與join操作使用的分區器相同。如下圖三,不會發生任何shuffle操作:

所以,我們可以實際情況,至少減少一次不必要的 shuffle 操作。

下一步我們要做的就是指定父RDD與 join 操作的的分區器爲相同的。我們知道,許多的寬依賴操作都可以爲其指定分區器,以決定其生成的RDD所使用的分區器,比如 reduceByKey。當然 join 操作也例外,所以我們可以在 join 的時候傳入指定的分區器,這樣來達到我們想要減少 shuffle 的目的。但是,當我們爲兩個父RDD指定了相同的分區器的時候,就不需要再爲 join 操作傳入指定的分區器,這是因爲join操作會拿到兩個父RDD的中分區器中分區數多的那個分區器作爲默認分區器。

關於 join 操作獲取默認分區器的詳細,具體請看源代碼(org.apache.spark.Partitioner 的 defaultPartitioner)

實踐一下

讓我們回到我們的例子,可以發現我們在使用 reduceByKey 生成 userBestScoreRDD 的時候,使用的是 userMobilesRDD 的分區器(或者在 join 時將要被使用的分區器)。

  def joinGetUserBestScoreWithMobile4(userScoresRDD: RDD[(String, Double)],
                                      userMobilesRDD: RDD[(String, String)]): RDD[(String, (Double, Option[String]))] = {
    // 如果 userMobilesRDD 存在已知的 partitioner,就直接獲取
    // 沒有就構建返回 userMobilesRDD 將要默認使用的 HashPartitioner.
    val mobileRDDPartitioner = userMobilesRDD.partitioner match {
      case (Some(p)) => p
      case (None) => new HashPartitioner(userMobilesRDD.partitions.length)
    }
    //
    val userBestScoreRDD = userScoresRDD.reduceByKey(mobileRDDPartitioner,
                                               (x,y) => if (x > y) x else y)
    // 在做 join 的時候。至少省去了一次 shuffle 的所帶來的代價。
    userBestScoreRDD.join(userMobilesRDD)
  }

仔細分析的話,在整個joinGetUserBestScoreWithMobile4方法裏,相比於之前的代碼示例,我們至少減少了一次shuffle操作。這取決於userMobilesRDD的分區器情況。如果userMobilesRDD沒有分區器(爲None),則userMobilesRDD在參與join的時候會進行 shuffle 操作,而userBestScoreRDD則不會發生shuffle操作。則一共的shuffle次數爲2(加上 reduceByKey 一次).這也就是我們所說的“好一點的情況”。

如果userMobilesRDD已經有了分區器,則 userMobilesRDD 與 userBestScoreRDD 在join的時候都不需要shuffle,所以僅僅 reduceByKey 進行了一次shuffle.這也就是我們所說的“完美情況”。

兩個分區器怎樣才叫做相同,具體要看分區器 equals 方法的實現,以HashPartitioner爲例,分區數相同,分區器就相同。

條件允許的情況下,使用 map-join 完成 join

Map join 想必都很熟悉,就不在寫介紹了。Spark core 沒有提供 map-join 的實現,具體的實現方案就是將小的 RDD 持久化到driver中後,廣播到大RDD的各個分區中,自己實現 join 操作。較爲通用的代碼如下:

  def manualBroadCastHashJoin[K: ClassTag, V1: ClassTag, V2: ClassTag](
                                      smallRDD: RDD[(K, V1)],
                                      bigRDD: RDD[(K, V2)],
                                      sc: SparkContext): RDD[(K, (V1, V2))] = {
    val smallDataLocaled: Map[K, V1] = smallRDD.collectAsMap()
    bigRDD.sparkContext.broadcast(smallDataLocaled)

    bigRDD.mapPartitions(p => {
      p.flatMap {
        case (k, v2) =>
          smallDataLocaled.get(k) match {
            case None => Seq.empty[(K, (V1, V2))]
            case Some(v1) => Seq((k, (v1, v2)))
          }
      }
    }, preservesPartitioning = true)
  }

End!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章