大數據系列之MapReduce的shuffle原理

CDA數據分析師 出品

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 任務不斷地以對的形式把數據輸出到在內存中構造的一個環形數據結構中。使用環形數據結構是爲了更有效地使用內存空間, 在內存中放置儘可能多的數據。這個數據結構其實就是個字節數組, 叫 Kvbuffer, 名如其義, 但是這裏面不光放置了數據, 還放置了一些索引數據, 給放置索引數據的區域起了一個 Kvmeta 的別名, 在 Kvbuffer的一塊區域上穿了一個 IntBuffer(字節序採用的是平臺自身的字節序) 的馬甲。

數據區域和索引數據區域在 Kvbuffer 中是相鄰不重疊的兩個區域, 用一個分界點來劃分兩者,分界點不是亙古不變的,而是每次Spill之後都會更新一次。初始的分界點是 0,數據的存儲方向是向上增長,索引數據的存儲方向是向下增長,如圖所示:

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

索引是對在 kvbuffer 中的索引, 是個四元組, 包括: value 的起始位置、 key 的起始位置、partition 值、value 的長度,佔用四個Int長度, Kvmeta的存放指針Kvindex 每次都是向下跳四個“格子”, 然後再向上一個格子一個格子地填充四元組的數據。

比如 Kvindex 初始位置是-4, 當第一個寫完之後, (Kvindex+0)的位置存放 value 的起始位置、 (Kvindex+1)的位置存放 key 的起始位置、 (Kvindex+2)的位置存放 partition 的值、 (Kvindex+3)的位置存放value 的長度, 然後 Kvindex 跳到-8 位置, 等第二個和索引寫完之後, Kvindex 跳到-32 位置。

Kvbuffer 的大小雖然可以通過參數設置, 但是總共就那麼大, 和索引不斷地增加, 加着加着, 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 的把數據吐到這個文件中,一個 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 中, 那問題就來了: 只顧着悶頭按照 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 爲關鍵字放置成最小堆, 然後從最小堆中每次取出最小的輸出到一個臨時文件中, 這樣就把這一批段合併成一個臨時的段, 把它加回到 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 過程至此結束。

疫情當下,昔日匆匆的步伐終於放慢了些,也是時候好好想想自己的職業計劃和人生規劃了。提前做好準備,未雨綢繆,爲未來蓄能——蓄勢待發!

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