小白的資料進階之路(上)——從Shell指令碼到MapReduce

張鐵蕾發表於2016-10-31

那一年,小白剛從學校畢業,學的是計算機專業。最開始他也不清楚自己想要一份怎樣的工作,只知道自己先找個網際網路公司乾乾技術再說。

有一天,小白來到一家剛成立不久的小創業公司參見面試。公司雖小,但團隊卻是華麗麗的。兩位創始人都是MIT的MBA,Co-CEO。他們號稱,公司的運營、財務、市場以及銷售人員,都是從大公司高薪挖過來的。此外,他們還告訴小白,公司另有一位政府背景深厚但不願透露姓名的神祕股東加盟。

我們手頭有百萬美金的風險投資,團隊也基本到位。現在萬事俱備,就差一位程式設計師了。

小白平常在學校裡就很喜歡閱讀那些創業成功的勵志故事,對於能拿到風險投資的人特別崇拜。

你們要做的是什麼產品?小白問道。

這個涉及到我們的創意,暫時保密。但可以告訴你的是,我們要做一款偉大的產品,它將顛覆整個網際網路行業。兩位CEO神祕地回答。

隨後,他們又補充道,

我們計劃在兩年內上市。

小白聽完不禁心潮澎湃,隨後就入職了這家公司。

公司已經有了一個能執行的網站系統,小白每天的工作就是維護這個網站。工作並不忙,平常只是讀讀程式碼,改改bug。

突然有一天,其中一位CEO讓小白統計一下網站的資料,比如日活躍(Dayliy Active Users)、周活躍(Weekly Active Users)、月活躍(Monthly Active Users),說是要給投資人看。

小白想了想,他手頭現有的一份基礎資料,就是訪問日誌(Access Log)。訪問日誌是每天一個檔案,每個檔案裡面每一行的資料格式如下:

[時間] [使用者ID] [操作名稱] [其它引數...]複製程式碼

比方說要統計日活躍,就要把一個檔案(對應一天)裡出現同樣使用者ID的行進行去重,去重後的檔案行數就是日活躍資料了。周活躍和月活躍與此類似,只是要分別在一週和一個月內進行去重。

小白那時只會寫Java程式,所以他寫了個Java程式來進行統計:

逐行讀取一個檔案的內容,同時記憶體裡維護一個HashSet用於做去重判斷。每讀一行,解析出使用者ID,判斷它在HashSet裡是否存在。如果不存在,就把這個使用者ID插入HashSet;如果存在,則忽略這一行,繼續讀下一行。一個檔案處理完之後,HashSet裡面存放的資料個數就是那一天對應的日活躍資料。

同樣,統計周活躍和月活躍只需要讓這個程式分別讀取7天和30天的檔案進行處理。

從此以後,兩位CEO時不時地來找小白統計各種資料。小白知道,他們最近頻繁地出入於投資圈的各種會議和聚會,大概是想給公司進行第二輪融資。

每次看到資料之後,他們都一臉難以置信的表情。沒有統計錯誤吧?我們就這麼點兒使用者嗎?

小白竟無言以對。

轉眼間一年時間過去了。小白髮現,他們與上市目標的距離跟一年前同樣遙遠。更糟糕的是,公司之前融到的錢已經花得差不多了,而第二輪融資又遲遲沒有結果,小白上個月的工資也被拖欠著沒發。於是,他果斷辭職。

截止到小白辭職的那一天,公司的日活躍資料也沒有超過四位數。


小白的第二份工作,是一家做手機App的公司。

這家公司的技術總監,自稱老王。在面試的時候,老王聽說小白做過資料統計,二話沒說把他招了進來。

小白進了公司才知道,CEO以前是做財務出身,非常重視資料,每天他自己提出的各種大大小小的資料統計需求就不下十幾項。

小白每天忙著寫各種統計程式,處理各種資料格式,經常加班到晚上十一二點。而且,更令他沮喪的是,很多統計需求都是一次性的,他寫的統計程式大部分也是隻執行過一次,以後就扔到一邊再也用不到了。

有一天,他正在加班統計資料。老王走過來,發現他用的是Java語言,感到很驚訝。老王跟小白一起分析後指出,大部分資料需求呢,其實都可以從訪問日誌統計出來。而處理文字檔案的日誌資料,用Shell指令碼會比較方便。

於是,小白猛學了一陣Shell程式設計。他發現,用一些Shell命令來統計一些資料,比如日活躍,變得非常簡單。以某一天的訪問日誌檔案"access.log"為例,它每行的格式如下:

[時間] [使用者ID] [操作名稱] [其它引數...]複製程式碼

只用一行命令就統計出了日活躍:

cat access.log | awk '{print $2}' | sort | uniq | wc -l複製程式碼

這行命令,使用awk把access.log中的第二列(也就是使用者ID)過濾出來,然後進行排序,這樣就使得相同的使用者ID挨在了一起。再經過uniq命令處理,對相鄰行進行去重,就得到了獨立使用者ID。最後用wc命令算出一共有多少行,就是日活躍。

寫了一些Shell指令碼之後,小白慢慢發現,使用一些簡單的命令就可以很快速地對檔案資料集合進行並、交、差運算。

假設a和b是兩個檔案,裡面每一行看作一個資料元素,且每一行都各不相同。

那麼,計算a和b的資料並集,使用下面的命令:

cat a b | sort | uniq > a_b.union複製程式碼

交集:

cat a b | sort | uniq -d > a_b.intersect複製程式碼

這裡uniq命令的-d參數列示:只列印相鄰重複的行。

計算a和b的差集稍微複雜一點:

cat a_b.union b | sort | uniq -u > a_b.diff複製程式碼

這裡利用了a和b的並集結果a_b.union,將它與b一起進行排序之後,利用uniq的-u引數把相鄰沒有重複的行列印出來,就得到了a和b的差集。

小白髮現,很多資料統計都可以用集合的並、交、差運算來完成。

首先,把每天的訪問日誌加工一下,就得到了一個由獨立使用者ID組成的集合檔案(每行一個使用者ID,不重複):

cat access.log | awk '{print $2}' | sort | uniq > access.log.uniq複製程式碼

比如,要計算周活躍,就先收集7天的獨立使用者集合:

  • access.log.uniq.1
  • access.log.uniq.2
  • ......
  • access.log.uniq.7

把7個集合求並集就得到周活躍:

cat access.log.uniq.[1-7] | sort | uniq | wc -l複製程式碼

同樣,要計算月活躍就對30天的獨立使用者集合求並集。

再比如,計算使用者留存率(Retention),則需要用到交集。先從某一天的日誌檔案中把新註冊的使用者集合分離出來,以它為基礎:

  • 計算這個新使用者集合和1天之後的獨立活躍使用者集合的交集,這個交集與原來新使用者集合的大小的比值,就是該天的1日留存率。
  • 計算這個新使用者集合和2天之後的獨立活躍使用者集合的交集,這個交集與原來新使用者集合的大小的比值,就是該天的2日留存率。
  • ......
  • 以此類推,可以計算出N日留存率。

再比如,類似這樣的統計需求:“在過去某段時間內執行過某個操作的使用者,他們N天之後又執行了另外某個操作的比率”,或者計算使用者深入到某個層級的頁面留存率,基本都可以通過並集和交集來計算。而類似“使用了某個業務但沒有使用另外某個業務的使用者”的統計,則涉及到差集的計算。

在掌握了Shell指令碼處理資料的一些技巧之後,小白又深入學習了awk程式設計,從此他做起資料統計的任務來,越發地輕鬆自如。而公司的CEO和產品團隊,每天仔細分析這些資料之後,有針對性地對產品進行調整,也取得了不錯的成績。

隨著使用者的增多和業務的發展,訪問日誌越來越大,從幾百MB到1個GB,再到幾十個GB,上百GB。統計指令碼執行的時間也越來越長,很多統計要跑幾個小時,甚至以天來計。以前那種靈活的資料統計需求,再也做不到“立等可取”了。而且,更糟糕的是,單臺機器的記憶體已經捉襟見肘。機器雖然記憶體已經配置到了很大,但還是經常出現嚴重的swap,以前的指令碼眼看就要“跑不動”了。

為了加快統計指令碼執行速度,小白打算找到一個辦法能夠讓資料統計指令碼在多臺機器上並行執行,並使用較小的記憶體就能執行。他冥思苦想,終於想到了一個樸素但有效的辦法。

還是以計算日活躍為例。他先把某一天的日誌檔案從頭至尾順序掃描一遍,得到10個使用者ID檔案。對於日誌檔案中出現的每個使用者ID,他通過計算使用者ID的雜湊值,來決定把這個使用者ID寫入這10個檔案中的哪一個。由於是順序處理,這一步執行所需要的記憶體並不大,而且速度也比較快。

然後,他把得到的10個檔案拷貝到不同的機器上,分別進行排序、去重,並計算各自的獨立使用者數。由於10個檔案中的使用者ID相互之間沒有交集,所以最後把計算出來的10個獨立使用者數直接加起來,就得到了這一天的日活躍資料。

依靠這種方法,小白把需要處理的資料規模降低到了原來的1/10。他發現,不管原始資料檔案多麼大,他只要在第一步掃描處理檔案的時候選擇拆分的檔案數多一些,總能把統計問題解決掉。但是,他也看到了這種方法的一些缺點:

  • 第一步掃描處理檔案的過程仍然是順序的,雖然記憶體佔用不多,但速度上不去。
  • 拆分後的檔案要全部拷貝到別的機器上,跨機器的網路傳輸也很耗時。
  • 最重要的一個問題,這全部的過程相當繁瑣,極易出錯,且不夠通用。

特別是最後這個問題,讓小白很是苦惱。看起來每次統計過程類似,似乎都在重複勞動,但每次又都有些不一樣的地方。比如,是根據什麼規則進行檔案拆分?拆分到多少份?拆分後的資料檔案又是怎麼處理?哪些機器空閒能夠執行這些處理?都要根據具體的統計需求和計算過程來定。

整個過程沒法自動化。小白雖然手下招了兩個實習生來分擔他的工作,但涉及到這種較大資料量的統計問題時,他還是不放心交給他們來做。

於是,小白在思考,如何才能設計出一套通用的資料計算框架,讓每個會寫指令碼的人都能分散式地執行他們的指令碼呢?

這一思考就是三年。在這期間,他無數次地感覺到自己已經非常接近於那個問題背後的本質了,但每次都無法達到融會貫通的那個突破點。

而與此同時,公司的業務發展也進入了瓶頸期。小白逐漸認識到,在原有的業務基礎上進行精耕細作的微小改進,固然能帶來一定程度的提升,但終究無法造就巨大的價值突破。這猶如他正在思考的問題,他需要換一個視野來重新審視。

正在這時,另一家處於高速增長期的網際網路公司要挖他過去。再三考慮之後,他選擇了一個恰當的時機提交了辭職信,告別了他的第二份工作。


小白在新公司入職以後,被分配到資料架構組。他的任務正是他一直想實現的那個目標:設計一套通用的分散式的資料計算框架。這一次,他面臨的是動輒幾個T的大資料。

小白做了無數次調研,自學了很多知識,最後,他從Lisp以及其它一些函式式語言的map和reduce原語中獲得了靈感。他重新設計了整個資料處理過程,如下圖:

小白的資料進階之路(上)——從Shell指令碼到MapReduce

  • (1) 輸入多個檔案。很多資料統計都需要輸入多個檔案,比如統計周活躍就需要同時輸入7個日誌檔案。
  • (2) 把每個檔案進行邏輯分塊,分成指定大小的資料塊,每個資料塊稱為InputSplit。
    • 由於要處理很大的檔案,所以首先必須要進行分塊,這樣接下來才便於資料塊的處理和傳輸。
    • 而且,這裡的檔案分塊是與業務無關的,這與小白在上家公司根據計算某個雜湊值來進行檔案拆分是不同的。之前根據雜湊值進行的檔案拆分,需要程式設計人員根據具體統計需求來確定如何進行雜湊,比如根據什麼欄位進行雜湊,以及雜湊成多少份。而現在的檔案分塊,只用考慮分塊大小就可以了。分塊過程與業務無關,意味著這個過程可以寫進框架,而無需使用者操心了。
    • 另外,還需要注意的一點是,這裡的分塊只是邏輯上的,並沒有真正地把檔案切成很多個小檔案。實際上那樣做是成本很高的。所謂邏輯上的分塊,就是說每個InputSplit只需要指明當前分塊是對應哪一個檔案、起始位元組位置以及分塊長度就可以了。
  • (3) 為每一個InputSplit分配一個Mapper任務,它允許排程到不同機器上並行執行。這個Mapper定義了一個高度抽象的map操作,它的輸入是一對key-value,而輸出則是key-value的列表。這裡可能產生的疑問點有下面幾個:
    • 輸入的InputSplit每次傳多少資料給Mapper呢?這些資料又是怎麼變成key-value的格式的呢?實際上,InputSplit的資料確實要經過一定的變換,一部分一部分地變換成key-value的格式傳進Mapper。這個轉換過程使用者自己可以指定。而對於一般的文字輸入檔案來說(比如訪問日誌),資料是一行一行傳給Mapper的,其中value=當前行,key=當前行在輸入檔案中的位置。
    • Mapper裡需要對輸入的key-value執行什麼處理呢?這其實正是需要使用者來實現的部分。一般來說呢,需要對輸入的一行資料進行解析,得到關鍵的欄位。以統計日活躍為例,這裡至少應該從輸入的一行資料中解析出“使用者ID”欄位。
    • Mapper輸出的key和value怎麼確定呢?這裡輸出的key很關鍵。整個系統保證,從Mapper輸出的相同的key,不管這些key是從同一個Mapper輸出的,還是從不同Mapper輸出的,它們後續都會歸類到同一個Reducer過程中去處理。比如要統計日活躍,那麼這裡就希望相同的使用者ID最終要送到一個地方去處理(計數),所以輸出的key就應該是使用者ID。對於輸出日活躍的例子,輸出的value是什麼並不重要。
  • (4) Mapper輸出的key-value列表,根據key的值雜湊到不同的資料分塊中,這裡的資料塊被稱為Partition。後面有多少個Reducer,每個Mapper的輸出就對應多少個Partition。最終,一個Mapper的輸出,會根據(PartitionId, key)來排序。這樣,Mapper輸出到同一個Partition中的key-value就是有序的了。這裡的過程其實有點類似於小白在前一家公司根據雜湊值來進行檔案拆分的做法,但那裡是對全部資料進行拆分,這裡只是對當前InputSplit的部分資料進行劃分,資料規模已經減小了。
  • (5) 從各個Mapper收集對應的Partition資料,並進行歸併排序,然後將每個key和它所對應的所有value傳給Reducer處理。Reducer也可以排程到不同機器上並行執行。
    • 由於資料在傳給Reducer處理之前進行了排序,所以前面所有Mapper輸出的同一個Partition內部相同key的資料都已經挨在了一起,因此可以把這些挨著的資料一次性傳給Reducer。如果相同key的資料特別多,那麼也沒有關係,因為這裡傳給Reducer的value列表是以Iterator的形式傳遞的,並不是全部在記憶體裡的列表。
    • Reducer在處理後再輸出自己的key-value,儲存到輸出檔案中。每個Reducer對應一個輸出檔案。

上面的資料處理過程,一般情況下使用者只需要關心Map(3)和Reduce(5)兩個過程,即重寫Mapper和Reducer。因此,小白把這個資料處理系統稱為MapReduce。

還是以統計日活躍為例,使用者需要重寫的Mapper和Reducer程式碼如下:

public class MyMapper
        extends Mapper<Object, Text, Text, Text> {
    private final static Text empty = new Text("");
    private Text userId = new Text();

    public void map(Object key, Text value, Context context
    ) throws IOException, InterruptedException {
        //value格式: [時間] [使用者ID] [操作名稱] [其它引數...]
        StringTokenizer itr = new StringTokenizer(value.toString());
        //先跳過第一個欄位
        if (itr.hasMoreTokens()) itr.nextToken();
        if (itr.hasMoreTokens()) {
            //找到使用者ID欄位
            userId.set(itr.nextToken());
            //輸出使用者ID
            context.write(userId, empty);
        }
    }
}

public class MyReducer
        extends Reducer<Text,Text,Text,Text> {
    private final static Text empty = new Text("");

    public void reduce(Text key, Iterable<Text> values,
                       Context context
    ) throws IOException, InterruptedException {
        //key就是使用者ID
        //重複的使用者ID只輸出一個, 去重
        context.write(key, empty);
    }
}複製程式碼

假設配置了r個Reducer,那麼經過上面的程式碼執行完畢之後,會得到r個輸出檔案。其中每個檔案由不重複的使用者ID組成,且不同檔案之間不存在交集。因此,這些輸出檔案就記錄了所有日活躍使用者,它們的行數累加,就得到了日活躍數。

設計出MapReduce的概念之後,小白髮現,這是一個很有效的抽象。它不僅能完成平常的資料統計任務,它還有更廣泛的一些用途,下面是幾個例子:

  • 分散式的Grep操作。
  • 反轉網頁引用關係。這在搜尋引擎中會用到。輸入資料是網頁source到網頁target的引用關係,現在要把這些引用關係倒過來。讓Mapper輸出(target, source),在Reducer中把同一個target頁面對應的所有source頁面歸到一起,輸出:(target, list(source))。
  • 分散式排序。本來每個Reducer的輸出檔案內部資料是有序的,但不同Reducer的輸出檔案之間不是有序的。為了能做到全域性有序,這裡需要在Mapper完成後生成Partition的時候定製一下劃分規則,保證Partition之間是有序的即可。

故事之外的說明

首先,本文出現的故事情節純屬虛構,但裡面出現的技術和思考是真實的。本文在嘗試用一個前後貫穿的故事主線來說明資料統計以及MapReduce的設計思路,重點在於思維的前後連貫,而不在於細節的面面俱到。因此,有很多重要的技術細節是本文沒有涵蓋的,但讀者們可能需要注意。比如:

  • 本文假設資料都在訪問日誌中能夠取到。實際中要複雜得多,資料有很多來源,格式也會比較複雜。在資料統計之前會有一個很重要的ETL過程(Extract-Transform-Load)。
  • 本文在介紹MapReduce的時候,是仿照Hadoop裡的相關實現來進行的,而Hadoop是受谷歌的Jeffrey Dean在2004年發表的一篇論文所啟發的。那篇論文叫做《MapReduce: Simplified Data Processing on Large Clusters》,下載地址:research.google.com/archive/map…
  • 本文沒有對Hadoop叢集的資源管理和任務排程監控系統進行介紹,在Hadoop裡這一部分叫做YARN。它非常重要。
  • 為了讓Mapper和Reducer在不同的機器上都能對檔案進行讀寫,實際上還需要一個分散式檔案系統來支撐。在Hadoop裡這部分是HDFS。
  • Hadoop和HDFS的一個重要設計思想是,移動計算本身比移動資料成本更低。因此,Mapper的執行會盡量就近執行。這部分本文沒有涉及。
  • 關於輸入的InputSplit的邊界問題。原始輸入檔案進行邏輯分塊的時候,邊界可能在任意的位元組位置。但對於文字輸入檔案來說,Mapper接收到的資料都是整行的資料,這是為什麼呢?這是因為對一個InputSplit進行輸入處理的時候,在邊界附近也經過了特殊處理。具體做法是:在InputSplit結尾的地方越過邊界多讀一行,而在InputSplit開始的時候跳過第一行資料。
  • 在每個Mapper結束的時候,還可以執行一個Combiner,對資料進行區域性的合併,以減小從Mapper到Reducer的資料傳輸。但是要知道,從Mapper執行,到排序(Sort and Spill),再到Combiner執行,再到Partition的生成,這一部分相當複雜,在實際應用的時候還需深入理解、多加小心。
  • Hadoop官網的文件不是很給力。這裡推薦一個介紹Hadoop執行原理的非常不錯的網站:ercoppa.github.io/HadoopInter…

(完)

其它精選文章

小白的資料進階之路(上)——從Shell指令碼到MapReduce

相關文章