Spark core學習筆記(二)-----(RDD屬性、RDD深度解析、RDD邏輯圖與物理圖生成、Job調度、寬窄依賴、Shuffle過程、廣播變量,閉包)

目錄

一、 深入 RDD

1.1. 案例

1.2. 再談 RDD

1.2.1. RDD 爲什麼會出現?

1.2.2. RDD

1.2.3. 什麼叫做彈性分佈式數據集

  總結: RDD 的五大屬性

二、RDD 的算子

2.1. Transformations 算子

2.2. Action 算子

2.3. RDD 對不同類型數據的支持

2.4. 階段練習和總結

三、 RDD 的 Shuffle 和分區

3.1. RDD 的分區操作

查看分區數

創建 RDD 時指定分區數

3.2. RDD 的 Shuffle 是什麼

3.3. RDD 的 Shuffle 原理(簡單介紹)

四、 緩存

4.1. 緩存的意義

4.2. 緩存相關的 API

4.3. 緩存級別

五、 Checkpoint

5.1. Checkpoint 的作用

5.2. 使用 Checkpoint

六、Spark 底層邏輯

6.1. 邏輯執行圖生成

6.1.2. RDD 之間的依賴關係

6.1.3. RDD 之間的依賴關係詳解

6.1.4. 常見的窄依賴類型

6.2. 物理執行圖生成

6.3. 調度過程

6.4. Shuffle 過程

七、 RDD 的分佈式共享變量

7.1. 累加器

7.2. 廣播變量


目標

  1. 深入理解 RDD 的內在邏輯
  2. 能夠使用 RDD 的算子
  3. 理解 RDD 算子的 Shuffle 和緩存
  4. 理解 RDD 整體的使用流程
  5. 理解 RDD 的調度原理
  6. 理解 Spark 中常見的分佈式變量共享方式

一、 深入 RDD

目標

  1. 深入理解 RDD 的內在邏輯, 以及 RDD 的內部屬性(RDD 由什麼組成)

1.1. 案例

數據

190.217.63.59 - - [01/Nov/2017:00:00:15 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=https%3A//securepubads.g.doubleclick.net/static/3p_cookie.html&cat=business-and-economy HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36"
76.114.21.96 - - [01/Nov/2017:00:00:31 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=http%3A//tricolor.entravision.com/sacramento/escucha-en-vivo/&cat=business-and-economy HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36"
206.126.121.204 - - [01/Nov/2017:00:00:46 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=http%3A//zone.msn.com/gameplayer/gameplayer.aspx%3Fgame%3Dfamilyfeud&cat=internet-portal HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36"
154.121.8.18 - - [01/Nov/2017:00:01:01 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=firefox_AntiPorn&ver=0.19.6.9&url=https%3A%2F%2Fwww.google.dz%2Fsearch&cat=search-engine HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko/20100101 Firefox/11.0"
190.238.37.217 - - [01/Nov/2017:00:01:17 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=https%3A//securepubads.g.doubleclick.net/static/3p_cookie.html&cat=business-and-economy HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36"
147.147.163.182 - - [01/Nov/2017:00:01:31 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=firefox_AntiPorn&ver=0.19.6.9&url=https%3A%2F%2Fs-usweb.dotomi.com%2Frenderer%2FdelPublishersCookies.html&cat=business-and-economy HTTP/1.1" 200 133 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/56.0"
200.78.93.132 - - [01/Nov/2017:00:01:45 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=https%3A//www.facebook.com/login/device-based/regular/login/&cat=social-networking HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36"
24.200.173.170 - - [01/Nov/2017:00:01:59 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=https%3A//securepubads.g.doubleclick.net/static/glade.js&cat=business-and-economy HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36"
189.252.185.4 - - [01/Nov/2017:00:02:15 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=firefox_AntiPorn&ver=0.19.6.9&url=https%3A%2F%2Fwww.google.cm%2Fblank.html&cat=internet-portal HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 6.1; rv:34.0) Gecko/20100101 Firefox/34.0"
190.90.22.125 - - [01/Nov/2017:00:02:29 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=http%3A//www.raicesdeeuropa.com/grandes-obras-de-los-principales-escritores-nacidos-durante-el-siglo-xix/&cat=unknown HTTP/1.1" 200 134 "-" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36"
181.64.62.158 - - [01/Nov/2017:00:02:45 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=https%3A//bancaporinternet.interbank.com.pe/Warhol/redireccionaInicioLogueo&cat=financial-service HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.75 Safari/537.36"
122.54.153.240 - - [01/Nov/2017:00:03:00 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=https%3A//securepubads.g.doubleclick.net/static/3p_cookie.html&cat=business-and-economy HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36"
181.64.62.158 - - [01/Nov/2017:00:03:16 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=https%3A//www.google.com.pe/&cat=search-engine HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36"
190.236.239.8 - - [01/Nov/2017:00:03:33 +0000] "GET /axis2/services/WebFilteringService/getCategoryByUrl?app=chrome_antiporn&ver=0.19.7.1&url=https%3A//www.google.com.pe/search%3Frlz%3D1C2AOHY_esPE760PE760%26source%3Dhp%26ei%3DUw_5WeGVA4TjmAHO8aCgDw%26q%3Dfb%26oq%3Dfb%26gs_l%3Dpsy-ab.3..0i131k1j0l4j0i131k1l2j0l3.1767.1916.0.2135.2.2.0.0.0.0.144.269.0j2.2.0....0...1.1.64.psy-ab..0.2.267....0.pWGbpZy6zwg%26safe%3Dhigh&cat=search-engine HTTP/1.1" 200 133 "-" "Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.112 Safari/537.36"

需求

  • 給定一個網站的訪問記錄, 俗稱 Access log
  • 計算其中出現的獨立 IP, 以及其訪問的次數

針對這個小案例,提出互相關聯但是又方向不同的五個問題

1. 假設要針對整個網站的歷史數據進行處理, 量有 1T, 如何處理?

放在集羣中, 利用集羣多臺計算機來並行處理

2.如何放在集羣中運行?

6088be299490adbaaeece8717ae985e8

簡單來講, 並行計算就是同時使用多個計算資源解決一個問題, 有如下四個要點

  • 要解決的問題必須可以分解爲多個可以併發計算的部分
  • 每個部分要可以在不同處理器上被同時執行
  • 需要一個共享內存的機制
  • 需要一個總體上的協作機制來進行調度

3.如果放在集羣中的話, 可能要對整個計算任務進行分解, 如何分解?

f738dbe3df690bc0ba8f580a3e2d1112

  1. 對於 HDFS 中的文件, 是分爲不同的 Block 的
  2. 在進行計算的時候, 就可以按照 Block 來劃分, 每一個 Block 對應一個不同的計算單元
  3. RDD  並沒有真實的存放數據, 數據是從 HDFS 中讀取的, 在計算的過程中讀取即可

  4. RDD 至少是需要可以 分片 的, 因爲HDFS中的文件就是分片的, RDD 分片的意義在於表示對源數據集每個分片的計算, RDD可以分片也意味着 可以並行計算

4.移動數據不如移動計算是一個基礎的優化, 如何做到?

1d344ab200bd12866c26ca2ea6ab1e37

每一個計算單元需要記錄其存儲單元的位置, 儘量調度過去

5. 在集羣中運行, 需要很多節點之間配合, 出錯的概率也更高, 出錯了怎麼辦?

 5c7bef41f177a96e99c7ad8a500b7310

RDD1 → RDD2 → RDD3 這個過程中, RDD2 出錯了, 有兩種辦法可以解決

  1. 緩存 RDD2 的數據, 直接恢復 RDD2, 類似 HDFS 的備份機制
  2. 記錄 RDD2 的依賴關係, 通過其父級的 RDD 來恢復 RDD2, 這種方式會少很多數據的交互和保存

如何通過父級 RDD 來恢復?

  1. 記錄 RDD2 的父親是 RDD1  (Dependencies, )
  2. 記錄 RDD2 的計算函數, 例如記錄 RDD2 = RDD1.map(…​)map(…​) 就是計算函數
  3. 當 RDD2 計算出錯的時候, 可以通過父級 RDD 和計算函數來恢復 RDD2

 

6.假如任務特別複雜, 流程特別長, 有很多 RDD 之間有依賴關係, 如何優化

dc87ed7f9b653bccb43d099bbb4f537f

 

上面提到了可以使用依賴關係來進行容錯, 但是如果依賴關係特別長的時候, 這種方式其實也比較低效, 這個時候就應該使用另外一種方式, 也就是記錄數據集的狀態

在 Spark 中有兩個手段可以做到

  • 緩存
  • Checkpoint

1.2. 再談 RDD

目標

  1. 理解 RDD 爲什麼會出現

  2. 理解 RDD 的主要特點

  3. 理解 RDD 的五大屬性

1.2.1. RDD 爲什麼會出現?

在 RDD 出現之前, 當時 MapReduce 是比較主流的, 而 MapReduce 如何執行迭代計算的任務呢?

306061ee343d8515ecafbce43bc54bc6

多個 MapReduce 任務之間沒有基於內存的數據 共享方式, 只能通過磁盤來進行共享

這種方式明顯比較低效

RDD 如何解決迭代計算非常低效的問題呢?

4fc644616fb13ef896eb3a8cea5d3bd7

在 Spark 中, 其實最終 Job3 從邏輯上的計算過程是: Job3 = (Job1.map).filter, 整個過程是共享內存的, 而不需要將中間結果存放在可靠的分佈式文件系統中

這種方式可以在保證容錯的前提下, 提供更多的靈活, 更快的執行速度,

1.2.2. RDD

RDD 不僅是數據集, 也是編程模型

RDD 即是一種數據結構, 同時也提供了上層 API, 同時 RDD 的 API 和 Scala 中對集合運算的 API 非常類似, 同樣也都是各種算子

02adfc1bcd91e70c1619fc6a67b13f92

RDD 的算子大致分爲兩類:

  • Transformation 轉換操作, 例如 map flatMap filter 等
  • Action 動作操作, 例如 reduce collect show 等

執行 RDD 的時候, 在執行到轉換操作的時候, 並不會立刻執行, 直到遇見了 Action 操作, 纔會觸發真正的執行, 這個特點叫做 惰性求值

RDD 可以分區

2ba2cc9ad8e745c26df482b4e968c802

RDD 是一個分佈式計算框架, 所以, 一定是要能夠進行分區計算的, 只有分區了, 才能利用集羣的並行計算能力

同時, RDD 不需要始終被具體化, 也就是說: RDD 中可以沒有數據, 只要有足夠的信息知道自己是從誰計算得來的就可以, 這是一種非常高效的容錯方式

RDD 是隻讀的

ed6a534cfe0a56de3c34ac6e1e8d504e

RDD 是隻讀的, 不允許任何形式的修改. 雖說不能因爲 RDD 和 HDFS 是隻讀的, 就認爲分佈式存儲系統必須設計爲只讀的. 但是設計爲只讀的, 會顯著降低問題的複雜度, 因爲 RDD 需要可以容錯, 可以惰性求值, 可以移動計算, 所以很難支持修改.

  • RDD2 中可能沒有數據, 只是保留了依賴關係和計算函數, 那修改啥?
  • 如果因爲支持修改, 而必須保存數據的話, 怎麼容錯?
  • 如果允許修改, 如何定位要修改的那一行? RDD 的轉換是粗粒度的, 也就是說, RDD 並不感知具體每一行在哪.

RDD 是可以容錯的

5c7bef41f177a96e99c7ad8a500b7310

RDD 的容錯有兩種方式

  • 保存 RDD 之間的依賴關係, 以及計算函數, 出現錯誤重新計算
  • 直接將 RDD 的數據存放在外部存儲系統, 出現錯誤直接讀取, Checkpoint

 

1.2.3. 什麼叫做彈性分佈式數據集

分佈式

    RDD 支持分區, 可以運行在集羣中

彈性

  • RDD 支持高效的容錯
  • RDD 中的數據即可以緩存在內存中, 也可以緩存在磁盤中, 也可以緩存在外部存儲中

數據集

  • RDD 可以不保存具體數據, 只保留創建自己的必備信息, 例如依賴和計算函數
  • RDD 也可以緩存起來, 相當於存儲具體數據

                                                             總結: RDD 的五大屬性

首先整理一下上面所提到的 RDD 所要實現的功能:

  1. RDD 有分區
  2. RDD 要可以通過依賴關係和計算函數進行容錯
  3. RDD 要針對數據本地性進行優化
  4. RDD 支持 MapReduce 形式的計算, 所以要能夠對數據進行 Shuffled

對於 RDD 來說, 其中應該有什麼內容呢? 如果站在 RDD 設計者的角度上, 這個類中, 至少需要什麼屬性?

  • Partition List 分片列表, 記錄 RDD 的分片, 可以在創建 RDD 的時候指定分區數目, 也可以通過算子來生成新的 RDD 從而改變分區數目

  • Compute Function 爲了實現容錯, 需要記錄 RDD 之間轉換所執行的計算函數

  • RDD Dependencies RDD 之間的依賴關係, 要在 RDD 中記錄其上級 RDD 是誰, 從而實現容錯和計算

  • Partitioner 爲了執行 Shuffled 操作, 必須要有一個函數用來計算數據應該發往哪個分區


二、RDD 的算子

目標

  1. 理解 RDD 的算子分類, 以及其特性

  2. 理解常見算子的使用

分類

RDD 中的算子從功能上分爲兩大類

  1. Transformation(轉換) 它會在一個已經存在的 RDD 上創建一個新的 RDD, 將舊的 RDD 的數據轉換爲另外一種形式後放入新的 RDD

  2. Action(動作) 執行各個分區的計算任務, 將的到的結果返回到 Driver 中

RDD 中可以存放各種類型的數據, 那麼對於不同類型的數據, RDD 又可以分爲三類

  • 針對基礎類型(例如 String)處理的普通算子

  • 針對 Key-Value 數據處理的 byKey 算子

  • 針對數字類型數據處理的計算算子

特點

  • Spark 中所有的 Transformations 是 Lazy(惰性) 的, 它們不會立即執行獲得結果. 相反, 它們只會記錄在數據集上要應用的操作. 只有當需要返回結果給 Driver 時, 纔會執行這些操作, 通過 DAGScheduler 和 TaskScheduler 分發到集羣中運行, 這個特性叫做 惰性求值

  • 默認情況下, 每一個 Action 運行的時候, 其所關聯的所有 Transformation RDD 都會重新計算, 但是也可以使用 presist 方法將 RDD 持久化到磁盤或者內存中. 這個時候爲了下次可以更快的訪問, 會把數據保存到集羣上.

2.1. Transformations 算子

Transformation function

解釋

map(T ⇒ U)

sc.parallelize(Seq(1, 2, 3))
  .map( num => num * 10 )
  .collect()

57c2f77284bfa8f99ade091fdd7e9f83

c59d44296918b864a975ebbeb60d4c04

作用

  • 把 RDD 中的數據 一對一 的轉爲另一種形式

簽名

def map[U: ClassTag](f: T ⇒ U): RDD[U]

參數

  • f → Map 算子是 原RDD → 新RDD 的過程, 傳入函數的參數是原 RDD 數據, 返回值是經過函數轉換的新 RDD 的數據

注意點

  • Map 是一對一, 如果函數是 String → Array[String] 則新的 RDD 中每條數據就是一個數組

flatMap(T ⇒ List[U])

  @Test
  def flatMap() : Unit = {
    sc.parallelize(Seq("hello kangna", "hello zhangsan", "hello lisi"))
      .flatMap( line => line.split(" ") )
      .collect().foreach(println(_))
  }

 

f6c4feba14bb71372aa0cb678067c6a8

作用

  • FlatMap 算子和 Map 算子類似, 但是 FlatMap 是一對多                                                                 

調用

def flatMap[U: ClassTag](f: T ⇒ List[U]): RDD[U]

參數

  • f → 參數是原 RDD 數據, 返回值是經過函數轉換的新 RDD 的數據, 需要注意的是返回值是一個集合, 集合中的數據會被展平後再放入新的 RDD

注意點

  • flatMap 其實是兩個操作, 是 map + flatten, 也就是先轉換, 後把轉換而來的 List 展開
  • Spark 中並沒有直接展平 RDD 中數組的算子, 可以使用 flatMap 做這件事

filter(T ⇒ Boolean)

sc.parallelize(Seq(1, 2, 3))
  .filter( value => value >= 3 )
  .collect()

 

05cdb79abd41a7b5baa41a4c62870d73

作用

  • Filter 算子的主要作用是過濾掉不需要的內容( 項目中可以對不需要的字段進行邏輯刪除)
  • mapPartitions(List[T] ⇒ List[U])

RDD[T] ⇒ RDD[U] 和 map 類似, 但是針對整個分區的數據轉換

mapPartitionsWithIndex

和 mapPartitions 類似, 只是在函數中增加了分區的 Index

mapValues

sc.parallelize(Seq(("a", 1), ("b", 2), ("c", 3)))
  .mapValues( value => value * 10 )
  .collect()

 

5551847febe453b134f3a4009df01bec

作用

  • MapValues 只能作用於 Key-Value 型數據, 和 Map 類似, 也是使用函數按照轉換數據, 不同點是 MapValues 只轉換 Key-Value 中的 Value

                                                

 

sample(withReplacement, fraction, seed)

sc.parallelize(Seq(1, 2, 3, 4, 5, 6, 7, 8, 9, 10))
  .sample(withReplacement = true, 0.6, 2)
  .collect()

 

ccd1ae121f6f6852158c044441437f04

作用

  • Sample 算子可以從一個數據集中抽樣出來一部分, 常用作於減小數據集以保證運行速度, 並可能減少規律的損失

參數

  • Sample 接受第一個參數爲 withReplacement, 意爲是否取樣以後是否還放回原數據集供下次使用, 簡單的說, 如果這個參數的值爲 true, 則抽樣出來的數據集中可能會有重複(每次事件獨立,爲 false 則  取出要放回)
  • Sample 接受第二個參數爲 fraction, 意爲抽樣的比例
  • Sample 接受第三個參數爲 seed, 隨機數種子, 用於 Sample 內部隨機生成下標, 一般不指定, 使用默認值

 

union(other)

val rdd1 = sc.parallelize(Seq(1, 2, 3))
val rdd2 = sc.parallelize(Seq(4, 5, 6))
rdd1.union(rdd2)
  .collect()

 

2a8b7d10930251ae32d6d276ab7f41f8

 

intersection(other)

val rdd1 = sc.parallelize(Seq(1, 2, 3, 4, 5))
val rdd2 = sc.parallelize(Seq(4, 5, 6, 7, 8))
rdd1.intersection(rdd2)
  .collect()

 

 

76a9873eae8de8a9ed5223921da7c245

作用

  • Intersection 算子是一個集合操作, 用於求得 左側集合 和 右側集合 的交集, 換句話說, 就是左側集合和右側集合都有的元素, 並生成一個新的 RDD

subtract(other, numPartitions)

(RDD[T], RDD[T]) ⇒ RDD[T] 差集, 可以設置分區數

distinct(numPartitions)

sc.parallelize(Seq(1, 1, 2, 2, 3))
  .distinct()
  .collect()

 

2bfefe5f5cab497d5aded3b7537a58ba

作用

  • Distinct 算子用於去重

注意點

  • Distinct 是一個需要 Shuffled 的操作(儘量少用
  • 本質上 Distinct 就是一個 reductByKey, 把重複的合併爲一個

reduceByKey((V, V) ⇒ V, numPartition)

sc.parallelize(Seq(("a", 1), ("a", 1), ("b", 1)))
  .reduceByKey( (curr, agg) => curr + agg )
  .collect()

 

07678e1b4d6ba1dfaf2f5df89489def4

  • 首先按照 Key 分組生成一個 Tuple, 然後針對每個組執行 reduce 算子,認真看圖就懂了,先爲每個 key打標記,然後再針對 key 計算,就像 Hadoop 中的 wordcount 案例(MapReduce)一樣 ,很常用。

調用

def reduceByKey(func: (V, V) ⇒ V): RDD[(K, V)]

參數

  • func → 執行數據處理的函數, 傳入兩個參數, 一個是當前值, 一個是局部彙總, 這個函數需要有一個輸出, 輸出就是這個 Key 的彙總結果

注意點

  • ReduceByKey 只能作用於 Key-Value 型數據(...ByKey 好像都一樣), Key-Value 型數據在當前語境中特指 Tuple2
  • ReduceByKey 是一個需要 Shuffled 的操作
  • 和其它的 Shuffled 相比, ReduceByKey是高效的, 因爲類似 MapReduce 的, 在 Map 端有一個 Cominer, 這樣 I/O 的數據便會減少

groupByKey()

sc.parallelize(Seq(("a", 1), ("a", 1), ("b", 1)))
  .groupByKey()
  .collect()

 

27de81df110abb6709bf1c5ffad184ab

作用

  • GroupByKey 算子的主要作用是按照 Key 分組, 和 ReduceByKey 有點類似, 但是 GroupByKey 並不求聚合, 只是列舉 Key 對應的所有 Value

 

注意點

  • GroupByKey 是一個 Shuffled
  • GroupByKey 和 ReduceByKey 不同, 因爲需要列舉 Key 對應的所有數據, 所以無法在 Map 端做 Combine, 所以 GroupByKey 的性能並沒有 ReduceByKey 好

combineByKey()

 

741d814a50e4c01686f394df079458bf

作用

  • 對數據集按照 Key 進行聚合

調用

  • combineByKey(createCombiner, mergeValue, mergeCombiners, [partitioner], [mapSideCombiner], [serializer])

參數

  • createCombiner 將 Value 進行初步轉換

  • mergeValue 在每個分區把上一步轉換的結果聚合

  • mergeCombiners 在所有分區上把每個分區的聚合結果聚合

  • partitioner 可選, 分區函數

  • mapSideCombiner 可選, 是否在 Map 端 Combine

  • serializer 序列化器

注意點

  • combineByKey 的要點就是三個函數的意義要理解

  • groupByKeyreduceByKey 的底層都是 combineByKey

aggregateByKey()

ee33b17dbc78705dbbd76d76ab4a9072

作用

 

  • 聚合所有 Key 相同的 Value, 換句話說, 按照 Key 聚合 Value

調用

  • rdd.aggregateByKey(zeroValue)(seqOp, combOp)

參數

  • zeroValue 初始值
  • seqOp 轉換每一個值的函數
  • comboOp 將轉換過的值聚合的函數

注意點 * 爲什麼需要兩個函數? aggregateByKey 運行將一個 RDD[(K, V)] 聚合爲 RDD[(K, U)], 如果要做到這件事的話, 就需要先對數據做一次轉換, 將每條數據從 V 轉爲 UseqOp 就是幹這件事的 ** 當 seqOp 的事情結束以後, comboOp 把其結果聚合

 

和 reduceByKey 的區別:

  • aggregateByKey 最終聚合結果的類型和傳入的初始值類型保持一致
  • reduceByKey 在集合中選取第一個值作爲初始值, 並且聚合過的數據類型不能改
  • foldByKey(zeroValue)((V, V) ⇒ V)

 

a406ff8395bb092e719007661b34d385

作用

  • 和 ReduceByKey 是一樣的, 都是按照 Key 做分組去求聚合, 但是 FoldByKey 的不同點在於可以指定初始值

調用

foldByKey(zeroValue)(func)

參數

  • zeroValue 初始值
  • func seqOp 和 combOp 相同, 都是這個參數

注意點

  • FoldByKey 是 AggregateByKey 的簡化版本, seqOp 和 combOp 是同一個函數
  • FoldByKey 指定的初始值作用於每一個 Value

join(other, numPartitions)

val rdd1 = sc.parallelize(Seq(("a", 1), ("a", 2), ("b", 1)))
val rdd2 = sc.parallelize(Seq(("a", 10), ("a", 11), ("a", 12)))

rdd1.join(rdd2).collect()

bb3eda1410d3b0f6e1bff6d5e6a45879

作用

  • 將兩個 RDD 按照相同的 Key 進行連接

調用

join(other, [partitioner or numPartitions])

參數

  • other 其它 RDD

  • partitioner or numPartitions 可選, 可以通過傳遞分區函數或者分區數量來改變分區

注意點

  • Join 有點類似於 SQL 中的內連接, 只會再結果中包含能夠連接到的 Key

  • Join 的結果是一個笛卡爾積形式, 例如 "a", 1), ("a", 2 和 "a", 10), ("a", 11 的 Join 結果集是 "a", 1, 10), ("a", 1, 11), ("a", 2, 10), ("a", 2, 11

cogroup(other, numPartitions)

val rdd1 = sc.parallelize(Seq(("a", 1), ("a", 2), ("a", 5), ("b", 2), ("b", 6), ("c", 3), ("d", 2)))
val rdd2 = sc.parallelize(Seq(("a", 10), ("b", 1), ("d", 3)))
val rdd3 = sc.parallelize(Seq(("b", 10), ("a", 1)))

val result1 = rdd1.cogroup(rdd2).collect()
val result2 = rdd1.cogroup(rdd2, rdd3).collect()

/*
執行結果:
Array(
  (d,(CompactBuffer(2),CompactBuffer(3))),
  (a,(CompactBuffer(1, 2, 5),CompactBuffer(10))),
  (b,(CompactBuffer(2, 6),CompactBuffer(1))),
  (c,(CompactBuffer(3),CompactBuffer()))
)
 */
println(result1)

/*
執行結果:
Array(
  (d,(CompactBuffer(2),CompactBuffer(3),CompactBuffer())),
  (a,(CompactBuffer(1, 2, 5),CompactBuffer(10),CompactBuffer(1))),
  (b,(CompactBuffer(2, 6),CompactBuffer(1),Co...
 */
println(result2)

42262ffe7f3ff35013fbe534d78e3518

作用

  • 多個 RDD 協同分組, 將多個 RDD 中 Key 相同的 Value 分組

調用

  • cogroup(rdd1, rdd2, rdd3, [partitioner or numPartitions])

參數

  • rdd…​ 最多可以傳三個 RDD 進去, 加上調用者, 可以爲四個 RDD 協同分組

  • partitioner or numPartitions 可選, 可以通過傳遞分區函數或者分區數來改變分區

注意點

  • 對 RDD1, RDD2, RDD3 進行 cogroup, 結果中就一定會有三個 List, 如果沒有 Value 則是空 List, 這一點類似於 SQL 的全連接, 返回所有結果, 即使沒有關聯上

  • CoGroup 是一個需要 Shuffled 的操作

cartesian(other)

(RDD[T], RDD[U]) ⇒ RDD[(T, U)] 生成兩個 RDD 的笛卡爾積

sortBy(ascending, numPartitions)

val rdd1 = sc.parallelize(Seq(("a", 3), ("b", 2), ("c", 1)))
val sortByResult = rdd1.sortBy( item => item._2 ).collect()
val sortByKeyResult = rdd1.sortByKey().collect()

println(sortByResult)
println(sortByKeyResult)

作用

  • 排序相關相關的算子有兩個, 一個是 sortBy, 另外一個是 sortByKey

調用

sortBy(func, ascending, numPartitions)

參數

  • func 通過這個函數返回要排序的字段

  • ascending 是否升序,默認爲升序

  • numPartitions 分區數

注意點

  • 普通的 RDD 沒有 sortByKey, 只有 Key-Value 的 RDD 纔有

  • sortBy 可以指定按照哪個字段來排序, sortByKey 直接按照 Key 來排序

partitionBy(partitioner)

使用用傳入的 partitioner 重新分區, 如果和當前分區函數相同, 則忽略操作

coalesce(numPartitions)

減少分區數

val rdd = sc.parallelize(Seq(("a", 3), ("b", 2), ("c", 1)))
val oldNum = rdd.partitions.length

val coalesceRdd = rdd.coalesce(4, shuffle = true)
val coalesceNum = coalesceRdd.partitions.length

val repartitionRdd = rdd.repartition(4)
val repartitionNum = repartitionRdd.partitions.length

print(oldNum, coalesceNum, repartitionNum)

作用

  • 一般涉及到分區操作的算子常見的有兩個, repartitioin 和 coalesce, 兩個算子都可以調大或者調小分區數量

調用

  • repartitioin(numPartitions)

  • coalesce(numPartitions, shuffle)

參數

  • numPartitions 新的分區數

  • shuffle 是否 shuffle, 如果新的分區數量比原分區數大, 必須 Shuffled, 否則重分區無效

注意點

  • repartition 和 coalesce 的不同就在於 coalesce 可以控制是否 Shuffle

  • repartition 是一個 Shuffled 操作

repartition(numPartitions)

重新分區

repartitionAndSortWithinPartitions

重新分區的同時升序排序, 在 partitioner 中排序, 比先重分區再排序要效率高, 建議使用在需要分區後再排序的場景使用

分區操作的算子補充: 

 

2.2. Action 算子

Action function 解釋

reduce( (T, T) ⇒ U )

val rdd = sc.parallelize(Seq(("手機", 10.0), ("手機", 15.0), ("電腦", 20.0)))
val result = rdd.reduce((curr, agg) => ("總價", curr._2 + agg._2))
println(result)

作用

  • 對整個結果集規約, 最終生成一條數據, 是整個數據集的彙總

調用

  • reduce( (currValue[T], agg[T]) ⇒ T )

注意點

  • reduce 和 reduceByKey 是完全不同的, reduce 是一個 action, 並不是 Shuffled 操作
  • 本質上 reduce 就是現在每個 partition 上求值, 最終把每個 partition 的結果再彙總

collect()

以數組的形式返回數據集中所有元素

count()

返回元素個數

first()

返回第一個元素

take( N )

返回前 N 個元素

takeSample(withReplacement, fract)

類似於 sample, 區別在這是一個Action, 直接返回結果

fold(zeroValue)( (T, T) ⇒ U )

指定初始值和計算函數, 摺疊聚合整個數據集

saveAsTextFile(path)

將結果存入 path 對應的文件中

saveAsSequenceFile(path)

將結果存入 path 對應的 Sequence 文件中

countByKey()

val rdd = sc.parallelize(Seq(("手機", 10.0), ("手機", 15.0), ("電腦", 20.0)))
val result = rdd.countByKey()
println(result)

作用

  • 求得整個數據集中 Key 以及對應 Key 出現的次數

注意點

  • 返回結果爲 Map(key → count)
  • 常在解決數據傾斜問題時使用, 查看傾斜的 Key

foreach( T ⇒ …​ )

遍歷每一個元素

總結

RDD 的算子大部分都會生成一些專用的 RDD

  • mapflatMapfilter 等算子會生成 MapPartitionsRDD

  • coalescerepartition 等算子會生成 CoalescedRDD

常見的 RDD 有兩種類型

  • 轉換型的 RDD, Transformation

  • 動作型的 RDD, Action

常見的 Transformation 類型的 RDD

  • map

  • flatMap

  • filter

  • groupBy

  • reduceByKey

常見的 Action 類型的 RDD

  • collect

  • countByKey

  • reduce

2.3. RDD 對不同類型數據的支持

目標

  1. 理解 RDD 對 Key-Value 類型的數據是有專門支持的
  2. 理解 RDD 對數字類型也有專門的支持

一般情況下 RDD 要處理的數據有三類

  • 字符串
  • 鍵值對
  • 數字型

RDD 的算子設計對這三類不同的數據分別都有支持

  • 對於以字符串爲代表的基本數據類型是比較基礎的一些的操作, 諸如 map, flatMap, filter 等基礎的算子
  • 對於鍵值對類型的數據, 有額外的支持, 諸如 reduceByKey, groupByKey 等 byKey 的算子
  • 同樣對於數字型的數據也有額外的支持, 諸如 max, min 等

RDD 對鍵值對數據的額外支持

鍵值型數據本質上就是一個二元元組, 鍵值對類型的 RDD 表示爲 RDD[(K, V)]

RDD 對鍵值對的額外支持是通過隱式支持來完成的, 一個 RDD[(K, V)], 可以被隱式轉換爲一個 PairRDDFunctions 對象, 從而調用其中的方法.

3b365c28403495cb8d07a2ee5d0a6376

既然對鍵值對的支持是通過 PairRDDFunctions 提供的, 那麼從 PairRDDFunctions 中就可以看到這些支持有什麼

類別 算子

聚合操作

reduceByKey

foldByKey

combineByKey

分組操作

cogroup

groupByKey

連接操作

join

leftOuterJoin

rightOuterJoin

排序操作

sortBy

sortByKey

Action

countByKey

take

collect

RDD 對數字型數據的額外支持

對於數字型數據的額外支持基本上都是 Action 操作, 而不是轉換操作

算子 含義

count

個數

mean

均值

sum

求和

max

最大值

min

最小值

variance

方差

sampleVariance

從採樣中計算方差

stdev

標準差

sampleStdev

採樣的標準差

val rdd = sc.parallelize(Seq(1, 2, 3))
// 結果: 3
println(rdd.max())

 

2.4. 階段練習和總結

導讀

  1. 通過本節, 希望大家能夠理解 RDD 的一般使用步驟

// 1. 創建 SparkContext
val conf = new SparkConf().setMaster("local[6]").setAppName("stage_practice1")
val sc = new SparkContext(conf)

// 2. 創建 RDD
val rdd1 = sc.textFile("dataset/BeijingPM20100101_20151231_noheader.csv")

// 3. 處理 RDD
val rdd2 = rdd1.map { item =>
  val fields = item.split(",")
  ((fields(1), fields(2)), fields(6))
}
val rdd3 = rdd2.filter { item => !item._2.equalsIgnoreCase("NA") }
val rdd4 = rdd3.map { item => (item._1, item._2.toInt) }
val rdd5 = rdd4.reduceByKey { (curr, agg) => curr + agg }
val rdd6 = rdd5.sortByKey(ascending = false)

// 4. 行動, 得到結果
println(rdd6.first())

通過上述代碼可以看到, 其實 RDD 的整體使用步驟如下

20190518105630

三、 RDD 的 Shuffle 和分區

目標

  1. RDD 的分區操作
  2. Shuffle 的原理

分區的作用

RDD 使用分區來分佈式並行處理數據, 並且要做到儘量少的在不同的 Executor 之間使用網絡交換數據, 所以當使用 RDD 讀取數據的時候, 會盡量的在物理上靠近數據源, 比如說在讀取 Cassandra 或者 HDFS 中數據的時候, 會盡量的保持 RDD 的分區和數據源的分區數, 分區模式等一 一對應

分區和 Shuffle 的關係

分區的主要作用是用來實現並行計算, 本質上和 Shuffle 沒什麼關係, 但是往往在進行數據處理的時候, 例如 reduceByKeygroupByKey 等聚合操作, 需要把 Key 相同的 Value 拉取到一起進行計算, 這個時候因爲這些 Key 相同的 Value 可能會坐落於不同的分區, 於是理解分區才能理解 Shuffle 的根本原理

Spark 中的 Shuffle 操作的特點

只有 Key-Value 型的 RDD 纔會有 Shuffle 操作, 例如 RDD[(K, V)], 但是有一個特例, 就是 repartition 算子可以對任何數據類型 Shuffle

  • 早期版本 Spark 的 Shuffle 算法是 Hash base shuffle, 後來改爲 Sort base shuffle, 更適合大吞吐量的場景

3.1. RDD 的分區操作

查看分區數

scala> sc.parallelize(1 to 100).count
res0: Long = 100

873af6194db362a1ab5432372aa8bd21

之所以會有 8 個 Tasks, 是因爲在啓動的時候指定的命令是 spark-shell --master local[8], 這樣會生成 1 個 Executors, 這個 Executors 有 8 個 Cores, 所以默認會有 8 個 Tasks, 每個 Cores 對應一個分區, 每個分區對應一個 Tasks, 可以通過 rdd.partitions.size 來查看分區數量

a41901e5af14f37c88b3f1ea9b97fbfb

同時也可以通過 spark-shell 的 WebUI 來查看 Executors 的情況

24b2646308923d7549a7758f7550e0a8

默認的分區數量是和 Cores 的數量有關的, 也可以通過如下三種方式修改或者重新指定分區數量

創建 RDD 時指定分區數

scala> val rdd1 = sc.parallelize(1 to 100, 6)
rdd1: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[1] at parallelize at <console>:24

scala> rdd1.partitions.size
res1: Int = 6

scala> val rdd2 = sc.textFile("hdfs:///dataset/wordcount.txt", 6)
rdd2: org.apache.spark.rdd.RDD[String] = hdfs:///dataset/wordcount.txt MapPartitionsRDD[3] at textFile at <console>:24

scala> rdd2.partitions.size
res2: Int = 7

rdd1 是通過本地集合創建的, 創建的時候通過第二個參數指定了分區數量. rdd2 是通過讀取 HDFS 中文件創建的, 同樣通過第二個參數指定了分區數, 因爲是從 HDFS 中讀取文件, 所以最終的分區數是由 Hadoop 的 InputFormat 來指定的, 所以比指定的分區數大了一個.

通過 coalesce 算子指定

coalesce(numPartitions: Int, shuffle: Boolean = false)(implicit ord: Ordering[T] = null): RDD[T]

numPartitions

  • 新生成的 RDD 的分區數

shuffle

  • 是否 Shuffle
scala> val source = sc.parallelize(1 to 100, 6)
source: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24

scala> source.partitions.size
res0: Int = 6

scala> val noShuffleRdd = source.coalesce(numPartitions=8, shuffle=false)
noShuffleRdd: org.apache.spark.rdd.RDD[Int] = CoalescedRDD[1] at coalesce at <console>:26

scala> noShuffleRdd.toDebugString 
res1: String =
(6) CoalescedRDD[1] at coalesce at <console>:26 []
 |  ParallelCollectionRDD[0] at parallelize at <console>:24 []

 scala> val noShuffleRdd = source.coalesce(numPartitions=8, shuffle=false)
 noShuffleRdd: org.apache.spark.rdd.RDD[Int] = CoalescedRDD[1] at coalesce at <console>:26

scala> shuffleRdd.toDebugString 
res3: String =
(8) MapPartitionsRDD[5] at coalesce at <console>:26 []
 |  CoalescedRDD[4] at coalesce at <console>:26 []
 |  ShuffledRDD[3] at coalesce at <console>:26 []
 +-(6) MapPartitionsRDD[2] at coalesce at <console>:26 []
    |  ParallelCollectionRDD[0] at parallelize at <console>:24 []

scala> noShuffleRdd.partitions.size     
res4: Int = 6

scala> shuffleRdd.partitions.size
res5: Int = 8
如果 shuffle 參數指定爲 false, 運行計劃中確實沒有 ShuffledRDD, 沒有 shuffled 這個過程
* 如果 shuffle 參數指定爲 true, 運行計劃中有一個 ShuffledRDD, 有一個明確的顯式的 shuffled 過程
* 如果 shuffle 參數指定爲 false 卻增加了分區數, 分區數並不會發生改變, 這是因爲增加分區是一個寬依賴, 沒有 shuffled 過程無法做到, 後續會詳細解釋寬依賴的概念

 通過 repartition 算子指定 

repartition(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T]

repartition 算子本質上就是 coalesce(numPartitions, shuffle = true)

45d7a2b6e9e2727504e9cf28adbe6c49

scala> val source = sc.parallelize(1 to 100, 6)
source: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[7] at parallelize at <console>:24

scala> source.partitions.size
res7: Int = 6

scala> source.repartition(100).partitions.size 
res8: Int = 100

scala> source.repartition(1).partitions.size 
res9: Int = 1

repartition 算子無論是增加還是減少分區都是有效的, 因爲本質上 repartition 會通過 shuffle 操作把數據分發給新的 RDD 的不同的分區, 只有 shuffle 操作纔可能做到增大分區數, 默認情況下, 分區函數是 RoundRobin(輪詢), 如果希望改變分區函數, 也就是數據分佈的方式, 可以通過自定義分區函數來實現

b1181258789202436ca6d2d92e604d59

3.2. RDD 的 Shuffle 是什麼

val sourceRdd = sc.textFile("hdfs://node01:9020/dataset/wordcount.txt")
val flattenCountRdd = sourceRdd.flatMap(_.split(" ")).map((_, 1))
val aggCountRdd = flattenCountRdd.reduceByKey(_ + _)
val result = aggCountRdd.collect

23377ac4a368fc94b6f8f3117af67154

10b536c17409ec37fa1f1b308b2b521e

reduceByKey 這個算子本質上就是先按照 Key 分組, 後對每一組數據進行 reduce, 所面臨的挑戰就是 Key 相同的所有數據可能分佈在不同的 Partition 分區中, 甚至可能在不同的節點中, 但是它們必須被共同計算.

爲了讓來自相同 Key 的所有數據都在 reduceByKey 的同一個 reduce 中處理, 需要執行一個 all-to-all 的操作, 需要在不同的節點(不同的分區)之間拷貝數據, 必須跨分區聚集相同 Key 的所有數據, 這個過程叫做 Shuffle.

3.3. RDD 的 Shuffle 原理(簡單介紹)

Spark 的 Shuffle 發展大致有兩個階段: Hash base shuffle 和 Sort base shuffle(具體來說分爲 三個階段)

Hash base shuffle

2daf43cc1750fffab62ae5e16fab54c2

大致的原理是分桶, 假設 Reducer 的個數爲 R, 那麼每個 Mapper 有 R 個桶, 按照 Key 的 Hash 將數據映射到不同的桶中, Reduce 找到每一個 Mapper 中對應自己的桶拉取數據.

假設 Mapper 的個數爲 M, 整個集羣的文件數量是 M * R, 如果有 1,000 個 Mapper 和 Reducer, 則會生成 1,000,000 個文件, 這個量非常大了.

過多的文件會導致文件系統打開過多的文件描述符, 佔用系統資源. 所以這種方式並不適合大規模數據的處理, 只適合中等規模和小規模的數據處理, 在 Spark 1.2 版本中廢棄了這種方式.

Sort base shuffle

94f038994f8553dd32370ae78878d038

對於 Sort base shuffle 來說, 每個 Map 側的分區只有一個輸出文件, Reduce 側的 Task 來拉取, 大致流程如下

  1. Map 側將數據全部放入一個叫做 AppendOnlyMap 的組件中, 同時可以在這個特殊的數據結構中做聚合操作

  2. 然後通過一個類似於 MergeSort 的排序算法 TimSort 對 AppendOnlyMap 底層的 Array 排序先按照 Partition ID 排序, 後按照 Key 的 HashCode 排序

  3. 最終每個 Map Task 生成一個 輸出文件, Reduce Task 來拉取自己對應的數據

從上面可以得到結論, Sort base shuffle 確實可以大幅度減少所產生的中間文件, 從而能夠更好的應對大吞吐量的場景, 在 Spark 1.2 以後, 已經默認採用這種方式.


四、 緩存

概要

  1. 緩存的意義

  2. 緩存相關的 API

  3. 緩存級別以及最佳實踐

4.1. 緩存的意義

使用緩存的原因 - 多次使用 RDD

需求: 在日誌文件中找到訪問次數最少的 IP 和訪問次數最多的 IP

val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
  .map(item => (item.split(" ")(0), 1))
  .filter(item => StringUtils.isNotBlank(item._1))
  .reduceByKey((curr, agg) => curr + agg) 

val resultLess = interimRDD.sortBy(item => item._2, ascending = true).first()
val resultMore = interimRDD.sortBy(item => item._2, ascending = false).first()

println(s"出現次數最少的 IP : $resultLess, 出現次數最多的 IP : $resultMore")

sc.stop()

 

這是一個 Shuffle 操作, Shuffle 操作會在集羣內進行數據拷貝 

在上述代碼中, 多次使用到了 interimRDD, 導致文件讀取兩次, 計算兩次, 有沒有什麼辦法增進上述代碼的性能?

 使用緩存的原因 - 容錯

20190511163654

當在計算 RDD3 的時候如果出錯了, 會怎麼進行容錯?

會再次計算 RDD1 和 RDD2 的整個鏈條, 假設 RDD1 和 RDD2 是通過比較昂貴的操作得來的, 有沒有什麼辦法減少這種開銷?

上述兩個問題的解決方案其實都是 緩存, 除此之外, 使用緩存的理由還有很多, 但是總結一句, 就是緩存能夠幫助開發者在進行一些昂貴操作後, 將其結果保存下來, 以便下次使用無需再次執行, 緩存能夠顯著的提升性能.

所以, 緩存適合在一個 RDD 需要重複多次利用, 並且還不是特別大的情況下使用, 例如迭代計算等場景.

4.2. 緩存相關的 API

可以使用 cache 方法進行緩存

val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
  .map(item => (item.split(" ")(0), 1))
  .filter(item => StringUtils.isNotBlank(item._1))
  .reduceByKey((curr, agg) => curr + agg)
  .cache() 

val resultLess = interimRDD.sortBy(item => item._2, ascending = true).first()
val resultMore = interimRDD.sortBy(item => item._2, ascending = false).first()

println(s"出現次數最少的 IP : $resultLess, 出現次數最多的 IP : $resultMore")

sc.stop()

 

方法簽名如下

cache(): this.type = persist()

cache 方法其實是 persist 方法的一個別名

 也可以使用 persist 方法進行緩存

val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
  .map(item => (item.split(" ")(0), 1))
  .filter(item => StringUtils.isNotBlank(item._1))
  .reduceByKey((curr, agg) => curr + agg)
  .persist(StorageLevel.MEMORY_ONLY) 

val resultLess = interimRDD.sortBy(item => item._2, ascending = true).first()
val resultMore = interimRDD.sortBy(item => item._2, ascending = false).first()

println(s"出現次數最少的 IP : $resultLess, 出現次數最多的 IP : $resultMore")

sc.stop()

 

方法簽名如下

persist(): this.type
persist(newLevel: StorageLevel): this.type

persist 方法其實有兩種形式, persist() 是 persist(newLevel: StorageLevel) 的一個別名, persist(newLevel: StorageLevel) 能夠指定緩存的級別

20190511164532

緩存其實是一種空間換時間的做法, 會佔用額外的存儲資源, 如何清理?

根據緩存級別的不同, 緩存存儲的位置也不同, 但是使用 unpersist 可以指定刪除 RDD 對應的緩存信息, 並指定緩存級別爲 NONE

4.3. 緩存級別

其實如何緩存是一個技術活, 有很多細節需要思考, 如下

  • 是否使用磁盤緩存?

  • 是否使用內存緩存?

  • 是否使用堆外內存?

  • 緩存前是否先序列化?

  • 是否需要有副本?

如果要回答這些信息的話, 可以先查看一下 RDD 的緩存級別對象

val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
  .map(item => (item.split(" ")(0), 1))
  .filter(item => StringUtils.isNotBlank(item._1))
  .reduceByKey((curr, agg) => curr + agg)
  .persist()

println(interimRDD.getStorageLevel)

sc.stop()

打印出來的對象是 StorageLevel, 其中有如下幾個構造參數

20190511170124

根據這幾個參數的不同, StorageLevel 有如下幾個枚舉對象

20190511170338

緩存級別 userDisk 是否使用磁盤 useMemory 是否使用內存 useOffHeap 是否使用堆外內存 deserialized 是否以反序列化形式存儲 replication 副本數

NONE

false

false

false

false

1

DISK_ONLY

true

false

false

false

1

DISK_ONLY_2

true

false

false

false

2

MEMORY_ONLY

false

true

false

true

1

MEMORY_ONLY_2

false

true

false

true

2

MEMORY_ONLY_SER

false

true

false

false

1

MEMORY_ONLY_SER_2

false

true

false

false

2

MEMORY_AND_DISK

true

true

false

true

1

MEMORY_AND_DISK

true

true

false

true

2

MEMORY_AND_DISK_SER

true

true

false

false

1

MEMORY_AND_DISK_SER_2

true

true

false

false

2

OFF_HEAP

true

true

true

false

1

如何選擇分區級別

Spark 的存儲級別的選擇,核心問題是在 memory 內存使用率和 CPU 效率之間進行權衡。建議按下面的過程進行存儲級別的選擇:

如果您的 RDD 適合於默認存儲級別(MEMORY_ONLY)。這是 CPU 效率最高的選項,允許 RDD 上的操作儘可能快地運行.

如果不是,試着使用 MEMORY_ONLY_SER 和 selecting a fast serialization library 以使對象更加節省空間,但仍然能夠快速訪問。(Java和Scala)

不要溢出到磁盤,除非計算您的數據集的函數是昂貴的,或者它們過濾大量的數據。否則,重新計算分區可能與從磁盤讀取分區一樣快.

如果需要快速故障恢復,請使用複製的存儲級別(例如,如果使用 Spark 來服務 來自網絡應用程序的請求)。All 存儲級別通過重新計算丟失的數據來提供完整的容錯能力,但複製的數據可讓您繼續在 RDD 上運行任務,而無需等待重新計算一個丟失的分區.


五、 Checkpoint

目標

  1. Checkpoint 的作用

  2. Checkpoint 的使用

5.1. Checkpoint 的作用

Checkpoint 的主要作用是斬斷 RDD 的依賴鏈, 並且將數據存儲在可靠的存儲引擎中, 例如支持分佈式存儲和副本機制的 HDFS.

Checkpoint 的方式

  • 可靠的 將數據存儲在可靠的存儲引擎中, 例如 HDFS

  • 本地的 將數據存儲在本地

什麼是斬斷依賴鏈

斬斷依賴鏈是一個非常重要的操作, 接下來以 HDFS 的 NameNode 的原理來舉例說明

HDFS 的 NameNode 中主要職責就是維護兩個文件, 一個叫做 edits, 另外一個叫做 fsimageedits 中主要存放 EditLogFsImage 保存了當前系統中所有目錄和文件的信息. 這個 FsImage 其實就是一個 Checkpoint.

HDFS 的 NameNode 維護這兩個文件的主要過程是, 首先, 會由 fsimage 文件記錄當前系統某個時間點的完整數據, 自此之後的數據並不是時刻寫入 fsimage, 而是將操作記錄存儲在 edits 文件中. 其次, 在一定的觸發條件下, edits 會將自身合併進入 fsimage. 最後生成新的 fsimage 文件, edits 重置, 從新記錄這次 fsimage 以後的操作日誌.

如果不合並 edits 進入 fsimage 會怎樣? 會導致 edits 中記錄的日誌過長, 容易出錯.

所以當 Spark 的一個 Job 執行流程過長的時候, 也需要這樣的一個斬斷依賴鏈的過程, 使得接下來的計算輕裝上陣.

Checkpoint 和 Cache 的區別

Cache 可以把 RDD 計算出來然後放在內存中, 但是 RDD 的依賴鏈(相當於 NameNode 中的 Edits 日誌)是不能丟掉的, 因爲這種緩存是不可靠的, 如果出現了一些錯誤(例如 Executor 宕機), 這個 RDD 的容錯就只能通過回溯依賴鏈, 重放計算出來.

但是 Checkpoint 把結果保存在 HDFS 這類存儲中, 就是可靠的了, 所以可以斬斷依賴, 如果出錯了, 則通過複製 HDFS 中的文件來實現容錯.

區別主要在以下三點

  • Checkpoint 可以保存數據到 HDFS 這類可靠的存儲上, Persist 和 Cache 只能保存在本地的磁盤和內存中

  • Checkpoint 可以斬斷 RDD 的依賴鏈, 而 Persist 和 Cache 不行

  • 因爲 CheckpointRDD 沒有向上的依賴鏈, 所以程序結束後依然存在, 不會被刪除. 而 Cache 和 Persist 會在程序結束後立刻被清除.

5.2. 使用 Checkpoint

val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)
sc.setCheckpointDir("checkpoint") 

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
  .map(item => (item.split(" ")(0), 1))
  .filter(item => StringUtils.isNotBlank(item._1))
  .reduceByKey((curr, agg) => curr + agg)

interimRDD.checkpoint() 

interimRDD.collect().foreach(println(_))

sc.stop()
  • 在使用 Checkpoint 之前需要先設置 Checkpoint 的存儲路徑, 而且如果任務在集羣中運行的話, 這個路徑必須是 HDFS 上的路徑 
 

一個小細節

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
  .map(item => (item.split(" ")(0), 1))
  .filter(item => StringUtils.isNotBlank(item._1))
  .reduceByKey((curr, agg) => curr + agg)
  .cache() 

interimRDD.checkpoint()
interimRDD.collect().foreach(println(_))
  • checkpoint 之前先 cache 一下, 準沒錯

 

注意:

應該在 checkpoint 之前先 cache 一下, 因爲 checkpoint 會重新計算整個 RDD 的數據然後再存入 HDFS 等地方.

所以上述代碼中如果 checkpoint 之前沒有 cache, 則整個流程會被計算兩次, 一次是 checkpoint, 另外一次是 collect

 

六、Spark 底層邏輯

案例

因爲要理解執行計劃, 重點不在案例, 所以本節以一個非常簡單的案例作爲入門, 就是我們第一個案例 WordCount

val sc = ...

val textRDD = sc.parallelize(Seq("Hadoop Spark", "Hadoop Flume", "Spark Sqoop"))
val splitRDD = textRDD.flatMap(_.split(" "))
val tupleRDD = splitRDD.map((_, 1))
val reduceRDD = tupleRDD.reduceByKey(_ + _)
val strRDD = reduceRDD.map(item => s"${item._1}, ${item._2}")

println(strRDD.toDebugString)
strRDD.collect.foreach(item => println(item))

整個案例的運行過程大致如下:

  1. 通過代碼的運行, 生成對應的 RDD 邏輯執行圖

  2. 通過 Action 操作, 根據邏輯執行圖生成對應的物理執行圖, 也就是 Stage 和 Task

  3. 將物理執行圖運行在集羣中

邏輯執行圖

對於上面代碼中的 reduceRDD 如果使用 toDebugString 打印調試信息的話, 會顯式如下內容

(6) MapPartitionsRDD[4] at map at WordCount.scala:20 []
 |  ShuffledRDD[3] at reduceByKey at WordCount.scala:19 []
 +-(6) MapPartitionsRDD[2] at map at WordCount.scala:18 []
    |  MapPartitionsRDD[1] at flatMap at WordCount.scala:17 []
    |  ParallelCollectionRDD[0] at parallelize at WordCount.scala:16 []

根據這段內容, 大致能得到這樣的一張邏輯執行圖

20190515002803

其實 RDD 並沒有什麼嚴格的邏輯執行圖和物理執行圖的概念, 這裏也只是借用這個概念, 從而讓整個 RDD 的原理可以解釋, 好理解.

對於 RDD 的邏輯執行圖, 起始於第一個入口 RDD 的創建, 結束於 Action 算子執行之前, 主要的過程就是生成一組互相有依賴關係的 RDD, 其並不會真的執行, 只是表示 RDD 之間的關係, 數據的流轉過程.

物理執行圖

當觸發 Action 執行的時候, 這一組互相依賴的 RDD 要被處理, 所以要轉化爲可運行的物理執行圖, 調度到集羣中執行.

因爲大部分 RDD 是不真正存放數據的, 只是數據從中流轉, 所以, 不能直接在集羣中運行 RDD, 要有一種 Pipeline 的思想, 需要將這組 RDD 轉爲 Stage 和 Task, 從而運行 Task, 優化整體執行速度.

以上的邏輯執行圖會生成如下的物理執行圖, 這一切發生在 Action 操作被執行時.

20190515235205

從上圖可以總結如下幾個點

  • 20190515235442 在第一個 Stage 中, 每一個這樣的執行流程是一個 Task, 也就是在同一個 Stage 中的所有 RDD 的對應分區, 在同一個 Task 中執行

  • Stage 的劃分是由 Shuffle 操作來確定的, 有 Shuffle 的地方, Stage 斷開

6.1. 邏輯執行圖生成

6.1.1. RDD 的生成

重點內容

本章要回答如下三個問題

  • 如何生成 RDD

  • 生成什麼 RDD

  • 如何計算 RDD 中的數據

val sc = ...

val textRDD = sc.parallelize(Seq("Hadoop Spark", "Hadoop Flume", "Spark Sqoop"))
val splitRDD = textRDD.flatMap(_.split(" "))
val tupleRDD = splitRDD.map((_, 1))
val reduceRDD = tupleRDD.reduceByKey(_ + _)
val strRDD = reduceRDD.map(item => s"${item._1}, ${item._2}")

println(strRDD.toDebugString)
strRDD.collect.foreach(item => println(item))

明確邏輯計劃的邊界

在 Action 調用之前, 會生成一系列的 RDD, 這些 RDD 之間的關係, 其實就是整個邏輯計劃

例如上述代碼, 如果生成邏輯計劃的, 會生成如下一些 RDD, 這些 RDD 是相互關聯的, 這些 RDD 之間, 其實本質上生成的就是一個 計算鏈

20190519000019

接下來, 採用迭代漸進式的方式, 一步一步的查看一下整體上的生成過程

textFile 算子的背後

研究 RDD 的功能或者表現的時候, 其實本質上研究的就是 RDD 中的五大屬性, 因爲 RDD 透過五大屬性來提供功能和表現, 所以如果要研究 textFile 這個算子, 應該從五大屬性着手, 那麼第一步就要看看生成的 RDD 是什麼類型的 RDD

  1. textFile 生成的是 HadoopRDD

  2. HadoopRDD 的 Partitions 對應了 HDFS 的 Blocks

    20190519203211

    其實本質上每個 HadoopRDD 的 Partition 都是對應了一個 Hadoop 的 Block, 通過 InputFormat 來確定 Hadoop 中的 Block 的位置和邊界, 從而可以供一些算子使用

  3. HadoopRDD 的 compute 函數就是在讀取 HDFS 中的 Block

    本質上, compute 還是依然使用 InputFormat 來讀取 HDFS 中對應分區的 Block

  4. textFile 這個算子生成的其實是一個 MapPartitionsRDD

    textFile 這個算子的作用是讀取 HDFS 上的文件, 但是 HadoopRDD 中存放是一個元組, 其 Key 是行號, 其 Value 是 Hadoop 中定義的 Text 對象, 這一點和 MapReduce 程序中的行爲是一致的

    但是並不適合 Spark 的場景, 所以最終會通過一個 map 算子, 將 (LineNum, Text) 轉爲 String 形式的一行一行的數據, 所以最終 textFile 這個算子生成的 RDD 並不是 HadoopRDD, 而是一個 MapPartitionsRDD

map 算子的背後

  • map 算子生成了 MapPartitionsRDD

    由源碼可知, 當 val rdd2 = rdd1.map() 的時候, 其實生成的新 RDD 是 rdd2rdd2 的類型是 MapPartitionsRDD, 每個 RDD 中的五大屬性都會有一些不同, 由 map 算子生成的 RDD 中的計算函數, 本質上就是遍歷對應分區的數據, 將每一個數據轉成另外的形式

  • MapPartitionsRDD 的計算函數是 collection.map( function )

    真正運行的集羣中的處理單元是 Task, 每個 Task 對應一個 RDD 的分區, 所以 collection 對應一個 RDD 分區的所有數據, 而這個計算的含義就是將一個 RDD 的分區上所有數據當作一個集合, 通過這個 Scala 集合的 map 算子, 來執行一個轉換操作, 其轉換操作的函數就是傳入 map 算子的 function

  • 傳入 map 算子的函數會被清理

    這個清理主要是處理閉包中的依賴, 使得這個閉包可以被序列化發往不同的集羣節點運行 (閉包:一個函數把外部的那些不屬於自己的對象也包含進來)

flatMap 算子的背後

flatMap 和 map 算子其實本質上是一樣的, 其步驟和生成的 RDD 都是一樣, 只是對於傳入函數的處理不同, map 是 collect.map( function ) 而 flatMap 是 collect.flatMap( function )

從側面印證了, 其實 Spark 中的 flatMap 和 Scala 基礎中的 flatMap 其實是一樣的

textRDD → splitRDD → tupleRDD

由 textRDD 到 splitRDD 再到 tupleRDD 的過程, 其實就是調用 map 和 flatMap 算子生成新的 RDD 的過程, 所以如下圖所示, 就是這個階段所生成的邏輯計劃

20190519211533

總結

如何生成 RDD ?

生成 RDD 的常見方式有三種

  • 從本地集合創建

  • 從外部數據集創建

  • 從其它 RDD 衍生

通過外部數據集創建 RDD, 是通過 Hadoop 或者其它外部數據源的 SDK 來進行數據讀取, 同時如果外部數據源是有分片的話, RDD 會將分區與其分片進行對照

通過其它 RDD 衍生的話, 其實本質上就是通過不同的算子生成不同的 RDD 的子類對象, 從而控制 compute 函數的行爲來實現算子功能

生成哪些 RDD ?

不同的算子生成不同的 RDD, 生成 RDD 的類型取決於算子, 例如 map 和 flatMap 都會生成 RDD 的子類 MapPartitions 的對象

如何計算 RDD 中的數據 ?

雖然前面我們提到過 RDD 是偏向計算的, 但是其實 RDD 還只是表示數據, 縱觀 RDD 的五大屬性中有三個是必須的, 分別如下

  • Partitions List 分區列表

  • Compute function 計算函數

  • Dependencies 依賴

雖然計算函數是和計算有關的, 但是隻有調用了這個函數纔會進行計算, RDD 顯然不會自己調用自己的 Compute 函數, 一定是由外部調用的, 所以 RDD 更多的意義是用於表示數據集以及其來源, 和針對於數據的計算

所以如何計算 RDD 中的數據呢? 一定是通過其它的組件來計算的, 而計算的規則, 由 RDD 中的 Compute 函數來指定, 不同類型的 RDD 子類有不同的 Compute 函數

6.1.2. RDD 之間的依賴關係

導讀

  1. 討論什麼是 RDD 之間的依賴關係

  2. 繼而討論 RDD 分區之間的關係

  3. 最後確定 RDD 之間的依賴關係分類

  4. 完善案例的邏輯關係圖

什麼是 RDD 之間的依賴關係?

20190519211533

  • 什麼是關係(依賴關係) ?

    從算子視角上來看, splitRDD 通過 map 算子得到了 tupleRDD, 所以 splitRDD 和 tupleRDD 之間的關係是 map

    但是 RDD 這個概念本身並不是數據容器, 數據真正應該存放的地方是 RDD 的分區, 所以如果把視角放在數據這一層面上的話, 直接講這兩個 RDD 之間有關係是不科學的, 應該從這兩個 RDD 的分區之間的關係來討論它們之間的關係

  • 那這些分區之間是什麼關係?

    如果僅僅說 splitRDD 和 tupleRDD 之間的話, 那它們的分區之間就是一對一的關係

    但是 tupleRDD 到 reduceRDD 呢? tupleRDD 通過算子 reduceByKey 生成 reduceRDD, 而這個算子是一個 Shuffle 操作, Shuffle操作的兩個 RDD 的分區之間並不是一對一, reduceByKey 的一個分區對應 tupleRDD 的多個分區

reduceByKey 算子會生成 ShuffledRDD

reduceByKey 是由算子 combineByKey 來實現的, combineByKey 內部會創建 ShuffledRDD 返回, 具體的代碼請大家通過 IDEA 來進行查看, 此處不再截圖, 而整個 reduceByKey 操作大致如下過程

20190520010402

去掉兩個 reducer 端的分區, 只留下一個的話, 如下

20190520010518

所以, 對於 reduceByKey 這個 Shuffle 操作來說, reducer 端的一個分區, 會從多個 mapper 端的分區拿取數據, 是一個多對一的關係

至此爲止, 出現了兩種分區間的關係了, 一種是一對一, 一種是多對一

整體上的流程圖

20190520011115

6.1.3. RDD 之間的依賴關係詳解

導讀

上個小節通過例子演示了 RDD 的分區間的關係有兩種形式

  • 一對一, 一般是直接轉換

  • 多對一, 一般是 Shuffle

本小節會說明如下問題:

  1. 如果分區間得關係是一對一或者多對一, 那麼這種情況下的 RDD 之間的關係的正式命名是什麼呢?

  2. RDD 之間的依賴關係, 具體有幾種情況呢?

窄依賴

假如 rddB = rddA.transform(…​), 如果 rddB 中一個分區依賴 rddA 也就是其父 RDD 的少量分區, 這種 RDD 之間的依賴關係稱之爲窄依賴

換句話說, 子 RDD 的每個分區依賴父 RDD 的少量個數的分區, 這種依賴關係稱之爲窄依賴

舉個栗子

val sc = ...

val rddA = sc.parallelize(Seq(1, 2, 3))
val rddB = sc.parallelize(Seq("a", "b"))

/**
  * 運行結果: (1,a), (1,b), (2,a), (2,b), (3,a), (3,b)
  */
rddA.cartesian(rddB).collect().foreach(println(_))
  • 上述代碼的 cartesian 是求得兩個集合的笛卡爾積

  • 上述代碼的運行結果是 rddA 中每個元素和 rddB 中的所有元素結合, 最終的結果數量是兩個 RDD 數量之和

  • rddC 有兩個父 RDD, 分別爲 rddA 和 rddB

對於 cartesian 來說, 依賴關係如下

20190520144103

它們之間是窄依賴, 事實上在 cartesian 中也是 NarrowDependency 這個所有窄依賴的父類的唯一一次直接使用, 爲什麼呢?

因爲所有的分區之間是拷貝關係, 並不是 Shuffle 關係

  • rddC 中的每個分區並不是依賴多個父 RDD 中的多個分區

  • rddC 中每個分區的數量來自一個父 RDD 分區中的所有數據, 是一個 FullDependence, 所以數據可以直接從父 RDD 流動到子 RDD

  • 不存在一個父 RDD 中一部分數據分發過去, 另一部分分發給其它的 RDD

寬依賴

並沒有所謂的寬依賴, 寬依賴應該稱作爲 ShuffleDependency

 

在 ShuffleDependency 的類聲明上如下寫到

Represents a dependency on the output of a shuffle stage.

上面非常清楚的說道, 寬依賴就是 Shuffle 中的依賴關係, 換句話說, 只有 Shuffle 產生的地方纔是寬依賴

那麼寬窄依賴的判斷依據就非常簡單明確了, 是否有 Shuffle 

舉個 reduceByKey 的例子, rddB = rddA.reduceByKey( (curr, agg) ⇒ curr + agg ) 會產生如下的依賴關係

20190520151040

  • rddB 的每個分區都幾乎依賴 rddA 的所有分區

  • 對於 rddA 中的一個分區來說, 其將一部分分發給 rddB 的 p1, 另外一部分分發給 rddB 的 p2, 這不是數據流動, 而是分發

如何分辨寬窄依賴 ?

其實分辨寬窄依賴的本身就是在分辨父子 RDD 之間是否有 Shuffle, 大致有以下的方法

  • 如果是 Shuffle, 兩個 RDD 的分區之間不是單純的數據流動, 而是分發和複製

  • 一般 Shuffle 的子 RDD 的每個分區會依賴父 RDD 的多個分區

但是這樣判斷其實不準確, 如果想分辨某個算子是否是窄依賴, 或者是否是寬依賴, 則還是要取決於具體的算子,看源碼就行。

總結

  • RDD 的邏輯圖本質上是對於計算過程的表達, 例如數據從哪來, 經歷了哪些步驟的計算

  • 每一個步驟都對應一個 RDD, 因爲數據處理的情況不同, RDD 之間的依賴關係又分爲窄依賴和寬依賴 

6.1.4. 常見的窄依賴類型

一對一窄依賴

其實 RDD 中默認的是 OneToOneDependency, 後被不同的 RDD 子類指定爲其它的依賴類型, 常見的一對一依賴是 map 算子所產生的依賴, 例如 rddB = rddA.map(…​)

20190520160405

  • 每個分區之間一 一對應, 所以叫做一對一窄依賴

Range 窄依賴

Range 窄依賴其實也是一對一窄依賴, 但是保留了中間的分隔信息, 可以通過某個分區獲取其父分區, 目前只有一個算子生成這種窄依賴, 就是 union 算子, 例如 rddC = rddA.union(rddB)

20190520161043

  • rddC 其實就是 rddA 拼接 rddB 生成的, 所以 rddC 的 p5 和 p6 就是 rddB 的 p1 和 p2

  • 所以需要有方式獲取到 rddC 的 p5 其父分區是誰, 於是就需要記錄一下邊界, 其它部分和一對一窄依賴一樣

多對一窄依賴

多對一窄依賴其圖形和 Shuffle 依賴非常相似, 所以在遇到的時候, 要注意其 RDD 之間是否有 Shuffle 過程, 比較容易讓人困惑, 常見的多對一依賴就是重分區算子 coalesce, 例如 rddB = rddA.coalesce(2, shuffle = false), 但同時也要注意, 如果 shuffle = true 那就是完全不同的情況了

20190520161621

  • 因爲沒有 Shuffle, 所以這是一個窄依賴

再談寬窄依賴的區別

寬窄依賴的區別非常重要, 因爲涉及了一件非常重要的事情: 如何計算 RDD ?

寬窄以來的核心區別是: 窄依賴的 RDD 可以放在一個 Task 中運行

6.2. 物理執行圖生成

誰來計算 RDD ?

問題一: RDD 是什麼, 用來做什麼 ?

回顧一下 RDD 的五個屬性

  • A list of partitions

  • A function for computing each split

  • A list of dependencies on other RDDs

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

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

簡單的說就是: 分區列表, 計算函數, 依賴關係, 分區函數, 最佳位置

  • 分區列表, 分區函數, 最佳位置, 這三個屬性其實說的就是數據集在哪, 在哪更合適, 如何分區

  • 計算函數和依賴關係, 這兩個屬性其實說的是數據集從哪來

所以結論是 RDD 是一個數據集的表示, 不僅表示了數據集, 還表示了這個數據集從哪來, 如何計算

問題二: 誰來計算 ?

前面我們明確了兩件事, RDD 在哪被計算? 在 Executor 中. RDD 是什麼? 是一個數據集以及其如何計算的圖紙.

直接使用 Executor 也是不合適的, 因爲一個計算的執行總是需要一個容器, 例如 JVM 是一個進程, 只有進程中才能有線程, 所以這個計算 RDD 的線程應該運行在一個進程中, 這個進程就是 ExeutorExecutor 有如下兩個職責

  • 和 Driver 保持交互從而認領屬於自己的任務

    20190521111630

  • 接受任務後, 運行任務

    20190521111456

所以, 應該由一個線程來執行 RDD 的計算任務, 而 Executor 作爲執行這個任務的容器, 也就是一個進程, 用於創建和執行線程, 這個執行具體計算任務的線程叫做 Task

問題三: Task 該如何設計 ?

第一個想法是每個 RDD 都由一個 Task 來計算 第二個想法是一整個邏輯執行圖中所有的 RDD 都由一組 Task 來執行 第三個想法是分階段執行

第一個想法: 爲每個 RDD 的分區設置一組 Task

20190521113535

 

第二個想法: 讓數據流動

很自然的, 第一個想法的問題是數據需要存儲和交換, 那不存儲不就好了嗎? 對, 可以讓數據流動起來

第一個要解決的問題就是, 要爲數據創建管道(Pipeline), 有了管道, 就可以流動

20190521114511

簡單來說, 就是爲所有的 RDD 有關聯的分區使用同一個 Task, 但是就沒問題了嗎? 請關注紅框部分

20190521114717

這兩個 RDD 之間是 Shuffle 關係, 也就是說, 右邊的 RDD 的一個分區可能依賴左邊 RDD 的所有分區, 這樣的話, 數據在這個地方流不動了, 怎麼辦?

第三個想法: 劃分階段

既然在 Shuffle 處數據流不動了, 那就可以在這個地方中斷一下, 後面 Stage 部分詳解

如何劃分階段 ?

爲了減少執行任務, 減少數據暫存和交換的機會, 所以需要創建管道, 讓數據沿着管道流動, 其實也就是原先每個 RDD 都有一組 Task, 現在改爲所有的 RDD 共用一組 Task, 但是也有問題, 問題如下

20190521114717

就是說, 在 Shuffle 處, 必須斷開管道, 進行數據交換, 交換過後, 繼續流動, 所以整個流程可以變爲如下樣子

20190521115759

把 Task 斷開成兩個部分, Task4 可以從 Task 1, 2, 3 中獲取數據, 後 Task4 又作爲管道, 繼續讓數據在其中流動

但是還有一個問題, 說斷開就直接斷開嗎? 不用打個招呼的呀?  所以可以爲這個斷開增加一個概念叫做階段, 按照階段斷開, 階段的英文叫做 Stage, 如下

20190521120501

所以劃分階段的本身就是設置斷開點的規則, 那麼該如何劃分階段呢?

  1. 第一步, 從最後一個 RDD, 也就是邏輯圖中最右邊的 RDD 開始, 向前滑動 Stage 的範圍, 爲 Stage0

  2. 第二步, 遇到 ShuffleDependency  斷開 Stage, 從下一個 RDD 開始創建新的 Stage, 爲 Stage1

  3. 第三步, 新的 Stage 按照同樣的規則繼續滑動, 直到包裹所有的 RDD

總結來看, 就是針對於寬窄依賴來判斷, 一個 Stage 中只有窄依賴, 因爲只有窄依賴才能形成數據的 Pipeline.

如果要進行 Shuffle 的話, 數據是流不過去的, 必須要拷貝和拉取. 所以遇到 RDD 寬依賴的兩個 RDD 時, 要切斷這兩個 RDD 的 Stage.

      這樣一個 RDD 依賴的鏈條, 我們稱之爲 RDD 的血統(lineage), 其中有寬依賴也有窄依賴

數據怎麼流動 ?

val sc = ...

val textRDD = sc.parallelize(Seq("Hadoop Spark", "Hadoop Flume", "Spark Sqoop"))
val splitRDD = textRDD.flatMap(_.split(" "))
val tupleRDD = splitRDD.map((_, 1))
val reduceRDD = tupleRDD.reduceByKey(_ + _)
val strRDD = reduceRDD.map(item => s"${item._1}, ${item._2}")

strRDD.collect.foreach(item => println(item))

上述代碼是我們一直使用的代碼流程, 如下是其完整的邏輯執行圖

20190521161456

如果放在集羣中運行, 通過 WebUI 可以查看到如下 DAG 結構

20190521161337

Step 1: 從 ResultStage 開始執行

最接近 Result 部分的 Stage id 爲 0, 這個 Stage 被稱之爲 ResultStage

由代碼可以知道, 最終調用 Action 促使整個流程執行的是最後一個 RDDstrRDD.collect, 所以當執行 RDD 的計算時候, 先計算的也是這個 RDD

Step 2: RDD 之間是有關聯的

前面已經知道, 最後一個 RDD 先得到執行機會, 先從這個 RDD 開始執行, 但是這個 RDD 中有數據嗎 ? 如果沒有數據, 它的計算是什麼? 它的計算是從父 RDD 中獲取數據, 並執行傳入的算子的函數

簡單來說, 從產生 Result 的地方開始計算, 但是其 RDD 中是沒數據的, 所以會找到父 RDD 來要數據, 父 RDD 也沒有數據, 繼續向上要, 所以, 計算從 Result 處調用, 但是從整個邏輯圖中的最左邊 RDD 開始, 類似一個遞歸的過程

20190521162302

這個過程就像 往  HDFS  上傳 數據一樣,建立 pinpline , 上傳 ,返回 ack。 且 這樣理解吧

6.3. 調度過程

導讀

  1. 生成邏輯圖和物理圖的系統組件

  2. Job 和 StageTask 之間的關係

  3. 如何調度 Job

邏輯圖

邏輯圖如何生成

一段 Scala 代碼的執行結果就是最後一行的執行結果,最後一個 RDD 也可以認爲就是邏輯執行圖, 爲什麼呢?

例如 rdd2 = rdd1.map(…​) 中, 其實本質上 rdd2 是一個類型爲 MapPartitionsRDD 的對象, 而創建這個對象的時候, 會通過構造函數傳入當前 RDD 對象, 也就是父 RDD, 也就是調用 map 算子的 rdd1rdd1 是 rdd2 的父 RDD

 

一個 RDD 依賴另外一個 RDD, 這個 RDD 又依賴另外的 RDD, 一個 RDD 可以通過 getDependency 獲得其父 RDD, 這種環環相扣的關係, 最終從最後一個 RDD 就可以推演出前面所有的 RDD

邏輯圖是什麼, 幹啥用

邏輯圖其實本質上描述的就是數據的計算過程, 數據從哪來, 經過什麼樣的計算, 得到什麼樣的結果, 再執行什麼計算, 得到什麼結果

可是數據的計算是描述好了, 這種計算該如何執行呢?

物理圖

數據的計算表示好了, 該正式執行了, 但是如何執行? 如何執行更快更好更酷? 就需要爲其執行做一個規劃, 所以需要生成物理執行圖

strRDD.collect.foreach(item => println(item))

上述代碼其實就是最後的一個 RDD 調用了 Action 方法, 調用 Action 方法的時候, 會請求一個叫做 DAGScheduler 的組件, DAGScheduler 會創建用於執行 RDD 的 Stage 和 Task

DAGScheduler 是一個由 SparkContext 創建, 運行在 Driver 上的組件, 其作用就是將由 RDD 構建出來的邏輯計劃, 構建成爲由真正在集羣中運行的 Task 組成的物理執行計劃, DAGScheduler 主要做如下三件事

  1. 幫助每個 Job 計算 DAG 併發給 TaskSheduler 調度

  2. 確定每個 Task 的最佳位置

  3. 跟蹤 RDD 的緩存狀態, 避免重新計算

從字面意思上來看, DAGScheduler 是調度 DAG 去運行的, DAG 被稱作爲有向無環圖, 其實可以將 DAG 理解爲就是 RDD 的邏輯圖, 其呈現兩個特點: RDD 的計算是有方向的, RDD 的計算是無環的, 所以 DAGScheduler 也可以稱之爲 RDD Scheduler, 但是真正運行在集羣中的並不是 RDD, 而是 Task 和 StageDAGScheduler 負責這種轉換

Job 是什麼 ?

Job 什麼時候生成 ?

當一個 RDD 調用了 Action 算子的時候, 在 Action 算子內部, 會使用 sc.runJob() 調用 SparkContext 中的 runJob 方法, 這個方法又會調用 DAGScheduler 中的 runJob, 後在 DAGScheduler 中使用消息驅動的形式創建 Job

簡而言之, Job 在 RDD 調用 Action 算子的時候生成, 而且調用一次 Action 算子, 就會生成一個 Job, 如果一個 SparkApplication 中調用了多次 Action 算子, 會生成多個 Job 串行執行, 每個 Job 獨立運作, 被獨立調度, 所以 RDD 的計算也會被執行多次

Job 是什麼 ?

如果要將 Spark 的程序調度到集羣中運行, Job 是粒度最大的單位, 調度以 Job 爲最大單位, 將 Job 拆分爲 Stage 和 Task 去調度分發和運行, 一個 Job 就是一個 Spark 程序從 讀取 → 計算 → 運行 的過程

一個 Spark Application 可以包含多個 Job, 這些 Job 之間是串行的, 也就是第二個 Job 需要等待第一個 Job 的執行結束後纔會開始執行

Job 和 Stage 的關係

Job 是一個最大的調度單位, 也就是說 DAGScheduler 會首先創建一個 Job 的相關信息, 後去調度 Job, 但是沒辦法直接調度 Job, 比如說現在要做一盤手撕包菜, 不可能直接去炒一整顆包菜, 要切好撕碎, 再去炒

爲什麼 Job 需要切分 ?

20190521161456

  • 因爲 Job 的含義是對整個 RDD 血統求值, 但是 RDD 之間可能會有一些寬依賴

  • 如果遇到寬依賴的話, 兩個 RDD 之間需要進行數據拉取和複製

    如果要進行拉取和複製的話, 那麼一個 RDD 就必須等待它所依賴的 RDD 所有分區先計算完成, 然後再進行拉取

  • 由上得知, 一個 Job 是無法計算完整個 RDD 血統的

如何切分 ?

創建一個 Stage, 從後向前回溯 RDD, 遇到 Shuffle 依賴就結束 Stage, 後創建新的 Stage 繼續回溯. 這個過程上面已經詳細的講解過, 但是問題是切分以後如何執行呢, 從後向前還是從前向後, 是串行執行多個 Stage, 還是並行執行多個 Stage

問題一: 執行順序

在圖中, Stage 0 的計算需要依賴 Stage 1 的數據, 因爲 reduceRDD 中一個分區可能需要多個 tupleRDD 分區的數據, 所以 tupleRDD 必須先計算完, 所以, 應該在邏輯圖中自左向右執行 Stage

問題二: 串行還是並行

 Stage 1 不僅需要先執行, 而且 Stage 1 執行完之前 Stage 0 無法執行, 它們只能串行執行

總結

  • 一個 Stage 就是物理執行計劃中的一個步驟, 一個 Spark Job 就是劃分到不同 Stage 的計算過程

  • Stage 之間的邊界由 Shuffle 操作來確定

    • Stage 內的 RDD 之間都是窄依賴, 可以放在一個管道中執行

    • 而 Shuffle 後的 Stage 需要等待前面 Stage 的執行

Stage 有兩種

  • ShuffMapStage, 其中存放窄依賴的 RDD

  • ResultStage, 每個 Job 只有一個, 負責計算結果, 一個 ResultStage 執行完成標誌着整個 Job 執行完畢

Stage 和 Task 的關係

20190521120501

前面我們說到 Job 無法直接執行, 需要先劃分爲多個 Stage, 去執行 Stage, 那麼 Stage 可以直接執行嗎?

  • 第一點: Stage 中的 RDD 之間是窄依賴

    因爲 Stage 中的所有 RDD 之間都是窄依賴, 窄依賴 RDD 理論上是可以放在同一個 Pipeline(管道, 流水線) 中執行的, 似乎可以直接調度 Stage 了? 其實不行, 看第二點

  • 第二點: 別忘了 RDD 還有分區

    一個 RDD 只是一個概念, 而真正存放和處理數據時, 都是以分區作爲單位的

    Stage 對應的是多個整體上的 RDD, 而真正的運行是需要針對 RDD 的分區來進行的

  • 第三點: 一個 Task 對應一個 RDD 的分區

    一個比 Stage 粒度更細的單元叫做 TaskStage 是由 Task 組成的, 之所以有 Task 這個概念, 是因爲 Stage 針對整個 RDD, 而計算的時候, 要針對 RDD 的分區

    假設一個 Stage 中有 10 個 RDD, 這些 RDD 中的分區各不相同, 但是分區最多的 RDD 有 30 個分區, 而且很顯然, 它們之間是窄依賴關係

    那麼, 這個 Stage 中應該有多少 Task 呢? 應該有 30 個 Task, 因爲一個 Task 計算一個 RDD 的分區. 這個 Stage 至多有 30 個分區需要計算

  • 總結

    • 一個 Stage 就是一組並行的 Task 集合

    • Task 是 Spark 中最小的獨立執行單元, 其作用是處理一個 RDD 分區

    • 一個 Task 只可能存在於一個 Stage 中, 並且只能計算一個 RDD 的分區

TaskSet

梳理一下這幾個概念, Job > Stage > TaskJob 中包含 Stage 中包含 Task

而 Stage 中經常會有一組 Task 需要同時執行, 所以針對於每一個 Task 來進行調度太過繁瑣, 而且沒有意義, 所以每個 Stage 中的 Task 們會被收集起來, 放入一個 TaskSet 集合中

  • 一個 Stage 有一個 TaskSet

  • TaskSet 中 Task 的個數由 Stage 中的最大分區數決定

整體執行流程

20190522015026

6.4. Shuffle 過程

Shuffle 過程的組件結構

從整體視角上來看, Shuffle 發生在兩個 Stage 之間, 一個 Stage 把數據計算好, 整理好, 等待另外一個 Stage 來拉取

20190522132537

放大視角, 會發現, 其實 Shuffle 發生在 Task 之間, 一個 Task 把數據整理好, 等待 Reducer 端的 Task 來拉取

20190522132852

如果更細化一下, Task 之間如何進行數據拷貝的呢? 其實就是一方 Task 把文件生成好, 然後另一方 Task 來拉取

20190522133401

現在是一個 Reducer 的情況, 如果有多個 Reducer 呢? 如果有多個 Reducer 的話, 就可以在每個 Mapper 爲所有的 Reducer 生成各一個文件, 這種叫做 Hash base shuffle, 這種 Shuffle 的方式問題大家也知道, 就是生成中間文件過多, 而且生成文件的話需要緩衝區, 佔用內存過大

20190522140738

那麼可以把這些文件合併起來, 生成一個文件返回, 這種 Shuffle 方式叫做 Sort base shuffle, 每個 Reducer 去文件的不同位置拿取數據

20190522141807

如果再細化一下, 把參與這件事的組件也放置進去, 就會是如下這樣

20190522170646

有哪些 ShuffleWriter ?

大致上有三個 ShufflWriterSpark 會按照一定的規則去使用這三種不同的 Writer

  • BypassMergeSortShuffleWriter

    這種 Shuffle Writer 也依然有 Hash base shuffle 的問題, 它會在每一個 Mapper 端對所有的 Reducer 生成一個文件, 然後再合併這個文件生成一個統一的輸出文件, 這個過程中依然是有很多文件產生的, 所以只適合在小量數據的場景下使用

    Spark 有考慮去掉這種 Writer, 但是因爲結構中有一些依賴, 所以一直沒去掉

    當 Reducer 個數小於 spark.shuffle.sort.bypassMergeThreshold, 並且沒有 Mapper 端聚合的時候啓用這種方式

  • SortShuffleWriter

    這種 ShuffleWriter 寫文件的方式非常像 MapReduce 了, 後面詳說

    當其它兩種 Shuffle 不符合開啓條件時, 這種 Shuffle 方式是默認的

  • UnsafeShuffleWriter

    這種 ShuffWriter 會將數據序列化, 然後放入緩衝區進行排序, 排序結束後 Spill 到磁盤, 最終合併 Spill 文件爲一個大文件, 同時在進行內存存儲的時候使用了 Java 得 Unsafe API, 也就是使用堆外內存, 是鎢絲計劃的一部分

    也不是很常用, 只有在滿足如下三個條件時候纔會啓用

    • 序列化器序列化後的數據, 必須支持排序

    • 沒有 Mapper 端的聚合

    • Reducer 的個數不能超過支持的上限 (2 ^ 24)

SortShuffleWriter 的執行過程

20190522160031

整個 SortShuffleWriter 如上述所說, 大致有如下幾步

  1. 首先 SortShuffleWriter 在 write 方法中回去寫文件, 這個方法中創建了 ExternalSorter

  2. write 中將數據 insertAll 到 ExternalSorter 中

  3. 在 ExternalSorter 中排序

    1. 如果要聚合, 放入 AppendOnlyMap 中, 如果不聚合, 放入 PartitionedPairBuffer 中

    2. 在數據結構中進行排序, 排序過程中如果內存數據大於閾值則溢寫到磁盤

  4. 使用 ExternalSorter 的 writePartitionedFile 寫入輸入文件

    1. 將所有的溢寫文件通過類似 MergeSort 的算法合併

    2. 將數據寫入最終的目標文件中

七、 RDD 的分佈式共享變量

目標

  1. 理解閉包以及 Spark 分佈式運行代碼的根本原理

  2. 理解累加變量的使用場景

  3. 理解廣播的使用場景

什麼是閉包

閉包是一個必須要理解, 但是又不太好理解的知識點, 先看一個小例子

@Test
def test(): Unit = {
  val areaFunction = closure()
  val area = areaFunction(2)
  println(area)
}

def closure(): Int => Double = {
  val factor = 3.14
  val areaFunction = (r: Int) => math.pow(r, 2) * factor
  areaFunction
}

上述例子中, `closure`方法返回的一個函數的引用, 其實就是一個閉包, 閉包本質上就是一個封閉的作用域, 要理解閉包, 是一定要和作用域聯繫起來的.

能否在 test 方法中訪問 closure 定義的變量?

@Test
def test(): Unit = {
  println(factor)
}

def closure(): Int => Double = {
  val factor = 3.14
}

有沒有什麼間接的方式?

@Test
def test(): Unit = {
  val areaFunction = closure()
  areaFunction()
}

def closure(): () => Unit = {
  val factor = 3.14
  val areaFunction = () => println(factor)
  areaFunction
}

什麼是閉包?

val areaFunction = closure()
areaFunction()

通過 closure 返回的函數 areaFunction 就是一個閉包, 其函數內部的作用域並不是 test 函數的作用域, 這種連帶作用域一起打包的方式, 我們稱之爲閉包, 在 Scala 中

Scala 中的閉包本質上就是一個對象, 是 FunctionX 的實例

分發閉包

sc.textFile("dataset/access_log_sample.txt")
  .flatMap(item => item.split(""))
  .collect()

上述這段代碼中, flatMap 中傳入的是另外一個函數, 傳入的這個函數就是一個閉包, 這個閉包會被序列化運行在不同的 Executor 中

1d0afe0f7a86237910b974f116fc1747

class MyClass {
  val field = "Hello"

  def doStuff(rdd: RDD[String]): RDD[String] = {
    rdd.map(x => field + x)
  }
}

這段代碼中的閉包就有了一個依賴, 依賴於外部的一個類, 因爲傳遞給算子的函數最終要在 Executor 中運行, 所以需要 序列化MyClass 發給每一個 Executor, 從而在 Executor 訪問 MyClass 對象的屬性

97d96cbd4169753a9c44c8e3d04735d2

總結

  1. 閉包就是一個封閉的作用域, 也是一個對象

  2. Spark 算子所接受的函數, 本質上是一個閉包, 因爲其需要封閉作用域, 並且序列化自身和依賴, 分發到不同的節點中運行

7.1. 累加器

一個小問題

var count = 0

val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]")
val sc = new SparkContext(config)

sc.parallelize(Seq(1, 2, 3, 4, 5))
  .foreach(count += _)

println(count)

上面這段代碼是一個非常錯誤的使用, 請不要仿照, 這段代碼只是爲了證明一些事情

先明確兩件事, var count = 0 是在 Driver 中定義的, foreach(count += _) 這個算子以及傳遞進去的閉包運行在 Executor 中

這段代碼整體想做的事情是累加一個變量, 但是這段代碼的寫法卻做不到這件事, 原因也很簡單, 因爲具體的算子是閉包, 被分發給不同的節點運行, 所以這個閉包中累加的並不是 Driver 中的這個變量

全局累加器

Accumulators(累加器) 是一個只支持 added(添加) 的分佈式變量, 可以在分佈式環境下保持一致性, 並且能夠做到高效的併發.

原生 Spark 支持數值型的累加器, 可以用於實現計數或者求和, 開發者也可以使用自定義累加器以實現更高級的需求

val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]")
val sc = new SparkContext(config)

val counter = sc.longAccumulator("counter")

sc.parallelize(Seq(1, 2, 3, 4, 5))
  .foreach(counter.add(_))

// 運行結果: 15
println(counter.value)

注意點:

  • Accumulator 是支持併發並行的, 在任何地方都可以通過 add 來修改數值, 無論是 Driver 還是 Executor

  • 只能在 Driver 中才能調用 value 來獲取數值

在 WebUI 中關於 Job 部分也可以看到 Accumulator 的信息, 以及其運行的情況

41b76292cc02a2e51cb086171e3420fb

累計器件還有兩個小特性, 第一, 累加器能保證在 Spark 任務出現問題被重啓的時候不會出現重複計算. 第二, 累加器只有在 Action 執行的時候纔會被觸發.

val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]")
val sc = new SparkContext(config)

val counter = sc.longAccumulator("counter")

sc.parallelize(Seq(1, 2, 3, 4, 5))
  .map(counter.add(_)) // 這個地方不是 Action, 而是一個 Transformation

// 運行結果是 0
println(counter.value)

自定義累加器

開發者可以通過自定義累加器來實現更多類型的累加器, 累加器的作用遠遠不只是累加, 比如可以實現一個累加器, 用於向裏面添加一些運行信息

class InfoAccumulator extends AccumulatorV2[String, Set[String]] {
  private val infos: mutable.Set[String] = mutable.Set()

  override def isZero: Boolean = {
    infos.isEmpty
  }

  override def copy(): AccumulatorV2[String, Set[String]] = {
    val newAccumulator = new InfoAccumulator()
    infos.synchronized {
      newAccumulator.infos ++= infos
    }
    newAccumulator
  }

  override def reset(): Unit = {
    infos.clear()
  }

  override def add(v: String): Unit = {
    infos += v
  }

  override def merge(other: AccumulatorV2[String, Set[String]]): Unit = {
    infos ++= other.value
  }

  override def value: Set[String] = {
    infos.toSet
  }
}

@Test
def accumulator2(): Unit = {
  val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]")
  val sc = new SparkContext(config)

  val infoAccumulator = new InfoAccumulator()
  sc.register(infoAccumulator, "infos")

  sc.parallelize(Seq("1", "2", "3"))
    .foreach(item => infoAccumulator.add(item))

  // 運行結果: Set(3, 1, 2)
  println(infoAccumulator.value)

  sc.stop()
}

注意點:

  • 可以通過繼承 AccumulatorV2 來創建新的累加器

  • 有幾個方法需要重寫

    • reset 方法用於把累加器重置爲 0

    • add 方法用於把其它值添加到累加器中

    • merge 方法用於指定如何合併其他的累加器

  • value 需要返回一個不可變的集合, 因爲不能因爲外部的修改而影響自身的值

7.2. 廣播變量

目標

  1. 理解爲什麼需要廣播變量, 以及其應用場景

  2. 能夠通過代碼使用廣播變量

廣播變量的作用

廣播變量允許開發者將一個 Read-Only 的變量緩存到集羣中每個節點中, 而不是傳遞給每一個 Task 一個副本.

  • 集羣中每個節點, 指的是一個機器

  • 每一個 Task, 一個 Task 是一個 Stage 中的最小處理單元, 一個 Executor 中可以有多個 Stage, 每個 Stage 有多個 Task

所以在需要跨多個 Stage 的多個 Task 中使用相同數據的情況下, 廣播特別的有用

7eb422ef368aec2a1e60636b0f9dfd77

廣播變量的API

方法名 描述

id

唯一標識

value

廣播變量的值

unpersist

在 Executor 中異步的刪除緩存副本

destroy

銷燬所有此廣播變量所關聯的數據和元數據

toString

字符串表示

使用廣播變量的一般套路

可以通過如下方式創建廣播變量

val b = sc.broadcast(1)

如果 Log 級別爲 DEBUG 的時候, 會打印如下信息

DEBUG BlockManager: Put block broadcast_0 locally took  430 ms
DEBUG BlockManager: Putting block broadcast_0 without replication took  431 ms
DEBUG BlockManager: Told master about block broadcast_0_piece0
DEBUG BlockManager: Put block broadcast_0_piece0 locally took  4 ms
DEBUG BlockManager: Putting block broadcast_0_piece0 without replication took  4 ms

創建後可以使用 value 獲取數據

b.value

獲取數據的時候會打印如下信息

DEBUG BlockManager: Getting local block broadcast_0
DEBUG BlockManager: Level for block broadcast_0 is StorageLevel(disk, memory, deserialized, 1 replicas)

廣播變量使用完了以後, 可以使用 unpersist 刪除數據

b.unpersist

刪除數據以後, 可以使用 destroy 銷燬變量, 釋放內存空間

b.destroy

銷燬以後, 會打印如下信息

DEBUG BlockManager: Removing broadcast 0
DEBUG BlockManager: Removing block broadcast_0_piece0
DEBUG BlockManager: Told master about block broadcast_0_piece0
DEBUG BlockManager: Removing block broadcast_0

使用 value 方法的注意點

方法簽名 value: T

在 value 方法內部會確保使用獲取數據的時候, 變量必須是可用狀態, 所以必須在變量被 destroy 之前使用 value 方法, 如果使用 value 時變量已經失效, 則會爆出以下錯誤

org.apache.spark.SparkException: Attempted to use Broadcast(0) after it was destroyed (destroy at <console>:27)
  at org.apache.spark.broadcast.Broadcast.assertValid(Broadcast.scala:144)
  at org.apache.spark.broadcast.Broadcast.value(Broadcast.scala:69)
  ... 48 elided

使用 destroy 方法的注意點

方法簽名 destroy(): Unit

destroy 方法會移除廣播變量, 徹底銷燬掉, 但是如果你試圖多次 destroy 廣播變量, 則會爆出以下錯誤

org.apache.spark.SparkException: Attempted to use Broadcast(0) after it was destroyed (destroy at <console>:27)
  at org.apache.spark.broadcast.Broadcast.assertValid(Broadcast.scala:144)
  at org.apache.spark.broadcast.Broadcast.destroy(Broadcast.scala:107)
  at org.apache.spark.broadcast.Broadcast.destroy(Broadcast.scala:98)
  ... 48 elided

廣播變量的使用場景

假設我們在某個算子中需要使用一個保存了項目和項目的網址關係的 Map[String, String] 靜態集合, 如下

val pws = Map("Apache Spark" -> "http://spark.apache.org/", "Scala" -> "http://www.scala-lang.org/")

val websites = sc.parallelize(Seq("Apache Spark", "Scala")).map(pws).collect

上面這段代碼是沒有問題的, 可以正常運行的, 但是非常的低效, 因爲雖然可能 pws 已經存在於某個 Executor 中了, 但是在需要的時候還是會繼續發往這個 Executor, 如果想要優化這段代碼, 則需要儘可能的降低網絡開銷

可以使用廣播變量進行優化, 因爲廣播變量會緩存在集羣中的機器中, 比 Executor 在邏輯上更 "大"

val pwsB = sc.broadcast(pws)
val websites = sc.parallelize(Seq("Apache Spark", "Scala")).map(pwsB.value).collect

上面兩段代碼所做的事情其實是一樣的, 但是當需要運行多個 Executor (以及多個 Task) 的時候, 後者的效率更高

擴展

正常情況下使用 Task 拉取數據的時候, 會將數據拷貝到 Executor 中多次, 但是使用廣播變量的時候只會複製一份數據到 Executor 中, 所以在兩種情況下特別適合使用廣播變量

  • 一個 Executor 中有多個 Task 的時候

  • 一個變量比較大的時候

而且在 Spark 中還有一個約定俗稱的做法, 當一個 RDD 很大並且還需要和另外一個 RDD 執行 join 的時候, 可以將較小的 RDD 廣播出去, 然後使用大的 RDD 在算子 map 中直接 join, 從而實現在 Map 端 join

val acMap = sc.broadcast(myRDD.map { case (a,b,c,b) => (a, c) }.collectAsMap)
val otherMap = sc.broadcast(myOtherRDD.collectAsMap)

myBigRDD.map { case (a, b, c, d) =>
  (acMap.value.get(a).get, otherMap.value.get(c).get)
}.collect

一般情況下在這種場景下, 會廣播 Map 類型的數據, 而不是數組, 因爲這樣容易使用 Key 找到對應的 Value 簡化使用

總結

  1. 廣播變量用於將變量緩存在集羣中的機器中, 避免機器內的 Executors 多次使用網絡拉取數據

  2. 廣播變量的使用步驟: (1) 創建 (2) 在 Task 中獲取值 (3) 銷燬

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