概述
我們知道Task是Spark計算的最小計算單位,一個Partition(分區)對應一個Task,因此Partition個數也是決定RDD並行計算的關鍵,合理設置Partition個數能夠極大的提高Spark程序執行效率。首先我們看下RDD、Partition和task的關係如下圖:
那Spark中分區個數是如何確定的呢?當發生shuffle時候,子RDD的分區個數又是如何確定的呢?
我們知道默認分區個數是通過spark.default.parallelism
參數控制的,我們結合該參數看在Spark中如何起作用的。
我們分別以窄依賴、寬依賴和源RDD等分別介紹。(以下代碼以Spark2.4.3版本爲準)
Spark的分區器(Partitioner)
Spark中的分區器都會繼承Partitioner(注意區別Partition),其是一個抽象類,位於 org.apache.spark.Partitioner 中,有兩個接口方法:
Spark在Partitioner類的伴生類中也實現了一個默認分區器,如下圖:
分析見代碼註釋,可以重點關注spark.default.parallelism
配置參數和父RDD最大分區數如何參與運算,最終可以得出:如果存在大於0的父RDD且父RDD的最大分區數大於默認分區數,則分區取該父RDD的分區;否則新建一個默認分區數的HashPartitioner分區。
除此默認分區器之外,Spark實現了幾個系統分區器,他們都繼承至Partitioner,如下圖:
- HashPartitioner:一般是默認分區器,分析源碼可知是按key求取hash值,再對hash值除以分區個數取餘,如果餘數<0,則用餘數+分區的個數,最後返回的值就是這個key所屬的分區ID。
- RangePartitioner:由於HashPartitioner根據key值hash取模方法可能導致每個分區中數據量不均勻,RangePartitioner則儘量保證每個分區中數據量的均勻,而且分區與分區之間是有序的,也就是說一個分區中的元素肯定都是比另一個分區內的元素小或者大;但是分區內的元素是不能保證順序的。簡單的說就是將一定範圍內的數映射到某一個分區內。參考:https://www.iteblog.com/archives/1522.html
- GridPartitioner:一個網格Partitioner,採用了規則的網格劃分座標,numPartitions等於行和列之積,一般用於mlib中。
- PartitionIdPassthrough:一個虛擬Partitioner,用於已計算好分區的記錄,例如:在(Int, Row)對的RDD上使用,其中Int就是分區id。
- CoalescedPartitioner:把父分區映射爲新的分區,例如:父分區個數爲5,映射後的分區起始索引爲[0,2,4],則映射後的新的分區爲[[0, 1], [2, 3], [4]]
- PythonPartitioner:提供給Python Api的分區器
如果Spark提供默認分區器和系統分區器不能滿足需要,用戶也可以繼承Partitioner實現自定義分區器,下面舉例一個簡單例子:
class CustomPartitioner(numParts: Int) extends Partitioner {
override def numPartitions: Int = numParts
override def getPartition(key: Any): Int = {
if(key == 1){
0
} else if (key == 2){
1
} else{
2
}
}
}
RDD分區數確認
窄依賴中分區數
上圖是窄依賴,通過分析源碼會發現map、flatMap和filter等常用算子,最後都會返回了MapPartitionsRDD對象,不同的僅僅是傳入的function不同而已。我們分析其分區源碼如下:
如上圖,子RDD直接獲得父RDD的分區,因此:生成MapPartitionsRDD對應的算子的子RDD分區與父RDD分區是一致的。
針對union算子,最後返回的是UnionRDD對象,分析其分區源碼如下:
如上圖可知,生成UnionRDD對象的算子子RDD分區數是父RDD分區數之和。
寬依賴中分區數
上圖是寬依賴,我們知道,寬依賴一般是發生shuffle的RDD,其中 子RDD分區數是由分區器決定的,分區器包含:默認分區器、系統分區器和自定義分區器。 首先我們看默認分區器RDD,默認分區器的實現在defaultPartitioner
函數中(見上節,即:如果存在大於0的父RDD且父RDD的最大分區數大於默認分區數,則分區取該父RDD的分區;否則新建一個默認分區數的HashPartitioner分區),默認分區器一般用於哪些RDD中,如下圖:
我們以reduceByKey
函數源碼詳細看如何使用
如上圖,調用reduceByKey函數時,針對不同的參數調用不同重載函數:
- 無分區器或分區數參數,則取默認分區器,例如:
testRDD.reduceByKey(func)
- 有分區數參數,則新建HashPartitioner分區器,例如:
testRDD.reduceByKey(func, 3)
- 有分區器參數,則直接使用參數分區器,參數提供的分區器可以是系統分區器也可以是自定義分區器。例如:
testRDD.reduceByKey(new CustomPartitioner(3), func)
源RDD的分區數
上面介紹寬窄依賴都涉及到父RDD的分區,那最源頭的RDD如何確定分區的呢?我們知道源頭RDD一般都是讀取加載各種數據源的數據, 分析源碼可以發現Spark對接不同的數據源,得到的分區數是不一樣的,我們重點分析加載hdfs文件的源RDD(以sc.textFile("hdfs://xx/test.txt")
爲例),最終會生成HadoopRDD,如下圖:
在調用textFile函數時候,如果沒有傳入minPartitions,則取默認的defaultMinPartitions,從上圖右面代碼可以看出其最大值爲2,此值參與hdfs分片大小的計算(先忽略下面再介紹)。我們看 org.apache.spark.rdd.HadoopRDD ,重點看其getPartitions
函數:
在hdfs中,block是物理存儲概念,split是邏輯概念,hdfs文件的讀寫是基於split的,從上面代碼分析可看出,讀取hdfs文件劃分了多少個split就會產生多少個Partition,那麼分析的關鍵就是產生可多少split,對應的代碼是val allInputSplits = getInputFormat(jobConf).getSplits(jobConf, minPartitions)
,我們繼續分析getSplit函數源碼(此源碼屬於Hadoop源碼部分)如下:
分析過程見註釋,可以得出如下幾點結論:
- 1、文件是否可分割是指hdfs存儲的文件格式是text等,則可分割;而如果是一些壓縮格式(例如orc等),則整塊block不可分割;
- 2、如果hdfs文件是不可分割的,那麼RDD的分區數與該文件的block數量保持一致;如果可分割,那麼RDD的分區數大於等於block數量;
- 3、根據spark加載hdfs文件的代碼分析,它只會把一個文件分得越來越小,而不會對小文件採取合併(小文件較多則會導致rdd產生更多的分區,進而影響性能);
參考:https://www.lagou.com/lgeduarticle/70041.html
RDD的重新分區
重新分區可以通過repartition算子實現,其主要是通過創建更過或更少的分區將數據隨機的打散,讓數據在不同分區之間相對均勻,此操作會進行shuffle。我們看其源碼如下圖:
可以看出repartition函數最終會調用coalesce函數,並設置shuffle參數爲true,也就是說分區數無論是增加還是減少都會執行shuffle操作。繼續分析coalesce函數可知,首先會對每個item隨機生成key值,然後使用HashPartitioner分區器進行shuffle分區,最終實現數據的均勻分散。
適用方法示例爲:testRDD.repartition(24)
repartition算子適用的場景包括:通過新增分區擴大並行計算能力,通過均勻打散特性解決數據傾斜和通過合併分區降低下游數據處理的併發量等。
Spark分區編程示例
- 產生shuffle的操作函數內設置並行度參數,優先級最高。
testRDD.groupByKey(24);
testRDD.groupByKey(new CustomPartitioner(3));
testRDD.repartition(24);
- 在代碼中配置“spark.default.parallelism”設置並行度,優先級次之。
val conf = new SparkConf();
conf.set("spark.default.parallelism", 24);
- 在
$SPARK_HOME/conf/spark-defaults.conf
文件中配置spark.default.parallelism
的值,優先級最低。
spark.default.parallelism 24