Spark 調優之數據傾斜

什麼是數據傾斜?

Spark 的計算抽象如下

Spark 的計算抽象.png

數據傾斜指的是:並行處理的數據集中,某一部分(如 Spark 或 Kafka 的一個 Partition)的數據顯著多於其它部分,從而使得該部分的處理速度成爲整個數據集處理的瓶頸。

如果數據傾斜不能解決,其他的優化手段再逆天都白搭,如同短板效應,任務完成的效率不是看最快的task,而是最慢的那一個。

數據傾導致的後果:
  1. 數據傾斜直接可能會導致一種情況:Out Of Memory 或者GC 超時。
  2. 任務不一定失敗,但是極端慢。(但是目前我遇到的數據傾斜幾乎都失敗了)

數據傾斜示意圖

image.png.png

如上圖所示
個別 ShuffleMapTask2 (98 億條數據)處理過度大量數據。導致拖慢了整個 Job 的執行時間。
這可能導致該 Task 所在的機器 OOM,或者運行速度非常慢。

傾斜原理:

在進行 shuffle 的時候,必須將各個節點上相同的 key 拉取到某個節點上的一個 task 來進行處理,比如按照 key 進行聚合或 join 等操作。此時如果某個 key 對應的數據量特別大的話,就會發生數據傾斜。

比如大部分 key 對應 幾十上百 條數據,但是個別 key 卻對應了 成千上萬 條數據,那麼大部分 task 可能就只會分配到 少量 數據,然後 1 秒鐘就運行完了;
但是個別 task 可能分配到了 海量數據,要運行一兩個小時。
因此,整個 Spark 作業的運行進度是由運行時間最長的那個 task 決定的。
注:由於同一個 Stage 內的所有 Task 執行相同的計算,在排除不同計算節點計算能力差異的前提下,不同 Task 之間耗時的差異主要由該 Task 所處理的數據量決定。

定位傾斜位置

可能觸發的算子

可能觸發的算子(不完全)

task 內存溢出

這種情況下去定位出問題的代碼(觸發JOb的Action位)就比較容易了。

可以直接看 yarn-client 模式下本地 log 的異常棧,或者是通過 YARN 查看 yarn-cluster 模式下的 log 中的異常棧。

一般來說,通過異常棧信息就可以定位到你的代碼中哪一行(觸發JOb的Action位置)發生了內存溢出和溢出的Stage是哪一個。然後在那行代碼附近找找,一般也會有 shuffle類算子,此時很可能就是這個算子導致了數據傾斜,但是是經工作中發現,這個定位具體行數還是比較困難,因爲日誌只會出現觸發JOb的Action算子的代碼行數,而一個Job可能有多可shuffle階段,你要很瞭解任務的劃分纔有可能找對位置。

要注意的是,出現內存溢出不一定就是傾斜。這只是一種可能而已。

task 執行特別慢的情況

與上面類似,雖然不報錯,但是程序就在這裏停住了,某部分task一直沒有完成。

爲了進一步確定是否傾斜,最好的辦法是去看web ui,查看當前task 所在Stage的所有task,看看執行特別慢的task 運行時間、所處理的數據量、GC等信息。

如果與其他task差異較大,說明出現了傾斜問題,那我們接下來就該去解決問題了。

key 的數據分佈情況

我工作中因爲權限、環境等各種問題,無法查看Web UI 所以對於定位GC、OOM的問題特別難受~~~。

所有有時候採用很笨的方法來確定一下是否數據傾斜

上述表格中列舉了可能出現傾斜的算子,那麼這些我們可以抽樣統計一下該算子操作的key對應的數據量。如果key 的分佈及不均勻,某種程度上也可以判定是出現了傾斜

df(dataFrame) 部分數據如下
+--------+-----------+------------+------+--------+----+
|  userid|  zubo_nums|  total_nums|  nums|     day|hour|
+--------+-----------+------------+------+--------+----+
| userid1| zubo_nums1| total_nums1| nums1|20190101|  00|
| userid2| zubo_nums2| total_nums2| nums2|20190101|  00|
| userid3| zubo_nums3| total_nums3| nums3|20190101|  00|
| userid4| zubo_nums4| total_nums4| nums4|20190101|  00|
| userid5| zubo_nums5| total_nums5| nums5|20190101|  00|
| userid6| zubo_nums6| total_nums6| nums6|20190101|  00|
| userid7| zubo_nums7| total_nums7| nums7|20190101|  00|
| userid8| zubo_nums8| total_nums8| nums8|20190101|  00|
| userid9| zubo_nums9| total_nums9| nums9|20190101|  00|
|userid10|zubo_nums10|total_nums10|nums10|20190101|  00|
+--------+-----------+------------+------+--------+----+
logger.info("\n df count=" +df.count())
df.sample(false,0.1).rdd.keyBy(row=>row.getAs("userid").toString).countByKey().foreach(println _)
df count=2058

多次抽樣對比
(userid88,3)
(userid99,1)
(userid61,2)
(userid50,2)
(userid34,2)
(userid1,33)
(userid39,4)
(userid83,3)
--------------------
(userid61,1)
(userid50,1)
(userid34,1)
(userid1,35)
(userid83,2)
(userid17,1)
(userid69,2)
---------
(userid99,2)
(userid61,1)
(userid50,2)
(userid34,2)
(userid1,25)
(userid39,1)
(userid83,1)
(userid94,2)
(userid17,1)

從上述抽樣結果接可以看出,userid1這個key數量明顯多餘其他key。
多次抽樣也可以看出,這樣統計一定程度上可以反應傾斜的問題並且可以確定傾斜的key,這樣對於我們後續解決傾斜問題有一定的幫助。


解決數據傾斜

從源端數據解決

下面距兩個例子說明:

kafka數據源
我們一般通過 DirectStream 方式讀取 Kafka數據。

由於 Kafka 的每一個 Partition 對應 Spark 的一個Task(Partition),所以 Kafka 內相關 Topic 的各Partition 之間數據是否平衡,直接決定 Spark處理該數據時是否會產生數據傾斜。

Kafka 某一 Topic 內消息在不同 Partition之間的分佈,主要由 Producer 端所使用的Partition 實現類決定。

如果使用隨機 Partitioner,則每條消息會隨機發送到一個 Partition 中,從而從概率上來講,各Partition間的數據會達到平衡。此時源 Stage(直接讀取 Kafka 數據的 Stage)不會產生數據傾斜。

所以如果業務沒有特別需求,我們可以在Producer端的 Partitioner 採用隨機的方式,並且可以每個批次數據量適當增加 Partition 的數量,達到增加task目的。

但是很多業務要求將具備同一特徵的數據順序消費,此時就需要將具有相同特徵的數據放於同一個 Partition 中。比如某個地市、區域的數據需要放在一個Partition 中,如果此時出現了數據傾斜,就只能採用其他的辦法解決了。

hive數據源
如果數據源是來自hive,那麼我們可以考慮在hive端就針對該key一次etl處理。

如果評估可行,那我們在Spark就可以在Spark端使用etl後的數據了,也就不用Spark中執行之前傾斜的部分的邏輯了。

優點:實現起來簡單便捷,效果不錯,完全規避掉了數據傾斜,Spark 作業的性能會大幅度提升。

缺點:治標不治本,我們只是把數據傾斜提前到了hive端,Hive ETL 中還是會發生數據傾斜,所以我們還是避免不了要在hive端處理傾斜問題。

適用情況:
因爲本質上沒有解決數據傾斜的問題,我們只有解決了Hive端數據傾斜的問題纔算真正的解決這個問題。
所以當hive端的數據僅僅被調用一兩次的時候,這樣做性能提升不大;
但是當頻繁的調用相關數據的時候,如果在Spark調用Hive etl後的數據是就不會出現數據傾斜的問題,這樣性能的提升就非常可觀了


調整並行度

原理:調整並行度,分散同一個 Task 的不同 Key 到更多的Task

注意:調整並行度不一定是增加,也可能是減少,目的是爲了,分散同一個 Task 中傾斜 Key 到更多的Task,所以如果減少並行度可以實現,也是可以的

對於Spark Sql配置下列參數spark.sql.shuffle.partitions

對於RDD,可以對shuflle算子設置並行度,如

 rdd.map(p=>(p._1,1)).reduceByKey( (c1, c2)=>(c1+c2),1000)
 
 默認使用HashPartitioner,並行度默認爲 spark.default.parallelism
 def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)] = self.withScope {
    reduceByKey(new HashPartitioner(numPartitions), func)
  }

優點:實現起來比較簡單,理論上可以有效緩解和減輕數據傾斜的影響。

方案缺點:只是緩解了數據傾斜而已,沒有徹底根除問題,對於某個key傾斜的情況毫無辦法,因爲無論你設置並行度爲多少,相同的key總會在同一個partition中

一般如果出現數據傾斜,都可以通過這種方法先試驗幾次,如果問題未解決,再嘗試其它方法。

適用場景少,只能將分配到同一 Task 的不同 Key 分散開,但對於同一 Key 傾斜嚴重的情況該方法並不適用。
並且該方法一般只能緩解數據傾斜,沒有徹底消除問題。

根據我工作遇到傾斜問題的來看,這方法有一定效果但是作用不大,還沒試過只調整並行度就直接解決的案例。


自定義分區函數

原理:使用自定義的 Partitioner(默認爲 HashPartitioner),將原本被分配到同一個 Task 的不同 Key 分配到不同 Task。

class CustomerPartitioner(numParts: Int) extends Partitioner{
  override def numPartitions: Int = numParts

  override def getPartition(key: Any): Int = {
    //自定義分區
    val id: Int = key.toString.toInt
    //這裏自定義分區的方式比較靈活,可以根據key的分佈設計不同的計算方式
    if (id <= 10000) //10000 以內的id容易出現傾斜
      return new java.util.Random().nextInt(10000) % numPartitions
    else
      return id % numPartitions
  }
}
rdd.map(p=>(p._1,1)).groupByKey(new CustomerPartitioner(10))

適用場景:大量不同的 Key 被分配到了相同的 Task 造成該 Task 數據量過大。

優點:不影響原有的並行度設計。如果改變並行度,後續 Stage 的並行度也會默認改變,可能會影響後續 Stage。

缺點:適用場景有限,只能將不同 Key 分散開,對於同一 Key 對應數據集非常大的場景不適用。
效果與調整並行度類似,只能緩解數據傾斜而不能完全消除數據傾斜。
而且不夠靈活,需要根據數據特點自定義專用的 Partitioner(即需要非常瞭解key的分分佈)。


ReduceJoin轉MapJoin(Broadcast )

原理:如果一個 RDD 是比較小的,則可以採用廣播小 RDD 全量數據 +map 算子來實現與 join 同樣的效果,也就是 map join,此時就不會發生 shuffle 操作,也就不會發生數據傾斜。

示意圖

ReduceJoin轉MapJoin.png

優點:對 join 操作導致的數據傾斜,效果非常好,因爲根本就不會發生 shuffle,也就根本不會發生數據傾斜。

缺點:要求參與 Join的一側數據集足夠小,並且主要適用於 Join 的場景,不適合聚合的場景,適用條件有限。

通過 Spark 的 Broadcast 機制,將 Reduce 側 Join 轉化爲 Map 側 Join,避免 Shuffle 從而完全消除 Shuffle 帶來的數據傾斜。

Web UI的DAG圖如下

ReduceJoin.png

MapJoin
MapJoin.png

相關參數:

將 Broadcast 的閾值設置得足夠大
SET spark.sql.autoBroadcastJoinThreshold=10485760

局部聚合+全局聚合

原理:將原本相同的 key 通過附加隨機前綴的方式,變成多個不同的 key,就可以讓原本被一個 task 處理的數據分散到多個 task 上去做局部聚合,進而解決單個 task 處理數據量過多的問題。接着去除掉隨機前綴,再次進行全局聚合,就可以得到最終的結果。

 rdd1
      .map(s=>(new Random().nextInt(100)+"_"+s._1,s._2))//添加前綴
      .reduceByKey(_+_)//局部聚合
      .map(s=>(s._1.split("_")(1),s._2))//去除前綴
      .reduceByKey(_+_)//全局聚合

局部聚合+全局聚合.png

適用場景:對 RDD 執行 reduceByKey 等聚合類 shuffle 算子或者在 Spark SQL 中使用 group by 語句進行分組聚合時,比較適用這種方案。

優點:對於聚合類的 shuffle 操作導致的數據傾斜,效果是非常不錯的。通常都可以解決掉數據傾斜,或者至少是大幅度緩解數據傾斜,將 Spark 作業的性能提升數倍以上。

缺點:僅僅適用於聚合類的 shuffle 操作,適用範圍相對較窄。如果是 join 類的 shuffle 操作,還得用其他的解決方案。


傾斜 key 增加隨機前/後綴

實現原理:將傾斜的key 與非傾斜的key 分別與右表join,得到skewedJoinRDD和unskewedJoinRDD最後unoin得到最終結果

skewedJoinRDD部分實現步驟:

  1. 將 rddLeft 中傾斜的 key(即 userid1 與 userid2)對應的數據單獨過濾出來,且加上 1 到 n 的隨機前綴)形成單獨的 left: RDD[(String, Int)]。
  2. 將 rddRight 中傾斜 key 對應的數據抽取出來,並通過 flatMap 操作將該數據集中每條數據均轉換爲 n 條數據(每條分別加上 1 到 n 的隨機前綴),形成單獨的 right: RDD[(String, String)]。
  3. 將 left 與 right 進行 Join,並將並行度設置爲 n,且在 Join 過程中將隨機前綴去掉,得到傾斜數據集的 Join 結果 skewedJoinRDD。

unskewedJoinRDD部分實現步驟:

  1. 將 rddLeft: RDD[(String, Int)] 中不包含傾斜 Key 的數據抽取出來作爲單獨的 leftUnSkewRDD。
  2. 對 leftUnSkewRDD 與原始的 rddRight: RDD[(String, String)] 進行Join,並行度也設置爲 n,得到 Join 結果 unskewedJoinRDD。
  3. 通過 union 算子將 skewedJoinRDD 與 unskewedJoinRDD 進行合併,從而得到完整的 Join 結果集。

實現代碼

  def prix(): Unit = {
    val sc = spark.sparkContext
    val rddLeft: RDD[(String, Int)] = srdd.rdd.keyBy(row => row.getAs("userid").toString).map(p => (p._1, 1))
    val rddRight: RDD[(String, String)] = srdd.rdd.keyBy(row => row.getAs("userid").toString).map(p => (p._1, p._2.getAs("nums").toString))
    val skewedKeySet = Set("userid1", "userid2") //傾斜的key

    val addList: Seq[Int] = 1 to 24 //右表前綴

    val skewedKey: Broadcast[Set[String]] = sc.broadcast(skewedKeySet) //廣播傾斜key

    val addLisPrix: Broadcast[Seq[Int]] = sc.broadcast(addList) //廣播右表前綴

    val left: RDD[(String, Int)] = rddLeft
      .filter(kv => skewedKey.value.contains(kv._1)) //左表篩選傾斜key
      .map(kv => (new Random().nextInt(24) + "," + kv._1, kv._2)) //傾斜key增加前綴

    val leftUnSkewRDD: RDD[(String, Int)] = rddLeft
      .filter(kv => !skewedKey.value.contains(kv._1)) //左表篩選非傾斜key
    val right: RDD[(String, String)] = rddRight
      .filter(kv => skewedKey.value.contains(kv._1)) //右表篩選傾斜key
      .map(kv => (addLisPrix.value.map(str => (str + "," + kv._1, kv._2)))) //右表傾斜key每個增加1 to 24 的前綴
      .flatMap(kv => kv.iterator)


    val skewedJoinRDD: RDD[(String, String)] = left
      .join(right, 100) //關聯操作
      .mapPartitions(kv => kv.map(str => (str._1.split(",")(1), str._2._2))) //去除前綴

    val unskewedJoinRDD: RDD[(String, String)] = leftUnSkewRDD
      .join(rddRight, 100) //非傾斜關聯操作
      .mapPartitions(kv => kv.map(str => (str._1, str._2._2)))
    
    //合併傾斜與非傾斜key
    skewedJoinRDD.union(unskewedJoinRDD).collect().foreach(println _)
  }

用場景:兩張表都比較大,無法使用 Map 側 Join。其中一個 RDD 有少數幾個 Key 的數據量過大,另外一個 RDD 的 Key 分佈較爲均勻。

優點:相對於 Map 側 Join,更能適應大數據集的 Join。
如果資源充足,傾斜部分數據集與非傾斜部分數據集可並行進行,效率提升明顯。
且只針對傾斜部分的數據做數據擴展,增加的資源消耗有限。

缺點:如果傾斜 Key 非常多,則另一側數據膨脹非常大,此方案不適用。
而且此時對傾斜 Key 與非傾斜 Key 分開處理,需要掃描數據集兩遍,增加了開銷。


傾斜表隨機添加n種隨機前綴,小表擴大n倍

原理:將包含傾斜 key 的rdd通過附加隨機前綴 1 to n 變成不一樣的 key,然後就可以將這些處理後的 “不同key” 分散到多個 task 中去處理。通過每條記錄增加前綴 1 to n 擴容非傾斜 rdd ,然後再join

(此方法還有一個變體,就是將傾斜的key拉出來添加n種隨機前綴,小表擴大n倍,傾斜與非傾斜分開來,類似上一個例子)

實現原理

  def prixAndMul(): Unit = {
    val sc = spark.sparkContext
    val rddLeft: RDD[(String, Int)] = srdd.rdd.keyBy(row => row.getAs("userid").toString).map(p => (p._1, 1))
    val rddRight: RDD[(String, String)] = srdd.rdd.keyBy(row => row.getAs("userid").toString).map(p => (p._1, p._2.getAs("nums").toString))
    val skewedKeySet = Set("userid1", "userid2") //傾斜的key

    val addList: Seq[Int] = 1 to 24 //右表前綴

    val addLisPrix: Broadcast[Seq[Int]] = sc.broadcast(addList) //廣播右表前綴

    val left: RDD[(String, Int)] = rddLeft
      .map(kv => (new Random().nextInt(24) + "," + kv._1, kv._2)) //傾斜key增加前綴
    
    val right: RDD[(String, String)] = rddRight
      .map(kv => (addLisPrix.value.map(str => (str + "," + kv._1, kv._2)))) //右表傾斜key每個增加1 to 24 的前綴
      .flatMap(kv => kv.iterator)
    
    val resultRDD: RDD[(String, String)] = left
      .join(right, 100) //關聯操作
      .mapPartitions(kv => kv.map(str => (str._1.split(",")(1), str._2._2))) //去除前綴
    
    resultRDD.collect().foreach(println _)
  }
  
  

優點:對 join 類型的數據傾斜基本都可以處理,而且效果也相對比較顯著,性能提升效果非常不錯。

缺點:該方案更多的是緩解數據傾斜,而不是徹底避免數據傾斜。而且需要對整個 RDD 進行擴容,對內存資源要求很高。

該方案至少能保證程序能夠運行完成,速度的話看實際情況了,畢竟先跑通再優化。


過濾少數導致傾斜的 key

對於數據要求不是很嚴謹的情況,可以通過抽樣獲取傾斜key ,然後直接過濾掉


關於數據傾斜,沒有一個固定的解決辦法,要根據數據的實際情況,靈活採用各種方案解決

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