大檔案排序優化實踐

等你歸去來發表於2020-10-12

  在很多應用場景中,我們都會面臨著排序需求,可以說是見怪不怪。我們也看過許多的排序演算法:從最簡單的氣泡排序、選擇排序,到稍微好點的插入排序、希爾排序,再到有點理論的堆排序、快速排序,再到高階的歸併排序、桶排序、基數排序。

  而實際工作中我們可能用到的排序有哪些呢?而且,大部分時序,相信大家都是使用一個現有庫API直接就完成了排序功能。所以,講真,大家還真不一定會很好排序。

  不過本文的目的不是基礎排序演算法,而是如何處理資料量的檔案的內容排序問題?

1. 多大的檔案算大檔案?

  多大的檔案算大檔案?這是個定義的問題,當我每天處理的都是幾百幾千的資料,那麼我遇到幾萬的資料後,我可以認為這個資料量是大的。

  但總體來說,我們還是需要定義一個量級的,不然無法評估處理能力問題。

  比如我們定義超過200M的檔案算大檔案可以不?我們定義超過5000w行的資料算大檔案可以不?

  好了,基於這樣大的資料量的時候,也許我們就不能簡單的呼叫幾個庫函式就解決問題了,至少你load到記憶體也將存在一定的風險了。

  所以,是時候想想優化的事了!

 

2. 如何利用好現有的工具?

  針對一些問題,我們可以自己做,也可以找別人幫忙做。具體誰來做,這是個問題!

  比如,你自己花個一兩天的時間,寫了個排序演算法,搞定了。但是,你能保證你的穩定性嗎?你能經受住生產環境複雜的環境考驗嗎?

  再比如,你可以現有的工具進行操作,如果有人提供了穩定的api函式供呼叫的話,你可以這麼幹。如果你的服務是跑在linux環境下,那麼,我們有必要試一下系統提供的排序功能。 sort . 這工具絕對是經過無數的考驗的,可以放心使用。它也有豐富的引數供選擇,這對我們的日常工作非常有幫助,但對於一個普通的排序也許我們並不需要。

  比如最簡單的,自然排序:

sort 1-merged.txt -o 1-sorted.txt

  就可以將檔案排好序了。但是當資料非常大的時候,比如我使用 7000w+ 的行數(約1.2G)進行排序時,就花費了 6min+ . 也許是我硬體不怎麼好,但是實際上它就是會很慢啊!

$ time sort 1-merged.txt -o 1-sorted.txt

real    8m5.709s
user    25m19.652s
sys     0m4.523s

  這種效能,在當今大資料橫行的時代,基本就是胎死腹中了。大資料應對的都是TB/PB 級別的數量,而我們僅為GB級並且沒有做其他業務就已經耗費了這麼長時間,這是沒辦法繼續了。讓我進一步深入。

  看到文件裡有說,系統本地化配置影響排序,實際就是存在一個編解碼的問題,它會依據本地的配置來進行轉換字元然後再進行排序。這個開銷可是不小哦,比如我們設定都是中文環境。而要去除這個影響,則可以使用新增 LC_ALL=C 之後就會使用原始的值進行排序,具體影響就是省去轉換編碼的開銷了。那麼,我們用這個引數試試。

*** WARNING ***
The locale specified by the environment affects sort order.
Set LC_ALL=C to get the traditional sort order that uses
native byte values.

$ time LC_ALL=C sort 1-merged.txt -o 1-sorted.txt

real    2m52.663s
user    2m7.577s
sys     0m5.146s

  哇,從8分鐘降到了3分鐘,雖然不是數量級的提升,但至少下降了一半以上的時間消耗,還是非常可觀的。到這個地步,也許能滿足我們的場景了。

  但是,請注意一個問題,這裡的 LC_ALL=C 之後,就會使用預設的進行處理了,那麼,會有什麼影響呢?實際上就是一些本地相關的東西,就會失效了。

  最直接的,就是中文處理的問題了,比如我有一個檔案內容是這樣的:

床前看月光,
疑是地上霜。
舉頭望山月,
低頭思故鄉。
天子呼來不上船,
自稱臣是酒中仙。
紅酥手,
黃藤酒,
滿城春色宮牆柳。

  那麼,我們使用 LC_ALL=C 設定來排序後,將會得到如下結果:

$ LC_ALL=C sort 1.txt -o 1-s1.txt

$ cat 1-s1.txt
舉頭望山月,
低頭思故鄉。
天子呼來不上船,
滿城春色宮牆柳。
疑是地上霜。
紅酥手,
自稱臣是酒中仙。
黃藤酒,
床前明月光,

  額,看不懂啥意思?中文咋排序的我也給整忘了(而且各自機器上得到的結果也可能不一樣)。好吧,沒關係,我們去掉 LC_ALL=C 來看看結果:

$ sort 1.txt -o 1-s1.txt

$ cat 1-s1.txt
床前明月光,
低頭思故鄉。
紅酥手,
黃藤酒,
舉頭望山月,
滿城春色宮牆柳。
天子呼來不上船,
疑是地上霜。
自稱臣是酒中仙。

  這下看懂了吧,這是按照拼音順序來排序的。所以,你說 LC_ALL=C 重不重要,如果沒有本地化設定,很多東西都是不符合情理的。所以,有時候我們還真不能這麼幹咯。

  如果真想這麼幹,除非你確認你的檔案裡只有英文字元符號和數字,或者是 ASCII 的127 個字元。

 

3. 繞個路高階一下

  前面的方法,不是不能解決問題,而是不能解決所有問題。所以,我們還得繼續想辦法。想想當下對大檔案的處理檔案都有哪些?實際也不多,平行計算是根本,但我們也許做不了平行計算,但我們可以拆分檔案嘛。一個檔案太大,我們就檔案拆小排序後再合併嘛!就是不知道效能如何?

split -l 100000 -d ../1-merged.txt -a 4 sp_; 
for file in sp_*.txt; do; 
    sort -o $file sorted_$file; 
done; 
sort -m sp_*.txt -o targed.txt;
# 一行化後的格式    
$ time for file in sp_*; do sort -o sorted_$file $file; done; sort -m sorted_* -o targetd.txt;

real    12m15.256s
user    10m11.465s
sys     0m18.623s
# 以上時間僅是單個檔案的排序時間還不算歸併的時間,下面這個程式碼可以統一計算
$ time `for file in sp_1_*; do sort $file -o sorted_$file; done; sort -m sorted_* -o targetd.txt;`

real    14m27.643s
user    11m13.982s
sys     0m22.636s

  看起來切分小檔案後,排序太耗時間了,看看能不能用多程式輔助下!(還是回到了平行計算的問題上了)

# shell 非同步執行就是在其後面新增 & 就可以了, 但是最後的歸併是同步的.
$ time `split -l 100000 -d ../1-merged.txt -a 4 sp_ ; for file in sp_* ; do {sort $file -o $file} &; done; wait; sort -m sp_* -o target.txt ; `
# 多處計時監控
$ time `time split -l 100000 -d ../1-merged.txt -a 4 sp_; time for file in sp_1_*; do { sort $file -o $file } & ; done; time wait; time sort -m sp_* -o target.txt;`
# 以上報錯,因為命令列下不允許使用 & 操作, 只能自己寫shell指令碼,然後執行了
# sort_merge.sh
time split -l 100000 -d ../1-merged.txt -a 4 sp_;
i=0
for file in sp_*;
do
{
    #echo "sort -o $file $file";
    sort -o $file $file;
} &
done;
time wait;
time sort -m sp_* -o target.txt;
# 以上指令碼的確是會以非同步進行排序,但會開啟非常多的程式,從而導致程式排程繁忙,機器假死
# 需要修復下
# sort_merge.sh
split_file_prefix='sp_'
rm -rf ${split_file_prefix}*;
time split -l 1000000 -d ../short-target.csv -a 4 ${split_file_prefix};
i=0
for file in ${split_file_prefix}*;
do
{
    sort -o $file $file;
} &
    # 每開5個程式,就等一下
    (( i=$i + 1 ))
    b=$(( $i % 10 ))
    if [ $b = 0 ] ; then
        # 小優化: 只要上一個程式退出就繼續,而不是等到所有程式退出再繼續
        time wait $!
        # time wait
    fi;
done;
time wait;
time sort -m ${split_file_prefix}* -o target.txt;
# 以上執行下來,耗時9min+, 比未優化時還要差, 尷尬!
real    9m54.076s
user    19m1.480s
sys     0m36.016s

  看起來沒啥優勢啊, 咋整? 我們們試著調下參試試!

# 1. 將單個檔案設定為50w行記錄, 耗時如下:
# 額, 沒跑完, 反正沒啥提升, 單個檔案排序由之前的2s左右, 上升到了11s左右
# 2. 將單個設定為20w行記錄試試:
# 單個檔案排序上升到了4.xs, 也不理想啊;
real    9m2.948s
user    21m25.373s
sys     0m27.067s

  增加下並行度試試!

# 增加並行度到10
real    9m3.569s
user    21m4.346s
sys     0m27.519s
# 單檔案行數 500000, 並行10個程式
real    8m12.916s
user    21m40.624s
sys     0m20.988s

  看起來效果更差了,或者差不多. 難道這引數咋調整也沒用了麼? 算了, 不搞了.

 

4. 換個效能好的機器試試

  前面的機器太差了,也沒了信心。乾脆換一個試試。直接進入優化引數環節:

# 50w行, 5程式
real    5m6.348s
user    5m38.684s
sys     0m44.997s
# 100w行, 5程式
real    2m39.386s
user    3m34.682s
sys     0m23.157s
# 100w行, 10程式
real    2m22.223s
user    3m41.079s
sys     0m25.418s
# 以上結論是行數更容易影響結果, 因排序是計算型密集型任務, 程式數也許等於CPU核數比較優的選擇
# 不過也有一個干擾項:即檔案的讀取寫入是IO開銷,此時2倍以上的CPU核數程式可能是更好的選擇
# 100w行, 10程式, 7100w總數(1.6G)
# 使用原始排序 sort
real    6m14.485s
user    5m10.292s
sys     0m13.042s
# 使用 LC_ALL=C 測試結果
real    2m1.992s
user    1m18.041s
sys     0m11.254s
# 使用分治排序, 100w行, 10程式
real    2m53.637s
user    4m22.170s
sys     0m29.979s

  好吧,linux的優化估計只能到這裡了。

 

5. 自行實現大檔案排序

  看起來shell幫不了太多忙了,咋整?用java實現?執行緒池可以很好利用佇列先後問題;但到底有多少優勢呢?試試就試試!但執行緒可以很方便地並行,比如在分片檔案的同時,其他執行緒就可以同進行排序了,也許等分片完成,排序就ok了呢!

  簡單時序可表示為: split -> f1 -> submit(f1) -> sort(f1) -> merge(f1) -> wait (所有merge都完成)。也就是說所有過程都並行化了,拆分一個檔案就可以進行排序檔案,排序完一個檔案就可以合併一個檔案。

  執行緒模型是這樣的: 1個拆分執行緒 -> n個排序執行緒 -> 1個歸併執行緒;任務執行完成的前提是:n個排序執行緒完成 + n個歸併任務完成;

  大體思路是這樣,但還可以優化的是,歸併執行緒是否可以維護一個指標,代表最後一次插入的結果,以便可以快速比較插入;(有點困難,還要寫新檔案,這裡可能是個瓶頸點,因為如果有1000次歸併,就會存在讀寫大檔案的過程,如果前面的檔案分片存在速度慢的問題,那麼此處的反覆寫更是一個瓶頸點,即使是用linux的sort進行歸併也要花2min+,更不用說一次次地寫讀了)

  程式碼實現:待完善!

。。。

相關文章