Spark低級API筆記

一、RDD

1. 在絕大部分場景下,用戶都應該儘量使用DataFrame等結構化API,只有當這類高級API無法解決遇到的業務或工程問題的時候,才需要使用Spark的低級API,例如彈性分佈式數據集(RDD)、SparkContext和分佈式共享變量(例如累加器和廣播變量)。下列三種場景,通常需使用到低級API:

(1)當在高級API中找不到所需的功能時,例如要對集羣中數據的物理放置進行非常嚴格的控制;

(2)當需要維護一些使用RDD編寫的遺留代碼庫時;

(3)當需要執行一些自定義共享變量操作時。

由於Spark所有高級API實質上都會編譯成RDD等底層原語,因此學習低級API也有助於更好的理解DataFrame等高級API。當調用一個DataFrame的transformation操作時,實際上等價於一組RDD的轉換操作SparkContext是低級API函數庫的入口,可以通過SparkSession來獲取SparkContext,SparkSession是用於在Spark集羣上執行計算的入口,可以通過下面的調用方式訪問SparkContext:

Spark.sparkContext

RDD是Spark 1.X系列中主要的API,在2.X系列中仍然可以使用它,但是已不常用。當然無論是DataFrame還是Dataset,運行的所有Spark代碼實際都將編譯成一個RDD。簡單來說,RDD是一個只讀不可變且已分塊的記錄集合,並可以被並行處理。RDD與DataFrame不同,DataFrame中每個記錄就是一個結構化的數據行,各字段已知且schema已知,而RDD中的記錄僅僅是程序員選擇的Java、Scala 或Python 對象

正因爲RDD中每個記錄僅僅是一個Java或Python 對象,因此能以任何格式在這些RDD對象中存儲任何內容,這使用戶具有很大的控制權,同時也帶來一些潛在問題:比如值之間的每個操作和交互都必須手動定義,也就是說,無論實現什麼任務,都必須從底層開發。另外,因爲Spark不像對結構化API那樣清楚地理解記錄的內部結構,所以往往需要用戶自己寫優化代碼

再比如,Spark的結構化API會自動以優化後的二進制壓縮格式存儲數據,而在使用低級API時爲了實現同樣的空間效率和性能,需要在對象內部實現這種壓縮格式,以及針對該格式進行計算的所有低級操作。同樣,像重排過濾和聚合等這類SparkSQL中自動化的優化操作,也需要用戶手動實現。因此,強烈建議儘可能使用Spark結構化API。RDD API與Dataset類似,所不同的是RDD不在結構化數據引擎上進行存儲或處理,然而在RDD和Dataset之間來回轉換的代價很小,因此可以同時使用兩種API來取長補短。

看一下Spark的API文檔,就會發現其中有很多RDD子類,大部分是DataFrame API優化物理執行計劃時用到的內部表示。但是作爲用戶,一般常用創建兩種類型的RDD:“通用”型RDD或提供附加函數的key-value RDD,例如基於key的聚合函數,兩者都是表示對象的集合,但是key-value RDD支持特殊操作並支持按key的自定義數據分區。現在來正式定義RDD,每個RDD具有以下五個主要內部屬性:

(1)數據分片(Partition)列表。

(2)作用在每個數據分區的計算函數。

(3)描述與其他RDD的依賴關係列表。

(4)(可選)爲key-value RDD配置的Partitioner(分片方法,如hash partitioner)。

(5)(可選)優先位置列表,根據數據的本地特性指定了每個Partition分片的處理位置偏好(preferred location),例如對於一個HDFS文件來說,這個列表就是每個文件塊所在的節點。

使用RDD的一個主要原因是想定製Partitioner,如果正確地使用自定義的Partitioner,則能在性能和穩定性上得到有效提升。RDD支持兩種類型(算子)操作:惰性執行的transformation操作和立即執行的action操作,它們的工作方式與DataFrame和Dataset上的轉換操作和動作操作相同,但是RDD中沒有“行”的概念,RDD的單個記錄只是原始的Java / Scala / Python對象,因此必須手動處理這些數據,而不能使用結構化API中的函數庫

RDD API支持Python,Scala和Java,對於Scala和Java而言其性能基本是相同的,主要的性能開銷花費在處理原始對象上。但對於Python來說,使用RDD會極大地影響性能,運行Python RDD等同於逐行運行Python UDF,這樣需要將數據序列化到Python進程中使用Python進行操作,然後再將其序列化到JVM中,這會導致Python RDD操作產生極大開銷。因此建議絕大部分情況下在Python中使用結構化API,除非有些情況必須使用RDD。

2. 一般來說,除非有非常明確的理由,否則不要手動創建RDD,它們是很低級的API,雖然它提供了大量的功能,但同時缺少結構化API中可用的許多優化。在絕大多數情況下DataFrame比RDD更高效、更穩定並且具有更強的表達能力。當需要對數據的物理分佈進行細粒度控制(自定義數據分區)時,可能才需要使用RDD。Case Class轉換的RDD和Dataset之間的區別在於,雖然它們可以執行同樣的功能,但是Dataset可以利用結構化API提供的豐富功能和優化,無需選擇是在JVM類型還是Spark類型上進行操作。

獲取RDD的最簡單方法之一是從現有的DataFrame或Dataset轉換,將它們轉換成RDD很簡單:只要調用這些數據類型的rdd方法即可。如果從Dataset[T]轉換成RDD,會返回合適的類型T(僅適用於Scala和Java):

// in Scala: 將Dataset[Long] 類型轉換爲RDD[Long]類型
spark.range(500).rdd

因爲Python中只有DataFrame並沒有Dataset,所以會得到一個Row類型的RDD:

# in Python
spark.range(10).rdd

要對這些數據進行操作,則需將此Row類型的對象轉換爲正確的數據類型或從中提取出值,下面就是一個Row類型的RDD了:

spark.range(10).toDF().rdd.map(rowObject => rowObject.getLong(0))

可以使用相同的方法從RDD中創建DataFrame或Dataset,操作方法就是調用RDD的toDF方法:

spark.range(10).rdd.toDF()

要從集合中創建RDD,需要調用SparkContext(在SparkSession中)中的parallelize方法,該方法會將位於單個節點的數據集合轉換成一個並行集合。在創建該並行集合時,還可以顯式指定該並行集合的partition數量。下面的示例中創建了兩個數據分片:

val myCollection = "Spark TheDefinitive Guide : Big Data Processing Made Simple"
    .split(" ")
val words = spark.sparkContext.parallelize(myCollection, 2)

另一個功能是,可以命名此RDD以方便將其展示在Spark UI上:

words.setName("myWords")
words.name // 結果是myWords

儘管從數據源或文本文件中都能創建RDD,但通常最好使用數據源API(Data Source API)來創建。RDD中沒有像DataFrame中“Data Source API”這樣的概念,使用RDD中主要是用來定義它們的依賴結構和數據分片方案,可以使用sparkContext將數據讀取爲RDD,以下示例將實現逐行讀取一個文本文件:

spark.sparkContext.textFile("/some/path/withTextFiles")

這將創建一個RDD,RDD中的每個記錄都代表該文本文件中的一行。或者可以按照下面方法把每個文本文件讀取成一條記錄:

spark.sparkContext.wholeTextFiles("/some/path/withTextFiles")

在該RDD中,文件的名稱是第一個對象,文本文件的內容則是第二個字符串對象(鍵值對)。

3. 操作RDD的方式與操作DataFrame的方式非常相似,核心區別在於RDD操作的是原始Java或Scala對象而非Spark類型,以及還缺乏一些可幫助簡化計算的“輔助”方法或函數。因此用戶必須自己定義每個filter、映射函數、聚合以及其他想被作爲函數使用的任何操作。大多數情況下transformation操作與結構化API中的轉換操作功能相同。就像使用DataFrame和Dataset一樣,需要在RDD上指定transformation操作來創建一個新的RDD,這也將導致新的RDD依賴於原有的RDD。

filter函數等同於創建一個類似SQL中的where子句,可以通過RDD中的記錄來查看謂詞函數匹配,它返回一個布爾類型值,而其輸入應該是用戶給定的行。如下示例中將過濾RDD中數據,僅保留以字母“S”開頭的單詞:

def startsWithS(individual:String) = {
    individual.startsWith("S")
}

words.filter(word => startsWithS(word)).collect()

上面代碼將返回Spark和Simple等以S開頭的單詞,可以看到與Dataset API一樣它返回的是native數據類型,這是因爲沒有將數據強制轉換爲Row類型,也沒有在收集到數據後再做transformation操作。對於map函數,是括號中指定一個函數,將給定數據集中的每條記錄輸入該函數處理以得到期望的結果。在下面例子中,將當前單詞映射爲一個三元組記錄,包括該原單詞,該單詞的首字母,以及該單詞是否以“S”開頭的布爾值:

val words2 = words.map(word => (word, word(0), word.startsWith("S")))

可以繼續根據words2結果中的布爾值(即第三項)來進行過濾:

words2.filter(record => record._3).take(5)

這將返回由“Spark”,“S”和“True”成的三元組,以及由“Simple”,“S”和“True”組成的三元組。

flatMap是對上面的map函數的一個簡單擴展。在映射操作中,有時每個當前行會映射爲多行,例如將一組單詞由flatMap映射爲一組字符。由於每個單詞有多個字符,因此需要使用flatMap來展開它。flatMap映射要求輸出是一個可展開的迭代器:

words.flatMap(word => word.toSeq).take(5)

結果將輸出S、P、A、R、K。

4. 就像在DataFrame和Dataset中的action操作一樣,RDD的action操作也用來觸發具體的transformation操作。action操作可以是將數據收集到driver,或者將其寫到外部數據源。reduce方法指定一個函數將RDD中任何類型的值聚合爲一個值,例如求和,reduce方法要指定一個函數來接收兩個參數,然後對它們求和返回一個新值,新值再作爲其中一個參數繼續傳遞給該函數,另一個參數爲下一個數字,以此重複多次直到最後一個元素,最終返回它們的和:

spark.sparkContext.parallelize(1 to 20).reduce(_ + _) // 210

也可以用reduce來獲取單詞集中最長的單詞,關鍵在於要傳入正確的函數:

def wordLengthReducer(leftWord:String, rightWord:String): String = {
    if (leftWord.length > rightWord.length)
    return leftWord
    else
    return rightWord
}
words.reduce(wordLengthReducer)

但對於單詞“definitive”或“processing”(長度均爲10)都可能成爲“left” word,這種情況意味着結果不是每次都一樣的,需要根據業務場景進行更多限制。

countApprox是count方法的一個近似方法,用於返回大概的計數結果,但它必須在指定時間內執行完成(當超過指定時間時,返回一個不準確的近似結果),confidence(置信度)表示近似結果的誤差區間包括真實值的概率,也就是說,如果以0.9的置信度重複調用countApprox方法,則表示在90%的情況下的近似計數結果中爲真實的計數值。置信度取值必須在[0,1]範圍內,否則會拋出異常:

val confidence = 0.95
val timeoutMilliseconds = 400
words.countApprox(timeoutMilliseconds, confidence)

countApproxDistinct函數用來計算去重後的值的大約個數。它有兩種實現,第一種函數實現中傳入函數的參數是相似精度(relative accuracy),值與值之間的相似度達到該參數指定值,則將其看作是一樣的值,相似精度值越小,代表值與值之間越相似,計數結果可能越大。該值要求必須大於0.000017:

words.countApproxDistinct(0.05)

另一種函數實現中,則可以基於兩個參數來指定相似精度:一個是用於“常規”數據,另一個是用於稀疏數據。這兩個參數分別是p和sp,其中p表示準確率(precision),sp表示稀疏準確率(sparseprecision),相似精度約爲1.054/sqrt(2p)。設置非零值sp(sp>p)能減少內存消耗,並在處理低選擇性(cardinality)數據時提高準確性,p和sp均爲整數:

words.countApproxDistinct(4, 10)

countByValue方法用於對給定RDD中的值的個數進行計數,它需要將整個結果map映射集合都加載到driver的內存中,因此只有在總行數較少或不同Key數量較少的情況下,才適合使用此方法

words.countByValue()

countByValueApprox和countByValue函數的功能是一樣的,只是它返回的是一個近似值。該函數必須在指定的timeout(第一個參數)時間內返回結果(如果超過了timeout時間,則會返回一個未完成的結果)。置信度表示返回結果集在誤差範圍內包含真實值的概率,也就是說,如果以0.9的置信度重複調用countApprox函數,則表示期望有90%的概率在結果中包含真實的計數值。置信度取值必須在[0,1]範圍內,否則將拋出異常:

words.countByValueApprox(1000, 0.95)

5. take和它的派生方法的功能是從RDD中讀取一定數量的值。具體流程是:首先掃描一個數據分區,然後根據該分區的實際返回結果的數量來預估還需要再讀取多少個分區。這個函數有很多變體,例如takeOrdered、takeSample和top函數。takeSample函數用於從RDD中獲取指定大小的隨機樣本,並可以指定withReplacement(採樣過程是否允許替換)、返回樣本數量和隨機數種子這3個參數。按默認排序返回前幾個值時,top函數與takeOrdered函數返回值的排序順序相反:

words.take(5)
words.takeOrdered(5)
words.top(5)
val withReplacement = true
val numberToTake = 6
val randomSeed = 100L
words.takeSample(withReplacement, numberToTake, randomSeed)

對於RDD,不能以常規的方式讀取它並保存到DataSource中,而是必須遍歷分區才能將每個分區的內容保存到某個外部數據庫,這是一種低級方法,它展現了在更高級的API中所執行的基礎操作。Spark會把RDD中每個分區都讀取出來,並寫到指定位置中。要將RDD保存到文本文件中,只需指定文件路徑和壓縮編碼器(可選參數)即可:

import org.apache.hadoop.io.compress.BZip2Codec
words.saveAsTextFile("file:/tmp/bookTitleCompressed", classOf [BZip2Codec])

sequenceFile是一種由二進制鍵值對(key-value)組成的文件,它也是MapReduce作業中常用的輸入/輸出格式。Spark中可以使用saveAsObjectFile方法或者顯式寫出鍵值對的方式將RDD寫入sequenceFile格式文件中:

words.saveAsObjectFile("/tmp/my/sequenceFilePath")

緩存RDD的原理與緩存DataFrame和Dataset相同,默認情況下僅對內存中的數據進行緩存和持久化:

words.cache()

可以通過org.spache.spark.storage.StorageLevel來指定單例對象的任意存儲級別,存儲級別包括僅內存(memory only)、僅磁盤(diskonly)、堆外內存(offheap)。接着來查詢一下存儲級別:

words.getStorageLevel

DataFrame API中沒有檢查點(checkpointing)這個概念。檢查點是將RDD保存到磁盤上的操作,以便將來對此RDD的引用能直接訪問磁盤上的那些中間結果,而不需要從其源頭重新計算RDD。它與緩存類似,只是它不存儲在內存中,只存在磁盤上,這在執行迭代計算時很有用:

spark.sparkContext.setCheckpointDir("/some/path/for/checkpointing")
words.checkpoint()

6.通過pipe方法,可以利用管道技術調用外部進程來生成RDD。將每個數據分區交給指定的外部進程來計算得到結果RDD,每個輸入分區的所有元素被當做另一個外部進程的標準輸入,輸入元素由換行符分隔,最終結果由該外部進程的標準輸出生成,標準輸出的每一行產生輸出分區的一個元素。空分區也會調用一個外部進程。print操作可以通過提供的兩個函數來進行適配。

舉一個簡單例子:將每個分區傳遞給wc命令,每個元素將作爲一個輸入行,因此如果執行行計數,將得到每個分區的總行數:

words.pipe("wc -l").collect()

前面的命令已表明Spark在實際執行代碼時是基於每個分區運行的。RDD上的map函數實際上是基於MapPartitionsRDD來實現的,map只是mapPartitions基於行操作的一個別名,mapPartitions函數每次處理一個數據分區(可以通過iterator來遍歷該數據分區)。實際上在集羣中也是每次處理一個分區,而不是具體的一行。舉個簡單例子:爲數據中的每個分區創建值“1”,以下表達式中計算總和將得出擁有的分區數量:

words.mapPartitions(part => Iterator[Int](1)).sum() // 2

這意味着能按照每個分區進行操作,處理單元爲整個分區。在RDD的整個子數據集上執行某些操作很有用,可以將屬於某類的值收集到一個分區中,或group by到一個分區上,然後對整個分組進行操作,例如可以通過一些自定義機器學習算法來管理這個數據集,並基於該公司的部分數據集訓練一個獨立的模型。

與mapPartitions函數類似的函數還有mapPartitionsWithIndex函數,它接收一個指定函數作爲參數,該指定函數接收兩個參數,一個爲分區的索引和另一個爲遍歷分區內所有項的迭代器。分區索引是RDD中的分區號,它標識數據集中每條記錄的位置(便於利用此信息調試),可以使用mapPartitionsWithIndex來測試map函數是否正確運行:

def indexedFunc(partitionIndex:Int, withinPartIterator: Iterator[String]) = {
    withinPartIterator.toList.map(
        value => s"Partition: $partitionIndex => $value").iterator
}
words.mapPartitionsWithIndex(indexedFunc).collect()

mapPartitions函數需要返回值才能正常執行,但foreachPartition函數不需要。foreachPartition函數僅用於迭代所有的數據分區,與mapPartitions的不同在於它沒有返回值,這使得它非常適合像寫入數據庫這樣的操作(不需要返回計算結果)。實際上,許多數據源連接器也是基於此函數實現的。如果需要創建自己的文本文件源,可以基於一個隨機ID指定輸出到臨時目錄中來實現:

words.foreachPartition { iter =>
  import java.io._
  import scala.util.Random
  val randomFileName = new Random().nextInt()
  val pw = new PrintWriter(new File(s"/tmp/random-file-${randomFileName}.txt"))
  while (iter.hasNext) {
      pw.write(iter.next())
  }
  pw.close()
}

執行完上述代碼後,瀏覽/tmp目錄便會發現文件。

glom用於將數據集中的每個分區都轉換爲數組。當需要將數據收集到driver並想爲每個分區創建一個數組時,就很適合用glom函數實現。但是這可能會導致嚴重的穩定性問題,因爲當有很大的分區或大量分區時,該函數很容易導致driver OOM。下面例子中可以看到其中有兩個分區,每個詞落入其中一個分區:

spark.sparkContext.parallelize(Seq("Hello", "World"), 2).glom().collect()
// 結果是Array(Array(Hello), Array(World))

二、高級RDD

7. 爲了舉例子,先使用上面使用過的數據集:

valmyCollection = "Spark The Definitive Guide: Big Data Processing Made Simple"
    .split(" ")
val words = spark.sparkContext.parallelize(myCollection, 2)

基於RDD的許多方法要求數據是key-value格式,這種方法都有形如< some-operation> ByKey的API名稱,只要在方法名稱中看到ByKey,就意味着只能以PairRDD類型執行此操作。最簡單的方法就是將當前的RDD映射到key-value結構:

words.map(word => (word.toLowerCase, 1))

前面的示例演示了創建key的方法,但是也可以使用keyBy函數,它是根據當前value創建key的函數。本例中將單詞中的第一個字母作爲key,然後將該單詞保存爲RDD的value:

val keyword = words.keyBy(word => word.toLowerCase.toSeq(0).toString)

在有一組鍵值對(key-value pair)之後,如果有一個元組,Spark將假設第一個元素是key,第二個是value。在這種格式中可以顯式選擇映射並只修改value(忽略key):

keyword.mapValues(word => word.toUpperCase).collect()

[('s','SPARK'),
('t','THE'),
('d','DEFINITIVE'),
('g','GUIDE'),
(':',':'),
('b','BIG'),
('d','DATA'),
('p','PROCESSING'),
('m','MADE'),
('s','SIMPLE')]

可以在行(row)上進行flatMap操作來擴展行數,使每行表示一個字符,flatMapValues類似於mapValues,不同的在於flatMapValues應用於KV對RDD中的Value。每一個元素的Value被輸入函數映射爲一系列的值,然後這些值再與原RDD中的對應Key組成一系列新的KV對。在下面的示例中它只是輸出每個字符,因爲將單詞轉換成了字符數組:

keyword.flatMapValues(word => word.toUpperCase).collect()

當數據是鍵值對(key-value pair)這種格式時,還可以使用以下方法提取特定的key或value:

keyword.keys.collect()
keyword.values.collect()

在RDD上常見的任務就是查找(look up)某個key對應的value。要注意的是每一個鍵未必只對應一個指,所以如果查找鍵“s”時將獲得與該key相關的兩個value,例如“Spark”和“Simple”:

keyword.lookup("s")

有兩種方法可以通過一組key來採樣RDD,可以是近似的方法也可以是精確的。這兩種操作都可以使用或不使用替換策略,以及根據給定鍵值對數據集部分採樣。這是通過對RDD的一次遍歷來簡單隨機採樣,採樣數量大約是所有key-value對數量的math.ceil(numItems * samplingRate)這麼多:

val distinctChars = words.flatMap(word => word.toLowerCase.toSeq).distinct
    .collect()
import scala.util.Random
val sampleMap = distinctChars.map(c => (c, new Random().nextDouble())).toMap
words.map(word => (word.toLowerCase.toSeq(0),word))
    .sampleByKey(true, sampleMap, 6L)
    .collect()

sampleByKey和sampleByKeyExact的區別在於 sampleByKey每次都通過給定的概率以一種類似於擲硬幣的方式來決定這個觀察值是否被放入樣本,因此一遍就可以過濾完所有數據,最後得到一個近似大小的樣本,但往往不夠準確。而sampleByKeyExtra會對全量數據做採樣計算,對於每個類別都會產生 (fk⋅nk)個樣本,其中fk是鍵爲k的樣本類別採樣的比例;nk是鍵k所擁有的樣本數。sampleByKeyExtra採樣的結果會更準確,有99.99%的置信度,但耗費的計算資源也更多:

words.map(word => (word.toLowerCase.toSeq(0), word))
     .sampleByKeyExact(true, sampleMap, 6L).collect()

8. 可以在RDD或PairRDD上執行聚合操作,具體取決於所使用的方法。下面使用數據集來演示一下:

val chars = words.flatMap(word => word.toLowerCase.toSeq)
val KVcharacters = chars.map(letter => (letter, 1))
def maxFunc(left: Int, right: Int) = math.max(left, right)
def addFunc(left: Int, right: Int) = left + right
val nums = sc.parallelize(1 to 30, 5)

然後就可以執行類似countByKey的操作,它對每個key對應的項進行計數,並將結果寫到本地Map中。還可以近似地執行此操作,可以在Scala中指定超時時間和置信度:

val timeout = 1000L //毫秒
val confidence = 0.95
KVcharacters.countByKey()
KVcharacters.countByKeyApprox(timeout, confidence)

有時用戶可能會覺得groupByKey配合使用map操作是彙總每個key數量的最佳方法,如下所示:

KVcharacters.groupByKey().map(row => (row._1,row._2.reduce(addFunc))).collect()

但是在大多數情況下,這是錯誤的方法,根本問題是每個executor在執行函數之前必須在內存中保存一個key對應的所有value,如果有嚴重的key數據傾斜現象,則某些分區可能由於key對應太多的value而導致OutOfMemoryErrors錯誤。groupByKey在某些情況下是可以的,如果每個key的value數量都差不多,並且知道不會超出executor內存那就可以。對於其他情況有一種首選的方法,就是使用reduceByKey。

因爲是執行一個簡單的計數,一個更穩妥的方法是同樣執行flatMap,然後執行map將每個字母實例映射爲數字1,然後執行reduceByKey配以求和函數將結果存儲到數組。這種方法更加穩定,因爲reduce發生在每個分區,並且不需要將所有內容放在內存中。此外,此操作不會導致shuffle過程,在執行最後的reduce之前所有的任務都在每個worker節點單獨執行,這大大提高了執行速度以及該操作的穩定性:

KVcharacters.reduceByKey(addFunc).collect()

以下是返回結果:

Array((d,4),(p,3), (t,3),(b,1), (h,1), (n,2),
...
(a,4), (i,7), (k,1),(u,1), (o,1), (g,3), (m,2), (c,1))

reduceByKey方法返回一個組(對應一個key)的RDD 和不保證有序的元素序列。因此當任務操作滿足結合律時,這種方法是完全可行的,而如果元素的順序很重要時就不適合

9.使用結構化API執行聚合時,很少會使用RDD中的低級API。有一個函數叫aggregate,此函數需要一個null值和一個起始值,並需要指定兩個不同的函數,第一個函數執行分區內聚合,第二個執行分區間聚合。起始值在兩個聚合級別都使用:

nums.aggregate(0)(maxFunc, addFunc)

但aggregate確實有一些性能問題,因爲它driver上執行最終聚合,如果executor的結果太大則會導致driver出現OutOfMemoryErro錯誤並最終讓程序崩潰。還有另一個方法treeAggregate,它基於不同的實現方法可以得到與aggregate相同的結果,它基本上是以“下推”方式完成一些子聚合(創建executor之間傳輸聚合結果的樹),最後再執行最終聚合。多層級的形式確保driver在聚合過程中不會耗盡內存,這些基於樹的實現通常會提高某些操作的穩定性:

val depth = 3
nums.treeAggregate(0)(maxFunc, addFunc, depth)

combineByKey針對某個key進行操作,並根據某個函數對value合併,然後合併各個combiner的輸出結果並得出最終結果,還可以指定輸出分區的數量:

//把當前的值作爲參數,此時可以對其做些附加操作(類型轉換)並把它返回 (類似於初始化操作)
val valToCombiner = (value:Int) => List(value)
//該函數用於把後者合併到前者上 (該操作在每個分區內進行)
val mergeValuesFunc = (vals:List[Int], valToAppend:Int) => valToAppend :: vals
//該函數用於把2個列表合併 (該操作在不同分區間進行)
val mergeCombinerFunc = (vals1:List[Int], vals2:List[Int]) => vals1 ::: vals2
val outputPartitions = 6

KVcharacters.combineByKey(
    valToCombiner,
    mergeValuesFunc,
    mergeCombinerFunc,
    outputPartitions)
  .collect()

foldByKey使用初始值0並利用括號裏的函數來合併每個key的value,如下所示:

KVcharacters.foldByKey(0)(addFunc).collect()

CoGroups在Scala中允許將三個key-value RDD一起分組,在Python中允許將兩個key-value RDD 一起分組。它基於key連接value,這實際上等效於full outer join操作。執行此操作時,還可以指定多個輸出分區或自定義分區函數,以精確控制此數據在整個集羣上的分佈情況:

import scala.util.Random
val distinctChars = words.flatMap(word => word.toLowerCase.toSeq).distinct
val charRDD = distinctChars.map(c => (c, new Random().nextDouble()))
val charRDD2 = distinctChars.map(c => (c, new Random().nextDouble()))
val charRDD3 = distinctChars.map(c => (c, new Random().nextDouble()))
charRDD.cogroup(charRDD2, charRDD3).take(5)

10. 下面給出inner join的示例代碼,注意下面如何設置輸出分區數:

val keyedChars = distinctChars.map(c => (c, new Random().nextDouble()))
val outputPartitions = 10
KVcharacters.join(keyedChars).count()
KVcharacters.join(keyedChars, outputPartitions).count()

zip把兩個RDD的元素對應匹配在一起,要求兩個RDD的元素個數相同,同時也要求兩個RDD的分區數也相同,結果會生成一個PairRDD:

val numRange = sc.parallelize(0 to 9, 2)
words.zip(numRange).collect()

下面是結果,即一個匹配後的key-value數組:

[('Spark',0),
('The',1),
('Definitive',2),
('Guide',3),
(':',4),
('Big',5),
('Data',6),
('Processing',7),
('Made',8),
('Simple',9)]

coalesce有效地摺疊(collapse)同一工作節點上的分區,以便在重新分區時避免數據洗牌(shuffle)。例如,存儲words變量的RDD當前有兩個分區,可以使用coalesce將其摺疊爲一個分區,從而避免了數據shuffle:

words.coalesce(1).getNumPartitions // 1

Repartition操作將對數據進行重新分區,跨節點的分區會執行shuffle操作。對於map和filter操作,增加分區可以提高並行度:

words.repartition(10) // 10個分區

repartitionAndSortWithinPartitions根據給定的分區函數對RDD進行重新分區,並在每個生成的分區內按鍵對記錄進行排序,這比調用repartition然後在每個分區內進行sortByKey更有效率,因爲它可以將排序壓入shuffle中:

val conf=new SparkConf().setAppName("Test").setMaster("local[2]")
val sc=new SparkContext(conf)
val array=Array(2,4,6,67,3,45,26,35,789,345)
val data=sc.parallelize(array)
// 替換repartition組合sortBy
data.zipWithIndex().repartitionAndSortWithinPartitions(new HashPartitioner(1)).foreach(println)

輸出結果爲:

(2,0)
(3,4)
(4,1)
(6,2)
(26,6)
(35,7)
(45,5)
(67,3)
(345,9)
(789,8)

11. 自定義分區是使用RDD的主要原因之一,而結構化API不支持自定義數據分區。自定義分區的典型示例是PageRank實現,需要控制集羣上數據的分佈並避免shuffle操作,而在這裏的shopping數據集中,可能需要根據客戶ID對數據進行分區。簡而言之,自定義分區的唯一目標是將數據均勻地分佈到整個集羣中,以避免諸如數據傾斜的問題

如果要使用自定義分區,則應從結構化API定義的數據降級爲RDD,應用自定義分區函數,然後再將RDD轉換回DataFrame或Dataset。要執行自定義分區需要實現Partitioner的子類,只有很瞭解這方面知識時才需要這樣做,如果只是想對一個值或一組值(列)進行分區,那麼用DataFrame API實現就可以。來看一個示例:

val df = spark.read.option("header", "true").option("inferSchema", "true")
  .csv("/data/retail-data/all/")
val rdd = df.coalesce(10).rdd
df.printSchema()

Spark有兩個內置的partitioner可以在RDD API中調用,它們是用於哈希值分區的HashPartitioner以及RangePartitioner(根據數值範圍分區),這兩個partitioner分別針對離散值和連續值。Spark的結構化API已經包含了它們,也可以在RDD中使用它們:

import org.apache.spark.HashPartitioner
rdd.map(r => r(6)).take(5).foreach(println)
val keyedRDD = rdd.keyBy(row => row(6).asInstanceOf[Int].toDouble)
keyedRDD.partitionBy(new HashPartitioner(10)).take(10)

雖然上面兩個partitioner都很有用,但它們是最基本的分區方法。有時由於某些key對應的value項比其他key對應的value項多很多導致數據傾斜,將需要實現一些非常底層的分區方法,儘可能多地拆分這些key以提高並行性,並在執行過程中防止OutOfMemoryError錯誤發生。

一個典型情況是,由於某個key對應的value太多,需要把這個key拆分成很多key。例如數據集中可能對某兩個客戶的數據處理總是會使程序OOM,需要對這兩個客戶數據進行細分,就是說比其他客戶ID更細粒度地分解它們。由於這兩個key傾斜的情況很嚴重,所以需要特別處理,而其他的key可以被集中到大組中:

import org.apache.spark.Partitioner
class DomainPartitioner extends Partitioner {
 def numPartitions = 3
 def getPartition(key: Any): Int = {
   val customerId = key.asInstanceOf[Double].toInt
   if (customerId == 17850.0 || customerId == 12583.0) {
     return 0
   } else {
     return new java.util.Random().nextInt(2) + 1
   }
 }
}

keyedRDD
  .partitionBy(new DomainPartitioner).map(_._1).glom().map(_.toSet.toSeq.length)
  .take(5)

運行代碼後將看到每個分區中的結果數量,而第二個分區和第三個分區的數量會有所不同,因爲後兩個分區是隨機分佈的,但劃分原則是一致的。自定義key分發的邏輯僅在RDD級別適用

12. 關於Kryo序列化問題,任何希望並行處理(或函數操作)的對象都必須是可序列化的

class SomeClass extends Serializable {
  var someValue = 0
  def setSomeValue(i:Int) = {
    someValue = i
    this
  }
}

sc.parallelize(1 to 10).map(num => new SomeClass().setSomeValue(num))

默認序列化的方式可能很慢,Spark可以使用Kryo庫更快地序列化對象。Kryo序列化的速度比Java序列化更快,壓縮也更緊湊(通常是10倍),但並不是支持所有序列化類型,並且要求先註冊在程序中使用的類。可以藉助於SparkConf使用Kryo初始化任務,並設置“spark.serializer”爲“org.apache.spark.serializer.KryoSerializer”,此配置用於在worker節點之間數據傳輸時或將RDD寫到磁盤上時。

Spark沒有選擇Kryo做爲默認序列化工具的原因是它要求自定義註冊,但建議在網絡傳輸量大的應用程序中嘗試使用它。自Spark 2後在對簡單類型、簡單類型數組或字符串類型的RDD進行shuffle操作時,已經默認採用Kryo序列化。如果要藉助於Kryo註冊自定義類,需要使用registerKryoClasses方法:

val conf = new SparkConf().setMaster(...).setAppName(...)
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))
val sc = new SparkContext(conf)

三、分佈式共享變量

13. 除了RDD外,Spark的第二種低級API是“分佈式共享變量”,它包括兩種類型:廣播變量(broadcast variable)和累加器(accumulator)。這些變量可以再例如RDD或DataFrame上的map函數等地方使用,在集羣上運行時具有特殊性質。具體地說,累加器將所有任務中的數據累加到一個共享結果中(例如實現一個計數器,以便可以查看有多少輸入記錄無法解析)。廣播變量允許在所有worker節點上保存一個共享值,當在Spark各種操作中重用它時,就不需要將其重新在機器間傳輸。

通過廣播變量可以在集羣上有效地共享只讀變量,而不需要將其封裝到函數中去。在driver節點上使用變量的一般方法是在函數閉包(function closure)中引用它(例如在map操作中),但這種方式效率很低,原因在於當在閉包(closure)中使用變量時,必須在worker節點上執行多次反序列化(每個task一次)。此外如果在多個Spark操作和作業中使用相同的變量,它將被重複發送到worker節點上的每一個作業中,而不是隻發送一次。

而廣播變量是共享的、不可修改的變量,它們緩存在集羣中的每個節點上,而不是在每個task中都反覆緩存和序列化,如下圖所示:

例如,假設有一個包含句子的列表:

val myCollection = "Spark The Definitive Guide : Big Data Processing Made Simple"
  .split(" ")
val words = spark.sparkContext.parallelize(myCollection, 2)

如果用其他信息補充這個單詞列表,從SQL的角度考慮這是一個right join:

val supplementalData = Map("Spark" -> 1000, "Definitive" -> 200,
                           "Big" -> -300, "Simple" -> 100)

可以通過Spark來廣播它,當觸發action操作時該值是不可變的,並且惰性複製到集羣中的所有節點上:

val suppBroadcast = spark.sparkContext.broadcast(supplementalData)

可以通過value方法引用此廣播變量的值。該方法在序列化函數中是可訪問的,無需對數據進行序列化,這可以節省大量序列化和反序列化的成本:

suppBroadcast.value

現在可以用這個值轉換RDD。在這個例子中將根據在廣播map變量中可能擁有的value來創建一個key-value對,如果沒有value將用0替代它:

words.map(word => (word, suppBroadcast.value.getOrElse(word, 0)))
     .sortBy(wordPair => wordPair._2)
     .collect()

結果會返回以下值:

[('Big',-300),
('The',0),
...
('Definitive',200),
('Spark',1000)]

14. Spark第二種類型的共享變量是累加器,它用於將transformation操作中更新的值以高效容錯的方式傳輸到driver節點,如下圖所示:

累加器僅支持由滿足交換律和結合律的操作進行累加的變量,因此對累加器的操作可以高效並行,可以使用累加器實現計數器(如MapReduce) 或求和操作。Spark提供對數字類型累加器的原生支持,用戶也可以自行添加對新類型的支持。對於僅發生在action操作內執行的累加器更新,Spark保證每個task對累加器的更新只發生一次,重新啓動的任務不會再次更新該值。但是transformation操作中,如果task或作業stage重新執行,則應注意每個task的累加更新可能發生多次

累加器也遵循Spark的惰性評估機制。如果RDD的某個操作要更新累加器,則它的值只會在實際計算RDD時更新(例如在對該RDD或依賴於它的RDD調用action操作時)。因此在像map這樣的惰性轉換中,不保證累加器更新會被立即執行。累加器可以是命名和未命名的,命名累加器將在Spark UI上顯示它們的運行結果,而未命名的累加器則不會顯示出來

這裏使用前面創建的Flight數據集並執行自定義聚合來進行實驗。在此示例中將使用Dataset API而不是RDD API,但非常相似:

case class Flight(DEST_COUNTRY_NAME: String,
                  ORIGIN_COUNTRY_NAME: String, 
                  count: BigInt)
val flights = spark.read
  .parquet("/data/flight-data/parquet/2010-summary.parquet")
  .as[Flight]

現在創建一個累加器,它將計算往返中國的航班數量。儘管可以在SQL中以很簡單的方式執行此操作,但累加器提供了一種以編程方式實現的計數器。下面演示如何創建未命名的累加器:

import org.apache.spark.util.LongAccumulator
val accUnnamed = new LongAccumulator
val acc = spark.sparkContext.register(accUnnamed)

在該例子中更適合命名的累加器,最簡單的是使用SparkContext,或者可以實例化累加器然後使用名稱對其註冊:

val accChina = new LongAccumulator
val accChina2 = spark.sparkContext.longAccumulator("China")
spark.sparkContext.register(accChina, "China")

可以傳遞給LongAccumulator函數一個字符串值作爲累加器的名稱,或者將該字符串作爲第二個參數傳遞到register函數中。下一步是定義遞增累加器的邏輯:

def accChinaFunc(flight_row: Flight) = {
  val destination = flight_row.DEST_COUNTRY_NAME
  val origin = flight_row.ORIGIN_COUNTRY_NAME
  if (destination == "China") {
    accChina.add(flight_row.count.toLong)
  }
  if (origin == "China") {
    accChina.add(flight_row.count.toLong)
  }
}

現在,通過foreach方法遍歷航班數據集中的每一行, foreach是一個action操作,爲輸入DataFrame中的每一行都運行一次自定義的函數,並相應地遞增累加器:

flights.foreach(flight_row => accChinaFunc(flight_row))

如果切換到Spark UI,則可以在每個executor上看到相關的值,如下圖所示:

當然也可以通過編程來訪問它,爲此需要使用value屬性:

accChina.value // 953

15. 儘管Spark確實提供了一些累加器類型,但有時可能需要構建自己的自定義累加器,爲此需要創建AccumulatorV2類的子類,實現幾種抽象方法,如下面的示例所示,這裏只把偶數加到累加器裏:

import scala.collection.mutable.ArrayBuffer
import org.apache.spark.util.AccumulatorV2

val arr = ArrayBuffer[BigInt]()

class EvenAccumulator extends AccumulatorV2[BigInt, BigInt] {
  private var num:BigInt = 0
  def reset(): Unit = {
    this.num = 0
  }
  def add(intValue: BigInt): Unit = {
    if (intValue % 2 == 0) {
        this.num += intValue
    }
  }
  def merge(other: AccumulatorV2[BigInt,BigInt]): Unit = {
    this.num += other.value
  }
  def value():BigInt = {
    this.num
  }
  def copy(): AccumulatorV2[BigInt,BigInt] = {
    new EvenAccumulator
  }
  def isZero():Boolean = {
    this.num == 0
  }
}

val acc = new EvenAccumulator
val newAcc = sc.register(acc, "evenAcc")
acc.value // 0
flights.foreach(flight_row => acc.add(flight_row.count))
acc.value // 31390

 

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