Spark性能調優:RDD的複用以及RDD持久化

避免創建重複的RDD

 

通常來說,開發一個Spark作業時,首先是基於某個數據源(比如Hive表或HDFS文件)創建一個初始的RDD;接着對這個RDD執行某個算子操作,然後得到下一個RDD;以此類推,循環往復,直到計算出最終我們需要的結果。在這個過程中,多個RDD會通過不同的算子操作(比如map、reduce等)串起來,這個“RDD串”,就是RDD lineage,也就是“RDD的血緣關係鏈”。

我們在開發過程中要注意:對於同一份數據,只應該創建一個RDD,不能創建多個RDD來代表同一份數據。

在開發RDD lineage極其冗長的Spark作業時,可能會忘了自己之前對於某一份數據已經創建過一個RDD了,從而導致對於同一份數據,創建了多個RDD。這就意味着,我們的Spark作業會進行多次重複計算來創建多個代表相同數據的RDD,進而增加了作業的性能開銷。

一個簡單的例子:

// 需要對名爲“hello.txt”的HDFS文件進行一次map操作,再進行一次reduce操作。也就是說,需要對一份數據執行兩次算子操作。

// 錯誤的做法:對於同一份數據執行多次算子操作時,創建多個RDD。
// 這裏執行了兩次textFile方法,針對同一個HDFS文件,創建了兩個RDD出來,然後分別對每個RDD都執行了一個算子操作。
// 這種情況下,Spark需要從HDFS上兩次加載hello.txt文件的內容,並創建兩個單獨的RDD;第二次加載HDFS文件以及創建RDD的性能開銷,很明顯是白白浪費掉的。
val rdd1 = sc.textFile("hdfs://192.168.0.0:8020/hello.txt")
rdd1.map(...)
val rdd2 = sc.textFile("hdfs://192.168.0.0:8020/hello.txt")
rdd2.reduce(...)

// 正確的用法:對於一份數據執行多次算子操作時,只使用一個RDD。
// 這種寫法很明顯比上一種寫法要好多了,因爲我們對於同一份數據只創建了一個RDD,然後對這一個RDD執行了多次算子操作。
// 但是要注意到這裏爲止優化還沒有結束,由於rdd1被執行了兩次算子操作,第二次執行reduce操作的時候,還會再次從源頭處重新計算一次rdd1的數據,因此還是會有重複計算的性能開銷。
// 要徹底解決這個問題,必須結合“原則三:對多次使用的RDD進行持久化”,才能保證一個RDD被多次使用時只被計算一次。
val rdd1 = sc.textFile("hdfs://192.168.0.0:8020/hello.txt")
rdd1.map(...)
rdd1.reduce(...)

 

儘可能複用同一個RDD

除了要避免在開發過程中對一份完全相同的數據創建多個RDD之外,在對不同的數據執行算子操作時還要儘可能地複用一個RDD。比如說,有一個RDD的數據格式是key-value類型的,另一個是單value類型的,這兩個RDD的value數據是完全一樣的。那麼此時我們可以只使用key-value類型的那個RDD,因爲其中已經包含了另一個的數據。對於類似這種多個RDD的數據有重疊或者包含的情況,我們應該儘量複用一個RDD,這樣可以儘可能地減少RDD的數量,從而儘可能減少算子執行的次數。

一個簡單的例子

<span style="color:#000000"><code>// 錯誤的做法。

// 有一個<Long, String>格式的RDD,即rdd1。
// 接着由於業務需要,對rdd1執行了一個map操作,創建了一個rdd2,而rdd2中的數據僅僅是rdd1中的value值而已,也就是說,rdd2是rdd1的子集。
JavaPairRDD<Long, String> rdd1 = <span style="color:#000088 !important">...</span>
JavaRDD<String> rdd2 = rdd1.map(<span style="color:#000088 !important">...</span>)

// 分別對rdd1和rdd2執行了不同的算子操作。
rdd1.reduceByKey(<span style="color:#000088 !important">...</span>)
rdd2.map(<span style="color:#000088 !important">...</span>)

//-------------------------------------------
// 正確的做法。

// 上面這個case中,其實rdd1和rdd2的區別無非就是數據格式不同而已,rdd2的數據完全就是rdd1的子集而已,卻創建了兩個rdd,並對兩個rdd都執行了一次算子操作。
// 此時會因爲對rdd1執行map算子來創建rdd2,而多執行一次算子操作,進而增加性能開銷。

// 其實在這種情況下完全可以複用同一個RDD。
// 我們可以使用rdd1,既做reduceByKey操作,也做map操作。
// 在進行第二個map操作時,只使用每個數據的tuple._2,也就是rdd1中的value值,即可。
JavaPairRDD<Long, String> rdd1 = <span style="color:#000088 !important">...</span>
rdd1.reduceByKey(<span style="color:#000088 !important">...</span>)
rdd1.map(tuple._2...)

// 第二種方式相較於第一種方式而言,很明顯減少了一次rdd2的計算開銷。
// 但是到這裏爲止,優化還沒有結束,對rdd1我們還是執行了兩次算子操作,rdd1實際上還是會被計算兩次。
// 因此還需要配合“對多次使用的RDD進行持久化”進行使用,才能保證一個RDD被多次使用時只被計算一次。
</code></span>

 

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

 

Spark中對於一個RDD執行多次算子的默認原理是這樣的:每次你對一個RDD執行一個算子操作時,都會重新從源頭處計算一遍,計算出那個RDD來,然後再對這個RDD執行你的算子操作。這種方式的性能是很差的。

因此對於這種情況,我們的建議是:對多次使用的RDD進行持久化。此時Spark就會根據你的持久化策略,將RDD中的數據保存到內存或者磁盤中。以後每次對這個RDD進行算子操作時,都會直接從內存或磁盤中提取持久化的RDD數據,然後執行算子,而不會從源頭處重新計算一遍這個RDD,再執行算子操作。

<span style="color:#000000"><code>// 如果要對一個RDD進行持久化,只要對這個RDD調用cache()和persist()即可。

// 正確的做法。
// cache()方法表示:使用非序列化的方式將RDD中的數據全部嘗試持久化到內存中。
// 此時再對rdd1執行兩次算子操作時,只有在第一次執行map算子時,纔會將這個rdd1從源頭處計算一次。
// 第二次執行reduce算子時,就會直接從內存中提取數據進行計算,不會重複計算一個rdd。
val rdd1 = sc.textFile(<span style="color:#009900 !important">"hdfs://192.168.0.1:9000/hello.txt"</span>).cache()
rdd1.map(<span style="color:#000088 !important">...</span>)
rdd1.reduce(<span style="color:#000088 !important">...</span>)

// persist()方法表示:手動選擇持久化級別,並使用指定的方式進行持久化。
// 比如說,StorageLevel.MEMORY_AND_DISK_SER表示,內存充足時優先持久化到內存中,內存不充足時持久化到磁盤文件中。
// 而且其中的_SER後綴表示,使用序列化的方式來保存RDD數據,此時RDD中的每個partition都會序列化成一個大的字節數組,然後再持久化到內存或磁盤中。
// 序列化的方式可以減少持久化的數據對內存/磁盤的佔用量,進而避免內存被持久化數據佔用過多,從而發生頻繁GC。
val rdd1 = sc.textFile(<span style="color:#009900 !important">"hdfs://192.168.0.1:9000/hello.txt"</span>).persist(StorageLevel.MEMORY_AND_DISK_SER)
rdd1.map(<span style="color:#000088 !important">...</span>)
rdd1.reduce(<span style="color:#000088 !important">...</span>)</code></span>

對於persist()方法而言,我們可以根據不同的業務場景選擇不同的持久化級別。

持久化級別 含義
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的所有數據。因爲不進行序列化與反序列化操作,就避免了這部分的性能開銷;對這個RDD的後續算子操作,都是基於純內存中的數據的操作,不需要從磁盤文件中讀取數據,性能也很高;而且不需要複製一份數據副本,並遠程傳送到其他節點上。但是這裏必須要注意的是,在實際的生產環境中,恐怕能夠直接用這種策略的場景還是有限的,如果RDD中數據比較多時(比如幾十億),直接用這種持久化級別,會導致JVM的OOM內存溢出異常。

  • 如果使用MEMORY_ONLY級別時發生了內存溢出,那麼建議嘗試使用MEMORY_ONLY_SER級別。該級別會將RDD數據序列化後再保存在內存中,此時每個partition僅僅是一個字節數組而已,大大減少了對象數量,並降低了內存佔用。這種級別比MEMORY_ONLY多出來的性能開銷,主要就是序列化與反序列化的開銷。但是後續算子可以基於純內存進行操作,因此性能總體還是比較高的。此外,可能發生的問題同上,如果RDD中的數據量過多的話,還是可能會導致OOM內存溢出的異常。

  • 如果純內存的級別都無法使用,那麼建議使用MEMORY_AND_DISK_SER策略,而不是MEMORY_AND_DISK策略。因爲既然到了這一步,就說明RDD的數據量很大,內存無法完全放下。序列化後的數據比較少,可以節省內存和磁盤的空間開銷。同時該策略會優先儘量嘗試將數據緩存在內存中,內存緩存不下才會寫入磁盤。

  • 通常不建議使用DISK_ONLY和後綴爲_2的級別:因爲完全基於磁盤文件進行數據的讀寫,會導致性能急劇降低,有時還不如重新計算一次所有RDD。後綴爲_2的級別,必須將所有數據都複製一份副本,併發送到其他節點上,數據複製以及網絡傳輸會導致較大的性能開銷,除非是要求作業的高可用性,否則不建議使用。

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