目錄
目標
- 深入理解 RDD 的內在邏輯
- 能夠使用 RDD 的算子
- 理解 RDD 算子的 Shuffle 和緩存
- 理解 RDD 整體的使用流程
- 理解 RDD 的調度原理
- 理解 Spark 中常見的分佈式變量共享方式
一、 深入 RDD
目標
-
深入理解 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.如何放在集羣中運行?
簡單來講, 並行計算就是同時使用多個計算資源解決一個問題, 有如下四個要點
- 要解決的問題必須可以分解爲多個可以併發計算的部分
- 每個部分要可以在不同處理器上被同時執行
- 需要一個共享內存的機制
- 需要一個總體上的協作機制來進行調度
3.如果放在集羣中的話, 可能要對整個計算任務進行分解, 如何分解?
- 對於 HDFS 中的文件, 是分爲不同的 Block 的
- 在進行計算的時候, 就可以按照 Block 來劃分, 每一個 Block 對應一個不同的計算單元
RDD 並沒有真實的存放數據, 數據是從 HDFS 中讀取的, 在計算的過程中讀取即可
RDD
至少是需要可以 分片 的, 因爲HDFS中的文件就是分片的,RDD
分片的意義在於表示對源數據集每個分片的計算,RDD
可以分片也意味着 可以並行計算
4.移動數據不如移動計算是一個基礎的優化, 如何做到?
每一個計算單元需要記錄其存儲單元的位置, 儘量調度過去
5. 在集羣中運行, 需要很多節點之間配合, 出錯的概率也更高, 出錯了怎麼辦?
RDD1 → RDD2 → RDD3 這個過程中, RDD2 出錯了, 有兩種辦法可以解決
- 緩存 RDD2 的數據, 直接恢復 RDD2, 類似 HDFS 的備份機制
- 記錄 RDD2 的依賴關係, 通過其父級的 RDD 來恢復 RDD2, 這種方式會少很多數據的交互和保存
如何通過父級 RDD 來恢復?
- 記錄 RDD2 的父親是 RDD1 (Dependencies, )
- 記錄 RDD2 的計算函數, 例如記錄
RDD2 = RDD1.map(…)
,map(…)
就是計算函數 - 當 RDD2 計算出錯的時候, 可以通過父級 RDD 和計算函數來恢復 RDD2
6.假如任務特別複雜, 流程特別長, 有很多 RDD 之間有依賴關係, 如何優化
上面提到了可以使用依賴關係來進行容錯, 但是如果依賴關係特別長的時候, 這種方式其實也比較低效, 這個時候就應該使用另外一種方式, 也就是記錄數據集的狀態
在 Spark 中有兩個手段可以做到
- 緩存
- Checkpoint
1.2. 再談 RDD
目標
-
理解 RDD 爲什麼會出現
-
理解 RDD 的主要特點
-
理解 RDD 的五大屬性
1.2.1. RDD 爲什麼會出現?
在 RDD 出現之前, 當時 MapReduce 是比較主流的, 而 MapReduce 如何執行迭代計算的任務呢?
多個 MapReduce 任務之間沒有基於內存的數據 共享方式, 只能通過磁盤來進行共享
這種方式明顯比較低效
RDD 如何解決迭代計算非常低效的問題呢?
在 Spark 中, 其實最終 Job3 從邏輯上的計算過程是: Job3 = (Job1.map).filter
, 整個過程是共享內存的, 而不需要將中間結果存放在可靠的分佈式文件系統中
這種方式可以在保證容錯的前提下, 提供更多的靈活, 更快的執行速度,
1.2.2. RDD
RDD 不僅是數據集, 也是編程模型
RDD 即是一種數據結構, 同時也提供了上層 API, 同時 RDD 的 API 和 Scala 中對集合運算的 API 非常類似, 同樣也都是各種算子
RDD 的算子大致分爲兩類:
- Transformation 轉換操作, 例如
map
flatMap
filter
等 - Action 動作操作, 例如
reduce
collect
show
等
執行 RDD 的時候, 在執行到轉換操作的時候, 並不會立刻執行, 直到遇見了 Action 操作, 纔會觸發真正的執行, 這個特點叫做 惰性求值
RDD 可以分區
RDD 是一個分佈式計算框架, 所以, 一定是要能夠進行分區計算的, 只有分區了, 才能利用集羣的並行計算能力
同時, RDD 不需要始終被具體化, 也就是說: RDD 中可以沒有數據, 只要有足夠的信息知道自己是從誰計算得來的就可以, 這是一種非常高效的容錯方式
RDD 是隻讀的
RDD 是隻讀的, 不允許任何形式的修改. 雖說不能因爲 RDD 和 HDFS 是隻讀的, 就認爲分佈式存儲系統必須設計爲只讀的. 但是設計爲只讀的, 會顯著降低問題的複雜度, 因爲 RDD 需要可以容錯, 可以惰性求值, 可以移動計算, 所以很難支持修改.
- RDD2 中可能沒有數據, 只是保留了依賴關係和計算函數, 那修改啥?
- 如果因爲支持修改, 而必須保存數據的話, 怎麼容錯?
- 如果允許修改, 如何定位要修改的那一行? RDD 的轉換是粗粒度的, 也就是說, RDD 並不感知具體每一行在哪.
RDD 是可以容錯的
RDD 的容錯有兩種方式
- 保存 RDD 之間的依賴關係, 以及計算函數, 出現錯誤重新計算
- 直接將 RDD 的數據存放在外部存儲系統, 出現錯誤直接讀取, Checkpoint
1.2.3. 什麼叫做彈性分佈式數據集
分佈式
RDD 支持分區, 可以運行在集羣中
彈性
- RDD 支持高效的容錯
- RDD 中的數據即可以緩存在內存中, 也可以緩存在磁盤中, 也可以緩存在外部存儲中
數據集
- RDD 可以不保存具體數據, 只保留創建自己的必備信息, 例如依賴和計算函數
- RDD 也可以緩存起來, 相當於存儲具體數據
總結: RDD 的五大屬性
首先整理一下上面所提到的 RDD 所要實現的功能:
- RDD 有分區
- RDD 要可以通過依賴關係和計算函數進行容錯
- RDD 要針對數據本地性進行優化
- RDD 支持 MapReduce 形式的計算, 所以要能夠對數據進行 Shuffled
對於 RDD 來說, 其中應該有什麼內容呢? 如果站在 RDD 設計者的角度上, 這個類中, 至少需要什麼屬性?
-
Partition List
分片列表, 記錄 RDD 的分片, 可以在創建 RDD 的時候指定分區數目, 也可以通過算子來生成新的 RDD 從而改變分區數目 -
Compute Function
爲了實現容錯, 需要記錄 RDD 之間轉換所執行的計算函數 -
RDD Dependencies
RDD 之間的依賴關係, 要在 RDD 中記錄其上級 RDD 是誰, 從而實現容錯和計算 -
Partitioner
爲了執行 Shuffled 操作, 必須要有一個函數用來計算數據應該發往哪個分區
二、RDD 的算子
目標
-
理解 RDD 的算子分類, 以及其特性
-
理解常見算子的使用
分類
RDD 中的算子從功能上分爲兩大類
-
Transformation(轉換) 它會在一個已經存在的 RDD 上創建一個新的 RDD, 將舊的 RDD 的數據轉換爲另外一種形式後放入新的 RDD
-
Action(動作) 執行各個分區的計算任務, 將的到的結果返回到 Driver 中
RDD 中可以存放各種類型的數據, 那麼對於不同類型的數據, RDD 又可以分爲三類
-
針對基礎類型(例如 String)處理的普通算子
-
針對
Key-Value
數據處理的byKey
算子 -
針對數字類型數據處理的計算算子
特點
-
Spark 中所有的 Transformations 是 Lazy(惰性) 的, 它們不會立即執行獲得結果. 相反, 它們只會記錄在數據集上要應用的操作. 只有當需要返回結果給 Driver 時, 纔會執行這些操作, 通過 DAGScheduler 和 TaskScheduler 分發到集羣中運行, 這個特性叫做 惰性求值
-
默認情況下, 每一個 Action 運行的時候, 其所關聯的所有 Transformation RDD 都會重新計算, 但是也可以使用
presist
方法將 RDD 持久化到磁盤或者內存中. 這個時候爲了下次可以更快的訪問, 會把數據保存到集羣上.
2.1. Transformations 算子
Transformation function |
解釋 |
---|---|
|
作用
簽名
參數
注意點
|
|
作用
調用
參數
注意點
|
|
作用
|
|
RDD[T] ⇒ RDD[U] 和 map 類似, 但是針對整個分區的數據轉換 |
|
和 mapPartitions 類似, 只是在函數中增加了分區的 Index |
|
作用
|
|
作用
參數
|
|
|
|
作用
|
|
(RDD[T], RDD[T]) ⇒ RDD[T] 差集, 可以設置分區數 |
|
作用
注意點
|
|
作
調用
參數
注意點
|
|
作用
注意點
|
|
作用
調用
參數
注意點
|
|
作用
調用
參數
注意點 * 爲什麼需要兩個函數? aggregateByKey 運行將一個
和 reduceByKey 的區別:
|
|
作用
調用
參數
注意點
|
|
作用
調用
參數
注意點
|
|
作用
調用
參數
注意點
|
|
(RDD[T], RDD[U]) ⇒ RDD[(T, U)] 生成兩個 RDD 的笛卡爾積 |
|
作用
調用
參數
注意點
|
|
使用用傳入的 partitioner 重新分區, 如果和當前分區函數相同, 則忽略操作 |
|
減少分區數
作用
調用
參數
注意點
|
|
重新分區 |
|
重新分區的同時升序排序, 在 |
分區操作的算子補充:
2.2. Action 算子
Action function | 解釋 |
---|---|
|
作用
調用
注意點
|
|
以數組的形式返回數據集中所有元素 |
|
返回元素個數 |
|
返回第一個元素 |
|
返回前 N 個元素 |
|
類似於 sample, 區別在這是一個Action, 直接返回結果 |
|
指定初始值和計算函數, 摺疊聚合整個數據集 |
|
將結果存入 path 對應的文件中 |
|
將結果存入 path 對應的 Sequence 文件中 |
|
作用
注意點
|
|
遍歷每一個元素 |
總結
RDD 的算子大部分都會生成一些專用的 RDD
map
,flatMap
,filter
等算子會生成MapPartitionsRDD
coalesce
,repartition
等算子會生成CoalescedRDD
常見的 RDD 有兩種類型
轉換型的 RDD, Transformation
動作型的 RDD, Action
常見的 Transformation 類型的 RDD
map
flatMap
filter
groupBy
reduceByKey
常見的 Action 類型的 RDD
collect
countByKey
reduce
2.3. RDD 對不同類型數據的支持
目標
- 理解 RDD 對 Key-Value 類型的數據是有專門支持的
- 理解 RDD 對數字類型也有專門的支持
一般情況下 RDD 要處理的數據有三類
- 字符串
- 鍵值對
- 數字型
RDD 的算子設計對這三類不同的數據分別都有支持
- 對於以字符串爲代表的基本數據類型是比較基礎的一些的操作, 諸如 map, flatMap, filter 等基礎的算子
- 對於鍵值對類型的數據, 有額外的支持, 諸如 reduceByKey, groupByKey 等 byKey 的算子
- 同樣對於數字型的數據也有額外的支持, 諸如 max, min 等
RDD 對鍵值對數據的額外支持
鍵值型數據本質上就是一個二元元組, 鍵值對類型的 RDD 表示爲
RDD[(K, V)]
RDD 對鍵值對的額外支持是通過隱式支持來完成的, 一個
RDD[(K, V)]
, 可以被隱式轉換爲一個PairRDDFunctions
對象, 從而調用其中的方法.既然對鍵值對的支持是通過
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. 階段練習和總結
導讀
-
通過本節, 希望大家能夠理解 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 的整體使用步驟如下
三、 RDD 的 Shuffle 和分區
目標
- RDD 的分區操作
- Shuffle 的原理
分區的作用
RDD 使用分區來分佈式並行處理數據, 並且要做到儘量少的在不同的 Executor 之間使用網絡交換數據, 所以當使用 RDD 讀取數據的時候, 會盡量的在物理上靠近數據源, 比如說在讀取 Cassandra 或者 HDFS 中數據的時候, 會盡量的保持 RDD 的分區和數據源的分區數, 分區模式等一 一對應
分區和 Shuffle 的關係
分區的主要作用是用來實現並行計算, 本質上和 Shuffle 沒什麼關係, 但是往往在進行數據處理的時候, 例如
reduceByKey
,groupByKey
等聚合操作, 需要把 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
之所以會有 8 個 Tasks, 是因爲在啓動的時候指定的命令是
spark-shell --master local[8]
, 這樣會生成 1 個 Executors, 這個 Executors 有 8 個 Cores, 所以默認會有 8 個 Tasks, 每個 Cores 對應一個分區, 每個分區對應一個 Tasks, 可以通過rdd.partitions.size
來查看分區數量同時也可以通過 spark-shell 的 WebUI 來查看 Executors 的情況
默認的分區數量是和 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)
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(輪詢)
, 如果希望改變分區函數, 也就是數據分佈的方式, 可以通過自定義分區函數來實現
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
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
大致的原理是分桶, 假設 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
對於 Sort base shuffle 來說, 每個 Map 側的分區只有一個輸出文件, Reduce 側的 Task 來拉取, 大致流程如下
-
Map 側將數據全部放入一個叫做 AppendOnlyMap 的組件中, 同時可以在這個特殊的數據結構中做聚合操作
-
然後通過一個類似於 MergeSort 的排序算法 TimSort 對 AppendOnlyMap 底層的 Array 排序先按照 Partition ID 排序, 後按照 Key 的 HashCode 排序
-
最終每個 Map Task 生成一個 輸出文件, Reduce Task 來拉取自己對應的數據
從上面可以得到結論, Sort base shuffle 確實可以大幅度減少所產生的中間文件, 從而能夠更好的應對大吞吐量的場景, 在 Spark 1.2 以後, 已經默認採用這種方式.
四、 緩存
概要
-
緩存的意義
-
緩存相關的 API
-
緩存級別以及最佳實踐
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
, 導致文件讀取兩次, 計算兩次, 有沒有什麼辦法增進上述代碼的性能?
使用緩存的原因 - 容錯
當在計算 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)
能夠指定緩存的級別
緩存其實是一種空間換時間的做法, 會佔用額外的存儲資源, 如何清理?
根據緩存級別的不同, 緩存存儲的位置也不同, 但是使用 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
, 其中有如下幾個構造參數
根據這幾個參數的不同, StorageLevel
有如下幾個枚舉對象
緩存級別 | userDisk 是否使用磁盤 |
useMemory 是否使用內存 |
useOffHeap 是否使用堆外內存 |
deserialized 是否以反序列化形式存儲 |
replication 副本數 |
---|---|---|---|---|---|
|
false |
false |
false |
false |
1 |
|
true |
false |
false |
false |
1 |
|
true |
false |
false |
false |
2 |
|
false |
true |
false |
true |
1 |
|
false |
true |
false |
true |
2 |
|
false |
true |
false |
false |
1 |
|
false |
true |
false |
false |
2 |
|
true |
true |
false |
true |
1 |
|
true |
true |
false |
true |
2 |
|
true |
true |
false |
false |
1 |
|
true |
true |
false |
false |
2 |
|
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
目標
-
Checkpoint 的作用
-
Checkpoint 的使用
5.1. Checkpoint 的作用
Checkpoint 的主要作用是斬斷 RDD 的依賴鏈, 並且將數據存儲在可靠的存儲引擎中, 例如支持分佈式存儲和副本機制的 HDFS.
Checkpoint 的方式
-
可靠的 將數據存儲在可靠的存儲引擎中, 例如 HDFS
-
本地的 將數據存儲在本地
什麼是斬斷依賴鏈
斬斷依賴鏈是一個非常重要的操作, 接下來以 HDFS 的 NameNode 的原理來舉例說明
HDFS 的 NameNode 中主要職責就是維護兩個文件, 一個叫做 edits
, 另外一個叫做 fsimage
. edits
中主要存放 EditLog
, FsImage
保存了當前系統中所有目錄和文件的信息. 這個 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 上的路徑
一個小細節
|
六、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))
整個案例的運行過程大致如下:
-
通過代碼的運行, 生成對應的
RDD
邏輯執行圖 -
通過
Action
操作, 根據邏輯執行圖生成對應的物理執行圖, 也就是Stage
和Task
-
將物理執行圖運行在集羣中
邏輯執行圖
對於上面代碼中的
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 []
根據這段內容, 大致能得到這樣的一張邏輯執行圖
其實 RDD 並沒有什麼嚴格的邏輯執行圖和物理執行圖的概念, 這裏也只是借用這個概念, 從而讓整個 RDD 的原理可以解釋, 好理解.
對於 RDD 的邏輯執行圖, 起始於第一個入口 RDD 的創建, 結束於 Action 算子執行之前, 主要的過程就是生成一組互相有依賴關係的 RDD, 其並不會真的執行, 只是表示 RDD 之間的關係, 數據的流轉過程.
物理執行圖
當觸發 Action 執行的時候, 這一組互相依賴的 RDD 要被處理, 所以要轉化爲可運行的物理執行圖, 調度到集羣中執行.
因爲大部分 RDD 是不真正存放數據的, 只是數據從中流轉, 所以, 不能直接在集羣中運行 RDD, 要有一種 Pipeline 的思想, 需要將這組 RDD 轉爲 Stage 和 Task, 從而運行 Task, 優化整體執行速度.
以上的邏輯執行圖會生成如下的物理執行圖, 這一切發生在 Action 操作被執行時.
從上圖可以總結如下幾個點
-
在第一個
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
之間, 其實本質上生成的就是一個 計算鏈接下來, 採用迭代漸進式的方式, 一步一步的查看一下整體上的生成過程
textFile
算子的背後
研究
RDD
的功能或者表現的時候, 其實本質上研究的就是RDD
中的五大屬性, 因爲RDD
透過五大屬性來提供功能和表現, 所以如果要研究textFile
這個算子, 應該從五大屬性着手, 那麼第一步就要看看生成的RDD
是什麼類型的RDD
textFile
生成的是HadoopRDD
HadoopRDD
的Partitions
對應了HDFS
的Blocks
其實本質上每個
HadoopRDD
的Partition
都是對應了一個Hadoop
的Block
, 通過InputFormat
來確定Hadoop
中的Block
的位置和邊界, 從而可以供一些算子使用
HadoopRDD
的compute
函數就是在讀取HDFS
中的Block
本質上,
compute
還是依然使用InputFormat
來讀取HDFS
中對應分區的Block
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
是rdd2
,rdd2
的類型是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
的過程, 所以如下圖所示, 就是這個階段所生成的邏輯計劃
總結
如何生成
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 之間的依賴關係
導讀
-
討論什麼是 RDD 之間的依賴關係
-
繼而討論 RDD 分區之間的關係
-
最後確定 RDD 之間的依賴關係分類
-
完善案例的邏輯關係圖
什麼是 RDD
之間的依賴關係?
-
什麼是關係(依賴關係) ?
從算子視角上來看,
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
操作大致如下過程去掉兩個
reducer
端的分區, 只留下一個的話, 如下所以, 對於
reduceByKey
這個Shuffle
操作來說,reducer
端的一個分區, 會從多個mapper
端的分區拿取數據, 是一個多對一的關係至此爲止, 出現了兩種分區間的關係了, 一種是一對一, 一種是多對一
整體上的流程圖
6.1.3. RDD 之間的依賴關係詳解
導讀
上個小節通過例子演示了 RDD 的分區間的關係有兩種形式
一對一, 一般是直接轉換
多對一, 一般是 Shuffle
本小節會說明如下問題:
如果分區間得關係是一對一或者多對一, 那麼這種情況下的 RDD 之間的關係的正式命名是什麼呢?
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
來說, 依賴關係如下它們之間是窄依賴, 事實上在
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 )
會產生如下的依賴關係
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(…)
-
每個分區之間一 一對應, 所以叫做一對一窄依賴
Range 窄依賴
Range
窄依賴其實也是一對一窄依賴, 但是保留了中間的分隔信息, 可以通過某個分區獲取其父分區, 目前只有一個算子生成這種窄依賴, 就是 union
算子, 例如 rddC = rddA.union(rddB)
-
rddC
其實就是rddA
拼接rddB
生成的, 所以rddC
的p5
和p6
就是rddB
的p1
和p2
-
所以需要有方式獲取到
rddC
的p5
其父分區是誰, 於是就需要記錄一下邊界, 其它部分和一對一窄依賴一樣
多對一窄依賴
多對一窄依賴其圖形和 Shuffle
依賴非常相似, 所以在遇到的時候, 要注意其 RDD
之間是否有 Shuffle
過程, 比較容易讓人困惑, 常見的多對一依賴就是重分區算子 coalesce
, 例如 rddB = rddA.coalesce(2, shuffle = false)
, 但同時也要注意, 如果 shuffle = true
那就是完全不同的情況了
-
因爲沒有
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
的線程應該運行在一個進程中, 這個進程就是Exeutor
,Executor
有如下兩個職責
和
Driver
保持交互從而認領屬於自己的任務接受任務後, 運行任務
所以, 應該由一個線程來執行
RDD
的計算任務, 而Executor
作爲執行這個任務的容器, 也就是一個進程, 用於創建和執行線程, 這個執行具體計算任務的線程叫做Task
問題三: Task 該如何設計 ?
第一個想法是每個
RDD
都由一個Task
來計算 第二個想法是一整個邏輯執行圖中所有的RDD
都由一組Task
來執行 第三個想法是分階段執行第一個想法: 爲每個 RDD 的分區設置一組 Task
第二個想法: 讓數據流動
很自然的, 第一個想法的問題是數據需要存儲和交換, 那不存儲不就好了嗎? 對, 可以讓數據流動起來
第一個要解決的問題就是, 要爲數據創建管道(
Pipeline
), 有了管道, 就可以流動簡單來說, 就是爲所有的
RDD
有關聯的分區使用同一個Task
, 但是就沒問題了嗎? 請關注紅框部分這兩個
RDD
之間是Shuffle
關係, 也就是說, 右邊的RDD
的一個分區可能依賴左邊RDD
的所有分區, 這樣的話, 數據在這個地方流不動了, 怎麼辦?第三個想法: 劃分階段
既然在
Shuffle
處數據流不動了, 那就可以在這個地方中斷一下, 後面Stage
部分詳解
如何劃分階段 ?
爲了減少執行任務, 減少數據暫存和交換的機會, 所以需要創建管道, 讓數據沿着管道流動, 其實也就是原先每個
RDD
都有一組Task
, 現在改爲所有的RDD
共用一組Task
, 但是也有問題, 問題如下就是說, 在
Shuffle
處, 必須斷開管道, 進行數據交換, 交換過後, 繼續流動, 所以整個流程可以變爲如下樣子把
Task
斷開成兩個部分,Task4
可以從Task 1, 2, 3
中獲取數據, 後Task4
又作爲管道, 繼續讓數據在其中流動但是還有一個問題, 說斷開就直接斷開嗎? 不用打個招呼的呀? 所以可以爲這個斷開增加一個概念叫做階段, 按照階段斷開, 階段的英文叫做
Stage
, 如下所以劃分階段的本身就是設置斷開點的規則, 那麼該如何劃分階段呢?
第一步, 從最後一個
RDD
, 也就是邏輯圖中最右邊的RDD
開始, 向前滑動Stage
的範圍, 爲Stage0
第二步, 遇到
ShuffleDependency
斷開Stage
, 從下一個RDD
開始創建新的Stage
, 爲Stage1
第三步, 新的
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))
上述代碼是我們一直使用的代碼流程, 如下是其完整的邏輯執行圖
如果放在集羣中運行, 通過
WebUI
可以查看到如下DAG
結構Step 1: 從
ResultStage
開始執行最接近
Result
部分的Stage id
爲 0, 這個Stage
被稱之爲ResultStage
由代碼可以知道, 最終調用
Action
促使整個流程執行的是最後一個RDD
,strRDD.collect
, 所以當執行RDD
的計算時候, 先計算的也是這個RDD
Step 2:
RDD
之間是有關聯的前面已經知道, 最後一個
RDD
先得到執行機會, 先從這個RDD
開始執行, 但是這個RDD
中有數據嗎 ? 如果沒有數據, 它的計算是什麼? 它的計算是從父RDD
中獲取數據, 並執行傳入的算子的函數簡單來說, 從產生
Result
的地方開始計算, 但是其RDD
中是沒數據的, 所以會找到父RDD
來要數據, 父RDD
也沒有數據, 繼續向上要, 所以, 計算從Result
處調用, 但是從整個邏輯圖中的最左邊RDD
開始, 類似一個遞歸的過程這個過程就像 往 HDFS 上傳 數據一樣,建立 pinpline , 上傳 ,返回 ack。 且 這樣理解吧
6.3. 調度過程
導讀
-
生成邏輯圖和物理圖的系統組件
-
Job
和Stage
,Task
之間的關係 -
如何調度
Job
邏輯圖
邏輯圖如何生成
一段
Scala
代碼的執行結果就是最後一行的執行結果,最後一個RDD
也可以認爲就是邏輯執行圖, 爲什麼呢?例如
rdd2 = rdd1.map(…)
中, 其實本質上rdd2
是一個類型爲MapPartitionsRDD
的對象, 而創建這個對象的時候, 會通過構造函數傳入當前RDD
對象, 也就是父RDD
, 也就是調用map
算子的rdd1
,rdd1
是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
主要做如下三件事
幫助每個
Job
計算DAG
併發給TaskSheduler
調度確定每個
Task
的最佳位置跟蹤
RDD
的緩存狀態, 避免重新計算從字面意思上來看,
DAGScheduler
是調度DAG
去運行的,DAG
被稱作爲有向無環圖, 其實可以將DAG
理解爲就是RDD
的邏輯圖, 其呈現兩個特點:RDD
的計算是有方向的,RDD
的計算是無環的, 所以DAGScheduler
也可以稱之爲RDD Scheduler
, 但是真正運行在集羣中的並不是RDD
, 而是Task
和Stage
,DAGScheduler
負責這種轉換
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
需要切分 ?
因爲
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
的關係
前面我們說到
Job
無法直接執行, 需要先劃分爲多個Stage
, 去執行Stage
, 那麼Stage
可以直接執行嗎?
第一點:
Stage
中的RDD
之間是窄依賴因爲
Stage
中的所有RDD
之間都是窄依賴, 窄依賴RDD
理論上是可以放在同一個Pipeline(管道, 流水線)
中執行的, 似乎可以直接調度Stage
了? 其實不行, 看第二點第二點: 別忘了
RDD
還有分區一個
RDD
只是一個概念, 而真正存放和處理數據時, 都是以分區作爲單位的
Stage
對應的是多個整體上的RDD
, 而真正的運行是需要針對RDD
的分區來進行的第三點: 一個
Task
對應一個RDD
的分區一個比
Stage
粒度更細的單元叫做Task
,Stage
是由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 > Task
, Job 中包含 Stage 中包含 Task
而 Stage
中經常會有一組 Task
需要同時執行, 所以針對於每一個 Task
來進行調度太過繁瑣, 而且沒有意義, 所以每個 Stage
中的 Task
們會被收集起來, 放入一個 TaskSet
集合中
-
一個
Stage
有一個TaskSet
-
TaskSet
中Task
的個數由Stage
中的最大分區數決定
整體執行流程
6.4. Shuffle 過程
Shuffle
過程的組件結構
從整體視角上來看, Shuffle
發生在兩個 Stage
之間, 一個 Stage
把數據計算好, 整理好, 等待另外一個 Stage
來拉取
放大視角, 會發現, 其實 Shuffle
發生在 Task
之間, 一個 Task
把數據整理好, 等待 Reducer
端的 Task
來拉取
如果更細化一下, Task
之間如何進行數據拷貝的呢? 其實就是一方 Task
把文件生成好, 然後另一方 Task
來拉取
現在是一個 Reducer
的情況, 如果有多個 Reducer
呢? 如果有多個 Reducer
的話, 就可以在每個 Mapper
爲所有的 Reducer
生成各一個文件, 這種叫做 Hash base shuffle
, 這種 Shuffle
的方式問題大家也知道, 就是生成中間文件過多, 而且生成文件的話需要緩衝區, 佔用內存過大
那麼可以把這些文件合併起來, 生成一個文件返回, 這種 Shuffle
方式叫做 Sort base shuffle
, 每個 Reducer
去文件的不同位置拿取數據
如果再細化一下, 把參與這件事的組件也放置進去, 就會是如下這樣
有哪些 ShuffleWriter
?
大致上有三個 ShufflWriter
, Spark
會按照一定的規則去使用這三種不同的 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
的執行過程
整個 SortShuffleWriter
如上述所說, 大致有如下幾步
-
首先
SortShuffleWriter
在write
方法中回去寫文件, 這個方法中創建了ExternalSorter
-
write
中將數據insertAll
到ExternalSorter
中 -
在
ExternalSorter
中排序-
如果要聚合, 放入
AppendOnlyMap
中, 如果不聚合, 放入PartitionedPairBuffer
中 -
在數據結構中進行排序, 排序過程中如果內存數據大於閾值則溢寫到磁盤
-
-
使用
ExternalSorter
的writePartitionedFile
寫入輸入文件-
將所有的溢寫文件通過類似
MergeSort
的算法合併 -
將數據寫入最終的目標文件中
-
七、 RDD 的分佈式共享變量
目標
-
理解閉包以及 Spark 分佈式運行代碼的根本原理
-
理解累加變量的使用場景
-
理解廣播的使用場景
什麼是閉包
閉包是一個必須要理解, 但是又不太好理解的知識點, 先看一個小例子
@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 中class MyClass { val field = "Hello" def doStuff(rdd: RDD[String]): RDD[String] = { rdd.map(x => field + x) } }
這段代碼中的閉包就有了一個依賴, 依賴於外部的一個類, 因爲傳遞給算子的函數最終要在 Executor 中運行, 所以需要 序列化
MyClass
發給每一個Executor
, 從而在Executor
訪問MyClass
對象的屬性總結
閉包就是一個封閉的作用域, 也是一個對象
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 的信息, 以及其運行的情況
累計器件還有兩個小特性, 第一, 累加器能保證在 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. 廣播變量
目標
-
理解爲什麼需要廣播變量, 以及其應用場景
-
能夠通過代碼使用廣播變量
廣播變量的作用
廣播變量允許開發者將一個
Read-Only
的變量緩存到集羣中每個節點中, 而不是傳遞給每一個 Task 一個副本.
集羣中每個節點, 指的是一個機器
每一個 Task, 一個 Task 是一個 Stage 中的最小處理單元, 一個 Executor 中可以有多個 Stage, 每個 Stage 有多個 Task
所以在需要跨多個 Stage 的多個 Task 中使用相同數據的情況下, 廣播特別的有用
廣播變量的API
方法名 | 描述 |
---|---|
|
唯一標識 |
|
廣播變量的值 |
|
在 Executor 中異步的刪除緩存副本 |
|
銷燬所有此廣播變量所關聯的數據和元數據 |
|
字符串表示 |
使用廣播變量的一般套路
可以通過如下方式創建廣播變量
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 簡化使用
總結
-
廣播變量用於將變量緩存在集羣中的機器中, 避免機器內的 Executors 多次使用網絡拉取數據
-
廣播變量的使用步驟: (1) 創建 (2) 在 Task 中獲取值 (3) 銷燬