騰訊大數據之TDW計算引擎解析——Shuffle

騰訊分佈式數據倉庫(Tencent distributed Data Warehouse, 簡稱TDW)基於開源軟件Hadoop和Hive進行構建,並且根據公司數據量大、計算複雜等特定情況進行了大量優化和改造,目前單集羣最大規模達到5600臺,每日作業數達到100多萬,已經成爲公司最大的離線數據處理平臺。爲了滿足用戶更加多樣的計算需求,TDW也在向實時化方向發展,爲用戶提供更加高效、穩定、豐富的服務。

TDW計算引擎包括兩部分:一個是偏離線的MapReduce,一個是偏實時的Spark,兩者內部都包含了一個重要的過程——Shuffle。本文對shuffle過程進行解析,並對兩個計算引擎的shuffle過程進行比較,對後續的優化方向進行思考和探索,期待經過我們不斷的努力,TDW計算引擎運行地更好。

Shuffle過程介紹

MapReduce的Shuffle過程介紹

        Shuffle的本義是洗牌、混洗,把一組有一定規則的數據儘量轉換成一組無規則的數據,越隨機越好。MapReduce中的shuffle更像是洗牌的逆過程,把一組無規則的數據儘量轉換成一組具有一定規則的數據。

        爲什麼MapReduce計算模型需要shuffle過程?我們都知道MapReduce計算模型一般包括兩個重要的階段:map是映射,負責數據的過濾分發;reduce是規約,負責數據的計算歸併。Reduce的數據來源於map,map的輸出即是reduce的輸入,reduce需要通過shuffle來獲取數據。

        從map輸出到reduce輸入的整個過程可以廣義地稱爲shuffle。Shuffle橫跨map端和reduce端,在map端包括spill過程,在reduce端包括copy和sort過程,如圖所示:

 

Spill過程

        Spill過程包括輸出、排序、溢寫、合併等步驟,如圖所示:

 

Collect

        每個Map任務不斷地以<key, value>對的形式把數據輸出到在內存中構造的一個環形數據結構中。使用環形數據結構是爲了更有效地使用內存空間,在內存中放置儘可能多的數據。

        這個數據結構其實就是個字節數組,叫kvbuffer,名如其義,但是這裏面不光放置了<key, value>數據,還放置了一些索引數據,給放置索引數據的區域起了一個kvmeta的別名,在kvbuffer的一塊區域上穿了一個IntBuffer(字節序採用的是平臺自身的字節序)的馬甲。<key, value>數據區域和索引數據區域在kvbuffer中是相鄰不重疊的兩個區域,用一個分界點來劃分兩者,分界點不是亙古不變的,而是每次spill之後都會更新一次。初始的分界點是0,<key, value>數據的存儲方向是向上增長,索引數據的存儲方向是向下增長,如圖所示:

 

        Kvbuffer的存放指針bufindex是一直悶着頭地向上增長,比如bufindex初始值爲0,一個Int型的key寫完之後,bufindex增長爲4,一個Int型的value寫完之後,bufindex增長爲8。

        索引是對<key, value>在kvbuffer中的索引,是個四元組,包括:value的起始位置、key的起始位置、partition值、value的長度,佔用四個Int長度,kvmeta的存放指針kvindex每次都是向下跳四個“格子”,然後再向上一個格子一個格子地填充四元組的數據。比如kvindex初始位置是-4,當第一個<key, value>寫完之後,(kvindex+0)的位置存放value的起始位置、(kvindex+1)的位置存放key的起始位置、(kvindex+2)的位置存放partition的值、(kvindex+3)的位置存放value的長度,然後kvindex跳到-8位置,等第二個<key, value>和索引寫完之後,kvindex跳到-32位置。

        Kvbuffer的大小雖然可以通過參數設置,但是總共就那麼大,<key, value>和索引不斷地增加,加着加着,kvbuffer總有不夠用的那天,那怎麼辦?把數據從內存刷到磁盤上再接着往內存寫數據,把kvbuffer中的數據刷到磁盤上的過程就叫spill,多麼明瞭的叫法,內存中的數據滿了就自動地spill到具有更大空間的磁盤。

        關於spill觸發的條件,也就是kvbuffer用到什麼程度開始spill,還是要講究一下的。如果把kvbuffer用得死死得,一點縫都不剩的時候再開始spill,那map任務就需要等spill完成騰出空間之後才能繼續寫數據;如果kvbuffer只是滿到一定程度,比如80%的時候就開始spill,那在spill的同時,map任務還能繼續寫數據,如果spill夠快,map可能都不需要爲空閒空間而發愁。兩利相衡取其大,一般選擇後者。

        Spill這個重要的過程是由spill線程承擔,spill線程從map任務接到“命令”之後就開始正式幹活,乾的活叫sortAndSpill,原來不僅僅是spill,在spill之前還有個頗具爭議性的sort。

Sort

        先把kvbuffer中的數據按照partition值和key兩個關鍵字升序排序,移動的只是索引數據,排序結果是kvmeta中數據按照partition爲單位聚集在一起,同一partition內的按照key有序。

Spill

        Spill線程爲這次spill過程創建一個磁盤文件:從所有的本地目錄中輪訓查找能存儲這麼大空間的目錄,找到之後在其中創建一個類似於“spill12.out”的文件。Spill線程根據排過序的kvmeta挨個partition的把<key, value>數據吐到這個文件中,一個partition對應的數據吐完之後順序地吐下個partition,直到把所有的partition遍歷完。一個partition在文件中對應的數據也叫段(segment)。

        所有的partition對應的數據都放在這個文件裏,雖然是順序存放的,但是怎麼直接知道某個partition在這個文件中存放的起始位置呢?強大的索引又出場了。有一個三元組記錄某個partition對應的數據在這個文件中的索引:起始位置、原始數據長度、壓縮之後的數據長度,一個partition對應一個三元組。然後把這些索引信息存放在內存中,如果內存中放不下了,後續的索引信息就需要寫到磁盤文件中了:從所有的本地目錄中輪訓查找能存儲這麼大空間的目錄,找到之後在其中創建一個類似於“spill12.out.index”的文件,文件中不光存儲了索引數據,還存儲了crc32的校驗數據。(spill12.out.index不一定在磁盤上創建,如果內存(默認1M空間)中能放得下就放在內存中,即使在磁盤上創建了,和spill12.out文件也不一定在同一個目錄下。)

        每一次spill過程就會最少生成一個out文件,有時還會生成index文件,spill的次數也烙印在文件名中。索引文件和數據文件的對應關係如下圖所示:

 

        話分兩端,在spill線程如火如荼的進行sortAndSpill工作的同時,map任務不會因此而停歇,而是一無既往地進行着數據輸出。Map還是把數據寫到kvbuffer中,那問題就來了:<key, value>只顧着悶頭按照bufindex指針向上增長,kvmeta只顧着按照kvindex向下增長,是保持指針起始位置不變繼續跑呢,還是另謀它路?如果保持指針起始位置不變,很快bufindex和kvindex就碰頭了,碰頭之後再重新開始或者移動內存都比較麻煩,不可取。Map取kvbuffer中剩餘空間的中間位置,用這個位置設置爲新的分界點,bufindex指針移動到這個分界點,kvindex移動到這個分界點的-16位置,然後兩者就可以和諧地按照自己既定的軌跡放置數據了,當spill完成,空間騰出之後,不需要做任何改動繼續前進。分界點的轉換如下圖所示:

 

        Map任務總要把輸出的數據寫到磁盤上,即使輸出數據量很小在內存中全部能裝得下,在最後也會把數據刷到磁盤上。

Merge

        Map任務如果輸出數據量很大,可能會進行好幾次spill,out文件和index文件會產生很多,分佈在不同的磁盤上。最後把這些文件進行合併的merge過程閃亮登場。

        Merge過程怎麼知道產生的spill文件都在哪了呢?從所有的本地目錄上掃描得到產生的spill文件,然後把路徑存儲在一個數組裏。Merge過程又怎麼知道spill的索引信息呢?沒錯,也是從所有的本地目錄上掃描得到index文件,然後把索引信息存儲在一個列表裏。到這裏,又遇到了一個值得納悶的地方。在之前spill過程中的時候爲什麼不直接把這些信息存儲在內存中呢,何必又多了這步掃描的操作?特別是spill的索引數據,之前當內存超限之後就把數據寫到磁盤,現在又要從磁盤把這些數據讀出來,還是需要裝到更多的內存中。之所以多此一舉,是因爲這時kvbuffer這個內存大戶已經不再使用可以回收,有內存空間來裝這些數據了。(對於內存空間較大的土豪來說,用內存來省卻這兩個io步驟還是值得考慮的。)

        然後爲merge過程創建一個叫file.out的文件和一個叫file.out.index的文件用來存儲最終的輸出和索引。

        一個partition一個partition的進行合併輸出。對於某個partition來說,從索引列表中查詢這個partition對應的所有索引信息,每個對應一個段插入到段列表中。也就是這個partition對應一個段列表,記錄所有的spill文件中對應的這個partition那段數據的文件名、起始位置、長度等等。

        然後對這個partition對應的所有的segment進行合併,目標是合併成一個segment。當這個partition對應很多個segment時,會分批地進行合併:先從segment列表中把第一批取出來,以key爲關鍵字放置成最小堆,然後從最小堆中每次取出最小的<key, value>輸出到一個臨時文件中,這樣就把這一批段合併成一個臨時的段,把它加回到segment列表中;再從segment列表中把第二批取出來合併輸出到一個臨時segment,把其加入到列表中;這樣往復執行,直到剩下的段是一批,輸出到最終的文件中。

        最終的索引數據仍然輸出到index文件中。

 

        Map端的shuffle過程到此結束。

Copy

        Reduce任務通過http向各個map任務拖取它所需要的數據。每個節點都會啓動一個常駐的http server,其中一項服務就是響應reduce拖取map數據。當有mapOutput的http請求過來的時候,http server就讀取相應的map輸出文件中對應這個reduce部分的數據通過網絡流輸出給reduce。

        Reduce任務拖取某個map對應的數據,如果在內存中能放得下這次數據的話就直接把數據寫到內存中。Reduce要向每個map去拖取數據,在內存中每個map對應一塊數據,當內存中存儲的map數據佔用空間達到一定程度的時候,開始啓動內存中merge,把內存中的數據merge輸出到磁盤上一個文件中。

        如果在內存中不能放得下這個map的數據的話,直接把map數據寫到磁盤上,在本地目錄創建一個文件,從http流中讀取數據然後寫到磁盤,使用的緩存區大小是64K。拖一個map數據過來就會創建一個文件,當文件數量達到一定閾值時,開始啓動磁盤文件merge,把這些文件合併輸出到一個文件。

        有些map的數據較小是可以放在內存中的,有些map的數據較大需要放在磁盤上,這樣最後reduce任務拖過來的數據有些放在內存中了有些放在磁盤上,最後會對這些來一個全局合併。

Merge Sort

        這裏使用的merge和map端使用的merge過程一樣。Map的輸出數據已經是有序的,merge進行一次合併排序,所謂reduce端的sort過程就是這個合併的過程。一般reduce是一邊copy一邊sort,即copy和sort兩個階段是重疊而不是完全分開的。

        Reduce端的shuffle過程至此結束。

Spark的Shuffle過程介紹

Shuffle Writer

        Spark豐富了任務類型,有些任務之間數據流轉不需要通過shuffle,但是有些任務之間還是需要通過shuffle來傳遞數據,比如wide dependency的group by key。

        Spark中需要shuffle輸出的map任務會爲每個reduce創建對應的bucket,map產生的結果會根據設置的partitioner得到對應的bucketId,然後填充到相應的bucket中去。每個map的輸出結果可能包含所有的reduce所需要的數據,所以每個map會創建R個bucket(R是reduce的個數),M個map總共會創建M*R個bucket。

        Map創建的bucket其實對應磁盤上的一個文件,map的結果寫到每個bucket中其實就是寫到那個磁盤文件中,這個文件也被稱爲blockFile,是DiskBlockManager管理器通過文件名的hash值對應到本地目錄的子目錄中創建的。每個map要在節點上創建R個磁盤文件用於結果輸出,map的結果是直接輸出到磁盤文件上的,100KB的內存緩衝是用來創建FastBufferedOutputStream輸出流。這種方式一個問題就是shuffle文件過多。

 

        針對上述shuffle過程產生的文件過多問題,Spark有另外一種改進的shuffle過程:consolidation shuffle,以期顯著減少shuffle文件的數量。在consolidation shuffle中每個bucket並非對應一個文件,而是對應文件中的一個segment部分。Job的map在某個節點上第一次執行,爲每個reduce創建bucket對應的輸出文件,把這些文件組織成ShuffleFileGroup,當這次map執行完之後,這個ShuffleFileGroup可以釋放爲下次循環利用;當又有map在這個節點上執行時,不需要創建新的bucket文件,而是在上次的ShuffleFileGroup中取得已經創建的文件繼續追加寫一個segment;當前次map還沒執行完,ShuffleFileGroup還沒有釋放,這時如果有新的map在這個節點上執行,無法循環利用這個ShuffleFileGroup,而是隻能創建新的bucket文件組成新的ShuffleFileGroup來寫輸出。

 

        比如一個job有3個map和2個reduce:(1) 如果此時集羣有3個節點有空槽,每個節點空閒了一個core,則3個map會調度到這3個節點上執行,每個map都會創建2個shuffle文件,總共創建6個shuffle文件;(2) 如果此時集羣有2個節點有空槽,每個節點空閒了一個core,則2個map先調度到這2個節點上執行,每個map都會創建2個shuffle文件,然後其中一個節點執行完map之後又調度執行另一個map,則這個map不會創建新的shuffle文件,而是把結果輸出追加到之前map創建的shuffle文件中;總共創建4個shuffle文件;(3) 如果此時集羣有2個節點有空槽,一個節點有2個空core一個節點有1個空core,則一個節點調度2個map一個節點調度1個map,調度2個map的節點上,一個map創建了shuffle文件,後面的map還是會創建新的shuffle文件,因爲上一個map還正在寫,它創建的ShuffleFileGroup還沒有釋放;總共創建6個shuffle文件。

Shuffle Fetcher

        Reduce去拖map的輸出數據,Spark提供了兩套不同的拉取數據框架:通過socket連接去取數據;使用netty框架去取數據。

        每個節點的Executor會創建一個BlockManager,其中會創建一個BlockManagerWorker用於響應請求。當reduce的GET_BLOCK的請求過來時,讀取本地文件將這個blockId的數據返回給reduce。如果使用的是Netty框架,BlockManager會創建ShuffleSender用於發送shuffle數據。

        並不是所有的數據都是通過網絡讀取,對於在本節點的map數據,reduce直接去磁盤上讀取而不再通過網絡框架。

        Reduce拖過來數據之後以什麼方式存儲呢?Spark map輸出的數據沒有經過排序,spark shuffle過來的數據也不會進行排序,spark認爲shuffle過程中的排序不是必須的,並不是所有類型的reduce需要的數據都需要排序,強制地進行排序只會增加shuffle的負擔。Reduce拖過來的數據會放在一個HashMap中,HashMap中存儲的也是<key, value>對,key是map輸出的key,map輸出對應這個key的所有value組成HashMap的value。Spark將shuffle取過來的每一個<key, value>對插入或者更新到HashMap中,來一個處理一個。HashMap全部放在內存中。

        Shuffle取過來的數據全部存放在內存中,對於數據量比較小或者已經在map端做過合併處理的shuffle數據,佔用內存空間不會太大,但是對於比如group by key這樣的操作,reduce需要得到key對應的所有value,並將這些value組一個數組放在內存中,這樣當數據量較大時,就需要較多內存。

        當內存不夠時,要不就失敗,要不就用老辦法把內存中的數據移到磁盤上放着。Spark意識到在處理數據規模遠遠大於內存空間時所帶來的不足,引入了一個具有外部排序的方案。Shuffle過來的數據先放在內存中,當內存中存儲的<key, value>對超過1000並且內存使用超過70%時,判斷節點上可用內存如果還足夠,則把內存緩衝區大小翻倍,如果可用內存不再夠了,則把內存中的<key, value>對排序然後寫到磁盤文件中。最後把內存緩衝區中的數據排序之後和那些磁盤文件組成一個最小堆,每次從最小堆中讀取最小的數據,這個和MapReduce中的merge過程類似。

MapReduce和Spark的Shuffle過程對比

 

MapReduce

Spark

collect

在內存中構造了一塊數據結構用於map輸出的緩衝

沒有在內存中構造一塊數據結構用於map輸出的緩衝,而是直接把輸出寫到磁盤文件

sort

map輸出的數據有排序

map輸出的數據沒有排序

merge

對磁盤上的多個spill文件最後進行合併成一個輸出文件

在map端沒有merge過程,在輸出時直接是對應一個reduce的數據寫到一個文件中,這些文件同時存在併發寫,最後不需要合併成一個

copy框架

jetty

netty或者直接socket流

對於本節點上的文件

仍然是通過網絡框架拖取數據

不通過網絡框架,對於在本節點上的map輸出文件,採用本地讀取的方式

copy過來的數據存放位置

先放在內存,內存放不下時寫到磁盤

一種方式全部放在內存;另一種方式先放在內存

merge sort

最後會對磁盤文件和內存中的數據進行合併排序

對於採用另一種方式時也會有合併排序的過程

 

Shuffle後續優化方向

        通過上面的介紹,我們瞭解到,shuffle過程的主要存儲介質是磁盤,儘量的減少io是shuffle的主要優化方向。我們腦海中都有那個經典的存儲金字塔體系,shuffle過程爲什麼把結果都放在磁盤上,那是因爲現在內存再大也大不過磁盤,內存就那麼大,還這麼多張嘴吃,當然是分配給最需要的了。如果具有“土豪”內存節點,減少shuffle io的最有效方式無疑是儘量把數據放在內存中。下面列舉一些現在看可以優化的方面,期待經過我們不斷的努力,TDW計算引擎運行地更好。

MapReduce Shuffle後續優化方向

  • 壓縮:對數據進行壓縮,減少寫讀數據量;
  • 減少不必要的排序:並不是所有類型的reduce需要的數據都是需要排序的,排序這個nb的過程如果不需要最好還是不要的好;
  • 內存化:shuffle的數據不放在磁盤而是儘量放在內存中,除非逼不得已往磁盤上放;當然瞭如果有性能和內存相當的第三方存儲系統,那放在第三方存儲系統上也是很好的;這個是個大招;
  • 網絡框架:netty的性能據說要佔優了;
  • 本節點上的數據不走網絡框架:對於本節點上的map輸出,reduce直接去讀吧,不需要繞道網絡框架。

Spark Shuffle後續優化方向

        Spark作爲MapReduce的進階架構,對於shuffle過程已經是優化了的,特別是對於那些具有爭議的步驟已經做了優化,但是Spark的shuffle對於我們來說在一些方面還是需要優化的。

  • 壓縮:對數據進行壓縮,減少寫讀數據量;
  • 內存化:Spark歷史版本中是有這樣設計的:map寫數據先把數據全部寫到內存中,寫完之後再把數據刷到磁盤上;考慮內存是緊缺資源,後來修改成把數據直接寫到磁盤了;對於具有較大內存的集羣來講,還是儘量地往內存上寫吧,內存放不下了再放磁盤。

 

 

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