咱們爲何須要Map-Reduce?

在討論咱們是否真的須要Map-Reduce這一分佈式計算技術以前,咱們先面對一個問題,這能夠爲咱們討論這個問題提供一個直觀的背景。php

問題

這裏寫圖片描述

咱們先從最直接和直觀的方式出發,來嘗試解決這個問題:
先僞一下這個問題:java

SELECT COUNT(DISTINCT surname) FROM big_name_file

咱們用一個指針來關聯這個文件.c++

接着考察每一行的數據,解析出裏面的姓氏,這裏咱們可能須要一個姓氏字典或者對照表,而後咱們能夠利用最長前綴匹配來解析出姓氏。這很像命名實體識別所幹的事。算法

拿到了姓氏,咱們還須要一個鏈表L,這個鏈表的每一個元素存儲兩個信息,一個是姓氏或者姓氏的編號,另外一個是這個姓氏出現的次數。sql

在考察每一行的數據時,咱們解析出姓氏,而後在鏈表L中查找這個姓氏對應的元素是否存在,若是存在就將這個元素的姓氏出現次數加一,不然就新增一個元素,而後置這個元素的姓氏出現次數爲1。markdown

當全部的行都遍歷完畢,鏈表L的長度就是不一樣的姓氏的個數出現的次數。分佈式

/** * 直接法僞代碼 */
    int distinctCount(file) {
    //將磁盤文件file關聯到一個內存中的指針f上
    f <- file;
    //初始化一個鏈表
    L <- new LinkedList();
    while(true) {
        line <- f.readline();
        if(line == null)
            break;
        //解析出此行的姓氏
        surname <- parse(line);
        //若是鏈表中沒有這個姓氏,就新增一個,若是有,就將這個姓氏的出現次數+1
        L.addOrUpdate(surname,1);
    }
    //鏈表的長度就是文件中不一樣姓氏的個數
    return L.size();
}

ok,這個方法在不關心效率和內存空間的狀況下是個解決辦法。
可是卻有一些值得注意的問題:ui

在進行addOrUpdate操做時,咱們須要進行一個find的操做來找到元素是否已在鏈表中了。對於無序鏈表來講,咱們必須採起逐一比較的方式來實現這個find的語義。編碼

對於上面的考慮,顯然咱們知道若是能按下標直接找出元素就最好不過了,咱們能夠在常量時間找出元素並更新姓氏出現的次數。atom

哈希表法

對於這一點,咱們能夠採起哈希表來作,採起這個結構,咱們能夠用常量時間來找到元素並更新。

int distinctCountWithHashTable(file) {
    //將磁盤文件file關聯到一個內存中的指針f上
    f <- file;
    //初始化一個哈希表
    T <- new HashTable();
    while(true) {
        line <- f.readline();
        if(line == null)
            break;
        //解析出此行的姓氏
        surname <- parse(line);
        //若是哈希表中沒有這個姓氏,就新增一個,若是有,就將這個姓氏的出現次數+1
        T.addOrUpdate(surname,1);
    }

    //哈希表中實際存儲的元素個數就是文件中不一樣姓氏的個數
    return T.size();
}

假設給定文件是有序的

哈希表法看起來很美,但仍是有潛在的問題,若是內存不夠大怎麼辦,哈希表在內存中放不下。這個問題一樣存在於直接法中。

想一想看,若是這個文件是個排好序的文件,那該多好。
全部重複的姓氏都會連着出現,這樣咱們只須要標記一個計數器,每次讀取一行文本,若是解析出的姓氏和上一行的不一樣,計數器就增1.
那麼代碼就像下面這樣:

int distinctCountWithSortedFile(file) {
    //將磁盤文件file關聯到一個內存中的指針f上
    f <- file;
    //不一樣姓氏的計數器,初始爲0
    C <- 0;
    //上一行的姓氏
    last_surname <- empty;
    while(true) {
        line <- f.readline();
        if(line == null)
            break;
        //解析出此行的姓氏
        surname <- parse(line);
        //若是和上一行的姓氏不一樣,計數器加1
        if(!last_surname.equals(surname))
            C++;
        last_surname <- surname;
    }

    return C;
}

遺憾的是,咱們並不能保證給定的文件是有序的。但上面方法的優勢是能夠破除內存空間的限制,對內存的需求很小很小。

那麼能不能先排個序呢?
確定是能夠的,那麼多排序算法在。可是有了內存空間的限制,能用到的排序算法大概只有位圖法和外排了吧。

位圖法

假設13億/32 + 1個int(這裏設32位)的內存空間仍是有的,那麼咱們用位圖法來作。
位圖法很簡單,基本上須要兩個操做:

/** * 將i編碼 */
    void encode(M,i) {
        (M[i >> 5]) |=  (1 << (i & 0x1F));
    }
    /** *將i解碼 */
    int decode(M,i) {
        return (M[i >> 5]) & (1 << (i & 0x1F));
    }

假設咱們採起和姓氏字典同樣的編號,咱們作一個天然升序,那麼這個方法就像下面這樣:

int distinctCountWithBitMap(file) {
    //將磁盤文件file關聯到一個內存中的指針f上
    f <- file;
    //初始化一個位圖結構M,長度爲13億/32 + 1
    M <- new Array();
    //不一樣姓氏的個數,初始爲0
    C <- 0;
    while(true) {
        line <- f.readline();
        if(line == null)
            break;
        //解析出此行的姓氏編號
        surname_index <- parse(line);
        //將姓氏編號編碼到位圖對應的位上
        encode(M,surname_index);        
    }

    //找出位圖中二進制1的個數
    C <- findCountOfOneBits(M);

    return C;
}

ok,一切看起來很完美,但如何有效地找出位圖中的二進制1的個數呢?上面使用了一個findCountOfOneBits方法,找出二進制1的個數,好吧,這是另一個問題,但咱們爲了完整,能夠給出它的一些算法:

int findCountOfOneBits_1(int[] array) {
    int c = 0;
    for(int i = 0 ; i < array.length; i++)
        c += __popcnt(array[i]);
    return c;
}

int findCountOfOneBits_2(int[] array) {
    int c = 0;
    for(int i = 0 ; i < array.length; i++) {
        while(array[i]) {
            array[i] &= array[i] - 1;
            c++;
        }
    }

    return c;
}

int findCountOfOneBits_3(int[] array) {
    int c = 0;
    unsigned int t;
    int e = 0;
    for(int i = 0 ; i < array.length; i++) {
        e = array[i];
        t = e
            - ((e >> 1) & 033333333333)
            - ((e >> 2) & 011111111111);

        t = (t + (t >> 3)) & 030707070707
        c += (t%63);
    }

    return c;
}

上面的算法哪一種效率最高呢?老三。

合併法

ok,位圖法看起來破除了內存的限制,的確如此嗎?若是內存小到連位圖都放不下怎麼辦?
不解決這個問題了!開玩笑~

既然內存嚴重不足,那麼咱們只能每次處理一小部分數據,而後對這部分數據進行不一樣姓氏的個數的統計,用一個{key,count}的結構去維護這個統計,其中key就表明了咱們的姓氏,count表明了它出現的次數。

處理完畢一小批數據後,咱們須要將統計結果持久化到硬盤,以備最後累計,這牽扯到一個合併的問題。

如何進行有效地合併也值得思索,由於一開始文件內的姓名是無序的,因此不能在最後時刻進行簡單合併,由於同一種姓氏可能出如今不一樣的統計結果分組中,這會使得統計結果出現重複。
因此咱們必須對每批統計結果維護一個group結構或者以下的結構:

統計結果1{{key=count=631}...}
統計結果2{{key=count=3124}...}

統計結果N : {{key=count=9956}...}

這樣,咱們在最後能夠按key進行合併,得出以下的結構:

彙總結果1{{key=count=20234520}...}
彙總結果2{{key=count=33000091...}

彙總結果M{{key=count=20009323}...}

BTW,數據是瞎編的,我我的並不知道到底哪一個姓氏最多。
這樣M就是咱們不一樣姓氏的個數。

合併的過程以下圖:
這裏寫圖片描述

因爲不斷地將部分的統計結果合併到硬盤中,這種方式很是相似LSM算法,不一樣的是,咱們對硬盤上中間文件的合併是on-line的,不是off-line的。

分佈式法 Map-Reduce

合併法中,顯然須要屢次的訪問硬盤,這有點問題:

若是是機械硬盤,那麼磁盤的尋道時間使人頭痛。
而且,合併的算法是串行的,咱們沒法下降攤還尋道代價。

面對內存容量有限的假設,咱們能夠推廣到單機的計算資源有限的場景中來,設想一下,上面所列舉的算法中,若是文檔是有序的,那麼咱們僅僅使用極小的內存就能夠解決問題,那麼咱們不須要分佈式,也不須要Map-Reduce。

固然,若是咱們不只須要統計不一樣姓氏的個數,還想知道不一樣姓氏出現的頻率,以研究到底姓王的多仍是姓張的多,那麼咱們須要一些新思路。

若是咱們能將姓名數據仔細分組,使得一樣的姓氏會出如今同一組中.
而後將這些組分派到不一樣的計算節點上,由這些節點並行計算出若干個數C1C2...Cn,最終咱們的答案就是:n.
而每一個姓氏的頻率能夠表示爲:
frequencyi=Cini=1Ci,iCii的出現的個數 。

而對應這種分佈式計算模型的,就是Map-Reduce.
一個典型的Map-Reduce模型,大概像下圖這樣:
這裏寫圖片描述
注:上圖來自Search Engines:Information Retrieval In Practice.

對應咱們這個問題,僞代碼以下:

function Map(file) {
    while(true) {
        line <- file.readline();
        if(line == null)
            break;
        surname <- parse(line);
        count <- 1;
        Emit(surname,count);
    }
}

function Reduce(key,values) {
    C <- 0;
    surname <- key;
    while(!values.empty()) {
        C <- C + values.next();
    }

    Emit(surname,C);
}

使用Map-Reduce技術,不只能夠並行處理姓氏頻率,同時也能夠應對big、big、big-data(好比全銀河系的「人」的姓名)。前提是你有足夠的計算節點或者機器。

這裏還有一個問題須要注意,就是上面的Reduce算法默認了數據已經按姓氏分組了,這個目標咱們依靠Shuffle來完成。

在Shuffle階段,依靠哈希表來完成group by surname.

在這裏,將全部數據按姓氏分組並將每一組分派到一個計算節點上顯得有些奢侈,因此若是在機器不足的狀況下,能夠將分組的粒度變大,好比100個姓氏爲一組,而後經過屢次的Map-Reduce來得到最終結果。

最後,但願我說明白了爲何咱們須要Map-Reduce技術。 同時,不得不認可這個問題的設定是比較尷尬的= _ =,由於在對姓氏的parse階段,咱們用到了一個全姓氏字典,顯然這個字典自己(Trie or Hash)能夠告訴咱們不一樣姓氏的個數。但若是問題的設定不是所有的姓氏都出如今文件中,或許這篇文章就能起到拋磚引玉的效果,那麼其中的過程也值得書寫下來。

相關文章
相關標籤/搜索