Spark性能調優——9項基本原則

原則一:避免創建重複的RDD

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

我們在開發過程中要注意:對於同一份數據,只應該創建一個RDD,不能創建多個RDD來代表同一份數據。
一些Spark初學者在剛開始開發Spark作業時,或者是有經驗的工程師在開發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.1:9000/hello.txt")
rdd1.map(...)
val rdd2 = sc.textFile("hdfs://192.168.0.1:9000/hello.txt")
rdd2.reduce(...)

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

原則二:儘可能復用同一個RDD

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

簡單的例子

// 錯誤的做法。

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

// 分別對rdd1和rdd2執行了不同的運算元操作。
rdd1.reduceByKey(...)
rdd2.map(...)

// 正確的做法。

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

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

// 第二種方式相較於第一種方式而言,很明顯減少了一次rdd2的計算開銷。
// 但是到這裡為止,優化還沒有結束,對rdd1我們還是執行了兩次運算元操作,rdd1實際上還是會被計算兩次。
// 因此還需要配合「原則三:對多次使用的RDD進行持久化」進行使用,才能保證一個RDD被多次使用時只被計算一次。

原則三:對多次使用的RDD進行持久化

當你在Spark程式碼中多次對一個RDD做了運算元操作後,恭喜,你已經實現Spark作業第一步的優化了,也就是儘可能復用RDD。
此時就該在這個基礎之上,進行第二步優化了,也就是要保證對一個RDD執行多次運算元操作時,這個RDD本身僅僅被計算一次。
Spark中對於一個RDD執行多次運算元的默認原理是這樣的:每次你對一個RDD執行一個運算元操作時,都會重新從源頭處計算一遍,
計算出那個RDD來,然後再對這個RDD執行你的運算元操作。這種方式的性能是很差的。
因此對於這種情況,我們的建議是:對多次使用的RDD進行持久化。此時Spark就會根據你的持久化策略,
將RDD中的數據保存到記憶體或者磁碟中。以後每次對這個RDD進行運算元操作時,都會直接從記憶體或磁碟中提取持久化的RDD數據,然後執行運算元,而不會從源頭處重新計算一遍這個RDD,再執行運算元操作。

對多次使用的RDD進行持久化的程式碼示例

// 如果要對一個RDD進行持久化,只要對這個RDD調用cache()和persist()即可。
// 正確的做法。
// cache()方法表示:使用非序列化的方式將RDD中的數據全部嘗試持久化到記憶體中。
// 此時再對rdd1執行兩次運算元操作時,只有在第一次執行map運算元時,才會將這個rdd1從源頭處計算一次。
// 第二次執行reduce運算元時,就會直接從記憶體中提取數據進行計算,不會重複計算一個rdd。
val rdd1 = sc.textFile("hdfs://192.168.0.1:9000/hello.txt").cache()
rdd1.map(...)
rdd1.reduce(...)

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

原則四:盡量避免使用shuffle類運算元

如果有可能的話,要盡量避免使用shuffle類運算元。因為Spark作業運行過程中,最消耗性能的地方就是shuffle過程。
shuffle過程,簡單來說,就是將分布在集群中多個節點上的同一個key,拉取到同一個節點上,進行聚合或join等操作。比如reduceByKey、join等運算元,都會觸發shuffle操作。
shuffle過程中,各個節點上的相同key都會先寫入本地磁碟文件中,然後其他節點需要通過網路傳輸拉取各個節點上的磁碟文件中的相同key。
而且相同key都拉取到同一個節點進行聚合操作時,還有可能會因為一個節點上處理的key過多,導致記憶體不夠存放,進而溢寫到磁碟文件中。
因此在shuffle過程中,可能會發生大量的磁碟文件讀寫的IO操作,以及數據的網路傳輸操作。磁碟IO和網路數據傳輸也是shuffle性能較差的主要原因。
因此在我們的開發過程中,能避免則儘可能避免使用reduceByKey、join、distinct、repartition等會進行shuffle的運算元,盡量使用map類的非shuffle運算元。
這樣的話,沒有shuffle操作或者僅有較少shuffle操作的Spark作業,可以大大減少性能開銷.

Broadcast與map進行join程式碼示例

// 傳統的join操作會導致shuffle操作。
// 因為兩個RDD中,相同的key都需要通過網路拉取到一個節點上,由一個task進行join操作。
val rdd3 = rdd1.join(rdd2)

// Broadcast+map的join操作,不會導致shuffle操作。
// 使用Broadcast將一個數據量較小的RDD作為廣播變數。
val rdd2Data = rdd2.collect()
val rdd2DataBroadcast = sc.broadcast(rdd2Data)

// 在rdd1.map運算元中,可以從rdd2DataBroadcast中,獲取rdd2的所有數據。
// 然後進行遍歷,如果發現rdd2中某條數據的key與rdd1的當前數據的key是相同的,那麼就判定可以進行join。
// 此時就可以根據自己需要的方式,將rdd1當前數據與rdd2中可以連接的數據,拼接在一起(String或Tuple)。
val rdd3 = rdd1.map(rdd2DataBroadcast...)

// 注意,以上操作,建議僅僅在rdd2的數據量比較少(比如幾百M,或者一兩G)的情況下使用。
// 因為每個Executor的記憶體中,都會駐留一份rdd2的全量數據。

原則五:使用map-side預聚合的shuffle操作

如果因為業務需要,一定要使用shuffle操作,無法用map類的運算元來替代,那麼盡量使用可以map-side預聚合的運算元。
所謂的map-side預聚合,說的是在每個節點本地對相同的key進行一次聚合操作,類似於MapReduce中的本地combiner。map-side預聚合之後,
每個節點本地就只會有一條相同的key,因為多條相同的key都被聚合起來了。其他節點在拉取所有節點上的相同key時,就會大大減少需要拉取的數據數量,
從而也就減少了磁碟IO以及網路傳輸開銷。通常來說,在可能的情況下,建議使用reduceByKey或者aggregateByKey運算元來替代掉groupByKey運算元。
因為reduceByKey和aggregateByKey運算元都會使用用戶自定義的函數對每個節點本地的相同key進行預聚合。而groupByKey運算元是不會進行預聚合的,
全量的數據會在集群的各個節點之間分發和傳輸,性能相對來說比較差。
比如如下兩幅圖,就是典型的例子,分別基於reduceByKey和groupByKey進行單詞計數。其中第一張圖是groupByKey的原理圖,
可以看到,沒有進行任何本地聚合時,所有數據都會在集群節點之間傳輸;第二張圖是reduceByKey的原理圖,
可以看到,每個節點本地的相同key數據,都進行了預聚合,然後才傳輸到其他節點上進行全局聚合。

原則六:使用高性能的運算元

使用reduceByKey/aggregateByKey替代groupByKey

使用mapPartitions替代普通map

mapPartitions類的運算元,一次函數調用會處理一個partition所有的數據,而不是一次函數調用處理一條,性能相對來說會高一些。
但是有的時候,使用mapPartitions會出現OOM(記憶體溢出)的問題。因為單次函數調用就要處理掉一個partition所有的數據,
如果記憶體不夠,垃圾回收時是無法回收掉太多對象的,很可能出現OOM異常。所以使用這類操作時要慎重

使用foreachPartitions替代foreach

原理類似於「使用mapPartitions替代map」,也是一次函數調用處理一個partition的所有數據,而不是一次函數調用處理一條數據。
在實踐中發現,foreachPartitions類的運算元,對性能的提升還是很有幫助的。
比如在foreach函數中,將RDD中所有數據寫MySQL,那麼如果是普通的foreach運算元,就會一條數據一條數據地寫,每次函數調用可能就會創建一個資料庫連接,
此時就勢必會頻繁地創建和銷毀資料庫連接,性能是非常低下;但是如果用foreachPartitions運算元一次性處理一個partition的數據,那麼對於每個partition,
只要創建一個資料庫連接即可,然後執行批量插入操作,此時性能是比較高的。實踐中發現,對於1萬條左右的數據量寫MySQL,性能可以提升30%以上。

使用filter之後進行coalesce操作

通常對一個RDD執行filter運算元過濾掉RDD中較多數據後(比如30%以上的數據),建議使用coalesce運算元,手動減少RDD的partition數量,將RDD中的數據壓縮到更少的partition中去。
因為filter之後,RDD的每個partition中都會有很多數據被過濾掉,此時如果照常進行後續的計算,
其實每個task處理的partition中的數據量並不是很多,有一點資源浪費,而且此時處理的task越多,可能速度反而越慢。
因此用coalesce減少partition數量,將RDD中的數據壓縮到更少的partition之後,只要使用更少的task即可處理完所有的partition。在某些場景下,對於性能的提升會有一定的幫助。

使用repartitionAndSortWithinPartitions替代repartition與sort類操作

repartitionAndSortWithinPartitions是Spark官網推薦的一個運算元,官方建議,如果需要在repartition重分區之後,還要進行排序,建議直接使用repartitionAndSortWithinPartitions運算元。
因為該運算元可以一邊進行重分區的shuffle操作,一邊進行排序。shuffle與sort兩個操作同時進行,比先shuffle再sort來說,性能可能是要高的。

原則七:廣播大變數

有時在開發過程中,會遇到需要在運算元函數中使用外部變數的場景(尤其是大變數,比如100M以上的大集合),那麼此時就應該使用Spark的廣播(Broadcast)功能來提升性能。
在運算元函數中使用到外部變數時,默認情況下,Spark會將該變數複製多個副本,通過網路傳輸到task中,此時每個task都有一個變數副本。
如果變數本身比較大的話(比如100M,甚至1G),那麼大量的變數副本在網路中傳輸的性能開銷,以及在各個節點的Executor中佔用過多記憶體導致的頻繁GC,都會極大地影響性能。
因此對於上述情況,如果使用的外部變數比較大,建議使用Spark的廣播功能,對該變數進行廣播。
廣播後的變數,會保證每個Executor的記憶體中,只駐留一份變數副本,而Executor中的task執行時共享該Executor中的那份變數副本。
這樣的話,可以大大減少變數副本的數量,從而減少網路傳輸的性能開銷,並減少對Executor記憶體的佔用開銷,降低GC的頻率。

// 以下程式碼在運算元函數中,使用了外部的變數。
// 此時沒有做任何特殊操作,每個task都會有一份list1的副本。
val list1 = ...
rdd1.map(list1...)

// 以下程式碼將list1封裝成了Broadcast類型的廣播變數。
// 在運算元函數中,使用廣播變數時,首先會判斷當前task所在Executor記憶體中,是否有變數副本。
// 如果有則直接使用;如果沒有則從Driver或者其他Executor節點上遠程拉取一份放到本地Executor記憶體中。
// 每個Executor記憶體中,就只會駐留一份廣播變數副本。
val list1 = ...
val list1Broadcast = sc.broadcast(list1)
rdd1.map(list1Broadcast...)

原則八:使用Kryo優化序列化性能

在Spark中,主要有三個地方涉及到了序列化:
在運算元函數中使用到外部變數時,該變數會被序列化後進行網路傳輸(見「原則七:廣播大變數」中的講解)。
將自定義的類型作為RDD的泛型類型時(比如JavaRDD,Student是自定義類型),所有自定義類型對象,都會進行序列化。因此這種情況下,也要求自定義的類必須實現Serializable介面。
使用可序列化的持久化策略時(比如MEMORY_ONLY_SER),Spark會將RDD中的每個partition都序列化成一個大的位元組數組。

在運算元函數中使用到外部變數時,該變數會被序列化後進行網路傳輸(見「原則七:廣播大變數」中的講解)。
將自定義的類型作為RDD的泛型類型時(比如JavaRDD,Student是自定義類型),所有自定義類型對象,都會進行序列化。因此這種情況下,也要求自定義的類必須實現Serializable介面。
使用可序列化的持久化策略時(比如MEMORY_ONLY_SER),Spark會將RDD中的每個partition都序列化成一個大的位元組數組。
對於這三種出現序列化的地方,我們都可以通過使用Kryo序列化類庫,來優化序列化和反序列化的性能。
Spark默認使用的是Java的序列化機制,也就是ObjectOutputStream/ObjectInputStream API來進行序列化和反序列化。
但是Spark同時支援使用Kryo序列化庫,Kryo序列化類庫的性能比Java序列化類庫的性能要高很多。官方介紹,Kryo序列化機制比Java序列化機制,性能高10倍左右。
Spark之所以默認沒有使用Kryo作為序列化類庫,是因為Kryo要求最好要註冊所有需要進行序列化的自定義類型,因此對於開發者來說,這種方式比較麻煩。

以下是使用Kryo的程式碼示例,我們只要設置序列化類,再註冊要序列化的自定義類型即可(比如運算元函數中使用到的外部變數類型、作為RDD泛型類型的自定義類型等):

// 創建SparkConf對象。
val conf = new SparkConf().setMaster(...).setAppName(...)
// 設置序列化器為KryoSerializer。
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
// 註冊要序列化的自定義類型。
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))

原則九:優化數據結構

Java中,有三種類型比較耗費記憶體:
對象,每個Java對象都有對象頭、引用等額外的資訊,因此比較佔用記憶體空間。
字元串,每個字元串內部都有一個字元數組以及長度等額外資訊。
集合類型,比如HashMap、LinkedList等,因為集合類型內部通常會使用一些內部類來封裝集合元素,比如Map.Entry。
因此Spark官方建議,在Spark編碼實現中,特別是對於運算元函數中的程式碼,盡量不要使用上述三種數據結構,
盡量使用字元串替代對象,使用原始類型(比如Int、Long)替代字元串,使用數組替代集合類型,這樣儘可能地減少記憶體佔用,從而降低GC頻率,提升性能。