一文帶你理清Spark Core調優的方方面面

說出你的願望吧發表於2020-02-21

前言

本文的注意事項

  1. 觀看本文前,可以先百度搜尋一下Spark程式的十大開發原則看看哦
  2. 文章雖然很長,可並不是什麼枯燥乏味的內容,而且都是面試時的乾貨(我覺得?)可以結合PC端的目錄食用,可以直接跳轉到你想要的那部分內容
  3. 圖非常的重要,是文章中最有價值的部分。如果不是很重要的圖一般不會親手畫
  4. 此文會很大程度上借鑑美團的文章分享內容和Spark官方資料去進行說明,也會結合筆者自身的理解。
  5. 資料傾斜部分和Spark Streaming調優息息相關

一、簡述Spark的十大開發原則

這裡會直接一筆帶過,不作詳細的展開了,大家可以通過搜尋引擎能找到它們的詳細說明。我們用最直接的話來闡述

1.1 避免建立重複的RDD

就如字面上的意思,對於同一份資料,只應該建立一個RDD,不能建立多個RDD來代表同一份資料。避免我們的Spark作業會進行多次重複計算來建立多個代表相同資料的RDD,進而增加了作業的效能開銷。

1.2 儘可能複用同一個RDD

對於類似多個RDD的資料有重疊或者包含的情況,我們應該儘量複用一個RDD,這樣可以儘可能地減少RDD的數量,從而儘可能減少運算元執行的次數。因為Spark中的RDD如果不快取下來每次它都會從源頭處開始重新計算一遍

比如說,有一個RDD的資料格式是key-value型別的,另一個是單value型別的,這兩個RDD的value資料是完全一樣的。那麼此時我們可以只使用key-value型別的那個RDD,因為其中已經包含了另一個的資料。

1.3 對多次使用的RDD進行持久化

對多次使用的RDD進行持久化。此時Spark就會根據你的持久化策略,將RDD中的資料儲存到記憶體或者磁碟中。以後每次對這個RDD進行運算元操作時,都會直接從記憶體或磁碟中提取持久化的RDD資料,然後執行運算元,而不會從源頭處重新計算一遍這個RDD,再執行運算元操作。從而保證對一個RDD執行多次運算元操作時,這個RDD本身僅僅被計算一次。

補充:Spark的持久化級別

這些英文其實都簡單到能直接看出來是啥意思了···需要多留意的詞或者句子我會用粗體打上

持久化級別 含義解釋
MEMORY_ONLY 使用未序列化的Java物件格式,將資料儲存在記憶體中。如果記憶體不夠存放所有的資料,則資料可能就不會進行持久化。那麼下次對這個RDD執行運算元操作時,那些沒有被持久化的資料,需要從源頭處重新計算一遍。這是預設的持久化策略,使用cache()方法時,實際就是使用的這種持久化策略。
MEMORY_AND_DISK 使用未序列化的Java物件格式,優先嚐試將資料儲存在記憶體中。如果記憶體不夠存放所有的資料,會將資料寫入磁碟檔案中,下次對這個RDD執行運算元時,持久化在磁碟檔案中的資料會被讀取出來使用。
MEMORY_ONLY_SER 基本含義同MEMORY_ONLY。唯一的區別是,會將RDD中的資料進行序列化,RDD的每個partition會被序列化成一個位元組陣列。這種方式更加節省記憶體,從而可以避免持久化的資料佔用過多記憶體導致頻繁GC。
MEMORY_AND_DISK_SER 基本含義同MEMORY_AND_DISK。唯一的區別是,會將RDD中的資料進行序列化,RDD的每個partition會被序列化成一個位元組陣列。這種方式更加節省記憶體,從而可以避免持久化的資料佔用過多記憶體導致頻繁GC。
DISK_ONLY 使用未序列化的Java物件格式,將資料全部寫入磁碟檔案中
MEMORY_ONLY_2, MEMORY_AND_DISK_2, 等等. 對於上述任意一種持久化策略,如果加上字尾_2,代表的是將每個持久化的資料,都複製一份副本,並將副本儲存到其他節點上。這種基於副本的持久化機制主要用於進行容錯。假如某個節點掛掉,節點的記憶體或磁碟中的持久化資料丟失了,那麼後續對RDD計算時還可以使用該資料在其他節點上的副本。如果沒有副本的話,就只能將這些資料從源頭處重新計算一遍了。

(補充)如何選擇一種最合適的持久化策略

預設情況下,效能最高的當然是MEMORY_ONLY,都是基於純記憶體中的資料的操作,不需要從磁碟檔案中讀取資料,效能也很高;而且不需要複製一份資料副本,並遠端傳送到其他節點上。

但是這裡必須要注意的是,在實際的生產環境中,恐怕能夠直接用這種策略的場景還是有限的,如果RDD中資料比較多時(比如幾十億),直接用這種持久化級別,會導致JVM的OOM記憶體溢位異常。

如果使用MEMORY_ONLY級別時發生了記憶體溢位,那麼建議嘗試使用MEMORY_ONLY_SER級別。該級別會將RDD資料序列化後再儲存在記憶體中,此時每個partition僅僅是一個位元組陣列而已,大大減少了物件數量,並降低了記憶體佔用。

這種級別比MEMORY_ONLY多出來的效能開銷,主要就是序列化與反序列化的開銷。但是後續運算元可以基於純記憶體進行操作,因此效能總體還是比較高的。此外,可能發生的問題同上,如果RDD中的資料量過多的話,還是可能會導致OOM記憶體溢位的異常。

如果純記憶體的級別都無法使用,那麼建議使用MEMORY_AND_DISK_SER策略。如果採用這個舉措,就說明RDD的資料量很大,記憶體無法完全放下。序列化後的資料比較少,可以節省記憶體和磁碟的空間開銷。同時該策略會優先儘量嘗試將資料快取在記憶體中,記憶體快取不下才會寫入磁碟。

通常不建議使用DISK_ONLY和字尾為2的級別:因為完全基於磁碟檔案進行資料的讀寫,會導致效能急劇降低,有時還不如重新計算一次所有RDD。字尾為2的級別,必須將所有資料都複製一份副本,併傳送到其他節點上,資料複製以及網路傳輸會導致較大的效能開銷,除非是要求作業的高可用性,否則不建議使用。

1.4 儘量避免使用shuffle類運算元

因為Spark作業執行過程中,最消耗效能的地方就是shuffle過程。shuffle過程簡單來說,就是將分佈在叢集中多個節點上的同一個key,拉取到同一個節點上,進行聚合或join等操作。

shuffle過程中,各個節點上的相同key都會先寫入本地磁碟檔案中,然後其他節點需要通過網路傳輸拉取各個節點上的磁碟檔案中的相同key。而且相同key都拉取到同一個節點進行聚合操作時,還有可能會因為一個節點上處理的key過多,導致記憶體不夠存放,進而溢寫到磁碟檔案中。因此在shuffle過程中,可能會發生大量的磁碟檔案讀寫的IO操作,以及資料的網路傳輸操作。磁碟IO和網路資料傳輸也是shuffle效能較差的主要原因。

1.5 使用map-side預聚合的shuffle操作

所謂的map-side預聚合,說的是在每個節點本地對相同的key進行一次聚合操作,類似於MapReduce中的本地combiner。map-side預聚合之後,每個節點本地就只會有一條相同的key,因為多條相同的key都被聚合起來了。其他節點在拉取所有節點上的相同key時,就會大大減少需要拉取的資料數量,從而也就減少了磁碟IO以及網路傳輸開銷。

最鮮明的例子其實就是reduceByKey和groupByKey,因為groupByKey是不會預聚合的

groupByKey:

img
img

reduceByKey:

img
img

我們可以看到reduceByKey在shuffle前先對key相同的進行了聚合

1.6 使用高效能的運算元

這塊可以自行了解,美團文章中給出的例子有下面5個

使用reduceByKey/aggregateByKey替代groupByKey
使用mapPartitions替代普通map
使用foreachPartitions替代foreach
使用filter之後進行coalesce操作
使用repartitionAndSortWithinPartitions替代repartition與sort類操作
複製程式碼

1.7 廣播大變數

在使用到外部變數時,預設情況下,Spark會將該變數複製多個副本,通過網路傳輸到task中,此時每個task都有一個變數副本。如果變數本身比較大的話(比如100M,甚至1G),那麼大量的變數副本在網路中傳輸的效能開銷,以及在各個節點的Executor中佔用過多記憶體導致的頻繁GC,都會極大地影響效能。

因此對於上述情況,如果使用的外部變數比較大,建議使用Spark的廣播功能,對該變數進行廣播。廣播後的變數,會保證每個Executor的記憶體中,只駐留一份變數副本,而Executor中的task執行時共享該Executor中的那份變數副本。這樣的話,可以大大減少變數副本的數量,從而減少網路傳輸的效能開銷,並減少對Executor記憶體的佔用開銷,降低GC的頻率。

簡單一句話說明就是把原本每個task裡面都得整一個變數的,不過現在就在Executor中存一份,然後task需要的時候就過來拿就可以了

1.8 使用Kryo優化序列化效能

在Spark中,主要有三個地方涉及到了序列化:

  1. 在使用到外部變數時,該變數會被序列化後進行網路傳輸
  2. 將自定義的型別作為RDD的泛型型別時(比如JavaRDD,Student是自定義型別),所有自定義型別物件,都會進行序列化。因此這種情況下,也要求自定義的類必須實現Serializable介面。
  3. 使用可序列化的持久化策略時(比如MEMORY_ONLY_SER),Spark會將RDD中的每個partition都序列化成一個大的位元組陣列。

我們都可以通過使用Kryo序列化類庫,來優化序列化和反序列化的效能,效能高10倍左右。

1.9 優化資料結構

Java中,有三種型別比較耗費記憶體:

  1. 物件,每個Java物件都有物件頭、引用等額外的資訊,因此比較佔用記憶體空間。
  2. 字串,每個字串內部都有一個字元陣列以及長度等額外資訊。
  3. 集合型別,比如HashMap、LinkedList等,因為集合型別內部通常會使用一些內部類來封裝集合元素,比如Map.Entry。

Spark官方建議,在Spark編碼實現中,特別是對於運算元函式中的程式碼,儘量不要使用上述三種資料結構,儘量使用字串替代物件,使用原始型別(比如Int、Long)替代字串,使用陣列替代集合型別,這樣儘可能地減少記憶體佔用,從而降低GC頻率,提升效能。

  1. 能用json字串的不要用物件表示,因為物件頭額外佔16個位元組

  2. 能不用字串就不用用字串,因為字串額外佔40個位元組,比如,能用1 就不要用”1”

  3. 儘量用屬組代替集合型別

  4. 當然不要為了效能好而效能好,我們還是要兼顧程式碼的可讀性和開發效率。

1.10 儘可能資料本地化

下文中會說明

二、Spark的執行流程

這和Stage的劃分一樣基本上說是面試必問的問題,問也很簡單,說一個Spark任務然後提交後的整個流程給說說看

2.1 Driver的初始化

首先是Driver的初始化,圖中已經把步驟標明清楚了,就不展開說明了(draw.io 出了些問題不知道為啥字型背景顏色預設天藍色了?,我也設定不回來,所以就將就一下吧)

2.2 Task的生成及分配

當程式碼遇到一個action運算元的時候,會產生一個job任務,在產生任務之後,DAGScheduler 會進行Stage的劃分(涉及寬窄依賴和劃分演算法)。stage裡面會有task任務,同一個Stage裡面的task,任務邏輯一樣,只是處理的資料不一樣而已 。然後Task會被分發到各個Worker中去執行

這裡提到的Task的分配演算法我這裡稍微提一下,其實這個是關於Spark Core調優的十大原則中的最後一點“儘可能資料本地化”所闡述的。

(補充)關於程式本地化級別的描述

程式本地化級別:

  1. PROCESS_LOCAL:程式本地化

程式碼和資料在同一個程式中,也就是在同一個executor中;計算資料的task由executor執行,資料在executor的BlockManager中;效能最好.

  1. NODE_LOCAL:節點本地化程式碼和資料在同一個節點中;

比如說,資料作為一個HDFS block塊,就在節點上,而task在節點上某個executor中執行;或者是,資料和task在一個節點上的不同executor中;資料需要在程式間進行傳輸

  1. NO_PREF
    對於task來說,資料從哪裡獲取都一樣,沒有好壞之分

  2. RACK_LOCAL:機架本地化
    資料和task在一個機架的兩個節點上;資料需要通過網路在節點之間進行傳輸

  3. ANY
    資料和task可能在叢集中的任何地方,而且不在一個機架中,效能最差

我們提交任務後有Spark任務的監控介面,大家一定要利用好這個介面,Spark的介面是做得很好的。比如我們看到這個task的資料本地性是NODE_LOCAL說明是極好的,但是如果有你的task任務的資料本地性較差,可以嘗試如下調優:

(補充)如何調優

spark.locality.wait 預設值是3s 這個代表的意思是,task任務分配的時候,先是按照 _PROCESS_LOCAL 的這種方式去分配task的,但是如果 PROCESS_LOCAL 這個不滿足,那麼預設就等3秒,看能不能按照這級別去分配,但是如果等了3秒也實現不了。那麼就按 NODE_LOCAL 這個級別去分配

以此類推,每次都是等三秒。但是我們知道,如果想程式碼執行速度快,那麼就儘可能的讓task分配在PROCESS_LOCALNODE_LOCAL 級別,所以調優的時候,就讓task在這兩種級別的時候多等一會兒,這樣儘可能的把任務分配到這兩個級別。所以預設3秒就有點少了。

spark.locality.wait.process 30s
spark.locality.wait.node 30s
複製程式碼

在這兩個級別的時候設定多等一會兒

2.3 回到Executor的步驟說明

在Executor裡面會生成一個執行緒池,這個執行緒池其實是對應了Driver初始化圖中的第4步,早就已經生成好的了

有很多小夥伴可能還真不瞭解各個名詞的關係,這裡也一併在圖中說一下,就是一個Application裡面會有很多Job,Job裡面會劃分Stage,Stage裡面又會有許多Task,然後一個Task對應一個Partition,就這麼簡單

一、基於Spark記憶體模型調優

1.1 概述

我們使用spark-submit提交一個Spark作業之後,這個作業就會啟動一個對應的Driver程式。根據你使用的部署模式(deploy-mode)不同,Driver程式可能在本地啟動,也可能在叢集中某個工作節點上啟動。Driver程式本身會根據我們設定的引數,佔有一定數量的記憶體和CPU core。

而Driver程式要做的第一件事情,就是向叢集管理器(可以是Spark Standalone叢集,也可以是其他的資源管理叢集,比如我們公司使用的是YARN作為資源管理叢集)申請執行Spark作業需要使用的資源,這裡的資源指的就是Executor程式。YARN叢集管理器會根據我們為Spark作業設定的資源引數,在各個工作節點上,啟動一定數量的Executor程式,每個Executor程式都佔有一定數量的記憶體和CPU core。

1.2 靜態記憶體模型

在2016年 spark 1.6 版本以前 spark的executor使用的靜態記憶體模型,但是在spark1.6開始,多增加了一個統一記憶體模型。通過spark.memory.useLegacyMode 這個引數去配置。預設這個值是false,帶表用的是新的動態記憶體模型,如果想用以前的靜態記憶體模型,那麼就要把這個值改為true。

我們先用一個比官方更為簡單的圖先說明一下大概,你可以先大致這麼去理解

這裡就是我們平時提交的—executor-memory的劃分,實際上就是把我們的一個executor分成了三部分,一部分是Storage記憶體區域,一部分是execution區域,還有一部分是其他區域。如果使用的靜態記憶體模型,那麼用這幾個引數去控制:

spark.storage.memoryFraction:預設0.6
spark.shuffle.memoryFraction:預設0.2  
所以第三部分就是0.2
複製程式碼

如果我們cache資料量比較大,或者是我們的廣播變數比較大,那我們就把spark.storage.memoryFraction這個值調大一點。但是如果我們程式碼裡面沒有廣播變數,也沒有cache,shuffle又比較多,那我們要把spark.shuffle.memoryFraction 這值調大。

好的然後我們可以上覆雜一點的那張圖了。其實你會發現它就是比我上方的那個多了預留的部分和一個unroll

靜態記憶體模型的缺點:

我們配置好了Storage記憶體區域和execution區域後,我們的一個任務假設execution記憶體不夠用了,但是它的Storage記憶體區域是空閒的,兩個之間不能互相借用,不夠靈活,所以才出來我們新的統一記憶體模型。

1.3 統一記憶體模型


動態記憶體模型先是預留了300m記憶體,防止記憶體溢位。

動態記憶體模型把整體記憶體分成了兩部分,由spark.memory.fraction這個參數列示 預設值是0.6 這部分又劃分成為兩個小部分。 這兩部分其實就是:Storage記憶體和execution記憶體。由spark.memory.storageFraction 這個引數去調配,如果spark.memory.storageFraction這個值配的是0.5,那說明這0.6裡面 storage佔了0.5,也就是execution佔了0.1 。(注意:這裡的零點幾完全就是相對於總記憶體來說的,千萬不要以為spark.memory.storageFraction是0.5是指佔spark.memory.fraction的0.5的意思,不是的,它是佔總記憶體的0.5的意思

統一記憶體模型有什麼特點呢?

Storage記憶體和execution記憶體 可以相互借用。不用像靜態記憶體模型那樣死板,但是是有規則的:

場景一:Execution使用的時候發現記憶體不夠了,然後就會把storage的記憶體裡的資料驅逐到磁碟上。

場景二:一開始execution的記憶體使用得不多,但是storage使用的記憶體多,所以storage就借用了execution的記憶體,但是後來execution也要需要記憶體了,這個時候就會把storage的記憶體裡的資料寫到磁碟上,騰出記憶體空間。

細心的小夥伴也會發現,每次都是去折騰storage。
是因為execution裡面的資料是馬上就要用的,而storage裡的資料不一定馬上就要用。

1.4 資源調優的部分

瞭解完了Spark作業執行的基本原理之後,對資源相關的引數就容易理解了。所謂的Spark資源引數調優,其實主要就是對Spark執行過程中各個使用資源的地方,通過調節各種引數,來優化資源使用的效率,從而提升Spark作業的執行效能。以下引數就是Spark中主要的資源引數,每個引數都對應著作業執行原理中的某個部分,同時也給出了一個調優的參考值。

1.4.1 num-executors

引數說明:該引數用於設定Spark作業總共要用多少個Executor程式來執行。Driver在向YARN叢集管理器申請資源時,YARN叢集管理器會盡可能按照你的設定來在叢集的各個工作節點上,啟動相應數量的Executor程式。這個引數非常之重要,如果不設定的話,預設只會給你啟動少量的Executor程式,此時你的Spark作業的執行速度是非常慢的。

引數調優建議:每個Spark作業的執行一般設定50~100個左右的Executor程式比較合適,設定太少或太多的Executor程式都不好。設定的太少,無法充分利用叢集資源;設定的太多的話,大部分佇列可能無法給予充分的資源。

我覺得正常來說一開始 num-executors 先按照1/10個節點數量去試水是比較合適的,也就是1000個節點就來100個,100個節點就10個

1.4.2 executor-memory

引數說明:該引數用於設定每個Executor程式的記憶體。Executor記憶體的大小,很多時候直接決定了Spark作業的效能,而且跟常見的JVM OOM異常,也有直接的關聯。

引數調優建議:每個Executor程式的記憶體設定4G~8G較為合適。但是這只是一個參考值,具體的設定還是得根據不同部門的資源佇列來定。可以看看自己團隊的資源佇列的最大記憶體限制是多少,num-executors乘以executor-memory,是不能超過佇列的最大記憶體量的。此外,如果你是跟團隊裡其他人共享這個資源佇列,那麼申請的記憶體量最好不要超過資源佇列最大總記憶體的1/3~1/2,避免你自己的Spark作業佔用了佇列所有的資源,導致其它同事的作業無法執行。

1.4.3 executor-cores

引數說明:該引數用於設定每個Executor程式的CPU core數量。這個引數決定了每個Executor程式並行執行task執行緒的能力。因為每個CPU core同一時間只能執行一個task執行緒,因此每個Executor程式的CPU core數量越多,越能夠快速地執行完分配給自己的所有task執行緒。

引數調優建議:Executor的CPU core數量設定為2~4個較為合適。同樣得根據不同部門的資源佇列來定,可以看看自己的資源佇列的最大CPU core限制是多少,再依據設定的Executor數量,來決定每個Executor程式可以分配到幾個CPU core。同樣建議,如果是跟他人共享這個佇列,那麼num-executors * executor-cores不要超過佇列總CPU core的1/3~1/2左右比較合適,也是避免影響其他同學的作業執行。

個人覺得1個cpu core對應3個task,這個情況是效果最佳的

1.4.4 driver-memory

引數說明:該引數用於設定Driver程式的記憶體

引數調優建議:Driver的記憶體通常來說不設定,或者設定1G左右應該就夠了。唯一需要注意的一點是,如果需要使用collect運算元將RDD的資料全部拉取到Driver上進行處理,那麼必須確保Driver的記憶體足夠大,否則會出現OOM記憶體溢位的問題。

1.4.5 spark.default.parallelism

引數說明:該引數用於設定每個stage的預設task數量。這個引數極為重要,如果不設定可能會直接影響你的Spark作業效能。

引數調優建議:Spark作業的預設task數量為500~1000個較為合適。很多同學常犯的一個錯誤就是不去設定這個引數,那麼此時就會導致Spark自己根據底層HDFS的block數量來設定task的數量,預設是一個HDFS block對應一個task。通常來說,Spark預設設定的數量是偏少的(比如就幾十個task),如果task數量偏少的話,就會導致你前面設定好的Executor的引數都前功盡棄。

試想一下,無論你的Executor程式有多少個,記憶體和CPU有多大,但是task只有1個或者10個,那麼90%的Executor程式可能根本就沒有task執行,也就是白白浪費了資源!因此Spark官網建議的設定原則是,設定該引數為num-executors * executor-cores的2~3倍較為合適,比如Executor的總CPU core數量為300個,那麼設定1000個task是可以的,此時可以充分地利用Spark叢集的資源。

1.4.6 spark.storage.memoryFraction

這個東西其實剛剛都已經提到過了?。引數說明:該引數用於設定RDD持久化資料在Executor記憶體中能佔的比例,預設是0.6。也就是說,預設Executor 60%的記憶體,可以用來儲存持久化的RDD資料。根據你選擇的不同的持久化策略,如果記憶體不夠時,可能資料就不會持久化,或者資料會寫入磁碟。

引數調優建議:如果Spark作業中,有較多的RDD持久化操作,該引數的值可以適當提高一些,保證持久化的資料能夠容納在記憶體中。避免記憶體不夠快取所有的資料,導致資料只能寫入磁碟中,降低了效能。

但是如果Spark作業中的shuffle類操作比較多,而持久化操作比較少,那麼這個引數的值適當降低一些比較合適。此外,如果發現作業由於頻繁的gc導致執行緩慢(通過spark web ui可以觀察到作業的gc耗時),意味著task執行使用者程式碼的記憶體不夠用,那麼同樣建議調低這個引數的值。

1.4.7 spark.shuffle.memoryFraction

這個剛剛也提到過了。引數說明:該引數用於設定shuffle過程中一個task拉取到上個stage的task的輸出後,進行聚合操作時能夠使用的Executor記憶體的比例,預設是0.2。也就是說,Executor預設只有20%的記憶體用來進行該操作。shuffle操作在進行聚合時,如果發現使用的記憶體超出了這個20%的限制,那麼多餘的資料就會溢寫到磁碟檔案中去,此時就會極大地降低效能。

引數調優建議:如果Spark作業中的RDD持久化操作較少,shuffle操作較多時,建議降低持久化操作的記憶體佔比,提高shuffle操作的記憶體佔比比例,避免shuffle過程中資料過多時記憶體不夠用,必須溢寫到磁碟上,降低了效能。此外,如果發現作業由於頻繁的gc導致執行緩慢,意味著task執行使用者程式碼的記憶體不夠用,那麼同樣建議調低這個引數的值。

1.4.8 小總結

資源引數的調優,沒有一個固定的值,需要根據自己的實際情況(包括Spark作業中的shuffle運算元量、RDD持久化運算元量以及spark web ui中顯示的作業gc情況),同時參考本篇文章中給出的原理以及調優建議,合理地設定上述引數。

以下是一份spark-submit命令的示例,大家可以參考一下,並根據自己的實際情況進行調節:

./bin/spark-submit \
  --master yarn-cluster \
  --num-executors 100 \
  --executor-memory 6G \
  --executor-cores 4 \
  --driver-memory 1G \
  --conf spark.default.parallelism=1000 \
  --conf spark.storage.memoryFraction=0.5 \
  --conf spark.shuffle.memoryFraction=0.3 \
複製程式碼

--master yarn-cluster這個引數需要注意,因為不同的Spark版本這個引數是不一致的。所以在提交任務時記得要注意我們叢集中的版本,如果是Spark1.5版本,就是剛剛的--master yarn-cluster,如果是2.4版本,這個引數又變成了--master yarn了,所以一定要注意

如果出現了類似於java.lang.OutOfMemoryError, ExecutorLostFailure, Executor exit code 為143, executor lost, hearbeat time out, shuffle file lost···等等,先別緊張,很有可能就是記憶體除了問題,可以先嚐試增加記憶體。如果還是解決不了,那麼請再把注意力放到資料傾斜方面

二、資料傾斜調優

2.1 概述

有的時候,我們可能會遇到大資料計算中一個最棘手的問題——資料傾斜,此時Spark作業的效能會比期望差很多。資料傾斜調優,就是使用各種技術方案解決不同型別的資料傾斜問題,以保證Spark作業的效能。

2.1.1 資料傾斜發生時的現象

絕大多數task執行得都非常快,但個別task執行極慢。比如,總共有1000個task,997個task都在1分鐘之內執行完了,但是剩餘兩三個task卻要一兩個小時。這種情況很常見。

原本能夠正常執行的Spark作業,某天突然報出OOM(記憶體溢位)異常,觀察異常棧,是我們寫的業務程式碼造成的。這種情況比較少見。

2.1.2 資料傾斜發生的原理

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

比如大部分key對應10條資料,但是個別key卻對應了100萬條資料,那麼大部分task可能就只會分配到10條資料,然後1秒鐘就執行完了;但是個別task可能分配到了100萬資料,要執行一兩個小時。因此,整個Spark作業的執行進度是由執行時間最長的那個task決定的。

因此出現資料傾斜的時候,Spark作業看起來會執行得非常緩慢,甚至可能因為某個task處理的資料量過大導致記憶體溢位。

上圖就是一個很清晰的例子:hello這個key,在三個節點上對應了總共7條資料,這些資料都會被拉取到同一個task中進行處理;而world和you這兩個key分別才對應1條資料,所以另外兩個task只要分別處理1條資料即可。此時第一個task的執行時間可能是另外兩個task的7倍,而整個stage的執行速度也由執行最慢的那個task所決定。

2.1.3 如何定位資料傾斜的程式碼

資料傾斜只會發生在shuffle過程中。這裡給大家羅列一些常用的並且可能會觸發shuffle操作的運算元:distinct、groupByKey、reduceByKey、aggregateByKey、join、cogroup、repartition等。出現資料傾斜時,可能就是你的程式碼中使用了這些運算元中的某一個所導致的。

某個task執行特別慢的情況
首先要看的,就是資料傾斜發生在第幾個stage中
如果是用yarn-client模式提交,那麼本地是直接可以看到log的,可以在log中找到當前執行到了第幾個stage;如果是用yarn-cluster模式提交,則可以通過Spark Web UI來檢視當前執行到了第幾個stage。此外,無論是使用yarn-client模式還是yarn-cluster模式,我們都可以在Spark Web UI上深入看一下當前這個stage各個task分配的資料量,從而進一步確定是不是task分配的資料不均勻導致了資料傾斜

我們可以這樣來定位發生資料傾斜的運算元:Spark Web UI中點進去一個Application裡面會有很多job,因為job的界定是action運算元來分割的,所以我們就看我們程式碼中的action運算元來判斷程式碼位置,再點進去job會有stage,stage的界定是shuffle類的運算元,以這個為依據又能定位一波程式碼位置,此時只要看到哪個task消耗時間長,那就知道了是哪個stage(shuffle運算元)出現了問題


比如上圖中,倒數第三列顯示了每個task的執行時間。明顯可以看到,有的task執行特別快,只需要幾秒鐘就可以執行完;而有的task執行特別慢,需要幾分鐘才能執行完,此時單從執行時間上看就已經能夠確定發生資料傾斜了。此外,倒數第一列顯示了每個task處理的資料量,明顯可以看到,執行時間特別短的task只需要處理幾百KB的資料即可,而執行時間特別長的task需要處理幾千KB的資料,處理的資料量差了10倍。此時更加能夠確定是發生了資料傾斜。

但是大家要注意的是,不能單純靠偶然的記憶體溢位就判定發生了資料傾斜。因為自己編寫的程式碼的bug,以及偶然出現的資料異常,也可能會導致記憶體溢位。因此還是要按照上面所講的方法,通過Spark Web UI檢視報錯的那個stage的各個task的執行時間以及分配的資料量,才能確定是否是由於資料傾斜才導致了這次記憶體溢位。

2.1.4 檢視導致資料傾斜的key的資料分佈情況

知道了資料傾斜發生在哪裡之後,通常需要分析一下那個執行了shuffle操作並且導致了資料傾斜的RDD/Hive表,檢視一下其中key的分佈情況。這主要是為之後選擇哪一種技術方案提供依據。針對不同的key分佈與不同的shuffle運算元組合起來的各種情況,可能需要選擇不同的技術方案來解決。

此時根據你執行操作的情況不同,可以有很多種檢視key分佈的方式:

  1. 如果是Spark SQL中的group by、join語句導致的資料傾斜,那麼就查詢一下SQL中使用的表的key分佈情況。
  2. 如果是對Spark RDD執行shuffle運算元導致的資料傾斜,那麼可以在Spark作業中加入檢視key分佈的程式碼,比如RDD.countByKey()。然後對統計出來的各個key出現的次數,collect/take到客戶端列印一下,就可以看到key的分佈情況。

2.2 資料傾斜的解決方案

2.2.1 使用Hive ETL預處理資料

其實這招純屬是讓一些費時間的操作留到凌晨的時候跑,然後第二天需要資料的時候直接拿到凌晨跑出來的結果過來用的方法(騙自己?)。適用於Hive表中的資料本身很不均勻(比如某個key對應了100萬資料,其他key才對應了10條資料),而且業務場景需要頻繁使用Spark對Hive表執行某個分析操作

通過Hive來進行資料預處理(即通過Hive ETL預先對資料按照key進行聚合,或者是預先和其他表進行join。此時由於資料已經預先進行過聚合或join操作了,那麼在Spark作業中也就不需要使用原先的shuffle類運算元執行這類操作了。實現起來簡單便捷,效果還非常好

但是這一招也會有它不能用的場景,也就是如果要玩實時的它就不能用了

2.2.2 過濾少數導致傾斜的key

如果我們判斷那少數幾個資料量特別多的key,對作業的執行和計算結果不是特別重要的話,那麼幹脆就直接過濾掉那少數幾個key。如果需要每次作業執行時,動態判定哪些key的資料量最多然後再進行過濾,那麼可以使用sample運算元對RDD進行取樣,然後計算出每個key的數量,取資料量最多的key過濾掉即可。

將導致資料傾斜的key給過濾掉之後,這些key就不會參與計算了,自然不可能產生資料傾斜,剛剛提到的定位運算元就立刻派上用場了,你不是數量多嘛,我就直接把你刪了(又是一招騙自己的?)

2.2.3 提高shuffle操作的並行度

增加shuffle read task的數量,可以讓原本分配給一個task的多個key分配給多個task,從而讓每個task處理比原來更少的資料。

舉例來說,如果原本有5個key,每個key對應10條資料,這5個key都是分配給一個task的,那麼這個task就要處理50條資料。而增加了shuffle read task以後,每個task就分配到一個key,即每個task就處理10條資料,那麼自然每個task的執行時間都會變短了。實現起來比較簡單,可以有效緩解和減輕資料傾斜的影響。

該方案其實非常扯,其實根本無法解決資料傾斜,因為如果出現一些極端情況,比如某個key對應的資料量有100萬,那麼無論你的task數量增加到多少,這個對應著100萬資料的key肯定還是會分配到一個task中去處理,因此註定還是會發生資料傾斜的。

前面3種方案都是些沒啥用的玩意,都是逃避了問題的,從第4種開始方案就變得合理起來了。

2.2.4 兩階段聚合(區域性聚合+全域性聚合)

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

核心實現思路就是進行兩階段聚合。

  1. 第一次是區域性聚合,先給每個key都打上一個隨機數,比如10以內的隨機數,此時原先一樣的key就變成不一樣的了,
    比如 (hello, 1) (hello, 1) (hello, 1) (hello, 1) ,就會變成 (1_hello, 1) (1_hello, 1) (2_hello, 1) (2_hello, 1)

  2. 接著對打上隨機數後的資料,執行reduceByKey等聚合操作,進行區域性聚合,那麼區域性聚合結果,就會變成了(1_hello, 2) (2_hello, 2)

  3. 然後將各個key的字首給去掉,就會變成(hello,2)(hello,2)

  4. 再次進行全域性聚合操作,就可以得到最終結果了,最後是(hello, 4)

這個方法的優點是對於聚合類的shuffle操作導致的資料傾斜,效果是非常不錯的。通常都可以解決掉資料傾斜,或者至少是大幅度緩解資料傾斜,將Spark作業的效能提升數倍以上。可是缺點也很明顯,它僅僅適用於聚合類的shuffle操作,適用範圍相對較窄。如果是join類的shuffle操作,還得用其他的解決方案。

2.2.5 將reduce join轉為map join

在對RDD使用join類操作,或者是在Spark SQL中使用join語句時,而且join操作中的一個RDD或表的資料量比較小(比如幾百M或者一兩G),比較適用此方案。

普通的join是會走shuffle過程的,而一旦shuffle,就相當於會將相同key的資料拉取到一個shuffle read task中再進行join,此時就是reduce join。但是如果一個RDD是比較小的,則可以採用廣播小RDD全量資料+map運算元來實現與join同樣的效果,也就是map join,此時就不會發生shuffle操作,也就不會發生資料傾斜。

優點:對join操作導致的資料傾斜,直接避開了shuffle,也就根本不會發生資料傾斜。

方案缺點:適用場景較少,因為這個方案只適用於一個大表和一個小表的情況。因為我們需要將小表進行廣播,會比較消耗記憶體資源,driver和每個Executor記憶體中都會駐留一份小RDD的全量資料。如果我們廣播出去的RDD資料比較大,比如10G以上,那麼就可能發生記憶體溢位了。因此並不適合兩個都是大表的情況。

2.2.6 (最nice的)取樣傾斜key並分拆join操作

兩個RDD/Hive表進行join的時候,如果資料量都比較大,無法採用2.2.5,那麼此時可以看一下兩個RDD/Hive表中的key分佈情況。如果出現資料傾斜,是因為其中某一個RDD/Hive表中的少數幾個key的資料量過大,而另一個RDD/Hive表中的所有key都分佈比較均勻,那麼採用這個解決方案是比較合適的。

  1. 對包含少數幾個資料量過大的key的那個RDD,通過sample運算元取樣出一份樣本來,然後統計一下每個key的數量,計算出來資料量最大的是哪幾個key
  2. 然後將這幾個key對應的資料從原來的RDD中拆分出來,形成一個單獨的RDD,並給每個key都打上n以內的隨機數作為字首,而不會導致傾斜的大部分key形成另外一個RDD。
  3. 接著將需要join的另一個RDD,也過濾出來那幾個傾斜key對應的資料並形成一個單獨的RDD,將每條資料擴容成n條資料,這n條資料都按順序附加一個0~n的字首,不會導致傾斜的大部分key也形成另外一個RDD。

到這一步我們可以得出這個套路,如果RDDA1或者RDDB1中有一份資料量較小可以滿足方案5的條件,那就直接執行方案5

可是其實在實際的生產環境中,就是兩個都非常大的情況,所以我們要繼續進行改良

  1. 再將附加了隨機字首的獨立RDD與另一個擴容n倍的獨立RDD進行join,此時就可以將原先相同的key打散成n份,分散到多個task中去進行join了。
  2. 而另外兩個普通的RDD就照常join即可。
  3. 最後將兩次join的結果使用union運算元合併起來即可,就是最終的join結果。

對於join導致的資料傾斜,如果只是某幾個key導致了傾斜,可以將少數幾個key分拆成獨立RDD,並附加隨機字首打散成n份去進行join,此時這幾個key對應的資料就不會集中在少數幾個task上,而是分散到多個task進行join了

PS:不需要覺得擴容3倍覺得這樣不好,發生資料傾斜有可能會導致一個任務計算好幾天甚至十幾天,相比於這種風險,這個擴容付出的代價是值得的

優點:對於join導致的資料傾斜,如果只是某幾個key導致了傾斜,採用該方式可以用最有效的方式打散key進行join。而且只需要針對少數傾斜key對應的資料進行擴容n倍,不需要對全量資料進行擴容。避免了佔用過多記憶體。

方案缺點:如果導致傾斜的key特別多的話,比如成千上萬個key都導致資料傾斜,那麼這種方式也不適合。

2.2.7 使用隨機字首和擴容RDD進行join

如果在進行join操作時,RDD中有大量的key導致資料傾斜,那麼進行分拆key也沒什麼意義,此時就只能使用這一種方案來解決問題了。簡單粗暴,不講道理?

  1. 該方案的實現思路基本和“解決方案六”類似,首先檢視RDD/Hive表中的資料分佈情況,找到那個造成資料傾斜的RDD/Hive表,比如有多個key都對應了超過1萬條資料。
  2. 然後將該RDD的每條資料都打上一個n以內的隨機字首。
  3. 同時對另外一個正常的RDD進行擴容,將每條資料都擴容成n條資料,擴容出來的每條資料都依次打上一個0~n的字首。
  4. 最後將兩個處理後的RDD進行join即可。

上一種方案是儘量只對少數傾斜key對應的資料進行特殊處理,由於處理過程需要擴容RDD,因此上一種方案擴容RDD後對記憶體的佔用並不大;而這一種方案是針對有大量傾斜key的情況,沒法將部分key拆分出來進行單獨處理,因此只能對整個RDD進行資料擴容,對記憶體資源要求很高。

把上面的幾種資料傾斜的解決方案綜合的靈活執行,這樣可以保證日常遇到的問題基本都能有思路解決


三、shuffle調優方面

Spark Shuffle在Spark1.3之前發展不太成熟,那時候可能面試提及非常多,現在的面試其實已經不太會去問這一塊的知識了,但是如果我們要深入地去了解一門技術,那還是得把這些細枝末節給摳一下,瞭解一下前世今生。

這裡的調優內容主要就是把上一篇Spark的Shuffle總結分析裡面提到的引數給再次說明一下

3.1 (提升效能)spark.shuffle.file.buffer

  • 預設值:32k
  • 引數說明:該引數用於設定shuffle write task的BufferedOutputStream的buffer緩衝大小。將資料寫到磁碟檔案之前,會先寫入buffer緩衝中,待緩衝寫滿之後,才會溢寫到磁碟。
  • 調優建議:如果作業可用的記憶體資源較為充足的話,可以適當增加這個引數的大小(比如64k),從而減少shuffle write過程中溢寫磁碟檔案的次數,也就可以減少磁碟IO次數

3.2 (提升效能)spark.reducer.maxSizeInFlight

  • 預設值:48m
  • 引數說明:該引數用於設定shuffle read task的buffer緩衝大小,而這個buffer緩衝決定了每次能夠拉取多少資料
  • 調優建議:如果作業可用的記憶體資源較為充足的話,可以適當增加這個引數的大小(比如96m),從而減少拉取資料的次數,也就可以減少網路傳輸的次數

3.3 (提高穩定)spark.shuffle.io.maxRetries

  • 預設值:3
  • 引數說明:shuffle read task從shuffle write task所在節點拉取屬於自己的資料時,如果因為網路異常導致拉取失敗,是會自動進行重試的。該引數就代表了可以重試的最大次數。如果在指定次數之內拉取還是沒有成功,就可能會導致作業執行失敗。
  • 調優建議:對於那些包含了特別耗時的shuffle操作的作業,建議增加重試最大次數(比如60次),以避免由於JVM的full gc或者網路不穩定等因素導致的資料拉取失敗。對於針對超大資料量(數十億~上百億)的shuffle過程,調節該引數可以大幅度提升穩定性。

3.4 (提高穩定)spark.shuffle.io.retryWait

  • 預設值:5s
  • 引數說明:具體解釋同上,該引數代表了每次重試拉取資料的等待間隔,預設是5s。
  • 調優建議:建議加大間隔時長(比如60s),以增加shuffle操作的穩定性。

3.5 (記憶體模型)spark.shuffle.memoryFraction

  • 預設值:0.2
  • 引數說明:(Spark1.6是這個引數,1.6以後引數變成spark.memory.fraction)該引數代表了Executor記憶體中,分配給shuffle read task進行聚合操作的記憶體比例,預設是20%。
  • 調優建議:在資源引數調優中講解過這個引數。如果記憶體充足,而且很少使用持久化操作,建議調高這個比例,給shuffle read的聚合操作更多記憶體,以避免由於記憶體不足導致聚合過程中頻繁讀寫磁碟。在實踐中發現,合理調節該引數可以將效能提升10%左右。

3.6 spark.shuffle.manager

  • 預設值:sort
  • 引數說明:該引數用於設定ShuffleManager的型別。Spark 1.5以後,有三個可選項:hash、sort和tungsten-sort。HashShuffleManager是Spark 1.2以前的預設選項,但是Spark 1.2以及之後的版本預設都是SortShuffleManager了。Spark1.6以後把hash方式給移除了,tungsten-sort與sort類似,但是使用了tungsten計劃中的堆外記憶體管理機制,記憶體使用效率更高。
  • 調優建議:由於SortShuffleManager預設會對資料進行排序,因此如果你的業務邏輯中需要該排序機制的話,則使用預設的SortShuffleManager就可以;而如果你的業務邏輯不需要對資料進行排序,那麼建議參考後面的幾個引數調優,通過bypass機制或優化的HashShuffleManager來避免排序操作,同時提供較好的磁碟讀寫效能。這裡要注意的是,tungsten-sort要慎用,因為之前發現了一些相應的bug。

3.7 spark.shuffle.sort.bypassMergeThreshold

  • 預設值:200
  • 引數說明:當ShuffleManager為SortShuffleManager時,如果shuffle read task的數量小於這個閾值(預設是200),則shuffle write過程中不會進行排序操作,而是直接按照未經優化的HashShuffleManager的方式去寫資料,但是最後會將每個task產生的所有臨時磁碟檔案都合併成一個檔案,並會建立單獨的索引檔案。
  • 調優建議:當你使用SortShuffleManager時,如果的確不需要排序操作,那麼建議將這個引數調大一些,大於shuffle read task的數量。那麼此時就會自動啟用bypass機制,map-side就不會進行排序了,減少了排序的效能開銷。但是這種方式下,依然會產生大量的磁碟檔案,因此shuffle write效能有待提高。

finally

萬字不易,這也是關於Spark core的最後一篇了。希望對大家有所幫助。之後會更Spark Streaming方面的內容,感興趣的朋友可以關注一下哦,公眾號:說出你的願望吧

相關文章