Spark學習記錄

RDD

RDD的全稱是:Resilient Distributed Dataset (彈性分布式數據集)


五個關鍵特性:

所有分區的列表

計算每個split的函數

對其他RDD上依賴的列表

可選的,對kv的RDD的Partitioner

可選的,對每個split計算偏好的位置


為解決資源不能在內存中,并且跨集群重復使用的問題,我們抽象出了RDD的概念,可以支持在大量的應用之間高效的重復利用數據。RDD是一種具備容錯性、并行的數據結構,可以在內存中持久化,以便在大量的算子之間操作。


Spark RDD編程接口

在Spark中,RDD被表示為對象,通過這些對象上的方法(或函數)調用轉換。


定義RDD之后,程序員就可以在動作中使用RDD了。動作是向應用程序返回值,或向存儲系統導出數據的那些操作,例如,count(返回RDD中的元素個數),collect(返回元素本身),save(將RDD輸出到存儲系統)。在Spark中,只有在動作第一次使用RDD時,才會計算RDD(即延遲計算)。這樣在構建RDD的時候,運行時通過管道的方式傳輸多個轉換。


程序員還可以從兩個方面控制RDD,即緩存和分區。用戶可以請求將RDD緩存,這樣運行時將已經計算好的RDD分區存儲起來,以加速后期的重用。緩存的RDD一般存儲在內存中,但如果內存不夠,可以寫到磁盤上。


另一方面,RDD還允許用戶根據關鍵字(key)指定分區順序,這是一個可選的功能。目前支持哈希分區和范圍分區。例如,應用程序請求將兩個RDD按照同樣的哈希分區方式進行分區(將同一機器上具有相同關鍵字的記錄放在一個分區),以加速它們之間的join操作。




Spark SQL

Join

當前SparkSQL支持三種Join算法-shuffle hash join、broadcast hash join以及sort merge join



1、 確定Build Table以及Probe Table:這個概念比較重要,Build Table使用join key構建Hash Table,而Probe Table使用join key進行探測,探測成功就可以join在一起。通常情況下,小表會作為Build Table,大表作為Probe Table。


2、 構建Hash Table:依次讀取Build Table(item)的數據,對于每一行數據根據join key(item.id)進行hash,hash到對應的Bucket,生成hash table中的一條記錄。


3、探測:再依次掃描Probe Table(order)的數據,使用相同的hash函數映射Hash Table中的記錄,映射成功之后再檢查join條件(item.id = order.i_id),如果匹配成功就可以將兩者join在一起。


基本流程可以參考上圖,這里有兩個小問題需要關注:

1、 hash join性能如何?很顯然,hash join基本都只掃描兩表一次,可以認為o(a+b),較之最極端的笛卡爾集運算a*b,不知甩了多少條街。

2、為什么Build Table選擇小表?道理很簡單,因為構建的Hash Table最好能全部加載在內存,效率最高;這也決定了hash join算法只適合至少一個小表的join場景,對于兩個大表的join場景并不適用;

上文說過,hash join是傳統數據庫中的單機join算法,在分布式環境下需要經過一定的分布式改造,說到底就是盡可能利用分布式計算資源進行并行化計算,提高總體效率。hash join分布式改造一般有兩種經典方案:

1、broadcast hash join:

? ? 將其中一張小表廣播分發到另一張大表所在的分區節點上,分別并發地與其上的分區記錄進行hash join。broadcast適用于小表很小,可以直接廣播的場景。

2、shuffle hash join:

? ? 一旦小表數據量較大,此時就不再適合進行廣播分發。這種情況下,可以根據join key相同必然分區相同的原理,將兩張表分別按照join key進行重新組織分區,這樣就可以將join分而治之,劃分為很多小join,充分利用集群資源并行化。(相當于在map端將大小按照key進行拆分重新組織分區,然后根據key分發到reduce端進行分別大小表的處理,最終再將結果進行匯總。)



shuffle hash join

在大數據條件下如果一張表很小,執行join操作最優的選擇無疑是broadcast hash join,效率最高。但是一旦小表數據量增大,廣播所需內存、帶寬等資源必然就會太大,broadcast hash join就不再是最優方案。此時可以按照join key進行分區,根據key相同必然分區相同的原理,就可以將大表join分而治之,劃分為很多小表的join,充分利用集群資源并行化。如下圖所示,shuffle hash join也可以分為兩步:

1、shuffle階段:分別將兩個表按照join key進行分區,將相同join key的記錄重分布到同一節點,兩張表的數據會被重分布到集群中所有節點。這個過程稱為shuffle

2、hash join階段:每個分區節點上的數據單獨執行單機hash join算法。(最后應該還要做一個union all的操作將之前處理的內容進行合并)





broadcast hash join

1、broadcast階段:將小表廣播分發到大表所在的所有主機。廣播算法可以有很多,最簡單的是先發給driver,driver再統一分發給所有executor;要不就是基于bittorrete的p2p思路;

基于bittorrete的p2p思路可參考:

https://zhidao.baidu.com/question/9782615.html

https://baike.baidu.com/item/BitTorrent/142795?fr=aladdin


2、hash join階段:在每個executor上執行單機版hash join,小表映射,大表試探;



sort merge join

1、shuffle階段:將兩張大表根據join key進行重新分區,兩張表數據會分布到整個集群,以便分布式并行處理

2、sort階段:對單個分區節點的兩表數據,分別進行排序

3、merge階段:對排好序的兩張分區表數據執行join操作。join操作很簡單,分別遍歷兩個有序序列,碰到相同join key就merge輸出,否則取更小一邊(兩張分區表進行join的過程中,會不斷的比較索引的大小,一直以索較小的索引值遍歷分區表數據。),見下圖示意:


仔細分析的話會發現,sort-merge join的代價并不比shuffle hash join小,反而是多了很多。那為什么SparkSQL還會在兩張大表的場景下選擇使用sort-merge join算法呢?這和Spark的shuffle實現有關,目前spark的shuffle實現都適用sort-based shuffle算法,因此在經過shuffle之后partition數據都是按照key排序的。因此理論上可以認為數據經過shuffle之后是不需要sort的,可以直接merge(也就是說sort-merge-join實際只需要執行shuffle和merge階段,而shuffle-hash-join需要執行shuffle和hash-join階段。而對于大表join大表來說,merge階段比hash-join階段更優!

為什么更優:hash-join的復雜度O(a+b);而merge小于O(a+b)。a,b代表數組的長度)。






Catalyst優化器

Spark引擎的Catalyst優化器編譯并優化了邏輯計劃,而且還有一個能夠確保生成最有效的物理計劃的成本優化器。


SQL語法樹和Rule

相信無論對SQL優化器有無了解,都肯定知道SQL語法樹這個概念,不錯,SQL語法樹就是SQL語句通過編譯器之后會被解析成一棵樹狀結構。這棵樹會包含很多節點對象,每個節點都擁有特定的數據類型,同時會有0個或多個孩子節點(節點對象在代碼中定義為TreeNode對象),下圖是個簡單的示例:

如上圖所示,箭頭左邊表達式有3種數據類型(Literal表示常量、Attribute表示變量、Add表示動作),表示x+(1+2)。映射到右邊樹狀結構后,每一種數據類型就會變成一個節點。另外,Tree還有一個非常重要的特性,可以通過一定的規則進行等價變換,如下圖:

上圖定義了一個等價變換規則(Rule):兩個Integer類型的常量相加可以等價轉換為一個Integer常量,這個規則其實很簡單,對于上文中提到的表達式x+(1+2)來說就可以轉變為x+3。對于程序來講,如何找到兩個Integer常量呢?其實就是簡單的二叉樹遍歷算法,每遍歷到一個節點,就模式匹配當前節點為Add、左右子節點是Integer常量的結構,定位到之后將此三個節點替換為一個Literal類型的節點。


上面用一個最簡單的示例來說明等價變換規則以及如何將規則應用于語法樹。在任何一個SQL優化器中,通常會定義大量的Rule(后面會講到),SQL優化器會遍歷語法樹中每個節點,針對遍歷到的節點模式匹配所有給定規則(Rule),如果有匹配成功的,就進行相應轉換,如果所有規則都匹配失敗,就繼續遍歷下一個節點。


優化器工作原理

任何一個優化器工作原理都大同小異:

SQL語句首先通過Parser模塊被解析為語法樹,此棵樹稱為Unresolved Logical Plan;

Unresolved Logical Plan通過Analyzer模塊借助于元數據解析為Logical Plan;

此時再通過各種基于規則的優化策略進行深入優化,得到Optimized Logical Plan;

優化后的邏輯執行計劃依然是邏輯的,并不能被Spark系統理解,此時需要將此邏輯執行計劃轉換為Physical Plan;

為了更好的對整個過程進行理解,下文通過一個簡單示例進行解釋。


Parser

Parser簡單來說是將SQL字符串切分成一個一個Token,再根據一定語義規則解析為一棵語法樹。Parser模塊目前基本都使用第三方類庫ANTLR進行實現,比如Hive、 Presto、SparkSQL等。下圖是一個示例性的SQL語句(有兩張表,其中people表主要存儲用戶基本信息,score表存儲用戶的各種成績),通過Parser解析后的AST語法樹如下圖所示:


Analyzer

通過解析后的邏輯執行計劃基本有了骨架,但是系統并不知道score、sum這些都是些什么鬼,此時需要基本的元數據信息來表達這些詞素,最重要的元數據信息主要包括兩部分:表的Scheme和基本函數信息,表的scheme主要包括表的基本定義(列名、數據類型)、表的數據格式(Json、Text)、表的物理位置等,基本函數信息主要指類信息。


Analyzer會再次遍歷整個語法樹,對樹上的每個節點進行數據類型綁定以及函數綁定,比如people詞素會根據元數據表信息解析為包含age、id以及name三列的表,people.age會被解析為數據類型為int的變量,sum會被解析為特定的聚合函數,如下圖所示:

SparkSQL中Analyzer定義了各種解析規則,有興趣深入了解的童鞋可以查看Analyzer類,其中定義了基本的解析規則,如下:


Optimizer(優化器)

優化器是整個Catalyst的核心,上文提到優化器分為基于規則優化和基于代價優化兩種,當前SparkSQL 2.1依然沒有很好的支持基于代價優化(下文細講),此處只介紹基于規則的優化策略,基于規則的優化策略實際上就是對語法樹進行一次遍歷,模式匹配能夠滿足特定規則的節點,再進行相應的等價轉換。因此,基于規則優化說到底就是一棵樹等價地轉換為另一棵樹。SQL中經典的優化規則有很多,下文結合示例介紹三種比較常見的規則:謂詞下推(Predicate Pushdown)、常量累加(Constant Folding)和列值裁剪(Column Pruning)。


上圖左邊是經過Analyzer解析前的語法樹,語法樹中兩個表先做join,之后再使用age>10對結果進行過濾。大家知道join算子通常是一個非常耗時的算子,耗時多少一般取決于參與join的兩個表的大小,如果能夠減少參與join兩表的大小,就可以大大降低join算子所需時間。謂詞下推就是這樣一種功能,它會將過濾操作下推到join之前進行,上圖中過濾條件age>0以及id!=null兩個條件就分別下推到了join之前。這樣,系統在掃描數據的時候就對數據進行了過濾,參與join的數據量將會得到顯著的減少,join耗時必然也會降低。

常量累加其實很簡單,就是上文中提到的規則 x+(1+2) -> x+3,雖然是一個很小的改動,但是意義巨大。示例如果沒有進行優化的話,每一條結果都需要執行一次100+80的操作,然后再與變量math_score以及english_score相加,而優化后就不需要再執行100+80操作。

列值裁剪是另一個經典的規則,示例中對于people表來說,并不需要掃描它的所有列值,而只需要列值id,所以在掃描people之后需要將其他列進行裁剪,只留下列id。這個優化一方面大幅度減少了網絡、內存數據量消耗,另一方面對于列存數據庫(Parquet)來說大大提高了掃描效率。


至此,邏輯執行計劃已經得到了比較完善的優化,然而,邏輯執行計劃依然沒辦法真正執行,他們只是邏輯上可行,實際上Spark并不知道如何去執行這個東西。比如Join只是一個抽象概念,代表兩個表根據相同的id進行合并,然而具體怎么實現這個合并,邏輯執行計劃并沒有說明。


此時就需要將邏輯執行計劃轉換為物理執行計劃,將邏輯上可行的執行計劃變為Spark可以真正執行的計劃。比如Join算子,Spark根據不同場景為該算子制定了不同的算法策略,有BroadcastHashJoin、ShuffleHashJoin以及SortMergeJoin等(可以將Join理解為一個接口,BroadcastHashJoin是其中一個具體實現),物理執行計劃實際上就是在這些具體實現中挑選一個耗時最小的算法實現,這個過程涉及到基于代價優化策略,后續文章細講。












PySpark 的原理

Spark運行時架構

首先我們先回顧下Spark的基本運行時架構,如下圖所示,其中橙色部分表示為JVM,Spark應用程序運行時主要分為Driver和Executor,Driver負載總體調度及UI展示,Executor負責Task運行,Spark可以部署在多種資源管理系統中,例如Yarn、Mesos等,同時Spark自身也實現了一種簡單的Standalone(獨立部署)資源管理系統,可以不用借助其他資源管理系統即可運行。

用戶的Spark應用程序運行在Driver上(某種程度上說,用戶的程序就是Spark Driver程序),經過Spark調度封裝成一個個Task,再將這些Task信息發給Executor執行,Task信息包括代碼邏輯以及數據信息,Executor不直接運行用戶的代碼。


PySpark運行時架構

為了不破壞Spark已有的運行時架構,Spark在外圍包裝一層Python API,借助Py4j實現Python和Java的交互,進而實現通過Python編寫Spark應用程序,其運行時架構如下圖所示。

其中白色部分是新增的Python進程,在Driver端,通過Py4j實現在Python中調用Java的方法,即將用戶寫的PySpark程序”映射”到JVM中,例如,用戶在PySpark中實例化一個Python的SparkContext對象,最終會在JVM中實例化Scala的SparkContext對象;在Executor端,則不需要借助Py4j,因為Executor端運行的Task邏輯是由Driver發過來的,那是序列化后的字節碼,雖然里面可能包含有用戶定義的Python函數或Lambda表達式,Py4j并不能實現在Java里調用Python的方法,為了能在Executor端運行用戶定義的Python函數或Lambda表達式,則需要為每個Task單獨啟一個Python進程,通過socket通信方式將Python函數或Lambda表達式發給Python進程執行。語言層面的交互總體流程如下圖所示,實線表示方法調用,虛線表示結果返回。



下面分別詳細剖析PySpark的Driver是如何運行起來的以及Executor是如何運行Task的。


Driver端運行原理

當我們通過spark-submmit提交pyspark程序,首先會上傳python腳本及依賴,并申請Driver資源,當申請到Driver資源后,會通過PythonRunner(其中有main方法)拉起JVM,如下圖所示。


PythonRunner入口main函數里主要做兩件事:

開啟Py4j GatewayServer

通過Java Process方式運行用戶上傳的Python腳本


用戶Python腳本起來后,首先會實例化Python版的SparkContext對象,在實例化過程中會做兩件事:

實例化Py4j GatewayClient,連接JVM中的Py4j GatewayServer,后續在Python中調用Java的方法都是借助這個Py4j Gateway

通過Py4j Gateway在JVM中實例化SparkContext對象


經過上面兩步后,SparkContext對象初始化完畢,Driver已經起來了,開始申請Executor資源,同時開始調度任務。用戶Python腳本中定義的一系列處理邏輯最終遇到action方法后會觸發Job的提交,提交Job時是直接通過Py4j調用Java的PythonRDD.runJob方法完成,映射到JVM中,會轉給sparkContext.runJob方法,Job運行完成后,JVM中會開啟一個本地Socket等待Python進程拉取,對應地,Python進程在調用PythonRDD.runJob后就會通過Socket去拉取結果。


把前面運行時架構圖中Driver部分單獨拉出來,如下圖所示,通過PythonRunner入口main函數拉起JVM和Python進程,JVM進程對應下圖橙色部分,Python進程對應下圖白色部分。Python進程通過Py4j調用Java方法提交Job,Job運行結果通過本地Socket被拉取到Python進程。還有一點是,對于大數據量,例如廣播變量等,Python進程和JVM進程是通過本地文件系統來交互,以減少進程間的數據傳輸。


Executor端運行原理

為了方便闡述,以Spark On Yarn為例,當Driver申請到Executor資源時,會通過CoarseGrainedExecutorBackend(其中有main方法)拉起JVM,啟動一些必要的服務后等待Driver的Task下發,在還沒有Task下發過來時,Executor端是沒有Python進程的。當收到Driver下發過來的Task后,Executor的內部運行過程如下圖所示。


Executor端收到Task后,會通過launchTask運行Task,最后會調用到PythonRDD的compute方法,來處理一個分區的數據,PythonRDD的compute方法的計算流程大致分三步走:

如果不存在pyspark.deamon后臺Python進程,那么通過Java Process的方式啟動pyspark.deamon后臺進程,注意每個Executor上只會有一個pyspark.deamon后臺進程,否則,直接通過Socket連接pyspark.deamon,請求開啟一個pyspark.worker進程運行用戶定義的Python函數或Lambda表達式。pyspark.deamon是一個典型的多進程服務器,來一個Socket請求,fork一個pyspark.worker進程處理,一個Executor上同時運行多少個Task,就會有多少個對應的pyspark.worker進程。

緊接著會單獨開一個線程,給pyspark.worker進程喂數據,pyspark.worker則會調用用戶定義的Python函數或Lambda表達式處理計算。

在一邊喂數據的過程中,另一邊則通過Socket去拉取pyspark.worker的計算結果。

把前面運行時架構圖中Executor部分單獨拉出來,如下圖所示,橙色部分為JVM進程,白色部分為Python進程,每個Executor上有一個公共的pyspark.deamon進程,負責接收Task請求,并fork pyspark.worker進程單獨處理每個Task,實際數據處理過程中,pyspark.worker進程和JVM Task會較頻繁地進行本地Socket數據通信。


總結

總體上來說,PySpark是借助Py4j實現Python調用Java,來驅動Spark應用程序,本質上主要還是JVM runtime,Java到Python的結果返回是通過本地Socket完成。雖然這種架構保證了Spark核心代碼的獨立性,但是在大數據場景下,JVM和Python進程間頻繁的數據通信導致其性能損耗較多,惡劣時還可能會直接卡死,所以建議對于大規模機器學習或者Streaming應用場景還是慎用PySpark,盡量使用原生的Scala/Java編寫應用程序,對于中小規模數據量下的簡單離線任務,可以使用PySpark快速部署提交。


參考:

http://sharkdtu.com/posts/pyspark-internal.html




Spark部署模式

Yarn-cluster

在Yarn-cluster模式下,driver運行在Appliaction Master上,Appliaction Master進程同時負責驅動Application和從Yarn中申請資源,該進程運行在Yarn container內,所以啟動Application Master的client可以立即關閉而不必持續到Application的生命周期,下圖是yarn-cluster模式



Yarn-client

在Yarn-client中,Application Master僅僅從Yarn中申請資源給Executor,之后client會跟container通信進行作業的調度,下圖是Yarn-client模式



Spark內存模型

堆內和堆外內存規劃

作為一個 JVM 進程,Executor 的內存管理建立在 JVM 的內存管理之上,Spark 對 JVM 的堆內(On-heap)空間進行了更為詳細的分配,以充分利用內存。同時,Spark 引入了堆外(Off-heap)內存,使之可以直接在工作節點的系統內存中開辟空間,進一步優化了內存的使用。


圖 1 . 堆內和堆外內存示意圖


堆內內存

堆內內存的大小,由 Spark 應用程序啟動時的 –executor-memory 或 spark.executor.memory 參數配置。Executor 內運行的并發任務共享 JVM 堆內內存,這些任務在緩存 RDD 數據和廣播(Broadcast)數據時占用的內存被規劃為存儲(Storage)內存,而這些任務在執行 Shuffle 時占用的內存被規劃為執行(Execution)內存,剩余的部分不做特殊規劃,那些 Spark 內部的對象實例,或者用戶定義的 Spark 應用程序中的對象實例,均占用剩余的空間。


堆外內存

為了進一步優化內存的使用以及提高 Shuffle 時排序的效率,Spark 引入了堆外(Off-heap)內存,使之可以直接在工作節點的系統內存中開辟空間,存儲經過序列化的二進制數據。利用 JDK Unsafe API(從 Spark 2.0 開始,在管理堆外的存儲內存時不再基于 Tachyon,而是與堆外的執行內存一樣,基于 JDK Unsafe API 實現),Spark 可以直接操作系統堆外內存,減少了不必要的內存開銷,以及頻繁的 GC 掃描和回收,提升了處理性能。堆外內存可以被精確地申請和釋放,而且序列化的數據占用的空間可以被精確計算,所以相比堆內內存來說降低了管理的難度,也降低了誤差。


在默認情況下堆外內存并不啟用,可通過配置 spark.memory.offHeap.enabled 參數啟用,并由 spark.memory.offHeap.size 參數設定堆外空間的大小。除了沒有 other 空間,堆外內存與堆內內存的劃分方式相同,所有運行中的并發任務共享存儲內存和執行內存。



內存空間分配

靜態內存管理

在 Spark 最初采用的靜態內存管理機制下,存儲內存、執行內存和其他內存的大小在 Spark 應用程序運行期間均為固定的,但用戶可以應用程序啟動前進行配置,堆內內存的分配如圖 2 所示:


圖 2 . 靜態內存管理圖示——堆內


統一內存管理

Spark 1.6 之后引入的統一內存管理機制,與靜態內存管理的區別在于存儲內存和執行內存共享同一塊空間,可以動態占用對方的空閑區域,如圖 4 和圖 5 所示

圖 4 . 統一內存管理圖示——堆內


圖 5 . 統一內存管理圖示——堆外


其中最重要的優化在于動態占用機制,其規則如下:


設定基本的存儲內存和執行內存區域(spark.storage.storageFraction 參數),該設定確定了雙方各自擁有的空間的范圍

雙方的空間都不足時,則存儲到硬盤;若己方空間不足而對方空余時,可借用對方的空間;(存儲空間不足是指不足以放下一個完整的 Block)

執行內存的空間被對方占用后,可讓對方將占用的部分轉存到硬盤,然后”歸還”借用的空間

存儲內存的空間被對方占用后,無法讓對方”歸還”,因為需要考慮 Shuffle 過程中的很多因素,實現起來較為復雜[4]

圖 6 . 動態占用機制圖示


憑借統一內存管理機制,Spark 在一定程度上提高了堆內和堆外內存資源的利用率,降低了開發者維護 Spark 內存的難度,但并不意味著開發者可以高枕無憂。譬如,所以如果存儲內存的空間太大或者說緩存的數據過多,反而會導致頻繁的全量垃圾回收,降低任務執行時的性能,因為緩存的 RDD 數據通常都是長期駐留內存的[5]。所以要想充分發揮 Spark 的性能,需要開發者進一步了解存儲內存和執行內存各自的管理方式和實現原理。


存儲內存管理

RDD 的持久化機制

彈性分布式數據集(RDD)作為 Spark 最根本的數據抽象,是只讀的分區記錄(Partition)的集合,只能基于在穩定物理存儲中的數據集上創建,或者在其他已有的 RDD 上執行轉換(Transformation)操作產生一個新的 RDD。轉換后的 RDD 與原始的 RDD 之間產生的依賴關系,構成了血統(Lineage)。憑借血統,Spark 保證了每一個 RDD 都可以被重新恢復。但 RDD 的所有轉換都是惰性的,即只有當一個返回結果給 Driver 的行動(Action)發生時,Spark 才會創建任務讀取 RDD,然后真正觸發轉換的執行。


Task 在啟動之初讀取一個分區時,會先判斷這個分區是否已經被持久化,如果沒有則需要檢查 Checkpoint 或按照血統重新計算。所以如果一個 RDD 上要執行多次行動,可以在第一次行動中使用 persist 或 cache 方法,在內存或磁盤中持久化或緩存這個 RDD,從而在后面的行動時提升計算速度。事實上,cache 方法是使用默認的 MEMORY_ONLY 的存儲級別將 RDD 持久化到內存,故緩存是一種特殊的持久化。 堆內和堆外存儲內存的設計,便可以對緩存 RDD 時使用的內存做統一的規劃和管 理 (存儲內存的其他應用場景,如緩存 broadcast 數據,暫時不在本文的討論范圍之內)。


RDD 的持久化由 Spark 的 Storage 模塊[7]負責,實現了 RDD 與物理存儲的解耦合。Storage 模塊負責管理 Spark 在計算過程中產生的數據,將那些在內存或磁盤、在本地或遠程存取數據的功能封裝了起來。在具體實現時 Driver 端和 Executor 端的 Storage 模塊構成了主從式的架構,即 Driver 端的 BlockManager 為 Master,Executor 端的 BlockManager 為 Slave。Storage 模塊在邏輯上以 Block 為基本存儲單位,RDD 的每個 Partition 經過處理后唯一對應一個 Block(BlockId 的格式為 rdd_RDD-ID_PARTITION-ID )。Master 負責整個 Spark 應用程序的 Block 的元數據信息的管理和維護,而 Slave 需要將 Block 的更新等狀態上報到 Master,同時接收 Master 的命令,例如新增或刪除一個 RDD。


圖 7 . Storage 模塊示意圖



RDD 緩存的過程

RDD 在緩存到存儲內存之前,Partition 中的數據一般以迭代器( Iterator )的數據結構來訪問,這是 Scala 語言中一種遍歷數據集合的方法。通過 Iterator 可以獲取分區中每一條序列化或者非序列化的數據項(Record),這些 Record 的對象實例在邏輯上占用了 JVM 堆內內存的 other 部分的空間,同一 Partition 的不同 Record 的空間并不連續。


RDD 在緩存到存儲內存之后,Partition 被轉換成 Block,Record 在堆內或堆外存儲內存中占用一塊連續的空間。 將 Partition 由不連續的存儲空間轉換為連續存儲空間的過程,Spark 稱之為”展開”(Unroll) 。Block 有序列化和非序列化兩種存儲格式,具體以哪種方式取決于該 RDD 的存儲級別。非序列化的 Block 以一種 DeserializedMemoryEntry 的數據結構定義,用一個數組存儲所有的對象實例,序列化的 Block 則以 SerializedMemoryEntry的數據結構定義,用字節緩沖區(ByteBuffer)來存儲二進制數據。每個 Executor 的 Storage 模塊用一個鏈式 Map 結構(LinkedHashMap)來管理堆內和堆外存儲內存中所有的 Block 對象的實例[6],對這個 LinkedHashMap 新增和刪除間接記錄了內存的申請和釋放。


因為不能保證存儲空間可以一次容納 Iterator 中的所有數據,當前的計算任務在 Unroll 時要向 MemoryManager 申請足夠的 Unroll 空間來臨時占位,空間不足則 Unroll 失敗,空間足夠時可以繼續進行。對于序列化的 Partition,其所需的 Unroll 空間可以直接累加計算,一次申請。而非序列化的 Partition 則要在遍歷 Record 的過程中依次申請,即每讀取一條 Record,采樣估算其所需的 Unroll 空間并進行申請,空間不足時可以中斷,釋放已占用的 Unroll 空間。如果最終 Unroll 成功,當前 Partition 所占用的 Unroll 空間被轉換為正常的緩存 RDD 的存儲空間,如下圖 8 所示。


圖 8. Spark Unroll 示意圖

在圖 3 和圖 5 中可以看到,在靜態內存管理時,Spark 在存儲內存中專門劃分了一塊 Unroll 空間,其大小是固定的,統一內存管理時則沒有對 Unroll 空間進行特別區分,當存儲空間不足時會根據動態占用機制進行處理。


淘汰和落盤

由于同一個 Executor 的所有的計算任務共享有限的存儲內存空間,當有新的 Block 需要緩存但是剩余空間不足且無法動態占用時,就要對 LinkedHashMap 中的舊 Block 進行淘汰(Eviction),而被淘汰的 Block 如果其存儲級別中同時包含存儲到磁盤的要求,則要對其進行落盤(Drop),否則直接刪除該 Block。


存儲內存的淘汰規則為:


被淘汰的舊 Block 要與新 Block 的 MemoryMode 相同,即同屬于堆外或堆內內存

新舊 Block 不能屬于同一個 RDD,避免循環淘汰

舊 Block 所屬 RDD 不能處于被讀狀態,避免引發一致性問題

遍歷 LinkedHashMap 中 Block,按照最近最少使用(LRU)的順序淘汰,直到滿足新 Block 所需的空間。其中 LRU 是 LinkedHashMap 的特性。

落盤的流程則比較簡單,如果其存儲級別符合_useDisk 為 true 的條件,再根據其_deserialized 判斷是否是非序列化的形式,若是則對其進行序列化,最后將數據存儲到磁盤,在 Storage 模塊中更新其信息。


執行內存管理

多任務間內存分配

Executor 內運行的任務同樣共享執行內存,Spark 用一個 HashMap 結構保存了任務到內存耗費的映射。每個任務可占用的執行內存大小的范圍為 1/2N ~ 1/N,其中 N 為當前 Executor 內正在運行的任務的個數。每個任務在啟動之時,要向 MemoryManager 請求申請最少為 1/2N 的執行內存,如果不能被滿足要求則該任務被阻塞,直到有其他任務釋放了足夠的執行內存,該任務才可以被喚醒。


Shuffle 的內存占用

執行內存主要用來存儲任務在執行 Shuffle 時占用的內存,Shuffle 是按照一定規則對 RDD 數據重新分區的過程,我們來看 Shuffle 的 Write 和 Read 兩階段對執行內存的使用:


Shuffle Write


若在 map 端選擇普通的排序方式,會采用 ExternalSorter 進行外排,在內存中存儲數據時主要占用堆內執行空間。

若在 map 端選擇 Tungsten 的排序方式,則采用 ShuffleExternalSorter 直接對以序列化形式存儲的數據排序,在內存中存儲數據時可以占用堆外或堆內執行空間,取決于用戶是否開啟了堆外內存以及堆外執行內存是否足夠。、



Shuffle Read


在對 reduce 端的數據進行聚合時,要將數據交給 Aggregator 處理,在內存中存儲數據時占用堆內執行空間。

如果需要進行最終結果排序,則要將再次將數據交給 ExternalSorter 處理,占用堆內執行空間。



在 ExternalSorter 和 Aggregator 中,Spark 會使用一種叫 AppendOnlyMap 的哈希表在堆內執行內存中存儲數據,但在 Shuffle 過程中所有數據并不能都保存到該哈希表中,當這個哈希表占用的內存會進行周期性地采樣估算,當其大到一定程度,無法再從 MemoryManager 申請到新的執行內存時,Spark 就會將其全部內容存儲到磁盤文件中,這個過程被稱為溢存(Spill),溢存到磁盤的文件最后會被歸并(Merge)。


Shuffle Write 階段中用到的 Tungsten 是 Databricks 公司提出的對 Spark 優化內存和 CPU 使用的計劃[9],解決了一些 JVM 在性能上的限制和弊端。Spark 會根據 Shuffle 的情況來自動選擇是否采用 Tungsten 排序。Tungsten 采用的頁式內存管理機制建立在 MemoryManager 之上,即 Tungsten 對執行內存的使用進行了一步的抽象,這樣在 Shuffle 過程中無需關心數據具體存儲在堆內還是堆外。每個內存頁用一個 MemoryBlock 來定義,并用 Object obj 和 long offset 這兩個變量統一標識一個內存頁在系統內存中的地址。堆內的 MemoryBlock 是以 long 型數組的形式分配的內存,其 obj 的值為是這個數組的對象引用,offset 是 long 型數組的在 JVM 中的初始偏移地址,兩者配合使用可以定位這個數組在堆內的絕對地址;堆外的 MemoryBlock 是直接申請到的內存塊,其 obj 為 null,offset 是這個內存塊在系統內存中的 64 位絕對地址。Spark 用 MemoryBlock 巧妙地將堆內和堆外內存頁統一抽象封裝,并用頁表(pageTable)管理每個 Task 申請到的內存頁。


Tungsten 頁式管理下的所有內存用 64 位的邏輯地址表示,由頁號和頁內偏移量組成:


頁號:占 13 位,唯一標識一個內存頁,Spark 在申請內存頁之前要先申請空閑頁號。

頁內偏移量:占 51 位,是在使用內存頁存儲數據時,數據在頁內的偏移地址。

有了統一的尋址方式,Spark 可以用 64 位邏輯地址的指針定位到堆內或堆外的內存,整個 Shuffle Write 排序的過程只需要對指針進行排序,并且無需反序列化,整個過程非常高效,對于內存訪問效率和 CPU 使用效率帶來了明顯的提升[10]。


Spark 的存儲內存和執行內存有著截然不同的管理方式:對于存儲內存來說,Spark 用一個 LinkedHashMap 來集中管理所有的 Block,Block 由需要緩存的 RDD 的 Partition 轉化而成;而對于執行內存,Spark 用 AppendOnlyMap 來存儲 Shuffle 過程中的數據,在 Tungsten 排序中甚至抽象成為頁式內存管理,開辟了全新的 JVM 內存管理機制。



Spark Shuffle

Spark Shuffle 的發展

Spark 0.8及以前 Hash Based Shuffle

Spark 0.8.1 為Hash Based Shuffle引入File Consolidation機制

Spark 0.9 引入ExternalAppendOnlyMap

Spark 1.1 引入Sort Based Shuffle,但默認仍為Hash Based Shuffle

Spark 1.2 默認的Shuffle方式改為Sort Based Shuffle

Spark 1.4 引入Tungsten-Sort Based Shuffle

Spark 1.6 Tungsten-sort并入Sort Based Shuffle

Spark 2.0 Hash Based Shuffle退出歷史舞臺


未優化的 HashShuffle

每一個 ShuffleMapTask 都會為每一個 ReducerTask 創建一個單獨的文件,總的文件數是 M * R,其中 M 是 ShuffleMapTask 的數量,R 是 ShuffleReduceTask 的數量。

在處理大數據時,ShuffleMapTask 和 ShuffleReduceTask 的數量很多,創建的磁盤文件數量 M*R 也越多,大量的文件要寫磁盤,再從磁盤讀出來,不僅會占用大量的時間,而且每個磁盤文件記錄的句柄都會保存在內存中(每個人大約 100k),因此也會占用很大的內存空間,頻繁的打開和關閉文件,會導致頻繁的GC操作,很容易出現 OOM 的情況。


也正是上述原因,該 HashShuffle 如今已退出歷史舞臺。


優化后 HashShuffle

在 Spark 0.8.1 版本中,引入了 Consolidation 機制,該機制是對 HashShuffle 的一種優化。

可以明顯看出,在一個 core 上連續執行的 ShuffleMapTasks 可以共用一個輸出文件 ShuffleFile。


先執行完的 ShuffleMapTask 形成 ShuffleBlock i,后執行的 ShuffleMapTask 可以將輸出數據直接追加到 ShuffleBlock i 后面,形成 ShuffleBlock i',每個 ShuffleBlock 被稱為 FileSegment。下一個 stage 的 reducer 只需要 fetch 整個 ShuffleFile 就行了。


這樣,每個 worker 持有的文件數降為 cores * R。cores 代表核數,R 是 ShuffleReduceTask 數。


Sort-Based Shuffle

由于 HashShuffle 會產生很多的磁盤文件,引入 Consolidation 機制雖然在一定程度上減少了磁盤文件數量,但是不足以有效提高 Shuffle 的性能,適合中小型數據規模的大數據處理。


為了讓 Spark 在更大規模的集群上更高性能處理更大規模的數據,因此在 Spark 1.1 版本中,引入了 SortShuffle。


該機制每一個 ShuffleMapTask 都只創建一個文件,將所有的 ShuffleReduceTask 的輸入都寫入同一個文件,并且對應生成一個索引文件。


以前的數據是放在內存緩存中,等到數據完了再刷到磁盤,現在為了減少內存的使用,在內存不夠用的時候,可以將輸出溢寫到磁盤,結束的時候,再將這些不同的文件聯合內存的數據一起進行歸并,從而減少內存的使用量。一方面文件數量顯著減少,另一方面減少Writer 緩存所占用的內存大小,而且同時避免 GC 的風險和頻率。


您可以輸出按“ reducer” id排序并建立索引的單個文件,這樣您就可以輕松地獲取塊通過僅獲取有關文件中相關數據塊位置的信息并在讀取前進行一次fseek來查找與“ reducer x”相關的數據。但是,當然對于少量的“ reducer”來說,對單獨文件進行散列操作顯然要比排序更快,因此排序改組有一個“后備”計劃:當“ reducers”的數量小于“ spark.shuffle. sort.bypassMergeThreshold”(默認為200)時,我們使用“后備”計劃,先對數據進行哈希處理以分離出文件,然后將這些文件合并為一個文件。此邏輯在單獨的類BypassMergeSortShuffleWriter中實現。



spark shuffle 過程

兩個stage中間結果通過shuffle傳遞,shuffle的三個步驟如圖所示


每一個executor一旦啟動就在同一節點的Spark External Shuffle Service(ESS)上面注冊,這樣的注冊讓spark ESS知道來自每個注冊executor的本地map任務生成的物化shuffle數據的位置,注意ESS是在executor之外的并且由多個spark應用共享。

在 shuffle map stage里面的每個任務處理自己的數據,在每個map任務的末尾,它生成一對文件,一個是shuffle的數據,一個是shuffle block的索引文件。為了這樣,map任務需要對所有transform的數據根據partition key的哈希值排序,在這個過程中,map任務可能會因為不能在內存中排序所有數據而將數據溢寫到磁盤里。一旦排序完成,shuffle數據文件就會生成,屬于同一個shuffle partition的所有記錄都會被整合到一個shuffle block里面,與之匹配的shuffle index文件也會生成,它記錄了每個block邊界的offset。

當下一個stage的reduce的任務要運行時,它們將會向driver查詢它們輸入的shuffle block的位置。一旦得到這些數據,每個reduce task會建立一個到對應ESS的實例連接以便獲取數據。ESS一旦接收到這些請求,會根據shuffle index文件查詢跳轉到對應的shuffle block文件,讀取這個block并將數據發送回reduce task。

將executor和ESS解耦的優點:

ESS在executor GC暫停是仍能提供shuffle block的服務。

即使生成shuffle block的executor銷毀仍然能夠提供服務。

閑置的executor可以被清空釋放資源

對該shuffle的優化可以參考:

https://issues.apache.org/jira/browse/SPARK-30602



任務調度器Scheduler

寬依賴與窄依賴

RDD之間的依賴關系可以分為兩類,即寬依賴和窄依賴,寬依賴與窄依賴的區分主要是父partition與子partition的對應關系,區分寬窄依賴主要就是看父RDD的一個partition的流向,要是流向一個的話就是窄依賴,流向多個的話就是寬依賴(用圖的概念即判斷父partition的出度)。


stage的劃分

Spark任務會根據RDD之間的依賴關系,形成一個DAG有向無環圖,DAG會提交給DAGScheduler,DAGScheduler會把DAG劃分成互相依賴的多個stage,劃分stage的依據就是RDD之間的寬窄依賴。


如上圖所示,A/B/C/D/E/F代表RDD,當執行算子存在shuffle操作的時候,就劃分一個stage,即用寬依賴來劃分stage。窄依賴會被劃分到同一個stage中,這樣他們就可以以管道的方式執行,寬依賴由于依賴的上游不止一個,所以往往需要需要跨節點傳輸數據。



Scheduler

DagScheduler(是高級的調度)。DAGScheduler負責將Task拆分成不同Stage的具有依賴關系(包含RDD的依賴關系)的多批任務,然后提交給TaskScheduler進行具體處理。DAG全稱 Directed Acyclic Graph,有向無環圖。簡單的來說,就是一個由頂點和有方向性的邊構成的圖,從任意一個頂點出發,沒有任何一條路徑會將其帶回到出發的頂點。

TaskScheduler(是低級的調度器接口)。TaskScheduler負責實際每個具體Task的物理調度。


Spark的任務調度總體來說分兩路進行,一路是Stage級的調度,一路是Task級的調度,總體調度流程如下圖所示。




Spark存儲系統

三種存儲數據的操作

任何一個存儲系統要解決的關鍵問題無非是數據的存與取、收與發,不過,在去探討 Spark 存儲系統如何工作之前,咱們先來搞清楚 Spark 存儲系統中“存”的主要是什么內容?總的來說,Spark 存儲系統用于存儲 3 個方面的數據:


RDD 緩存:RDD 緩存指的是將 DAG 中某些計算成本較高且訪問頻率較高的數據形態以緩存的形式物化到內存或磁盤的過程。對于血統較長的 DAG 來說,RDD 緩存一來可以通過截斷 DAG 從而降低失敗重試的開銷,二來通過緩存在內存或磁盤中的數據來從整體上提升作業的端到端執行性能。

Shuffle 中間結果:Shuffle writer 按照 Shuffle 分區規則將本節點數據以分片(Splits)的形式寫入本地磁盤(或內存)。

廣播變量:廣播變量的設計初衷是為了解決 Spark 調度系統中任務調度的開銷問題。


詳解

Spark 存儲系統提供了兩種存儲實現,分別是內存存儲 MemoryStore 和磁盤存儲 DiskStore。從名字我們就能看出來,MemoryStore 用于存儲內存中的數據塊,而 DiskStore 則用來存儲磁盤中的數據塊。


Spark 支持兩種數據存儲形式,即對象值和字節數組,且兩者之間可以相互轉化。將對象值壓縮為字節數組的過程,稱為序列化;相反,將字節數組還原為對象值,稱之為反序列化。序列化的字節數組就像是從宜家家具超市購買的待組裝板材(外加組裝說明書),而對象值則是將板材拆包、并根據說明書組裝而成的各種桌椅板凳。對象值的優點是“拿來即用”、所見即所得,缺點是所需的存儲空間大、占地兒。相比之下,序列化的字節數組的空間利用率要高得多,不過要是急用桌椅板凳的話,還得根據說明書現裝,略麻煩。


由此可見,二者的關系是一種博弈,所謂的“以空間換時間”和“以時間換空間”,具體的取舍還要看使用場景,想省地兒,您就用字節數組,想以最快的速度訪問對象,對象值的存儲方式還是來的更直接一些。不過像這種選擇的煩惱只存在于 MemoryStore 之中,DiskStore 只能存序列化后的字節數組 —— 這里咱們多說兩嘴,凡是需要走網絡或落盤的數據,都是需要序列化的。


先說 DiskBlockManager,DiskBlockManager 的主要職責是記錄邏輯數據塊 Block 與磁盤文件系統中物理文件的對應關系。每個 Block 都對應一個磁盤文件,同理,每個磁盤文件都有一個與之對應的 Block ID,這就好比倉庫中的每一件貨物都有唯一的 ID 標識,顯而易見,DiskBlockManager 就是專用倉儲基地 DiskStore 的“庫管”。


相較于 DiskBlockManager,MemoryStore“庫管”BlockInfoManager 就顯得沒那么“稱職”,對于 MemoryStore 中存儲的數據,BlockInfoManager 的職責僅包含如下各項:記錄數據塊大小、維護加持在 Block 上的讀鎖與寫鎖、維護任務的讀寫權限狀態。簡言之,BlockInfoManager 主要用于保持 MemoryStore 中數據狀態的一致性,而不是用于維護邏輯塊與物理存儲的對應關系。那么問題來了,想要拉取 MemoryStore 中“貨物”的卡車司機怎么知道貨物存儲在哪個貨架呢?

要回答這個問題,咱們還要說回 MemoryStore,前文書咱們說到 MemoryStore 可以存儲兩種形式的數據,即對象值和字節數組,對于這兩種數據形式,MemoryStore 統一采用 MemoryEntry 抽象來進行封裝。

MemoryEntry 實現為 Scala Trait,主要成員為數據塊大小和數據類型,它的兩個實現類 DeserializedMemoryEntry 和 SerializedMemoryEntry 分別用于封裝對象值和字節數組,其中 DeserializedMemoryEntry 利用 Array[T] 來存儲對象值序列,而 SerializedMemoryEntry 利用 ByteBuffer 來存儲序列化后的字節序列。


MemoryStore 通過一種高效的數據結構來統一數據塊的存儲與訪問:LinkedHashMap[BlockId, MemoryEntry],即 Key 為 BlockId、Value 是 MemoryEntry 的映射。顯然,一個 Block 對應一個 MemoryEntry,MemoryEntry 既可以是 DeserializedMemoryEntry、也可以是 SerializedMemoryEntry。有了這個 LinkedHashMap,通過 BlockId 即可方便地定位 MemoryEntry,從而實現數據塊的快速存取。


MemoryStore 中的數據存儲流程


先來看數據存儲的流程,也即將 RDD 中數據分片(Partitions/Blocks)的 Iterator 物化為數據存儲的過程。


邏輯上,RDD 數據分片(也即 Partition)與 Block 是一一對應的,不過需要指出的是,Partition 的編號規則與 Block 的編號規則不見得保持一致。MemoryStore 提供了 putIteratorAsValues 和 putIteratorAsBytes 來將 RDD 數據分片對應的迭代器分別物化為對象值序列和字節序列,具體流程如上圖所示。


值得注意的是,在將數據封裝為 MemoryEntry 之前,MemoryStore 先利用 ValuesHolder 對 Iterator 進行展開(Unroll),展開的過程實際上就是物化的過程,數據實實在在地存儲到 ValuesHolder 封裝的數據結構(Vector 或 OutputStream)中,這些物化的數據在之后封裝為 MemoryEntry 的過程中,僅僅(通過 toArray、toByteBuffer 等操作)在數據類型上做了轉換,并沒有帶來額外的內存消耗,Spark 源碼中將這個過程稱之為:從 Unroll memory 到 Storage memory 的“Transfer(轉移)”。


理順了數據存儲的流程,數據的讀取和訪問則一目了然。MemoryStore 提供 getValues 和 getBytes 方法,根據 BlockId 分別訪問對象值與字節序列,如下圖所示。兩種方法首先通過 BlockId 獲取到對應的 DeserializedMemoryEntry 或 SerializedMemoryEntry,然后在通過訪問各自封裝的 Array[T] 和 ByteBuffer 來讀取數據內容。


MemoryStore 中的數據訪問


說到 MemoryStore 中數據的存與取,有幾個重要的角色不得不提,他們分別是:


BlockInfoManager:前文書已有交代,其主要職責是通過鎖機制來保證多任務并發情況下數據訪問的一致性。

SerializerMananger:顧名思義,自然是負責 MemoryStore 中數據的序列化與反序列化。

MemoryManager:Spark 內存管理器,這可是斯巴克國際建筑集團分公司舉足輕重的一位大佬,我們在下一篇《Spark 內存管理》中會有更詳細的交代。

如果非要用一句話概括,MemoryManager 的主要職能是維持不同內存區域(Storage memory, Shuffle memory, Runtime memory 等)之間的平衡、以及維持多任務并發下不同線程之間內存消耗的平衡。

BlockEvictionHandler:這個角色比較有意思,他負責把 MemoryStore 中的數據塊“驅逐”出內存 —— 通常情況下都會把這些被驅逐的 Block“攆”到 DiskStore 中去,也即把內存中物化的數據轉移到磁盤存儲中。一個典型的場景是當 RDD 緩存采用 MEMORY_AND_DISK 模式且內存不足以容納整個 RDD 數據集時,根據 LRU 原則,訪問頻次較低且訪問時間較為久遠的 Block 就會被 BlockEvictionHandler“下放”到 DiskStore 中去。


在本篇的開始,咱們說到 SparkContext 在初始化的過程中會創建一系列的對象來分別服務于眾多的 Spark 子系統 —— 如調度系統、存儲系統、內存管理、Shuffle 管理、RPC 系統等,我們暫且把這些對象稱之為“上下文對象”。BlockManager 作為 Spark 存儲系統的入口,以組合的設計模式持有多個“上下文對象”的引用,封裝了與數據存取有關的所有抽象。BlockManager 的組合對象星羅云布,到目前為止我們接觸過的有:


MemoryStore、DiskStore、BlockInfoManager、DiskBlockManager —— 本地數據存取

MemoryManager —— 維護不同內存區域之間的平衡

SerializerManager —— 序列化管理器


除了以上用于訪問本地存儲的類,Spark 存儲系統正是仰仗 BlockTransferService 來提供跨節點的數據存取。

BlockTransferService 抽象主要提供兩種方法來支持不同類型的計算任務,即如上圖所示的 fetchBlockSync 方法和 uploadBlockSync 方法。


BlockTransferService,顧名思義,既然是 Service,自然就繞不開 Server/Client 的概念。fetchBlockSync 方法和 uploadBlockSync 方法都屬于客戶端方法,用于向服務端提交“下載數據塊”和“上傳數據塊”的請求。




Spark存儲體系設計


BlockManagerMaster:代理BlockManager與Driver上的BlockManagerMasterEndpoint通信。記號①表示Executor節點上的BlockManager通過BockManagerMaster與BlockManagerMasterEndpoint進行通信,記號②表示Driver節點上的BlockManager通過BlockManagerMaster與BlockManagerMasterEndpoint進行通信。這些通信的內容有很多,例如,注冊BlockManager、更新Block信息、獲取Block的位置(即Block所在的BlockManager)、刪除Executor等。BlockManagerMaster之所以能夠和BlockManagerMasterEndpoint通信,是因為它持有了BlockManagerMasterEndpoint的RpcEndpointRef。

BlockManagerMasterEndpoint:由Driver上的SparkEnv負責創建和注冊到Driver的RpcEnv中。BlockManagerMasterEndpoint只存在于Driver的SparkEnv中,Driver或Executor上BlockManagerMaster的driverEndpoint屬性將持有BlockManagerMasterEndpoint的RpcEndpointRef。BlockManagerMasterEndpoint主要對各個節點上的BlockManager、BlockManager與Executor的映射關系及Block位置信息(即Block所在的BlockManager)等進行管理。

BlockManagerSlaveEndpoint:每個Executor或Driver的SparkEnv中都有屬于自己的BlockManagerSlaveEndpoint,分別由各自的SparkEnv負責創建和注冊到各自的RpcEnv中。Driver或Executor都存在各自的BlockManagerSlaveEndpoint,并由各自BlockManager的slaveEndpoint屬性持有各自BlockManagerSlaveEndpoint下發的命令。記號③表示BlockManagerMasterEndpoint向Driver節點上的BlockManagerSlaveEndpoint下發命令,記號④表示BlockManagerMasterEndpoint向Executor節點上的BlockManagerSlaveEndpoint下發命令。例如,刪除Block、獲取Block狀態、獲取匹配的BlockId等。

SerializerManager:序列化管理器

MemoryManager:內存管理器。負責對單個節點上內存的分配與回收

MapOutPutTracker:map任務輸出跟蹤器。

ShuffleManager:Shuffle管理器

BlockTransferService:塊傳輸服務。此組件也與Shuffle相關聯,主要用于不同階段的任務之間的Block數據的傳輸與讀寫。

shuffleClinet:Shuffle的客戶端。與BlockTransferService配合使用。記號⑤表示Executor上的shuffleClient通過Driver上的BlockTransferService提供的服務上傳和下載Block,記號⑥表示Driver上的shuffleClient通過Executor上的BlockTransferService提供的服務上傳和下載Block。此外,不同Executor節點上的BlockTransferService和shuffleClient之間也可以互相上傳、下載Block。

SecurityManager:安全管理器

DiskBlockManager:磁盤塊管理器。對磁盤上的文件及目錄的讀寫操作進行管理

BlockInfoManager:塊信息管理器。負責對Block的元數據及鎖資源進行管理

MemoryStore:內存存儲。依賴于MemoryManager,負責對Block的內存存在

DiskStore:磁盤存儲。依賴于DiskBlockManager,負責對Block的磁盤存儲





Spark度量系統

Spark的度量系統有以下幾部分,也可以參照MetricsSystem類的注釋部分

Instance: 數據實例。Spark的Instance有Master、Worker、ApplicationInfo、StreamingContext等,主要用來提供Source數據、啟停MetricsSystem

Source: 度量數據輸入源。Source采集的數據來源于Instance實例屬性

Sink: 度量數據輸出源。Spark使用MetricsServlet作為默認Sink

MetricsConfig: 度量需要的配置信息。initialize()方法初始化properties

MetricsSystem: instance粒度的Source、Sink控制中心



Source

Spark將度量數據來源抽象為Source接口。提供了ApplicationSource、MasterSource、WorkerSource、DAGSchedulerSource、StreamingSource、JvmSource等實現


Sink

Spark將度量數據統計輸出源抽象為Sink接口。提供了ConsoleSink、CsvSink、MetricsServlet、GraphiteSink、JmxSink、Slf4jSink等實現


MetricsConfig

讀取Metrics相關的配置信息


MetricsSystem

負責register Sources、Sinks,并start sinks。MetricsSystem不是系統的控制中心,而是每個instance一個MetricsSystem對象,負責instance粒度的控制


MetricsSystem類三個核心方法: registerSources()、registerSinks()、sinks.foreach(_.start)



使用Graphite Sink監控spark應用

spark 是自帶 Graphite Sink 的,這下省事了,只需要配置一把就可以生效了

/path/to/spark/conf/metrics.properties

*.sink.graphite.class=org.apache.spark.metrics.sink.GraphiteSink

*.sink.graphite.host=<metrics_hostname>

*.sink.graphite.port=<metrics_port>

*.sink.graphite.period=5

*.sink.graphite.unit=seconds

driver.source.jvm.class=org.apache.spark.metrics.source.JvmSource

executor.source.jvm.class=org.apache.spark.metrics.source.JvmSource

提交時記得使用 --files /path/to/spark/conf/metrics.properties 參數將配置文件分發到所有的 Executor,否則將采集不到相應的 executor 數據。


具體可以參考

http://rokroskar.github.io/monitoring-spark-on-hadoop-with-prometheus-and-grafana.html


Spark3.0特性

忽略數據本地性

在生產環境中有一個純Spark集群和一個S3存儲系統,其中計算與存儲層是分開的。在這種部署模式下,數據局部性永遠無法到達。

Spark調度程序中有一些配置可以減少數據本地性的等待時間(例如“ spark.locality.wait”)。而問題在于,在列出文件階段,所有文件的位置信息以及每個文件內的所有block都從分布式文件系統中獲取。實際上,在生產環境中,表可能是如此之大,以至于獲取所有這些位置信息都需要花費數十秒的時間。

為了改善這種情況,Spark需要提供一個選項,在該選項中可以完全忽略數據位置,在列表文件階段,我們需要的是文件位置,而沒有任何塊位置信息。

參數spark.sql.sources.ignore.datalocality默認為false,如果為true, Spark不會獲取列表文件中每個文件的塊位置。這可以加快文件列表的速度,但是調度器不能調度任務來利用數據局部性。如果數據是從遠程集群讀取的,這樣調度器無論如何都無法利用局部性,那么這種方法就特別有用。


參考:https://issues.apache.org/jira/browse/SPARK-29189


Barrier Execution Mode

為了在大數據上讓Spark支持深度學習而新增的特性,想象這樣一個場景:我要建設一個數據流水線,從數據倉庫中拿到訓練數據并且以數據的并行度去訓練深度學習模型。數倉一般是Hive、、Redshift,可以通過Spark拿到數據,而深度學習模型分布式訓練一般是用TensorFlow?、Horovod,二者之間有割裂,我們可以讓這件事更簡單嗎?于是便有屏障執行模式了。

這個模式為Spark添加了一個新的調度模型讓用戶可以適當的將深度學習模型嵌入為Spark的Stage從而簡化深度學習任務流。

列如,Horovod?使用MPI實現?all-reduce去加速分布式TensorFlow訓練。這種計算模型與Spark的MapReduce?不同。

在Spark中,一個stage中的task不會依賴同一個stage中的任何其他task,因此每個task可以被獨立調度。

在MPI中,所有worker同時啟動并相互傳遞信息。為了將這種工作模式帶入Spark,便有了Barrier Execution Mode,這種模式使用了新的調度模式----barrier scheduling,這種模式同時啟動task并且提供用戶足夠的信息和工具嵌入深度學習模型。Spark也提供額外的容錯層以防中間的task失敗,這種情況下Spark會丟棄所有task并重啟stage。


參考:https://issues.apache.org/jira/browse/SPARK-24374


動態分區裁剪

使用spark不同的API都可以觸發動態分區裁剪特性,spark生成邏輯執行計劃后再生成物理執行計劃,并在計劃層面進行優化,這里從計劃層面討論下動態分區裁剪特性。

從邏輯執行計劃考慮動態分區裁剪優化,假設有一個以顏色分區的分區表,一個未必分區的小表,假設我們對小表進行過濾,分區表上只有兩個分區與過濾后的小表對應,如果進行join,分區表就只有這兩個分區會被保留。因此,在這里可以優化,無需對分區表所有數據取出后join,在取出前按照小表的結果過濾,把過濾后的結果進行join,這樣效率更高,我們只要在分區表前加上一個子查詢,這個子查詢拿到的是小表的結果,這樣就可以對大表只取部分數據。



自適應查詢執行AQE

自適應查詢執行(又稱 Adaptive Query Optimisation 或者 Adaptive Optimisation)是對查詢執行計劃的優化,允許 Spark Planner 在運行時執行可選的執行計劃,這些計劃將基于運行時統計數據進行優化。


自適應查詢執行框架

自適應查詢執行最重要的問題之一是何時進行重新優化。Spark 算子通常是以 pipeline 形式進行,并以并行的方式執行。然而,shuffle 或 broadcast exchange 打破了這個管道。我們稱它們為物化點,并使用術語“查詢階段”來表示查詢中由這些物化點限定的子部分。每個查詢階段都會物化它的中間結果,只有當運行物化的所有并行進程都完成時,才能繼續執行下一個階段。這為重新優化提供了一個絕佳的機會,因為此時所有分區上的數據統計都是可用的,并且后續操作還沒有開始。

當查詢開始時,自適應查詢執行框架首先啟動所有葉子階段(leaf stages)——這些階段不依賴于任何其他階段。一旦其中一個或多個階段完成物化,框架便會在物理查詢計劃中將它們標記為完成,并相應地更新邏輯查詢計劃,同時從完成的階段檢索運行時統計信息。基于這些新的統計信息,框架將運行優化程序、物理計劃程序以及物理優化規則,其中包括常規物理規則(regular physical rules)和自適應執行特定的規則,如合并分區(coalescing partitions)、Join 數據傾斜處理(skew join handling)等。現在我們有了一個新優化的查詢計劃,其中包含一些已完成的階段,自適應執行框架將搜索并執行子階段已全部物化的新查詢階段,并重復上面的 execute-reoptimize-execute 過程,直到完成整個查詢。


AQE 框架目前提供了三個功能:


動態合并 shuffle partitions

當在 Spark 中運行查詢來處理非常大的數據時,shuffle 通常對查詢性能有非常重要的影響。Shuffle 是一個昂貴的操作符,因為它需要在網絡中移動數據,因此數據是按照下游操作符所要求的方式重新分布的。


shuffle 的一個關鍵屬性是分區的數量。分區的最佳數量取決于數據,但是數據大小可能在不同的階段、不同的查詢之間有很大的差異,這使得這個數字很難調優:


如果分區數太少,那么每個分區處理的數據大小可能非常大,處理這些大分區的任務可能需要將數據溢寫到磁盤(例如,涉及排序或聚合),從而減慢查詢速度。

如果分區數太多,那么每個分區處理的數據大小可能非常小,并且將有大量的網絡數據獲取來讀取 shuffle 塊,這也會由于低效的 I/O 模式而減慢查詢速度。擁有大量的任務也會給 Spark 任務調度程序帶來更多的負擔。


要解決這個問題,我們可以在開始時設置相對較多的 shuffle 分區數,然后在運行時通過查看 shuffle 文件統計信息將相鄰的小分區合并為較大的分區。


假設我們運行 SELECT max(i)FROM tbl GROUP BY j 查詢,tbl 表的輸入數據相當小,所以在分組之前只有兩個分區。我們把初始的 shuffle 分區數設置為 5,因此在 shuffle 的時候數據被打亂到 5 個分區中。如果沒有 AQE,Spark 將啟動 5 個任務來完成最后的聚合。然而,這里有三個非常小的分區,為每個分區啟動一個單獨的任務將是一種浪費。

使用 AQE 之后,Spark 將這三個小分區合并為一個,因此,最終的聚合只需要執行三個任務,而不是五個。





動態調整 join 策略

Spark 支持許多 Join 策略,其中 broadcast hash join 通常是性能最好的,前提是參加 join 的一張表的數據能夠裝入內存。由于這個原因,當 Spark 估計參加 join 的表數據量小于廣播大小的閾值時,其會將 Join 策略調整為 broadcast hash join。但是,很多情況都可能導致這種大小估計出錯——例如存在一個非常有選擇性的過濾器。


為了解決這個問題,AQE 現在根據最精確的連接關系大小在運行時重新規劃 join 策略。在下面的示例中可以看到,Join 的右側比估計值小得多,并且小到足以進行廣播,因此在 AQE 重新優化之后,靜態計劃的 sort merge join 現在被轉換為 broadcast hash join。

對于在運行時轉換的 broadcast hash join ,我們可以進一步將常規的 shuffle 優化為本地化 shuffle來減少網絡流量。


動態優化傾斜的 join(skew joins)

當數據在集群中的分區之間分布不均時,就會發生數據傾斜。嚴重的傾斜會顯著降低查詢性能,特別是在進行 Join 操作時。AQE 傾斜 Join 優化從 shuffle 文件統計信息中自動檢測到這種傾斜。然后,它將傾斜的分區分割成更小的子分區,這些子分區將分別從另一端連接到相應的分區。


假設表 A join 表B,其中表 A 的分區 A0 里面的數據明顯大于其他分區。

skew join optimization 將把分區 A0 分成兩個子分區,并將每個子分區 join 表 B 的相應分區 B0。

如果沒有這個優化,將有四個任務運行 sort merge join,其中一個任務將花費非常長的時間。在此優化之后,將有5個任務運行 join,但每個任務將花費大致相同的時間,從而獲得總體更好的性能。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容