一文帶你過完Spark RDD的基礎概念

說出你的願望吧發表於2020-02-09

前言

上一篇權當吹水了,從這篇開始進入正題。

二、Spark 的記憶體計算框架(重點?)

RDD(Resilient Distributed Dataset)叫做 彈性分散式資料集 ,是Spark中最基本的資料抽象,它代表一個不可變、可分割槽、裡面的元素可平行計算的集合.

Dataset:就是一個集合,儲存很多資料.
Distributed:它內部的元素進行了分散式儲存,方便於後期進行分散式計算.
Resilient:表示彈性,rdd的資料是可以儲存在記憶體或者是磁碟中.
複製程式碼

在程式碼中的表現是這樣的,每一個方法所對應的結果都是一個RDD,比如上面的scala程式碼,下一個RDD的結果會依賴於上一個RDD

我知道到目前大家都沒整懂,沒事,繼續往下看就會懂的?

2.1 RDD 的五大特性


接下來是在原始碼中對於RDD的解釋,分點中我使用了我覺得(接受反駁?)較為合理的語句來解釋

2.1.1 A list of partitions

一個分割槽(Partition)列表,組成了該RDD的資料。

這裡表示一個rdd有很多分割槽,每一個分割槽內部是包含了該rdd的部分資料,spark中任務是以task執行緒的方式執行, 一個分割槽就對應一個task執行緒。

使用者可以在建立RDD時指定RDD的分割槽個數,如果沒有指定,那麼就會採用預設值。(比如:讀取HDFS上資料檔案產生的RDD分割槽數跟block的個數相等)

RDD的分割槽其實可以簡單這樣理解,比如說我現在要來一個wordCount,這個文字的大小是300M,那按照我們 HDFS 的套路,每128M是一個block塊,那這個300M的檔案就是3個block,然後我們的RDD會按照你這個檔案的擁有的block塊數來決定RDD的分割槽數,此時RDD的分割槽數就是3,但是如果我這個檔案本身就小於128M呢,那RDD就會預設為2個分割槽數

2.1.2 A function for computing each split

每個分割槽的計算函式都算是一個RDD --- Spark中RDD的計算是以分割槽為單位的,每個RDD都會實現compute函式以達到這個目的.

2.1.3 A list of dependencies on other RDDs

一個rdd會依賴於其他多個rdd --- rdd與rdd之間的依賴關係,spark任務的容錯機制就是根據這個特性而來。

2.1.4 Optionally, a Partitioner for key-value RDDs (e.g. to say that the RDD is hash-partitioned)

針對key-value型別的RDD才有分割槽函式,分割槽函式其實就是把計算的結果丟到不同的分割槽中。

當前Spark中實現了兩種型別的分割槽函式,一個是基於雜湊的 HashPartitioner,另外一個是基於範圍的 RangePartitioner
只有對於key-value的RDD,並且產生shuffle,才會有 Partitioner,非key-value的RDD的 Parititioner 的值是None。

HashPartitioner的套路在之前的 MapReduce 的那篇已經有提到過了,其實大資料的那些分割槽套路好多都是這個套路,RangePartitioner就是類似規定了多少到多少丟這個分割槽,多少到多少又丟那個分割槽這樣的玩法。

2.1.5 Optionally, a list of preferred locations to compute each split on (e.g. block locations for an HDFS file)

計算任務的位置優先為儲存每個Partition的位置 (可自定義)

這裡涉及到資料的本地性,資料塊位置最優。簡單點說也就是說哪裡有資料我們就在哪裡做計算的意思。

spark任務在排程的時候會優先注意只是優先,而不是必然)考慮存有資料的節點開啟計算任務,減少資料的網路傳輸,提升計算效率。

2.2 基於wordCount分析RDD的5大屬性

需求就是HDFS上有一個大小為300M的檔案,通過 Spark 實現檔案單詞統計,最後把結果資料儲存到 HDFS 上,程式碼如下,注意我們使用的是 Scala 而不是 Java 程式碼

sc.textFile("/words.txt"// 讀取資料檔案
    .flatMap(_.split(" ")) // 切分每一行,獲取所有單詞
        .map((_,1)) // 每個單詞計為1
            .reduceByKey(_+_) // 相同單詞(key)出現的1累加
                .saveAsTextFile("/out"// 儲存輸出到/out
複製程式碼

因為我本地沒有部署好環境,所以我以下過程不會截圖,但是會把操作步驟和結果說明一下

2.2.1 RDD1 : sc.textFile("/words.txt")

把 Spark-shell跑起來,然後我們可以一步一步地把程式碼執行一下,此時我們可以看到


此時我們就可以執行scala的程式碼了,第一步先把sc.textFile("/words.txt")給打上去

因為RDD是一個抽象類,所以sc.textFile("/words.txt")的結果由它的子類 MapPartitionsRDD 進行了接收,這個程式碼會得出RDD的分割槽結果,我們也可以通過

sc.textFile("/words.txt").partitions
複製程式碼

檢視分割槽,此時會得出一個陣列,這個陣列的長度為3(300M的檔案會有3個block塊,而RDD的分割槽數是由block塊來決定的,注意RDD的分割槽數不一定都在不同的伺服器上。但是如果block塊只有1,RDD的分割槽數就會預設2

sc.textFile("/words.txt").partitions.length
複製程式碼

執行這個檢視陣列的長度,必然為3

此時RDD2,RDD3的結果和RDD1的結果都是由 MapPartitionsRDD 進行接收,不同的是map得出來的RDD會是key-value型別而已

2.2.2 RDD4 : sc.textFile("/words.txt").flatMap(.split(" ")).map((,1)).reduceByKey(+)

RDD4的接收型別為 ShuffleRDD,因為此時的結果需要按照key來分組,就必定會產生 shuffle,那shuffle可以先簡單這麼理解,比如我們現在的wordCount中words.txt只有3個key分別是"zookeeper","kafka","spark",那我shuffle時我就做出這樣的規定

此時我們 saveAsTextFile 會得到3個檔案,不難發現其實 RDD 有幾個分割槽就會有幾個檔案了。每個RDD所對應體現的5大特性也已經寫在了圖右側。

2.3 RDD 的建立方式

2.3.1 通過已經存在的scala集合去構建

val rdd1=sc.parallelize(List(1,2,3,4,5))
val rdd2=sc.parallelize(Array("zookeeper","kafka","spark"))
val rdd3=sc.makeRDD(List(1,2,3,4))
複製程式碼

2.3.2 載入外部的資料來源去構建

val rdd1=sc.textFile("/words.txt")
複製程式碼

2.3.3 從已經存在的rdd進行轉換生成一個新的rdd

val rdd2=rdd1.flatMap(_.split(" "))
val rdd3=rdd2.map((_,1))
複製程式碼

2.4 RDD 的運算元分類

2.4.1 transformation(轉換)

根據已經存在的rdd轉換生成一個新的rdd, 它是延遲載入,它不會立即執行

就例如剛剛wordCount中用到的 map,flatMap,reduceByKey

2.4.2 action (動作)

它會真正觸發任務的執行。將rdd的計算的結果資料返回給Driver端,或者是儲存結果資料到外部儲存介質中

就例如剛剛wordCount中用到的 collect,saveAsTextFile 等

2.5 RDD 的常見運算元(後面結合程式碼說明)

2.5.1 transformation運算元

轉換 含義
map(func) 返回一個新的RDD,該RDD由每一個輸入元素經過func函式轉換後組成
filter(func) 返回一個新的RDD,該RDD由經過func函式計算後返回值為true的輸入元素組成
flatMap(func) 類似於map,但是每一個輸入元素可以被對映為0或多個輸出元素(所以func應該返回一個序列,而不是單一元素)
mapPartitions(func) 類似於map,但獨立地在RDD的每一個分片上執行,因此在型別為T的RDD上執行時,func的函式型別必須是Iterator[T] => Iterator[U]
mapPartitionsWithIndex(func) 類似於mapPartitions,但func帶有一個整數參數列示分片的索引值,因此在型別為T的RDD上執行時,func的函式型別必須是(Int, Interator[T]) => Iterator[U]
union(otherDataset) 對源RDD和引數RDD求並集後返回一個新的RDD
intersection(otherDataset) 對源RDD和引數RDD求交集後返回一個新的RDD
distinct([numTasks])) 對源RDD進行去重後返回一個新的RDD
groupByKey([numTasks]) 在一個(K,V)的RDD上呼叫,返回一個(K, Iterator[V])的RDD
reduceByKey(func, [numTasks]) 在一個(K,V)的RDD上呼叫,返回一個(K,V)的RDD,使用指定的reduce函式,將相同key的值聚合到一起,與groupByKey類似,reduce任務的個數可以通過第二個可選的引數來設定
sortByKey([ascending], [numTasks]) 在一個(K,V)的RDD上呼叫,K必須實現Ordered介面,返回一個按照key進行排序的(K,V)的RDD
sortBy(func,[ascending], [numTasks]) 與sortByKey類似,但是更靈活
join(otherDataset, [numTasks]) 在型別為(K,V)和(K,W)的RDD上呼叫,返回一個相同key對應的所有元素對在一起的(K,(V,W))的RDD
cogroup(otherDataset, [numTasks]) 在型別為(K,V)和(K,W)的RDD上呼叫,返回一個(K,(Iterable,Iterable))型別的RDD
coalesce(numPartitions) 減少 RDD 的分割槽數到指定值。
repartition(numPartitions) 重新給 RDD 分割槽
repartitionAndSortWithinPartitions(partitioner) 重新給 RDD 分割槽,並且每個分割槽內以記錄的 key 排序

2.5.2 action運算元

動作 含義
reduce(func) reduce將RDD中元素前兩個傳給輸入函式,產生一個新的return值,新產生的return值與RDD中下一個元素(第三個元素)組成兩個元素,再被傳給輸入函式,直到最後只有一個值為止。
collect() 在驅動程式中,以陣列的形式返回資料集的所有元素
count() 返回RDD的元素個數
first() 返回RDD的第一個元素(類似於take(1))
take(n) 返回一個由資料集的前n個元素組成的陣列
takeOrdered(n, [ordering]) 返回自然順序或者自定義順序的前 n 個元素
saveAsTextFile(path) 將資料集的元素以textfile的形式儲存到HDFS檔案系統或者其他支援的檔案系統,對於每個元素,Spark將會呼叫toString方法,將它裝換為檔案中的文字
saveAsSequenceFile(path) 將資料集中的元素以Hadoop sequencefile的格式儲存到指定的目錄下,可以使HDFS或者其他Hadoop支援的檔案系統。
saveAsObjectFile(path) 將資料集的元素,以 Java 序列化的方式儲存到指定的目錄下
countByKey() 針對(K,V)型別的RDD,返回一個(K,Int)的map,表示每一個key對應的元素個數。
foreach(func) 在資料集的每一個元素上,執行函式func
foreachPartition(func) 在資料集的每一個分割槽上,執行函式func

2.6 常見運算元的程式碼說明

好吧我忘了我本地沒環境了···

這裡只提醒一下,執行了transformation運算元操作其實並沒有真正意義上的得出結果,要跑collect()才真正的開始計算

2.6.1 注意:repartition 和 coalesce

val rdd1 = sc.parallelize(1 to 10,3)
//列印rdd1的分割槽數
rdd1.partitions.size

//利用repartition改變rdd1分割槽數
//減少分割槽
rdd1.repartition(2).partitions.size

//增加分割槽
rdd1.repartition(4).partitions.size

//利用coalesce改變rdd1分割槽數
//減少分割槽
rdd1.coalesce(2).partitions.size
複製程式碼

repartition : 重新分割槽,有shuffle,可以用來處理小檔案的問題

coalesce : 合併分割槽 / 減少分割槽,預設不shuffle

預設 coalesce 不能擴大分割槽數量。除非新增true的引數,或者使用repartition。

適用場景:

  1. 如果要shuffle,都用 repartition
  2. 不需要shuffle,僅僅是做分割槽的合併,coalesce
  3. repartition常用於擴大分割槽。

2.6.2 注意:map、mapPartitions 和 mapPartitionsWithIndex

map:用於遍歷RDD,將函式f應用於每一個元素,返回新的RDD(transformation運算元)。

mapPartitions:用於遍歷操作RDD中的每一個分割槽,返回生成一個新的RDD(transformation運算元)。

總結:
如果在對映的過程中需要頻繁建立額外的物件,使用mapPartitions要比map高效
比如,將RDD中的所有資料通過JDBC連線寫入資料庫,如果使用map函式,可能要為每一個元素都建立一個connection,這樣開銷很大,如果使用mapPartitions,那麼只需要針對每一個分割槽建立一個connection。

2.6.3 注意:foreach、foreachPartition

foreach:用於遍歷RDD,將函式f應用於每一個元素,無返回值(action運算元)。

foreachPartition: 用於遍歷操作RDD中的每一個分割槽。無返回值(action運算元)。

總結:
一般使用mapPartitions或者foreachPartition運算元比map和foreach更加高效,推薦使用。

所以我們可以使用 foreachPartition 運算元實現

2.7 RDD的依賴關係

RDD和它依賴的父RDD的關係有兩種不同的型別:窄依賴(narrow dependency)和寬依賴(wide dependency)

2.7.1 窄依賴

窄依賴指的是每一個父RDD的Partition最多被子RDD的一個Partition使用,比如map/flatMap/filter/union等等,且所有的窄依賴不會產生shuffle

2.7.2 寬依賴

寬依賴指的是多個子RDD的Partition會依賴同一個父RDD的Partition,比如reduceByKey/sortByKey/groupBy/groupByKey/join等等
。所有的寬依賴會產生shuffle

上圖也可以看出join操作分為寬依賴和窄依賴,如果RDD有相同的partitioner,那麼將不會引起shuffle,這種join是窄依賴,反之就是寬依賴

2.8 lineage

lineage翻譯過來是血統的意思。還是之前的那張圖。


RDD的 Lineage 會記錄RDD的後設資料資訊和轉換行為,lineage儲存了RDD的依賴關係,當該RDD的部分分割槽資料丟失時,它可以根據這些資訊來重新運算和恢復丟失的資料分割槽。

不過需要注意RDD只支援 粗粒度轉換 (即只記錄單個塊上執行的單個操作),比如此時如果我是RDD4的0號莫得了,我就要重新拿到RDD3的所有資料,然後重新reducebyKey一次,這樣才能恢復結果,這種寬依賴需要經過shuffle的操作,恢復起來的成本就會高很多

值得再提的是:我們其實不需要人為干預分割槽資料的恢復,程式自身就可以幫我們根據 RDD 的血統關係自行恢復

2.9 RDD 的快取機制

可以把一個rdd的資料快取起來,後續有其他的job需要用到該rdd的結果資料,可以直接從快取中獲取得到,避免了重複計算。快取是加快後續對該資料的訪問操作。

比如上圖中我只要對RDD2的結果做一個快取,到時候假如用的上,恢復起來就非常方便

2.9.1 如何對 RDD 設定快取

RDD通過 persist 方法或 cache 方法可以將前面的計算結果快取。但是需要注意並不是這兩個方法被呼叫時立即快取,而是觸發後面的action時,該RDD將會被快取在計算節點的記憶體中,並供後面重用

我們可以看看去RDD的原始碼彙總找出來look look,這倆方法是放在了一起的


通過檢視原始碼發現cache最終也是呼叫了persist方法,預設的快取級別就是 MEMORY_ONLY 都是僅在記憶體儲存一份,Spark的儲存級別還有好多種,儲存級別在object StorageLevel中定義的

StorageLevel 在Scala中是 object (注意這裡我用的是小寫,object 是單例物件的意思,而非Java中的Object),這裡面還有大量不同的儲存級別,這裡就不展開了,這些英文其實也不難懂

cache和persist區別是:cache: 預設是把資料快取在記憶體中,其本質就是呼叫persist方法,而persist:可以把資料快取在記憶體或者是磁碟,有豐富的快取級別,這些快取級別都被定義在StorageLevel這個object中。

2.9.2 快取的使用時機

當第一次使用RDD2做相應的運算元操作得到RDD3的時候,就會從RDD1開始計算,先讀取HDFS上的檔案,然後對RDD1 做對應的運算元操作得到RDD2,再由RDD2計算之後得到RDD3。同樣為了計算得到RDD4,前面的邏輯會被重新計算。

預設情況下多次對一個RDD執行運算元操作, RDD都會對這個RDD及之前的父RDD全部重新計算一次。 這種情況在實際開發程式碼的時候會經常遇到,但是我們一定要避免一個RDD重複計算多次,否則會導致效能急劇降低

為了獲取得到一個RDD的結果資料,經過了大量的運算元操作或者是計算邏輯比較複雜,也就是某個RDD的資料來之不易的時候,就可以設定快取

總結:可以把多次使用到的RDD,也就是公共RDD進行持久化,避免後續需要,再次重新計算,提升效率。

2.9.3 清除快取資料

  1. 自動清除 : 一個application應用程式結束之後,對應的快取資料也就自動清除

  2. 手動清除 : 呼叫 RDD 的 unpersist 方法

雖然我們可以對 RDD 的資料進行快取,儲存在記憶體或者是磁碟中,之後就可以直接從記憶體或者磁碟中獲取得到,但是注意這個方式不是特別安全

cache 它是直接把資料儲存在記憶體中,後續操作起來速度比較快,直接從記憶體中獲取得到。但這種方式很不安全,由於伺服器掛掉或者是程式終止,會導致資料的丟失。

persist 它可以把資料儲存在本地磁碟中,後續可以從磁碟中獲取得到該資料,但它也不是特別安全,由於系統管理員一些誤操作刪除了,或者是磁碟損壞,也有可能導致資料的丟失。

那麼我們有沒有一種更為安全的方式呢?

2.10 RDD 的 checkpoint 機制

checkpoint 提供了一種相對而言更加可靠的資料持久化方式。它是把資料儲存在分散式檔案系統,比如HDFS上。這裡就是利用了HDFS高可用性,高容錯性(多副本)來最大程度保證資料的安全性。

2.10.1 如何設定checkpoint

1.在 HDFS 上設定一個 checkpoint 目錄

sc.setCheckpointDir("hdfs://node1:9000/checkpoint"
複製程式碼

2.對需要做 checkpoint 操作的rdd呼叫 checkpoint 方法

val rdd1=sc.textFile("/words.txt")
rdd1.checkpoint
val rdd2=rdd1.flatMap(_.split(" ")) 
複製程式碼

3.最後需要有一個 action 操作去觸發任務的執行(checkpoint操作要執行需要有一個action操作,一個action操作對應後續的一個job。該job執行完成之後,它會再次單獨開啟另外一個job來執行 rdd1.checkpoint操作。

對checkpoint在使用的時候進行優化,在呼叫checkpoint操作之前,可以先來做一個cache操作,快取對應rdd的結果資料,後續就可以直接從cache中獲取到rdd的資料寫入到指定checkpoint目錄中

rdd2.collect
複製程式碼

所以我們總結一下 cache、persist、checkpoint 三者區別

cache和persist

cache預設資料快取在記憶體中
persist可以把資料儲存在記憶體或者磁碟中
後續要觸發 cache 和 persist 持久化操作,需要有一個action操作
它不會開啟其他新的任務,一個action操作就對應一個job 
它不會改變RDD的依賴關係,程式執行完成後對應的快取資料就自動消失
複製程式碼

checkpoint

可以把資料持久化寫入到 HDFS 上
後續要觸發checkpoint持久化操作,需要有一個action操作,後續會開啟新的job執行checkpoint操作
它會改變RDD的依賴關係,後續資料丟失了不能夠在通過血統進行資料的恢復。
    (因為它判斷你已經持久化到 HDFS 中所以把依賴關係刪除了)
程式執行完成後對應的checkpoint資料就不會消失
複製程式碼

2.11 DAG 有向無環圖生成

DAG(Directed Acyclic Graph) 叫做有向無環圖(有方向,無閉環,代表著資料的流向),原始的RDD通過一系列的轉換就形成了DAG。

這個就是我們的 wordCount 例子裡生成的 DAG,這個圖我們可以在上一講中提到的web介面中的 Running Application 的 spark-shell ---> Completed Jobs 的 Description 中檢視

2.11.1 DAG劃分stage

stage是什麼: 一個Job會被拆分為多組Task,每組任務被稱為一個stage,stage表示不同的排程階段,一個spark job會對應產生很多個stage

stage型別一共有2種:

  1. ShuffleMapStage: 最後一個shuffle之前的所有變換叫ShuffleMapStage,它對應的task是shuffleMapTask
  2. ResultStage: 最後一個shuffle之後的操作叫ResultStage,它是最後一個Stage.它對應的task是ResultTask

2.11.2 為啥我們要劃分stage

根據RDD之間依賴關係的不同將DAG劃分成不同的Stage(排程階段)
對於窄依賴,partition的轉換處理在一個Stage中完成計算
對於寬依賴,由於有Shuffle的存在,只能在parent RDD處理完成後,才能開始接下來的計算,

由於劃分完stage之後,在同一個stage中只有窄依賴,沒有寬依賴,可以實現流水線計算,
stage中的每一個分割槽對應一個task,在同一個stage中就有很多可以並行執行的task。

2.11.3 如何劃分stage

我們劃分stage的依據就是寬依賴

  1. 首先根據rdd的運算元操作順序生成DAG有向無環圖,接下里從最後一個 RDD 往前推,建立一個新的stage,把該rdd加入到該stage中,它是最後一個stage。

  2. 在往前推的過程中執行遇到了窄依賴就把該 RDD 加入到本stage中,如果遇到了寬依賴,就從寬依賴的位置切開,那麼最後一個stage也就被劃分出來了。

  3. 重新建立一個新的stage,按照第二個步驟繼續往前推,一直到最開始的 RDD,整個劃分stage也就劃分結束了

2.11.4 stage與stage之間的關係

劃分完stage之後,每一個stage中有很多可以並行執行的task,後期把每一個stage中的task封裝在一個taskSet集合中,最後把一個一個的taskSet集合提交到worker節點上的executor程式中執行。

RDD 與 RDD 之間存在依賴關係,stage與stage之前也存在依賴關係,前面stage中的task先執行,執行完成了再執行後面stage中的task,也就是說後面stage中的task輸入資料是前面stage中task的輸出結果資料。

finally

以上我們就是把 RDD 的一些基礎知識點給簡單地過了一遍,還有一些更加深入,更加細緻的方面我們要放到 Spark Core,Spark Streaming中去闡述,感興趣的朋友可以繼續地關注一下

相關文章