深入淺出Spark(一):內存計算的由來

專題介紹

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

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

本文系專題系列第一篇。

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

2014,是個久遠的年代,那個時候,大數據江湖羣雄並起,門派林立。論內功,有少林派的Hadoop,Hadoop可謂德高望重、資歷頗深,2006年由當時的互聯網老大哥Yahoo!開源並迅速成爲Apache頂級項目。所謂天下武功出少林,Hadoop的三招絕學:HDFS(分佈式文件系統)、YARN(分佈式調度系統)、MapReduce(分佈式計算引擎),爲各門各派武功絕學的發展奠定了堅實基礎。論陣法,有武當派的Hive,Hive可謂是開源分佈式數據倉庫的鼻祖。論劍法,有峨眉派的Mahout,峨眉武功向來“一樹開五花、五花八葉扶”,Mahout在分佈式系統之上提供主流的經典機器學習算法實現。論輕功,有崑崙派的Storm,在當時,Storm輕巧的分佈式流處理框架幾乎佔據着互聯網流計算場景的半壁江山。

Spark師從Hadoop,習得MapReduce內功心法,因天資聰慧、勤奮好學,年紀輕輕即獨創內功絕學:Spark Core —— 基於內存的分佈式計算引擎。青,出於藍而勝於藍;冰,水爲之而寒於水。憑藉紮實的內功,Spark練就一身能爲:

  • Spark SQL —— 分佈式數據分析
  • Spark Streaming —— 分佈式流處理
  • Spark MLlib —— 分佈式機器學習
  • Spark GraphX —— 分佈式圖計算

自恃內功深厚、招式變幻莫測,Spark初涉江湖便立下豪言壯語:One stack to rule them all —— 劍鋒直指各大門派。小馬乍行嫌路窄,大鵬展翅恨天低。各位看官不禁要問:Spark何以傲視羣雄?Spark修行的內功心法Spark Core,與老師Hadoop的MapReduce絕學相比,究竟有何獨到之處?

Hadoop MapReduce

欲探究竟,還需從頭說起。在Hadoop出現以前,數據分析市場的參與者主要由以IOE(IBM、Oracle、EMC)爲代表的傳統IT巨頭構成,Share-nothing架構的分佈式計算框架大行其道。傳統的Share-nothing架構憑藉其預部署、高可用、高性能的特點在金融業、電信業大放異彩。然而,隨着互聯網行業飛速發展,瞬息萬變的業務場景對於分佈式計算框架的靈活性與擴展性要求越來越高,笨重的Share-nothing架構無法跟上行業發展的步伐。2006年,Hadoop應運而生,MapReduce提供的分佈式計算抽象,結合分佈式文件系統HDFS與分佈式調度系統YARN,完美地詮釋了“數據不動代碼動”的新一代分佈式計算思想。

顧名思義,MapReduce提供兩類計算抽象,即Map和Reduce。Map抽象用於封裝數據映射邏輯,開發者通過實現其提供的map接口來定義數據轉換流程;Reduce抽象用於封裝數據聚合邏輯,開發者通過實現reduce接口來定義數據匯聚過程。Map計算結束後,往往需要對數據進行分發才能啓動Reduce計算邏輯來執行數據聚合任務,數據分發的過程稱之爲Shuffle。MapReduce提供的分佈式任務調度讓開發者專注於業務邏輯實現,而無需關心依賴管理、代碼分發等分佈式實現問題。在MapReduce框架下,爲了完成端到端的計算作業,Hadoop採用YARN來完成分佈式資源調度從而充分利用廉價的硬件資源,採用HDFS作爲計算抽象之間的數據接口來規避廉價磁盤引入的系統穩定性問題。

由此可見,Hadoop的“三招一套”自成體系,MapReduce搭配YARN與HDFS,幾乎可以實現任何分佈式批處理任務。然而,近乎完美的組合也不是鐵板一塊,每一隻木桶都有它的短板。HDFS利用副本機制實現數據的高可用從而提升系統穩定性,但額外的分片副本帶來更多的磁盤I/O和網絡I/O開銷,衆所周知,I/O開銷會嚴重損耗端到端的執行性能。更糟的是,一個典型的批處理作業往往需要多次Map、Reduce迭代計算來實現業務邏輯,因此上圖中的計算流程會被重複多次,直到最後一個Reduce任務輸出預期的計算結果。我們來想象一下,完成這樣的批處理作業,在整個計算過程中需要多少次落盤、讀盤、發包、收包的操作?因此,隨着Hadoop在互聯網行業的應用越來越廣泛,人們對其MapReduce框架的執行性能詬病也越來越多。

Spark Core

時勢造英雄,Spark這孩子不僅天資過人,學起東西來更是認真刻苦。當別人都在抱怨老師Hadoop的MapReduce心法有所欠缺時,他居然已經開始盤算如何站在老師的肩膀上推陳出新。在Spark拜師學藝三年後的2009年,這孩子提出了“基於內存的分佈式計算引擎”—— Spark Core,此心法一出,整個武林爲之譁然。Spark Core最引入注目的地方莫過於“內存計算”,這一說法幾乎鎮住了當時所有的初學者,大家都認爲Spark Core的全部計算都在內存中完成,人們興奮地爲之奔走相告。興奮之餘,大家開始潛心研讀Spark Core內功心法,纔打開心法的手抄本即發現一個全新的概念 —— RDD。

RDD

RDD(Resilient Distributed Datasets),全稱是“彈性分佈式數據集”。全稱本身並沒能很好地解釋RDD到底是什麼,本質上,RDD是Spark用於對分佈式數據進行抽象的數據模型。簡言之,RDD是一種抽象的數據模型,這種數據模型用於囊括、封裝所有內存中和磁盤中的分佈式數據實體。對於大部分Spark初學者來說,大家都有一個共同的疑惑:Spark爲什麼要提出這麼一個新概念?與其正面回答這個問題,不如我們來反思另一個問題:Hadoop老師的MapReduce框架,到底欠缺了什麼?有哪些可以改進的地方?前文書咱們提到:MapReduce計算模型採用HDFS作爲算子(Map或Reduce)之間的數據接口,所有算子的臨時計算結果都以文件的形式存儲到HDFS以供下游算子消費。下游算子從HDFS讀取文件並將其轉化爲鍵值對(江湖人稱KV),用Map或Reduce封裝的計算邏輯處理後,再次以文件的形式存儲到HDFS。不難發現,問題就出在數據接口上。HDFS引發的計算效率問題我們不再贅述,那麼,有沒有比HDFS更好的數據接口呢?如果能夠將所有中間環節的數據文件以某種統一的方式歸納、抽象出來,那麼所有map與reduce算子是不是就可以更流暢地銜接在一起,從而不再需要HDFS了呢?—— Spark提出的RDD數據模型,恰好能夠實現如上設想。

爲了弄清楚RDD的基本構成和特性,我們從它的5大核心屬性說起。

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

對於RDD數據模型的抽象,我們只需關注前兩個屬性,即dependencies和compute。任何一個RDD都不是憑空產生的,每個RDD都是基於一定的“計算規則”從某個“數據源”轉換而來。dependencies指定了生成該RDD所需的“數據源”,術語叫作依賴或父RDD;compute描述了從父RDD經過怎樣的“計算規則”得到當前的RDD。這兩個屬性看似簡單,實則大有智慧。

與MapReduce以算子(Map和Reduce)爲第一視角、以外部數據爲銜接的設計方式不同,Spark Core中RDD的設計以數據作爲第一視角,不再強調算子的重要性,算子僅僅是RDD數據轉換的一種計算規則,map算子和reduce算子紛紛被弱化、稀釋在Spark提供的茫茫算子集合之中。dependencies與compute兩個核心屬性實際上抽象出了“從哪個數據源經過怎樣的計算規則和轉換,從而得到當前的數據集”。父與子的關係是相對的,將思維延伸,如果當前RDD還有子RDD,那麼從當前RDD的視角看過去,子RDD的dependencies與compute則描述了“從當前RDD出發,再經過怎樣的計算規則與轉換,可以獲得新的數據集”。

不難發現,所有RDD根據dependencies中指定的依賴關係和compute定義的計算邏輯構成了一條從起點到終點的數據轉換路徑。這條路徑在Spark中有個專門的術語,叫作Lineage —— 血統。Spark Core依賴血統進行依賴管理、階段劃分、任務分發、失敗重試,任意一個Spark計算作業都可以析構爲一個Spark Core血統。關於血統,到後文書再展開討論,我們繼續介紹RDD抽象的另外3個屬性,即partitions、partitioner和preferredLocations。相比dependencies和compute屬性,這3個屬性更“務實”一些。

在分佈式計算中,一個RDD抽象可以對應多個數據分片實體,所有數據分片構成了完整的RDD數據集。partitions屬性記錄了RDD的每一個數據分片,方便開發者靈活地訪問數據集。partitioner則描述了RDD劃分數據分片的規則和邏輯,採用不同的partitioner對RDD進行劃分,能夠以不同的方式得到不同數量的數據分片。因此,partitioner的選取,直接決定了partitions屬性的分佈。preferredLocations —— 位置偏好,該屬性與partitions屬性一一對應,定義了每一個數據分片的物理位置偏好。具體來說,每個數據分片可以有以下幾種不同的位置偏好:

  • 本地內存:數據分片已存儲在當前計算節點的內存中,可就地訪問
  • 本地磁盤:數據分片在當前計算節點的磁盤中有副本,可就地訪問
  • 本機架磁盤:當前節點沒有分片副本,但是同機架其他機器的磁盤中有副本
  • 其他機架磁盤:當前機架所有節點都沒有副本,但其他機架的機器上有副本
  • 無所謂:當前數據分片沒有位置偏好

根據“數據不動代碼動”的原則,Spark Core優先尊重數據分片的本地位置偏好,儘可能地將計算任務分發到本地計算節點去處理。顯而易見,本地計算的優勢來源於網絡開銷的大幅減少,進而從整體上提升執行性能。

RDD的5大屬性從“虛”與“實”兩個角度刻畫了對數據模型的抽象,任何數據集,無論格式、無論形態,都可以被RDD抽象、封裝。前面提到,任意分佈式計算作業都可以抽象爲血統,而血統由不同RDD抽象的依次轉換構成,因此,任意的分佈式作業都可以由RDD抽象之間的轉換來實現。理論上,如果計算節點內存足夠大,那麼所有關於RDD的轉換操作都可以放到內存中來執行,這便是“內存計算”的由來。

土豆工坊

從理論出發學習、理解新概念總是枯燥而乏味,通過生活化的類比來更好地理解RDD的構成和內存計算的由來也許會更輕鬆一些。假設有個生產桶裝薯片的工坊,這個工坊規模小、工藝也比較原始。爲了充分利用每一顆土豆、降低生產成本,工坊使用3條流水線來同時生產3種不同尺寸的桶裝薯片,分別是小號、中號、大號桶裝薯片。3條流水線可以同時加工3顆土豆,每條流水線的作業流程都是一樣的,即土豆的清洗、切片、烘焙、分發、裝桶,其中分發環節用於區分小號、中號、大號3種薯片。所有小號薯片都會分發給第一條流水線,中號薯片分發給第二條流水線,不消說,大號薯片都分發給第三條流水線。看得出來,這家工坊工藝雖然簡單,倒是也蠻有章法。桶裝薯片的製作流程,與Spark分佈式計算的執行過程頗爲神似。

我們先從食材的視角審視薯片的加工流程,首先,3顆土豆作爲原始素材被送上流水線。流水線的第一道工序是清洗,原來帶泥的土豆經過清洗變成了一顆顆“乾淨的土豆”。第二道工序是切片,土豆經過切片操作後,變成了一枚枚大小不一、薄薄的薯片,當然,這些薯片都還是生的,等到烘烤之後方能食用。第三道工序正是用來烘焙,生薯片在經過烘烤後,變成了可以食用的零食。到目前爲止,所有流水線上都生產出了 “原味”的薯片,不過,薯片的尺寸參差不齊,如果現在就裝桶的話,一來用戶體驗較差,二來桶的利用效率也低,不利於節約成本。因此,流水線上增加了分發的環節,分發操作先把不同尺寸的薯片區分開,然後根據預定規則把不同尺寸的薯片發送到對應的流水線上。每條流水線都執行同樣的分發操作,即先區分大小號,然後再轉發薯片。分發步驟完成後,每條流水線的薯片尺寸大小相當,最後通過機械手把薯片封裝到對應尺寸的桶裏,從而完成一次完整的薯片加工流程。

橫看成嶺側成峯,我們再從流水線的視角,重新審視這個過程。從頭至尾,除了分發環節,3條流水線沒有任何交集。在分發環節之前,每條流水線都是專心致志、各顧各地開展工作 —— 把土豆食材加載到流水線上、清洗、切片、烘焙;在分發環節完成後,3條流水線也是各自裝桶,互不影響。流水線式的作業方式提供了較強的容錯能力,如果某個加工環節出錯,流水線只需要重新加載一顆新的土豆食材就能夠恢復生產。例如,假設第一條流水線在烘焙階段不小心把薯片烤糊了,此時只需要在流水線的源頭重新加載一顆新的土豆,所有加工流程會自動重新開始,不會影響最終的裝桶操作。另外,3條流水線提供了同時處理3顆土豆的能力,因此土豆工坊的併發能力爲3,每次可以同時裝載並加工3顆土豆,大幅地提升了生產效率。

那麼,用土豆工坊薯片加工的流程類比Spark分佈式計算,會有哪些有趣的發現呢?仔細對比,每一種食材形態,如剛從地裏挖出來的土豆食材、清洗後的“乾淨土豆”、生薯片、烤熟的薯片、分發後的薯片,不就是Spark中的RDD抽象嗎?每個RDD都有dependencies和compute屬性,對應地,每一種食材形態的dependencies就是流水線上前一個步驟的食材形態,而其compute屬性就是從前一種食材形態轉換到當前這種食材形態的加工方法。例如,對於烤熟的薯片(圖中bakedChipsRDD)來說,它的dependencies就是上一步的“已切好的生薯片”(chipsRDD),而它的compute屬性,就是“烘焙”這一工藝方法。在土豆工坊的製作流程中,從頭至尾會產生6個RDD,即potatosRDD、cleanedPotatosRDD、chipsRDD、bakedChipsRDD和shuffledBakedChipsRDD,分別對應不同的食材形態。注意,RDD是對數據模型的抽象,它的partitions屬性會對應多個數據分片實體。例如,對於原始食材potatosRDD,它的partitions屬性對應的是圖中的3顆帶泥土豆,每顆土豆代表一個“數據分片”。

同理,chipsRDD的partitions屬性包含的是從3顆土豆切出來的所有“生薯片”,每一枚生薯片都有一個preferredLocation用來標記自己所在的流水線,所有生薯片的preferredLocation集合構成了chipsRDD的preferredLocations屬性。不難發現,如果我們把土豆工坊中的流水線看成是分佈式計算節點,流水線上每一種食材形態的轉換,都可以在計算節點中按序完成。特別地,如果節點內存足夠大,那麼所有上述轉換,都可以在內存中完成。隨着納米工藝的飛速發展,在不遠的將來,也許內存的價格會像現在的磁盤一樣便宜。正是基於這樣的判斷,Spark提出了“內存計算”的概念。

Show me the code

Linus Torvalds他老人家常說:“Talk is cheap. Show me the code.”。在本篇的最後,我們通過代碼示例來直觀地感受一下RDD的轉換過程。學習一門新的編程語言,我們通常從“Hello World”開始;學習分佈式開發,我們得從“Word Count”說起。在開始之前,我們準備一個純文本文件,內容非常簡單,只有3行文本,如下圖所示。

“Word Count”任務的目標是拆分文本中的單詞並對所有單詞計數,對於上圖中的文本內容,我們期望的結果是I的計數是3,chips的計數爲2,等等。在用代碼來實現這個任務之前,我們先來思考一下:解決這個問題,都需要哪些步驟。首先,我們需要將文件內容讀取到計算節點內存,同時對數據進行分片;對於每個數據分片,我們要將句子分割爲一個個的單詞,同樣的單詞可能存在於多個不同的分片中(如單詞I),因此需要對單詞進行分發,從而使得同樣的單詞只存在於一個分片之中;最後,在所有分片上計算每個單詞的計數。對於這樣一個分詞計數任務,如果採用Hadoop MapReduce框架來實現,往往需要用Java來實現Map、Reduce抽象,編寫上百行代碼。得益於Spark RDD數據模型的設計及其提供的豐富算子,無論是用Java、Scala還是Python,只消幾行代碼,即可實現“Word Count”任務。

結合剛剛分析的“解題步驟”,我們首先通過textFile算子將文件內容加載到內存,同時對數據進行分片。然後,用flatMap和map算子實現分詞和計1的操作。這裏計1的目的有二,一來是將數據轉換爲(鍵, 值)對的形式從而調用pairRDD相關算子;二來爲Map端聚合計算打下基礎。關於pairRDD、性能優化,我們在後文書會詳細展開,此處先行略過。最後,通過reduceByKey算子完成單詞的分發和計數。在這份代碼中,我們僅用5行Scala code就實現了“Word Count”分佈式計算作業。在算子的驅動下,不同形態RDD之間的依賴關係與轉換過程一目瞭然。那麼,如果把這段代碼放到土豆工坊的流水線上,會是怎樣的流程呢?

Postscript

本篇是《Spark分佈式計算科普專欄》的第一篇,筆者學淺才疏、疏漏難免。如果您有任何疑問,或是覺得文章中的描述有所遺漏或不妥,歡迎在評論區留言、討論。掌握一門技術,書本中的知識往往只佔兩成,三成靠討論,五成靠實踐。更多的討論能激發更多的觀點、視角與洞察,也只有這樣,對於一門技術的認知與理解才能更深入、牢固。在本篇博文中,我們從分佈式計算髮展歷史的角度,審視了Spark、RDD以及內存計算的由來;以RDD的5大核心屬性展開,講解RDD的構成、依賴關係、轉換過程,並結合“土豆工坊”的生活化示例來類比RDD轉換和Spark分佈式內存計算的工作流程。

最後,我們用一個簡單的代碼示例 —— Word Count來直觀地體會Spark算子與RDD的轉換邏輯。細心的讀者可能早已發現,文中多次提及“後文書再展開”,Spark是一個精妙而複雜的分佈式計算引擎,在本篇博文中我們不得不對Spark中的許多概念都進行了“前置引用”。換句話說,有些概念還沒來得及解釋(如Lineage —— 血統),就已經被引入到了本篇博文中。這樣的敘述方法也許會給一些讀者帶來困惑,畢竟,用一個還未說清的概念,去解釋另一個新概念,總是感覺沒那麼牢靠。常言道:“出來混,遲早是要還的”。在後續的專欄文章中,我們會繼續對Spark的核心概念與原理進行探討,儘可能地還原Spark分佈式內存計算引擎的全貌。

作者簡介

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

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