SparkRdd 的分區操作及Shuffle原理

RDD 的 Shuffle 和分區

分區的作用

RDD 使用分區來分佈式並行處理數據, 並且要做到儘量少的在不同的 Executor 之間使用網絡交換數據, 所以當使用 RDD 讀取數據的時候, 會盡量的在物理上靠近數據源, 比如說在讀取 Cassandra 或者 HDFS 中數據的時候, 會盡量的保持 RDD 的分區和數據源的分區數, 分區模式等一一對應

分區和 Shuffle 的關係

分區的主要作用是用來實現並行計算, 本質上和 Shuffle 沒什麼關係, 但是往往在進行數據處理的時候, 例如 reduceByKeygroupByKey 等聚合操作, 需要把 Key 相同的 Value 拉取到一起進行計算, 這個時候因爲這些 Key 相同的 Value 可能會坐落於不同的分區, 於是理解分區才能理解 Shuffle 的根本原理

Spark 中的 Shuffle 操作的特點

  • 只有 Key-Value 型的 RDD 纔會有 Shuffle 操作, 例如 RDD[(K, V)], 但是有一個特例, 就是 repartition 算子可以對任何數據類型 Shuffle

  • 早期版本 Spark 的 Shuffle 算法是 Hash base shuffle, 後來改爲 Sort base shuffle, 更適合大吞吐量的場景

1. RDD 的分區操作

查看分區數

scala> sc.parallelize(1 to 100).count
res0: Long = 100

873af6194db362a1ab5432372aa8bd21

之所以會有 8 個 Tasks, 是因爲在啓動的時候指定的命令是 spark-shell --master local[8], 這樣會生成 1 個 Executors, 這個 Executors 有 8 個 Cores, 所以默認會有 8 個 Tasks, 每個 Cores 對應一個分區, 每個分區對應一個 Tasks, 可以通過 rdd.partitions.size 來查看分區數量

a41901e5af14f37c88b3f1ea9b97fbfb

同時也可以通過 spark-shell 的 WebUI 來查看 Executors 的情況

24b2646308923d7549a7758f7550e0a8

默認的分區數量是和 Cores 的數量有關的, 也可以通過如下三種方式修改或者重新指定分區數量

創建 RDD 時指定分區數

scala> val rdd1 = sc.parallelize(1 to 100, 6)
rdd1: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[1] at parallelize at <console>:24

scala> rdd1.partitions.size
res1: Int = 6

scala> val rdd2 = sc.textFile("hdfs:///dataset/wordcount.txt", 6)
rdd2: org.apache.spark.rdd.RDD[String] = hdfs:///dataset/wordcount.txt MapPartitionsRDD[3] at textFile at <console>:24

scala> rdd2.partitions.size
res2: Int = 7

rdd1 是通過本地集合創建的, 創建的時候通過第二個參數指定了分區數量. rdd2 是通過讀取 HDFS 中文件創建的, 同樣通過第二個參數指定了分區數, 因爲是從 HDFS 中讀取文件, 所以最終的分區數是由 Hadoop 的 InputFormat 來指定的, 所以比指定的分區數大了一個.

通過 coalesce 算子指定

coalesce(numPartitions: Int, shuffle: Boolean = false)(implicit ord: Ordering[T] = null): RDD[T]

numPartitions

新生成的 RDD 的分區數

shuffle

是否 Shuffle

scala> val source = sc.parallelize(1 to 100, 6)
source: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24

scala> source.partitions.size
res0: Int = 6

scala> val noShuffleRdd = source.coalesce(numPartitions=8, shuffle=false)
noShuffleRdd: org.apache.spark.rdd.RDD[Int] = CoalescedRDD[1] at coalesce at <console>:26

scala> noShuffleRdd.toDebugString 
res1: String =
(6) CoalescedRDD[1] at coalesce at <console>:26 []
 |  ParallelCollectionRDD[0] at parallelize at <console>:24 []

 scala> val noShuffleRdd = source.coalesce(numPartitions=8, shuffle=false)
 noShuffleRdd: org.apache.spark.rdd.RDD[Int] = CoalescedRDD[1] at coalesce at <console>:26

scala> shuffleRdd.toDebugString 
res3: String =
(8) MapPartitionsRDD[5] at coalesce at <console>:26 []
 |  CoalescedRDD[4] at coalesce at <console>:26 []
 |  ShuffledRDD[3] at coalesce at <console>:26 []
 +-(6) MapPartitionsRDD[2] at coalesce at <console>:26 []
    |  ParallelCollectionRDD[0] at parallelize at <console>:24 []

scala> noShuffleRdd.partitions.size     
res4: Int = 6

scala> shuffleRdd.partitions.size
res5: Int = 8
  如果 shuffle 參數指定爲 false, 運行計劃中確實沒有 ShuffledRDD, 沒有 shuffled 這個過程
  如果 shuffle 參數指定爲 true, 運行計劃中有一個 ShuffledRDD, 有一個明確的顯式的 shuffled 過程
  如果 shuffle 參數指定爲 false 卻增加了分區數, 分區數並不會發生改變, 這是因爲增加分區是一個寬依賴, 沒有 shuffled 過程無法做到, 後續會詳細解釋寬依賴的概念

通過 repartition 算子指定

repartition(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T]

repartition 算子本質上就是 coalesce(numPartitions, shuffle = true)

45d7a2b6e9e2727504e9cf28adbe6c49

scala> val source = sc.parallelize(1 to 100, 6)
source: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[7] at parallelize at <console>:24

scala> source.partitions.size
res7: Int = 6

scala> source.repartition(100).partitions.size 
res8: Int = 100

scala> source.repartition(1).partitions.size 
res9: Int = 1
  增加分區有效
  減少分區有效

repartition 算子無論是增加還是減少分區都是有效的, 因爲本質上 repartition 會通過 shuffle 操作把數據分發給新的 RDD 的不同的分區, 只有 shuffle 操作纔可能做到增大分區數, 默認情況下, 分區函數是 RoundRobin, 如果希望改變分區函數, 也就是數據分佈的方式, 可以通過自定義分區函數來實現

b1181258789202436ca6d2d92e604d59

2. RDD 的 Shuffle 是什麼

val sourceRdd = sc.textFile("hdfs://node01:9020/dataset/wordcount.txt")
val flattenCountRdd = sourceRdd.flatMap(_.split(" ")).map((_, 1))
val aggCountRdd = flattenCountRdd.reduceByKey(_ + _)
val result = aggCountRdd.collect

23377ac4a368fc94b6f8f3117af67154

10b536c17409ec37fa1f1b308b2b521e

reduceByKey 這個算子本質上就是先按照 Key 分組, 後對每一組數據進行 reduce, 所面臨的挑戰就是 Key 相同的所有數據可能分佈在不同的 Partition 分區中, 甚至可能在不同的節點中, 但是它們必須被共同計算.

爲了讓來自相同 Key 的所有數據都在 reduceByKey 的同一個 reduce 中處理, 需要執行一個 all-to-all 的操作, 需要在不同的節點(不同的分區)之間拷貝數據, 必須跨分區聚集相同 Key 的所有數據, 這個過程叫做 Shuffle.

3. RDD 的 Shuffle 原理

Spark 的 Shuffle 發展大致有兩個階段: Hash base shuffle 和 Sort base shuffle

Hash base shuffle

2daf43cc1750fffab62ae5e16fab54c2

大致的原理是分桶, 假設 Reducer 的個數爲 R, 那麼每個 Mapper 有 R 個桶, 按照 Key 的 Hash 將數據映射到不同的桶中, Reduce 找到每一個 Mapper 中對應自己的桶拉取數據.

假設 Mapper 的個數爲 M, 整個集羣的文件數量是 M * R, 如果有 1,000 個 Mapper 和 Reducer, 則會生成 1,000,000 個文件, 這個量非常大了.

過多的文件會導致文件系統打開過多的文件描述符, 佔用系統資源. 所以這種方式並不適合大規模數據的處理, 只適合中等規模和小規模的數據處理, 在 Spark 1.2 版本中廢棄了這種方式.

Sort base shuffle

94f038994f8553dd32370ae78878d038

對於 Sort base shuffle 來說, 每個 Map 側的分區只有一個輸出文件, Reduce 側的 Task 來拉取, 大致流程如下

  1. Map 側將數據全部放入一個叫做 AppendOnlyMap 的組件中, 同時可以在這個特殊的數據結構中做聚合操作

  2. 然後通過一個類似於 MergeSort 的排序算法 TimSort 對 AppendOnlyMap 底層的 Array 排序

    • 先按照 Partition ID 排序, 後按照 Key 的 HashCode 排序

  3. 最終每個 Map Task 生成一個 輸出文件, Reduce Task 來拉取自己對應的數據

從上面可以得到結論, Sort base shuffle 確實可以大幅度減少所產生的中間文件, 從而能夠更好的應對大吞吐量的場景, 在 Spark 1.2 以後, 已經默認採用這種方式.

但是需要大家知道的是, Spark 的 Shuffle 算法並不只是這一種, 即使是在最新版本, 也有三種 Shuffle 算法, 這三種算法對每個 Map 都只產生一個臨時文件, 但是產生文件的方式不同, 一種是類似 Hash 的方式, 一種是剛纔所說的 Sort, 一種是對 Sort 的一種優化(使用 Unsafe API 直接申請堆外內存)

 

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