Hive和Spark分割槽策略

哥不是小蘿莉發表於2021-06-27

1.概述

離線資料處理生態系統包含許多關鍵任務,最大限度的提高資料管道基礎設施的穩定性和效率是至關重要的。這邊部落格將分享Hive和Spark分割槽的各種策略,以最大限度的提高資料工程生態系統的穩定性和效率。

2.內容

大多數Spark Job可以通過三個階段來表述,即讀取輸入資料、使用Spark處理、儲存輸出資料。這意味著雖然實際資料轉換主要發生在記憶體中,但是Job通常以大量的I/O開始和結束。使用Spark常用堆疊是使用儲存在HDFS上的Hive表作為輸入和輸出資料儲存。Hive分割槽有效地表示為分散式檔案系統上的檔案目錄。理論上,儘可能多的檔案寫入是有意義的,但是,這個也是有代價的。HDFS不能很好的支援大量小檔案,每個檔案在NameNode記憶體中大概有150位元組的開銷,而HDFS的整體IOPS數量有限。檔案寫入中的峰值絕對會導致HDFS基礎架構的某些部分產生效能瓶頸。

比如從某個歷史日期到當前日期重新計算表,通常用於修復錯誤或者資料質量問題。在處理包含一年資料的大型資料集(比如1TB以上)時,可能會將資料分成幾千個Spark分割槽進行處理。雖然從表面上看,這種處理方法並不是最合適的,使用動態分割槽並將資料結果寫入按照日期分割槽的Hive表中將產生多大100+萬個檔案。

假如有一個包含3個分割槽的Spark任務,並且想將資料寫入到包含3個分割槽的Hive中。在這種情況下,希望傳送的是將3個檔案寫入到HDFS,所有資料都儲存在每個分割槽鍵的單個檔案中。實際發生的是將生成9個檔案,並且每個檔案都有1個記錄。使用動態分割槽寫入Hive時,每個Spark分割槽都由執行程式並行處理。處理Spark分割槽資料時,每次執行程式在給定Spark分割槽中遇到新的分割槽鍵時,它都會開啟一個新檔案。預設情況下,Spark對資料會使用Hash或者Round Robin分割槽器。當應用於任意資料時,可以假設這2中方法在整個Spark分割槽中相對均勻但是隨機分佈資料行。如下圖所示:

 

 

理想情況下,目標檔案大小應該大約是HDFS Block大小的倍數,預設情況下為128MB。在Hive管道中,提供了一些配置來自動將結果收集到合理大小的檔案中,從開發人員的角度來看幾乎是透明的,比如hive.merge.smallfiles.avgsize和hive.merge.size.per.task。但是,Spark中不存在此類功能,因此,我們需要自己開發實現,來給定一個資料集,應該寫入多少檔案。

2.1 基於Size的計算

理論上,這是最直接的方法,設定目標大小,估計資料的大小,然後進行劃分。但是,在很多情況下,檔案被寫入磁碟時會進行壓縮,並且其格式與儲存在Java堆中的記錄格式有所不同。這意味著估算寫入磁碟時記憶體的記錄大小不是一件容易的事情。

雖然可以使用Spark SizeEstimator實用程式通過記憶體中資料的大小進行估計,然後應用某種估計的壓縮檔案格式因此,但是SizeEstimator會考慮資料幀、資料集的內部消耗,以及資料的大小。總體來說,這種方式不太容易準確實現。

2.2 基於行數的計算

這種方法是設定目標行數,計算資料集的大小,然後執行除法以估計目標。我們的目標行數可以通過多種方式確定,或者通過為所有資料集選擇一個靜態數字,或者通過確定磁碟上單個記錄的大小並執行必要的計算。哪種方式是最好取決於你的資料集數量及其複雜性。計數相對來說成本較低,但是需要在計數前快取以避免重新計算資料集。

2.3 靜態檔案計數

最簡單的解決方案是隻要求開發人員在每個插入的基礎上告訴Spark總共應該寫入多少個檔案,這種方式需要給開發人員一些其他方法來獲得具體的數字,可以通過這種方式來替換昂貴的計算。

3.如何讓Spark以合理的方式分發處理資料?

即使我們知道希望如何將檔案寫入磁碟,我們仍然必須讓Spark以符合實際的方式生成這些檔案來構建我們的分割槽。Spark提供了許多工具來確定資料在整個分割槽中的分佈方式。但是,各種功能中隱藏著很多複雜性,在某些情況下,它們的含義並不明顯。下面將介紹Spark提供的一些選項來控制Spark輸出檔案的數量。

3.1 合併

Spark Coalesce是一個特殊版本的重新分割槽,它只允許減少總的分割槽,但是不需要完全的Shuffle,因此比重新分割槽要快得多。它通過有效的合併分割槽來實現這一點。如下圖所示:

 

 

Coalesce在某些情況下看起來不錯,但是也有一些問題。首先,Coalesce有一個讓我們難以使用的行為。以一個非常基本的Spark應用程式為例,程式碼如下:

load().map(…).filter(…).save()

比如設定的並行度為1000,但是最終只想寫入10個檔案,可以設定如下:

load().map(…).filter(…).coalesce(10).save()

但是,Spark會盡可能早的有效的將合併操作下推,因此這將執行為:

load().coalesce(10).map(…).filter(…).save()

有效的解決這種問題的方法是在轉換和合並之間強制執行,程式碼如下所示:

val df = load().map(…).filter(…).cache() 
df.count() 
df.coalesce(10)

快取是必須的,否則,你將不得不重新計算資料,這可能會重新消耗資源。然後,快取是需要消費一定資源的,如果你的資料集無法放入記憶體中,或者無法騰出記憶體將資料有效的儲存在記憶體中兩次,那麼必須使用磁碟快取,這有其自身的侷限性和顯著的效能損失。

此外,正如我們看到的,通常需要執行Shuffle來獲得我們想要的更復雜的資料集結果。因此,Coalesce僅適用於特定的情況:

  • 保證只寫入1個Hive分割槽;
  • 目標檔案數少於你用於處理資料的Spark分割槽數;
  • 有充足的快取資源。

3.2 簡單重新分割槽

一個簡單的重新分割槽,它的唯一引數是目標Spark分割槽計數,即df.repartition(100)。在這種情況下,使用迴圈分割槽器,這意味著唯一的保證是輸出資料具有大致相同大小的Spark分割槽。

這種分割槽僅適用於以下情況的檔案計數問題:

  • 保證只需要寫入1個Hive分割槽;
  • 正在寫入的檔案數大於你的Spark分割槽數或者由於某些其他原因你無法使用合併。

3.3 按列重新分割槽

按列重新分割槽接收目標Spark分割槽計數,以及要重新分割槽的列序列,例如,df.repartition(100,$"date")。這對於強制Spark將具有相同鍵的記錄分發到同一個分割槽很有用。一般來說,這對許多Spark操作(如JOIN)很有用,但是理論上,它也可以解決我們的問題。

按列重新分割槽使用HashPartitioner,它將具有相同值的記錄分配給同一個分割槽,實際上,它將執行以下操作:

 

但是,這種方法只有在每個分割槽鍵都可以安全的寫入到一個檔案時才有效。這是因為無論有多少值具有特定的Hash值,它們最終都會在同一個分割槽中。按列重新分割槽僅在你寫入一個或者多個小的Hive分割槽時才有效。在任何其他情況下,它都沒有用,因為每個Hive分割槽總是會得到一個檔案,這僅適用於最小的資料集。

3.4 按具有隨機因子的列重新分割槽

我們可以通過新增約束的隨機因子來按列修改重新分割槽,程式碼如下:

df 
.withColumn("rand", rand() % filesPerPartitionKey) 
.repartition(100, $"key", $"rand")

理論上,只要滿足以下條件,這種方法應該會產生排序良好的記錄和大小相當均勻的檔案:

  • Hive分割槽的大小大致相同;
  • 知道每個Hive分割槽的目標檔案數並且可以在執行時對其進行編碼。

但是,即使我們滿足上述這些條件,還有另外一個問題:雜湊衝突。假設,現在正在處理一年的資料,日期作為分割槽的唯一鍵。如果每個分割槽需要5個檔案,可以執行如下操作:

df.withColumn("rand", rand() % 5).repartition(5*365, $"date", $"rand")

在後臺,Scala將構造一個包含日期和隨機因素的鍵,例如(<date>,<0-4>)。然後,如果我們檢視HashPartitioner程式碼,可以發現它將執行以下操作:

class HashPartitioner(partitions: Int) extends Partitioner { 
def getPartition(key: Any): Int = key match { 
   case null => 0 
   case _ => Utils.nonNegativeMod(key.hashCode, numPartitions) 
} 
}

實際上,所做的就是獲取關鍵元組的雜湊,然後使用目標數量的Spark分割槽獲取它的mod。我們可以分析一下在這種情況下我們的記錄將如何實現分佈,分析程式碼如下:

import java.time.LocalDate

def hashCodeTuple(one: String, two: Int, mod: Int): Int = {
 val rawMod = (one, two).hashCode % mod
 rawMod + (if (rawMod < 0) mod else 0) 
} 
def hashCodeSeq(one: String, two: Int, mod: Int): Int = {
 val rawMod = Seq(one, two).hashCode % mod
 rawMod + (if (rawMod < 0) mod else 0) 
} 

def iteration(numberDS: Int, filesPerPartition: Int): (Double, Double, Double) = {
  val hashedRandKeys = (0 to numberDS - 1).map(x => LocalDate.of(2019, 1, 1).plusDays(x)).flatMap(
    x => (0 to filesPerPartition - 1).map(y => hashCodeTuple(x.toString, y, filesPerPartition*numberDS))
  )

  hashedRandKeys.size // Number of unique keys, with the random factor

  val groupedHashedKeys = hashedRandKeys.groupBy(identity).view.mapValues(_.size).toSeq

  groupedHashedKeys.size // number of actual sPartitions used

  val sortedKeyCollisions = groupedHashedKeys.filter(_._2 != 1).sortBy(_._2).reverse
  
  val sortedSevereKeyCollisions = groupedHashedKeys.filter(_._2 > 2).sortBy(_._2).reverse

  sortedKeyCollisions.size // number of sPartitions with a hashing collision

  // (collisions, occurences)
  val collisionCounts = sortedKeyCollisions.map(_._2).groupBy(identity).view.mapValues(_.size).toSeq.sortBy(_._2).reverse
  
  (
    groupedHashedKeys.size.toDouble / hashedRandKeys.size.toDouble, 
    sortedKeyCollisions.size.toDouble / groupedHashedKeys.size.toDouble,
  sortedSevereKeyCollisions.size.toDouble / groupedHashedKeys.size.toDouble
  )
}

val results = Seq(
  iteration(365, 1),
  iteration(365, 5),
  iteration(365, 10),
  iteration(365, 100),
  iteration(365 * 2, 100),
  iteration(365 * 5, 100),
  iteration(365 * 10, 100)
)

val avgEfficiency = results.map(_._1).sum / results.length
val avgCollisionRate = results.map(_._2).sum / results.length
val avgSevereCollisionRate = results.map(_._3).sum / results.length

(avgEfficiency, avgCollisionRate, avgSevereCollisionRate) // 63.2%, 42%, 12.6%

上面的指令碼計算了3個數量:

  • 效率:非空的Spark分割槽與輸出檔案數量的比率;
  • 碰撞率:(date,rand)的Hash值傳送衝突的Spark分割槽的百分比;
  • 嚴重衝突率:同上,但是此鍵上的衝突次數為3或者更多。

衝突很重要,因為它們意味著我們的Spark分割槽包含多個唯一的分割槽鍵,而我們預計每個Spark分割槽只有1個。分析的結果可知,我們使用了63%的執行器,並且可能會出現嚴重的偏差,我們將近一半的執行者正在處理比預期多2到3倍或者在某些情況下高達8倍的資料。

現在,有一個解決方法,即分割槽縮放。在之前示例中,輸出的Spark分割槽數量等於預期的總檔案數。如果將N個物件隨機分配給N個插槽,可以預期會有多個插槽包含多個物件,並且有幾個空插槽。因此,需要解決此問題,必須要降低物件與插槽的比率。

我們通過縮放輸出分割槽計數來實現這一點,通過將我們的輸出Spar分割槽計數乘以一個大因子,類似於:

df 
.withColumn(“rand”, rand() % 5) 
.repartition(5*365*SCALING_FACTOR, $”date”, $”rand”)

分析程式碼如下:

import java.time.LocalDate

def hashCodeTuple(one: String, two: Int, mod: Int): Int = {
 val rawMod = (one, two).hashCode % mod
 rawMod + (if (rawMod < 0) mod else 0) 
} 

def hashCodeSeq(one: String, two: Int, mod: Int): Int = {
 val rawMod = Seq(one, two).hashCode % mod
 rawMod + (if (rawMod < 0) mod else 0) 
} 

def iteration(numberDS: Int, filesPerPartition: Int, partitionFactor: Int = 1): (Double, Double, Double, Double) = {
  val partitionCount = filesPerPartition*numberDS * partitionFactor
  val hashedRandKeys = (0 to numberDS - 1).map(x => LocalDate.of(2019, 1, 1).plusDays(x)).flatMap(
    x => (0 to filesPerPartition - 1).map(y => hashCodeTuple(x.toString, y, partitionCount))
  )
  
  hashedRandKeys.size // Number of unique keys, with the random factor

  val groupedHashedKeys = hashedRandKeys.groupBy(identity).view.mapValues(_.size).toSeq

  groupedHashedKeys.size // number of unique hashes - and thus, sPartitions with > 0 records
  
  val sortedKeyCollisions = groupedHashedKeys.filter(_._2 != 1).sortBy(_._2).reverse
  
  val sortedSevereKeyCollisions = groupedHashedKeys.filter(_._2 > 2).sortBy(_._2).reverse

  sortedKeyCollisions.size // number of sPartitions with a hashing collision

  // (collisions, occurences)
  val collisionCounts = sortedKeyCollisions.map(_._2).groupBy(identity).view.mapValues(_.size).toSeq.sortBy(_._2).reverse
  
  (
    groupedHashedKeys.size.toDouble / partitionCount,
    groupedHashedKeys.size.toDouble / hashedRandKeys.size.toDouble, 
    sortedKeyCollisions.size.toDouble / groupedHashedKeys.size.toDouble,
    sortedSevereKeyCollisions.size.toDouble / groupedHashedKeys.size.toDouble
  )
}

// With a scale factor of 1
val results = Seq(
  iteration(365, 1),
  iteration(365, 5),
  iteration(365, 10),
  iteration(365, 100),
  iteration(365 * 2, 100),
  iteration(365 * 5, 100),
  iteration(365 * 10, 100)
)

val avgEfficiency = results.map(_._2).sum / results.length // What is the ratio of executors / output files
val avgCollisionRate = results.map(_._3).sum / results.length // What is the average collision rate
val avgSevereCollisionRate = results.map(_._4).sum / results.length // What is the average collision rate where 3 or more hashes collide

(avgEfficiency, avgCollisionRate, avgSevereCollisionRate) // 63.2% Efficiency, 42% collision rate, 12.6% severe collision rate

iteration(365, 5, 2) // 37.7% partitions in-use, 77.4% Efficiency, 24.4% collision rate, 4.2% severe collision rate
iteration(365, 5, 5)
iteration(365, 5, 10)
iteration(365, 5, 100)

隨著我們的比例因子接近無窮大,碰撞很快接近於0,效率接近100%。但是,這會產生另外一個問題,即大量的輸出Spark分割槽將為空。同時這些空的Spark分割槽也會帶來一些資源開銷,增加驅動程式的記憶體要求,並使我們更容易受到由於錯誤或者意外複雜性而導致分割槽鍵空間意外大的問題。

這裡的一個常見方法是在使用這種方法時不顯示設定分割槽技術(預設並行度和縮放),如果不提供分割槽計數,則依賴Spark預設的spark.default.parallelism值。雖然,通常並行度自然高於總輸出檔案數(因此,隱式提供大於1 的縮放因子)。如果滿足以下條件,這種方式依然是一種有效的方法:

  • Hive分割槽的檔案數大致相等;
  • 可以確定平均分割槽檔案數應該是多少;
  • 大致知道唯一分割槽鍵的總數。

在示例中,我們假設其中的許多事情都很容易知道,主要是輸出Hive分割槽的總數和每個Hive分割槽所需要的檔案數。無論如何,這種方法都是可行的,並且可能適用於需要用例。

3.5 按範圍重新分割槽

按範圍重新分割槽是一個特列,它不使用RoundRobin和Hash Partitioner,而是使用一種特殊的方法,叫做Range Partitioner。

範圍分割槽器根據某些給定鍵的順序在Spark分割槽之間進行拆分行,但是,它不只是全域性排序,它做出的保證是:

  • 具有相同雜湊的所有記錄將在同一個分割槽中結束;
  • 所有Spark分割槽都將有一個最小值和最大值與之關聯;
  • 最小值和最大值將通過使用取樣來檢測關鍵頻率和範圍來確定,分割槽邊界將根據這些估計值進行初始設定;
  • 分割槽的大小不能保證完全相等,它們的相等性基於樣本的準確性,因此,預測的每個Spark分割槽的最小值和最大值,分割槽將根據需要增長或縮小以保證前2個條件。

總而言之,範圍分割槽將導致Spark建立與請求的Spark分割槽數量相等的Bucket數量,然後它將這些Bucket對映到指定分割槽鍵的範圍。例如,如果你的分割槽鍵是日期,則範圍可能是(最小值2021-01-01,最大值2022-01-01)。然後,對於每條記錄,將記錄的分割槽鍵與儲存Bucket的最小值和最大值進行比較,並相應的進行分配。

 

4.結束語

這篇部落格就和大家分享到這裡,如果大家在研究學習的過程當中有什麼問題,可以加群進行討論或傳送郵件給我,我會盡我所能為您解答,與君共勉!

另外,博主出書了《Kafka並不難學》和《Hadoop大資料探勘從入門到進階實戰》,喜歡的朋友或同學, 可以在公告欄那裡點選購買連結購買博主的書進行學習,在此感謝大家的支援。關注下面公眾號,根據提示,可免費獲取書籍的教學視訊。

相關文章