上周我遇到一個(gè)需要處理大文件的場(chǎng)景,需要遍歷大文件,并且對(duì)數(shù)據(jù)進(jìn)行一定的處理。由于這批數(shù)據(jù)對(duì)時(shí)效性要求較高,所以當(dāng)時(shí)我編寫的程序是希望運(yùn)行地越快越好,所以我在編程的過程中對(duì)程序進(jìn)行一次一次的優(yōu)化,最終從開始時(shí)需要運(yùn)行8小時(shí)到現(xiàn)在只需運(yùn)行50分鐘,算是達(dá)到了需求。當(dāng)然,你可能會(huì)說一開始的程序?qū)懙锰珷€,所以導(dǎo)致運(yùn)行太緊,無所謂啦,這并不是重點(diǎn)。實(shí)際上,很多人第一次寫程序都很難按預(yù)想那樣寫出簡(jiǎn)單高效的代碼片段,只是有的場(chǎng)景對(duì)性能要求并不高,所以有的人看著自己的程序能運(yùn)行后就不再進(jìn)行優(yōu)化了。其實(shí),絕大多數(shù)程序都是有優(yōu)化空間的,并且還有很大的優(yōu)化空間,這個(gè)空間指的是時(shí)間和空間。
廢話不多說,我把這次優(yōu)化的過程和思路總結(jié)為8個(gè)點(diǎn),分享出來,希望能對(duì)大家有所幫助。
1 需求
大數(shù)據(jù)時(shí)代,處理大量數(shù)據(jù)成為了不少程序員的家常便飯。那么多大的數(shù)據(jù)可以稱為大數(shù)據(jù)呢?對(duì)于數(shù)據(jù)量,很難用一個(gè)確定的量去描述其界限,我理解大數(shù)據(jù)不僅僅是數(shù)據(jù)量大,還應(yīng)該具有數(shù)據(jù)復(fù)雜的特性,比如維度高、字段多。我平時(shí)處理的數(shù)據(jù)并不是大到磁盤都存不下那種,最多就是億的量級(jí)。此次處理的數(shù)據(jù)是一個(gè)文本的大文件,數(shù)據(jù)量不到一億,占用磁盤空間幾百GB,它是行列結(jié)構(gòu),即每一行數(shù)據(jù)用tab鍵分割,有不到100個(gè)字段,其中有部分字段是json,且這個(gè)json異常復(fù)雜,一條大的數(shù)據(jù)可能會(huì)占用好幾MB的空間。
我要做的事情很簡(jiǎn)單,最費(fèi)勁的就是在每一條數(shù)據(jù)的大json串中增加一個(gè)字段,這個(gè)字段經(jīng)過了一些復(fù)雜的計(jì)算。簡(jiǎn)單來說就是寫程序遍歷一遍這個(gè)文件,然后對(duì)每條數(shù)據(jù)做一些計(jì)算,其中包含了把一個(gè)字段塞到大json串中。
2 Python
說到處理數(shù)據(jù),我個(gè)人覺得Python是最好用的,當(dāng)然它最caodan的地方是對(duì)中文編碼不夠支持,不過這還算是一個(gè)可以克服的缺點(diǎn)。和往常一樣,我用Python編寫了一版,試著跑了一下數(shù)據(jù),大約每一萬條數(shù)據(jù)需要大約耗費(fèi)5秒。算下來,大概需要跑8到10小時(shí),這是不能接受的,所以我開始了一系列的優(yōu)化。
另外多說一點(diǎn),我們?cè)跍y(cè)試自己的程序的時(shí)候,并不用全量的數(shù)據(jù)去測(cè),比如從正式數(shù)據(jù)中去head(Linux命令,可以取文件頭部的n行數(shù)據(jù))出200萬條數(shù)據(jù)來進(jìn)行測(cè)試,跑完測(cè)試用例就可以大致估算出整個(gè)程序耗時(shí)了。
3 并發(fā)
因?yàn)槌绦蛑邪艘欢ǖ挠?jì)算邏輯,即消耗CPU的邏輯片段(說個(gè)題外話,有的程序可能是計(jì)算型的,比較耗CPU,有的是內(nèi)存型的,比較耗內(nèi)存,還有些是IO型的,大多數(shù)情況下磁盤讀寫會(huì)成為瓶頸),可能是受之前慣性思維的影響,對(duì)于優(yōu)化的方法,我首先想到的就是增加程序的并發(fā),開多個(gè)線程,開多個(gè)進(jìn)程。多線程最簡(jiǎn)單,一是好實(shí)現(xiàn),二是代碼相對(duì)好控制。三下兩下把代碼改成了多線程,這里我使用的是線程池而不是每來一條數(shù)據(jù)新創(chuàng)建一個(gè)線程去處理它,這樣可以減少程序的運(yùn)行時(shí)間。所謂線程池就是,事先,一般在程序啟動(dòng)的時(shí)候先創(chuàng)建一定數(shù)量的線程,然后每次有處理需求時(shí),就從線程池中撈一個(gè)線程來處理它,因?yàn)榫€程的創(chuàng)建和消亡是會(huì)消耗資源和時(shí)間的,所以,理論上,使用線程池就可以減少這部分創(chuàng)建和消亡的時(shí)間。
我一開始創(chuàng)建了8個(gè)線程,運(yùn)行了一下,程序并沒有快多少,所以我又把線程數(shù)提到了16,最后提到了128,運(yùn)行速度反而減慢了。還是因?yàn)閼T性思維,因?yàn)镻ython的線程模型是N:1模型。所謂N:1模型就是所有線程都跑在了一個(gè)物理核上,不同線程實(shí)際上是在同一個(gè)核上頻繁切換。我認(rèn)為多線程已經(jīng)把一個(gè)核給榨干了,要提升程序的運(yùn)行速度只能采取多進(jìn)程。如果在CPU相對(duì)空閑時(shí),不同的進(jìn)程幾乎是可以獨(dú)占不同的物理核的,準(zhǔn)確來說就是切換不太頻繁,可以實(shí)現(xiàn)真正意義上的并發(fā)。說到多進(jìn)程,對(duì)于處理大文件,程序運(yùn)行最快的方法是將一個(gè)大文件按行平均切割成m份,然后開啟m個(gè)進(jìn)程獨(dú)立地跑這些數(shù)據(jù)。不過按照之前的經(jīng)驗(yàn),使用Linux下的文件切割命令split來切割這么一個(gè)大文件,大概需要3個(gè)小時(shí),這屬于程序員的額外時(shí)間消耗,所以我放棄了。我還是選擇了進(jìn)程池,在程序啟動(dòng)的時(shí)候主進(jìn)程fork出許多子進(jìn)程,即master/worker模式,并且自己實(shí)現(xiàn)了一個(gè)生產(chǎn)者消費(fèi)者,否則任務(wù)等待隊(duì)列會(huì)被撐爆的。代碼寫好了以后,根據(jù)我的以往實(shí)驗(yàn),在開啟5個(gè)進(jìn)程的時(shí)候程序運(yùn)行得最快,并且總的運(yùn)行時(shí)間是之前的80%左右。優(yōu)化效果并不好,因?yàn)檫M(jìn)程池也會(huì)存在進(jìn)程的切換,和真正獨(dú)立的進(jìn)程是有區(qū)別的。這幾個(gè)進(jìn)程不是一直在running狀態(tài),也是會(huì)經(jīng)歷進(jìn)程的狀態(tài)切換,running、wait、ready等狀態(tài)。從8個(gè)小時(shí)縮短為6個(gè)多小時(shí)是遠(yuǎn)遠(yuǎn)不達(dá)標(biāo)的,至少要在兩個(gè)小時(shí)內(nèi)才能接受。
4 瓶頸
瞎搞了這么久,我終于想著去理性地尋找瓶頸,一種最好的方式就是在程序運(yùn)行的時(shí)候?qū)崟r(shí)監(jiān)控計(jì)算機(jī)的狀態(tài),比如大家熟知的命令top,第三方工具h(yuǎn)top。我這里使用的是公司內(nèi)部的監(jiān)控工具,可以查看內(nèi)存、CPU、IO的實(shí)時(shí)狀態(tài)。還有另一種方式就是計(jì)算程序每個(gè)部分的耗時(shí),如果你的程序封裝或者抽象得足夠好,我們是很容易計(jì)算某個(gè)代碼片段的耗時(shí)的,比如計(jì)算在程序流程中某個(gè)函數(shù)需要運(yùn)行多長(zhǎng)時(shí)間,我們只用在調(diào)用函數(shù)之前打印一下Unix時(shí)間戳,然后在函數(shù)運(yùn)行結(jié)束的時(shí)候打印時(shí)間戳,最后做個(gè)差就能算出耗時(shí)了。所以說我們的程序要高內(nèi)聚、低耦合,這樣后期維護(hù)起來是很爽的。經(jīng)查看,我發(fā)現(xiàn),在單線程的情況下,每處理一萬條數(shù)據(jù)大致耗時(shí)5秒,其中計(jì)算只花費(fèi)了不到一秒,中文編碼解碼耗時(shí)不到一秒,IO耗時(shí)差不多兩秒,其中最耗時(shí)的就是解大json(json的decode和encode的步驟),耗時(shí)兩秒多一點(diǎn)。通過監(jiān)控計(jì)算機(jī)狀態(tài),在程序運(yùn)行的時(shí)候我讀寫的那塊磁盤的IO被打滿了,原因是服務(wù)器上還有很多其他進(jìn)程在讀寫這塊磁盤。
所以這里有兩個(gè)優(yōu)化點(diǎn),第一點(diǎn)就是優(yōu)化IO,第二點(diǎn)就是把大json解包封包的時(shí)間縮短。
5 換磁盤
說到IO,很多同學(xué)第一時(shí)間想到的就是換塊SSD。換塊SSD當(dāng)然是個(gè)很好的解決辦法,但是如果每次編寫程序遇到IO問題都要通過硬件來優(yōu)化,公司豈不是得破產(chǎn)?程序員寫代碼并不是想要啥就有啥的,要用最小的預(yù)算達(dá)到目的。我所使用的服務(wù)器上并沒有掛載SSD磁盤,倒是掛載了很多機(jī)械盤,并且很多盤都沒有讀寫操作。在大多數(shù)情況下,我們所說的IO基本上都指的是某一塊磁盤的IO。這里有一個(gè)優(yōu)化點(diǎn)就是,我把讀寫的磁盤分離開,還是從之前的那塊磁盤讀數(shù)據(jù),但是往另外一塊磁盤寫數(shù)據(jù)。還是原來的單線程代碼,什么也沒有改變,程序的運(yùn)行時(shí)間一下降低了四分之一。從8小時(shí)縮短為6小時(shí)。
6 算法
IO進(jìn)行了一定的優(yōu)化,接下來就該優(yōu)化最耗時(shí)的json解包封包了。我使用的是Python的官方工具,json.loads和json.dumps。事實(shí)上,有時(shí)候官方的工具并不一定是最好的,就拿json處理的相關(guān)工具來說,同一種語(yǔ)言可能有很多不同的工具,它們的處理效率可能會(huì)相差10倍以上。接下來的操作是不是該換個(gè)json解包封包工具了?我并不想這么做,因?yàn)橛懈斓姆绞剑恢来蠹疫€記得我前文提到的一句話嗎?我是想在json串中增加一個(gè)字段,在多數(shù)情況下,增加比減少容易得多。既然是增加,那完全沒有必要解包,正常情況下,我們從文件中讀取的json串實(shí)際上是一個(gè)字符串,解包會(huì)把它變?yōu)橐粋€(gè)dict,處理完,又把這個(gè)dict轉(zhuǎn)化為一個(gè)字符串,然后再寫入文件。實(shí)際上,我們可以省略string轉(zhuǎn)dict再?gòu)膁ict轉(zhuǎn)string的步驟,因?yàn)閖son字符串的末尾是一個(gè)}符號(hào),那么我們直接在}插入想要添加的字段即可。舉個(gè)例子:加入原始的json字符串是:{"key1":"value1"} ,我們想要加上一個(gè)字段key2,那么可以直接對(duì)字符串做切片操作(切片是Python中的一個(gè)操作)。即可以直接把這個(gè)過程變成 {"key1":"value1" + "key2":"value2" + }。這么一做,程序運(yùn)行從之前的6小時(shí)縮短為3小時(shí),處理時(shí)間減少了一倍,這算是在算法上的提升。
7 語(yǔ)言
3小時(shí)雖然已經(jīng)比最開始的8小時(shí)快很多了,但我還是嫌棄它太長(zhǎng)。Python雖然比較適合處理數(shù)據(jù),寫起來也比較容易,不過比較偏上層,無法進(jìn)行更底層的控制,比如內(nèi)存、線程、進(jìn)程等。都說Go語(yǔ)言的運(yùn)行效率接近C++,開發(fā)效率接近Python,所以我也準(zhǔn)備嘗嘗鮮。Go語(yǔ)言是編譯型語(yǔ)言,并且其語(yǔ)言本身就支持進(jìn)程線程等特性。當(dāng)然,這里我并沒使用并發(fā),我只是用Go把之前的Python代碼重新寫了一遍,不過還是做了適當(dāng)?shù)膬?yōu)化。
1. 為了讓IO充分使用,我將Python一行一行的讀寫改為了Go語(yǔ)言一塊一塊的讀寫,即之前是一次讀一行,現(xiàn)在是一次讀一塊固定大小的二進(jìn)制,然后用換行符來區(qū)分這一塊里面的每一行,誰快誰慢一下就能見分曉。
2. 我給文件的讀寫各添加了一個(gè)30MB的緩存,構(gòu)成兩個(gè)生產(chǎn)者消費(fèi)者。對(duì)寫操作來說,生產(chǎn)者是代碼處理邏輯,消費(fèi)者是IO寫。經(jīng)測(cè)試,生產(chǎn)者的速度是快于消費(fèi)者的(所以這里提高并發(fā)已經(jīng)沒有什么意義了,瓶頸在IO)。
在這種情況下,最后整個(gè)程序運(yùn)行完成只需要50分鐘,已經(jīng)在兩個(gè)小時(shí)內(nèi)了,符合了最初的需求。
8 Todo
其實(shí)這個(gè)程序還有優(yōu)化的空間,因?yàn)橐呀?jīng)符合我最開始的需求,我就沒有繼續(xù)再優(yōu)化下去。我剛說到,程序的瓶頸在于IO寫。那么我們可以同時(shí)往掛載在同一臺(tái)機(jī)器上的多個(gè)磁盤循環(huán)寫,這樣就能分散每塊磁盤的IO了。不過,寫程序不能為了優(yōu)化而優(yōu)化,在開發(fā)效率和運(yùn)行效率上我們要選擇一個(gè)折中點(diǎn)。再盲目繼續(xù)優(yōu)化,代碼的復(fù)雜度就該上升了。