深入淺出Spark(二):血統(DAG)

專題介紹

2009 年,Spark 誕生於加州大學伯克利分校的 AMP 實驗室(the Algorithms, Machines and People lab),並於 2010 年開源。2013 年,Spark 捐獻給阿帕奇軟件基金會(Apache Software Foundation),並於 2014 年成爲 Apache 頂級項目。

如今,十年光景已過,Spark 成爲了大大小小企業與研究機構的常用工具之一,依舊深受不少開發人員的喜愛。如果你是初入江湖且希望瞭解、學習 Spark 的“小蝦米”,那麼 InfoQ 與 FreeWheel 技術專家吳磊合作的專題系列文章——《深入淺出 Spark:原理詳解與開發實踐》一定適合你!

本文系專題系列第二篇。

書接前文,在上一篇《內存計算的由來 —— RDD》,我們從“虛”、“實”兩個方面介紹了RDD的基本構成。RDD通過dependencies和compute屬性首尾相連構成的計算路徑,專業術語稱之爲Lineage —— 血統,又名DAG(Directed Acyclic Graph,有向無環圖)。一個概念爲什麼會有兩個稱呼呢?這兩個不同的名字又有什麼區別和聯繫?簡單地說,血統與DAG是從兩個不同的視角出發,來描述同一個事物。血統,側重於從數據的角度描述不同RDD之間的依賴關係;DAG,則是從計算的角度描述不同RDD之間的轉換邏輯。如果說RDD是Spark對於分佈式數據模型的抽象,那麼與之對應地,DAG就是Spark對於分佈式計算模型的抽象。

顧名思義,DAG是一種“圖”,圖計算模型的應用由來已久,早在上個世紀就被應用於數據庫系統(Graph databases)的實現中。任何一個圖都包含兩種基本元素:節點(Vertex)和邊(Edge),節點通常用於表示實體,而邊則代表實體間的關係。例如,在“倚天屠龍”社交網絡的好友關係中,每個節點表示一個具體的人,每條邊意味着兩端的實體之間建立了好友關係。

倚天屠龍社交網絡

在上面的社交網絡中,好友關係是相互的,如張無忌和周芷若互爲好友,因此該關係圖中的邊是沒有指向性的;另外,細心的同學可能已經發現,上面的圖結構是有“環”的,如張無忌、謝遜、白眉鷹王構成的關係環,張無忌、謝遜、紫衫龍王、小昭之間的關係環,等等。像上面這樣的圖結構,術語稱之爲“無向有環圖”。沒有比較就沒有鑑別,有向無環圖(DAG)自然是一種帶有指向性、不存在“環”結構的圖模型。各位看官還記得土豆工坊的例子嗎?

土豆工坊DAG

在上面的土豆加工DAG中,每個節點是一個個RDD,每條邊代表着不同RDD之間的父子關係 —— 父子關係自然是單向的,因此整張圖是有指向性的。另外我們注意到,整個圖中是不存在環結構的。像這樣的土豆加工流水線可以說是最簡單的有向無環圖,每個節點的入度(Indegree,指向自己的邊)與出度(Outdegree,從自己出發的邊)都是1,整個圖看下來只有一條分支。

不過,工業應用中的Spark DAG要比這複雜得多,往往是由不同RDD經過關聯、拆分產生多個分支的有向無環圖。爲了說明這一點,我們還是拿土豆工坊來舉例,在將“原味”薯片推向市場一段時間後,工坊老闆發現季度銷量直線下滑,老闆心急如焚、一籌莫展。此時有人向他建議:“何不推出更多風味的薯片,來迎合大衆的多樣化選擇”,於是老闆一聲令下,工人們對流水線做了如下改動。

土豆工坊高級生產線

與之前相比,新的流程增加了3條風味流水線,用於分發不同的調料粉。新流水線上的辣椒粉被分發到收集小號薯片的流水線、孜然粉分發到中號薯片流水線,相應地,番茄粉分發到大號薯片流水線。經過改造,土豆工坊現在可以生產3種風味、不同尺寸的薯片,即麻辣味的小號薯片、孜然味的中號薯片和番茄味的大號薯片。如果我們用flavoursRDD來抽象調味品的話,那麼工坊新作業流程所對應的DAG會演化爲如下所示帶有2個分支的有向無環圖。

多個分支的DAG

在上一篇,我們探討了Spark Core內功心法的第一要義 —— RDD,這一篇,咱們來說說內功心法的第二個祕訣 —— DAG。

RDD算子 —— DAG的邊

在上一篇《內存計算的由來 —— RDD》最後,我們以WordCount爲例展示不同RDD之間轉換而形成的DAG計算圖。通讀代碼,從開發的角度來看,我們發現DAG構成的關鍵在於RDD算子調用。不同於Hadoop MapReduce,Spark以數據爲導向提供了豐富的RDD算子,供開發者靈活地排列組合,從而實現多樣化的數據處理邏輯。那麼問題來了,Spark都提供哪些算子呢?

數據來源:https://spark.apache.org/docs/latest/rdd-programming-guide.html

從表格中我們看到,Spark的RDD算子豐富到讓人眼花繚亂的程度,對於初次接觸Spark的同學來說,如果不稍加歸類,面對多如繁星的算子還真是無從下手。Apache Spark官網將RDD算子歸爲Transformations和Actions兩種類型,這也是大家在各類Spark技術博客中常見的分類方法。爲了說明Transformations和Actions算子的本質區別,我們必須得提一提Spark計算模型的“惰性計算”(Lazy evaluation,又名延遲計算)特性。

掌握一個新概念最有效的方法之一就是找到與之相對的概念 —— 與“惰性計算”相對,大多數傳統編程語言、編程框架的求值策略是“及早求值”(Eager evaluation)。例如,對於我們熟悉的C、C++、Java來說,每一條指令都會嘗試調度CPU、佔用時鐘週期、觸發計算的執行,同時,CPU寄存器需要與內存通信從而完成數據交換、數據緩存。在傳統編程模式中,每一條指令都很“急”(Eager),都恨不得自己馬上被調度到“前線”、參與戰鬥。

惰性計算模型則不然 —— 具體到Spark,絕大多數RDD算子都很“穩”、特別能沉得住氣,他們會明確告訴DAGScheduler:“老兄,你先往前走着,不用理我,我先繃會兒、抽袋煙。隊伍的前排是我們帶頭大哥,沒有他的命令,我們不會貿然行動。”有了惰性計算和及早求值的基本瞭解,我們再說回Transformations和Actions的區別。在Spark的RDD算子中,Transformations算子都屬於惰性求值操作,僅參與DAG計算圖的構建、指明計算邏輯,並不會被立即調度、執行。惰性求值的特點是當且僅當數據需要被物化(Materialized)時纔會觸發計算的執行,RDD的Actions算子提供各種數據物化操作,其主要職責在於觸發整個DAG計算鏈條的執行。當且僅當Actions算子觸發計算時, DAG從頭至尾的所有算子(前面用於構建DAG的Transformations算子)纔會按照依賴關係的先後順序依次被調度、執行。

說到這裏,各位看官不禁要問:Spark採用惰性求值的計算模型,有什麼優勢嗎?或者反過來問:Spark爲什麼沒有采用傳統的及早求值?不知道各位看官有沒有聽說過“延遲滿足效應”(又名“糖果效應”),它指的是爲了獲取長遠的、更大的利益而自願延緩甚至放棄目前的、較小的滿足。正所謂:“雲想衣裳花想容,豬想發福人想紅”。Spark這孩子不僅天資過人,小小年紀竟頗具城府,獨創的內功心法意不在贏得眼下的一招半式,而是着眼於整個武林。扯遠了,我們收回來。籠統地說,惰性計算爲Spark執行引擎的整體優化提供了廣闊的空間。關於惰性計算具體如何幫助Spark做全局優化 —— 說書的一張嘴表不了兩家事,後文書咱們慢慢展開。

還是說回RDD算子,除了常見的按照Transformations和Actions分類的方法,筆者又從適用範圍和用途兩個維度爲老鐵們做了歸類,畢竟人類的大腦喜歡結構化的知識,官網上一字長蛇陣的羅列總是讓人看了昏昏欲睡。有了這個表格,我們就知道 *ByKey 的操作一定是作用在Paired RDD上的,所謂Paired RDD是指Schema明確區分(Key, Value)對的RDD,與之相對,任意RDD指的是不帶Schema或帶任意Schema的RDD。從用途的角度來區分RDD算子的歸類相對比較分散,篇幅的原因,這裏就不一一展開介紹,老鐵們各取所需吧。

值得一提的是,對於相同的計算場景,採用不同算子實現帶來的執行性能可能會有天壤之別,在後續的性能調優篇咱們再具體問題具體分析。好吧,坑越挖越多,列位看官您稍安勿躁,咱們按照FIFO的原則,先來說說剛剛纔提到的、還熱乎的DAGScheduler。

DAGScheduler —— DAG的嚮導官

DAGScheduler是Spark分佈式調度系統的重要組件之一,其他組件還包括TaskScheduler、MapOutputTracker、SchedulerBackend等。DAGScheduler的主要職責是根據RDD依賴關係將DAG劃分爲Stages,以Stage爲粒度提交任務(TaskSet)並跟蹤任務進展。如果把DAG看作是Spark作業的執行路徑或“戰略地形”,那麼DAGScheduler就是這塊地形的嚮導官,這個嚮導官負責從頭至尾將地形摸清楚,根據地形特點排兵佈陣。更形象地,回到土豆工坊的例子,DAGScheduler要做的事情是把抽象的土豆加工DAG轉化爲工坊流水線上一個個具體的薯片加工操作任務。那麼問題來了,DAGScheduler以怎樣的方式摸索“地形”?如何劃分Stages?劃分Stages的依據是什麼?更進一步,將DAG劃分爲Stages的收益有哪些?Spark爲什麼要這麼做?

DAGScheduler的核心職責

爲了回答這些問題,我們需要先對於DAG的“首”和“尾”進行如下定義:在一個DAG中,沒有父RDD的節點稱爲首節點,而沒有子RDD的節點稱爲尾節點。還是以土豆工坊爲例,其中首節點有兩個,分別是potatosRDD和flavoursRDD,而尾節點是flavouredBakedChipsRDD。

DAG中首與尾的定義

DAGScheduler在嘗試探索DAG“地形”時,是以首尾倒置的方式從後向前進行。具體說來,對於土豆工坊的DAG,DAGScheduler會從尾節點flavouredBakedChipsRDD開始,根據RDD依賴關係依次向前遍歷所有父RDD節點,在遍歷的過程中以Shuffle爲邊界劃分Stage。Shuffle的字面意思是“洗牌”,沒錯,就是撲克遊戲中的洗牌,在大數據領域Shuffle引申爲“跨節點的數據分發”,指的是爲了實現某些計算邏輯需要將數據在集羣範圍內的不同計算節點之間定向分發。在絕大多數場景中,Shuffle都是當之無愧的“性能瓶頸擔當”,毫不客氣地說,有Shuffle的地方,就有性能優化的空間。關於Spark Shuffle的原理和性能優化技巧,後面我們會單獨開一篇來專門探討。在土豆工坊的DAG中,有兩個地方發生了Shuffle,一個是從bakedChipsRDD到flavouredBakedChipsRDD的計算,另一個是從flavoursRDD到flavouredBakedChipsRDD的計算,如下圖所示。

土豆工坊DAG中的Shuffle

各位看官不禁要問:DAGScheduler如何判斷RDD之間的轉換是否會發生Shuffle呢?那位看官說了:“前文書說了半天算子是RDD之間轉換的關鍵,莫不是根據算子來判斷會不會發生Shuffle?”您還真猜錯了,算子與Shuffle沒有對應關係。就拿join算子來說,在大部分場景下,join都會引入Shuffle;然而在collocated join中,左右表數據分佈一致的情況下,是不會發生Shuffle的。所以您看,DAGScheduler還真不能依賴算子本身來判斷髮生Shuffle與否。要回答這個問題,咱們還是得回到前文書《內存計算的由來 —— RDD》中介紹RDD時提到的5大屬性。

屬性名 成員類型 屬性含義
dependencies 變量 生成該RDD所依賴的父RDD
compute 方法 生成該RDD的計算接口
partitions 變量 該RDD的所有數據分片實體
partitioner 方法 劃分數據分片的規則
preferredLocations 變量 數據分片的物理位置偏好
RDD的5大屬性及其含義

其中第一大屬性dependencies又可以細分爲NarrowDependency和ShuffleDependency,NarrowDependency又名“窄依賴”,它表示RDD所依賴的數據無需分發,基於當前現有的數據分片執行compute屬性封裝的函數即可;ShuffleDependency則不然,它表示RDD依賴的數據分片需要先在集羣內分發,然後才能執行RDD的compute函數完成計算。因此,RDD之間的轉換是否發生Shuffle,取決於子RDD的依賴類型,如果依賴類型爲ShuffleDependency,那麼DAGScheduler判定:二者的轉換會引入Shuffle。在回溯DAG的過程中,一旦DAGScheduler發現RDD的依賴類型爲ShuffleDependency,便依序執行如下3項操作:

  • 沿着Shuffle邊界的子RDD方向創建新的Stage對象
  • 把新建的Stage註冊到DAGScheduler的 stages 系列字典中,這些字典用於存儲、記錄與Stage有關的狀態和元信息,以備後用
  • 沿着當前RDD的父RDD遵循廣度優先搜索算法繼續回溯DAG

拿土豆工坊來說,其尾節點flavouredBakedChipsRDD同時依賴bakedChipsRDD和flavoursRDD兩個父RDD,且依賴類型都是ShuffleDependency,那麼依據DAGScheduler的執行邏輯,此時會執行如下3項具體操作:

DAGScheduler回溯DAG過程當中遇到ShuffleDependency時的主要操作流程

DAGScheduler沿着尾節點回溯並劃分出stage0

在完成第一個Stage(stage0)的創建和註冊之後,DAGScheduler先沿着bakedChipsRDD方向繼續向前回溯。在沿着這條路向前跑的時候,我們的這位DAGScheduler嚮導官驚喜地發現:“我去!這一路上一馬平川、風景甚好,各個驛站之間什麼障礙都沒有,交通甚是順暢,真是片好地形!”—— 沿路遇到的所有RDD(bakedChipsRDD,chipsRDD,cleanedPotatosRDD,potatosRDD)的依賴類型都是NarrowDependency。

在回溯完畢時,DAGScheduler同樣會重複上述3個步驟,根據DAGScheduler以Shuffle爲邊界劃分Stage的原則,沿途的所有RDD都劃歸爲同一個Stage,暫且記爲stage1。值得一提的是,Stage對象的rdd屬性對應的數據類型是RDD[],而不是List[RDD[]]。對於一個邏輯上包含多個RDD的Stage來說,其rdd屬性存儲的是路徑末尾的RDD節點,具體到我們的案例中就是bakedChipsRDD。

DAGScheduler沿着bakedChipsRDD方向回溯並劃分出stage1

勤勤懇懇的DAGScheduler在成功創建stage1之後,依然不忘初心、牢記使命,繼續奔向還未探索的路線。從上圖中我們清楚地看到整塊地形還剩下flavoursRDD方向的路徑沒有納入DAGScheduler的視野範圍。咱們的這位DAGScheduler嚮導官記性相當得好,早在劃分stage0的時候,他就用小本子(棧)記下:“此路口有分叉,先沿着bakedChipsRDD方向走,然後再回過頭來沿着flavoursRDD的方向探索。切記,切記!”此時,嚮導大人拿出之前的小本子,用橫線把bakedChipsRDD方向的路徑劃掉 —— 表示該方向路徑已探索過,然後沿着flavoursRDD方向大踏步地走下去。一腳下去,發現:“我去!到頭兒了!”,然後緊接着執行一貫的“三招一套”流程 —— 創建Stage、註冊Stage、繼續回溯。隨着DAGScheduler創建最後一個Stage:stage2,地形上的所有路徑都已探索完畢。

DAGScheduler創建最後一個Stage:stage2

到此爲止,我們的嚮導大人幾乎跑斷了腿、以首尾倒置的順序對整片地形進行了地毯式搜查,最終將地形劃分爲3塊戰略區域(Stage)。那麼問題來了,嚮導大人劃分出的3塊區域,有啥用呢?DAGScheduler他老人家馬不停蹄地這麼跑,到底圖啥?前面我們提到,DAGScheduler的核心職責,是將抽象的DAG計算圖轉換爲具體的、可並行計算的分佈式任務。回溯DAG、創建Stage,只是這個核心職責的第一步,DAGScheduler以Stage(TaskSet)爲粒度進行任務調度,夥同TaskScheduler、SchedulerBackend等一衆大佬運籌帷幄、調兵遣將。不過,畢竟本篇的主題是DAG,到Spark調度系統的核心還有些距離,因此這裏咱們暫且挖個坑,後面再單獨開篇(Spark調度系統)專門講述幾位大佬之間的趣事逸聞。填坑之路漫漫其修遠兮,吾將上下而挖坑。

咱們來回顧一下向導大人的心路歷程,首先,DAGScheduler沿着DAG的尾節點一路北上,並沿途判斷每一個RDD節點的dependencies屬性。之後,如果判定RDD的dependencies屬性是NarrowDependency,則DAGScheduler繼續向前回溯;若RDD的依賴是ShuffleDependency,DAGScheduler便開啓“三招一套”的招式,創建Stage、註冊Stage並繼續向前回溯。由此可見,何時切割DAG並生成新的Stage由RDD的依賴類型決定,當且僅當RDD的依賴是ShuffleDependency時,DAGScheduler纔會新建Stage。

喜歡刨根問底的您一定會問:“DAGScheduler怎麼知道RDD的依賴類型到底是哪一個?他怎麼判別RDD的依賴是窄依賴還是ShuffleDependency?”要回答這個問題,我們就還得回到RDD的5大屬性上,不過這次出場的是partitioner。還記得這個屬性嗎?partitioner是RDD的分區器、定義了RDD數據分片的分區規則,它決定了RDD的數據分片在分佈式集羣中如何分佈,這個屬性至關重要,後面介紹Shuffle的時候我們還會提到它。DAGScheduler正是通過partitioner來判定每個RDD的依賴類型,具體來說,如果子RDD的partitioner與父RDD的partitioner一致,那麼DAGScheduler判定子RDD對父RDD的依賴屬於窄依賴;相反,如果兩者partitioner不一致,也即分區規則不同(分區規則不同則意味着一定存在數據的“重洗牌”,即Shuffle),那麼DAGScheduler判定子對父的依賴關係是ShuffleDependency。到此,DAGScheduler對於DAG的劃分邏輯可以暫且告一段落。原理說了,例子舉了,還缺啥?對!代碼。

Show me the code

古人云:“光說不練假把式”,我們用一個小例子來展示一下DAG與Stage的關係。還是用上篇《內存計算的由來 —— RDD》中的WordCount依樣畫葫蘆,文件內容如下。

示例文件內容

代碼也沒變:

WordCount示例代碼

雖然文件內容和代碼都沒變,但是我們觀察問題的視角變了,這次我們關心的是DAG中Stage的劃分以及Stage之間的關係。RDD的toDebugString函數讓我們可以一覽DAG的構成以及Stage的劃分,如下圖所示。

DAG構成及Stage劃分

在上圖中,從第3行往下,每一行表示一個RDD,很顯然,第3行的ShuffledRDD是DAG的尾節點,而第7行的HadoopRDD是首節點。我們來觀察每一行字符串打印的特點,首先最明顯地,第4、5、6、7行的前面都有個製表符(Tab),與第3行有個明顯的錯位,這表示第3行的ShuffledRDD被劃分到了一個Stage(記爲stage0),而第4、5、6、7行的其他RDD被劃分到了另外一個Stage(記爲stage1),且stage0對stage1有依賴關係。假設第7行下面的RDD字符串打印有兩個製表符,即與第7行產生錯位,那麼第7行下面的RDD則被劃到了新的Stage,以此類推。

由此可見,通過RDD的toDebugString觀察DAG的Stage劃分時,製表符是個重要的指示牌。另外,我們看到第3、4行的開頭都有個括號,括號裏面是數字,這個數字標記的是RDD的partitions大小。當然了,觀察RDD、DAG、Stage還有更直觀的方式,Spark的Web UI提供了更加豐富的可視化信息,不過Spark的Web UI面板繁多,對於新同學來說一眼望去反而容易不知所措,也許後面時間允許的話我們單開一篇Spark Web UI的串講。

Postscript

本篇是《Spark分佈式計算科普專欄》的第二篇,筆者學淺才疏、疏漏難免。如果您有任何疑問,或是覺得文章中的描述有所遺漏或不妥,歡迎在評論區留言、討論。掌握一門技術,書本中的知識往往只佔兩成,三成靠討論,五成靠實踐。更多的討論能激發更多的觀點、視角與洞察,也只有這樣,對於一門技術的認知與理解才能更深入、牢固。

在本篇博文中,我們從DAG的邊 —— Spark RDD算子入手,介紹了銜接RDD的兩大類算子:Transformations和Actions,並對惰性計算有了初步的認知。然後,還是以土豆工坊爲例,介紹DAGScheduler切割DAG、生成Stage的流程和步驟,尤其需要注意的是DAGScheduler以Shuffle爲邊界劃分Stage。

最後,用上一篇的WordCount簡單展示了DAG與Stage的關係。細心的讀者可能早已發現,文中多次提及“後文書再展開”、“後面再單開一篇”,Spark是一個精妙而複雜的分佈式計算引擎,在本篇博文中我們不得不對Spark中的許多概念都進行了“前置引用”。換句話說,有些概念還沒來得及解釋(如惰性計算、Shuffle、TaskScheduler、TaskSet、Spark調度系統),就已經被引入到了本篇博文中。這樣的敘述方法也許會給您帶來困惑,畢竟,用一個還未說清楚的概念,去解釋另一個新概念,總是感覺沒那麼牢靠。

常言道:“殺人償命、欠債還錢”,在後續的專欄文章中,我們會繼續對Spark的核心概念與原理進行探討,慢慢地把欠您的技術債還上,儘可能地還原Spark分佈式內存計算引擎的全貌。畢竟Spark調度系統爲何方神聖,DAGScheduler夥同TaskScheduler、SchedulerBackend、TaskSetManager等一衆大佬如何演繹權利的遊戲,且聽下回分解。

作者簡介

吳磊,Spark Summit China 2017 講師、World AI Conference 2020 講師,曾任職於 IBM、聯想研究院、新浪微博,具備豐富的數據庫、數據倉庫、大數據開發與調優經驗,主導基於海量數據的大規模機器學習框架的設計與實現。現擔任 Comcast Freewheel 機器學習團隊負責人,負責計算廣告業務中機器學習應用的實踐、落地與推廣。熱愛技術分享,熱衷於從生活的視角解讀技術,曾於《IBM developerWorks》和《程序員》雜誌發表多篇技術文章。

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