1. 程式人生 > 其它 >Spark 效能優化

Spark 效能優化

1.避免建立重複的RDD

2.儘可能複用同一個RDD

3.對多次使用的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(...)

4.儘量避免使用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作業,可以大大減少效能開銷。

5.廣播大變數

有時在開發過程中,會遇到需要在運算元函式中使用外部變數的場景(尤其是大變數,比如100M以上的大集合),那麼此時就應該使用Spark的廣播(Broadcast)功能來提升效能。

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

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

# 獲取合法ad_id對inst進行過濾
valid_ad_ids = set(sc.textFile(...).map(...).filter(...).collect())



# 方式一 通過 task 閉包傳遞變數
get_valid_neg_inst(inst, valid_ad_ids):
    # 解析instance
    .....
    if instance.line_id.ad_id in valid_ad_ids:
        return sort_id, pb_bytes
     else:
        return None
neg_inst = sc.HadoopFile(...)\
             .map(lambda line: get_valid_neg_inst(line, valid_ad_ids))
             .filter(lambda x: x)
 
            
# 方式二 通過 廣播 運算元傳遞變數
valid_ad_ids_bc = sc.broadcast(valid_ad_ids)
get_valid_neg_inst(inst):
    # 解析instance
    .....
    if instance.line_id.ad_id in valid_ad_ids_bc.value: # 注意此處
        return sort_id, pb_bytes
     else:
        return None
neg_inst = sc.HadoopFile(...)\
             .map(lambda line: get_valid_neg_inst(line))
             .filter(lambda x: x)     

6.使用reduceByKey/aggregateByKey替代groupByKey

如果因為業務需要,一定要使用shuffle操作,無法用map類的運算元來替代,那麼儘量使用可以map-side預聚合的運算元。

所謂的map-side預聚合,說的是在每個節點本地對相同的key進行一次聚合操作,類似於MapReduce中的本地combiner。map-side預聚合之後,每個節點本地就只會有一條相同的key,因為多條相同的key都被聚合起來了。其他節點在拉取所有節點上的相同key時,就會大大減少需要拉取的資料數量,從而也就減少了磁碟IO以及網路傳輸開銷。通常來說,在可能的情況下,建議使用reduceByKey或者aggregateByKey運算元來替代掉groupByKey運算元。因為reduceByKey和aggregateByKey運算元都會使用使用者自定義的函式對每個節點本地的相同key進行預聚合。而groupByKey運算元是不會進行預聚合的,全量的資料會在叢集的各個節點之間分發和傳輸,效能相對來說比較差。

7.使用mapPartitions替代普通map

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

8. 使用foreachPartitions替代foreach

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

9. 使用filter之後進行coalesce操作

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

10. 使用repartitionAndSortWithinPartitions替代repartition與sort類操作

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

11. 使用Kryo優化序列化效能

在Spark中,主要有三個地方涉及到了序列化: * 在運算元函式中使用到外部變數時,該變數會被序列化後進行網路傳輸(見“原則七:廣播大變數”中的講解)。 * 將自定義的型別作為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]))

12.優化資料結構

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

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

但是在筆者的編碼實踐中發現,要做到該原則其實並不容易。因為我們同時要考慮到程式碼的可維護性,如果一個程式碼中,完全沒有任何物件抽象,全部是字串拼接的方式,那麼對於後續的程式碼維護和修改,無疑是一場巨大的災難。同理,如果所有操作都基於陣列實現,而不使用HashMap、LinkedList等集合型別,那麼對於我們的編碼難度以及程式碼可維護性,也是一個極大的挑戰。因此筆者建議,在可能以及合適的情況下,使用佔用記憶體較少的資料結構,但是前提是要保證程式碼的可維護性。

參考資料

https://tech.meituan.com/2016/04/29/spark-tuning-basic.html