背景
只要存在資料庫,就會有後台批量處理資料的需求,比如資料表備份、定期清理、資料替換、資料移轉,對於批量處理來說,往往會涉及大量的查詢、過濾、歸類、彙總計算,在批量指令碼中直接查詢資料庫往往效能太低,甚至會因為一個大型的SQL導致資料庫鎖表出現線上事故,因此一般採用先匯出到檔案,在檔案上計算然後再匯入,比如:
1、使用mysql -e “select * from table” > output.txt的方式,執行SQL,將結果匯出到檔案中;
2、針對檔案,使用各種方式進行彙總、過濾、替換等計算,最後產出成需要使用的格式;
3、發布產出的檔案,或者使用load data命令匯入到資料庫;
由於只是一次性的批量查詢資料庫匯出到檔案,然後針對檔案進行計算,而不是每次都查詢資料庫,大量節省了網路的IO耗費,從而提升處理的速度。
然而得到了匯出的檔案之後,如果檔案過大,或者計算邏輯複雜比如大量的調用了耗費CPU的正則匹配、彙總計算,那麼單線程的處理會耗費大量的時間,這時候就可以引入並發處理,使得機器的CPU、記憶體、IO、網路等資源全部充分利用起來,大幅度降低處理時間。
引入多線程,拆分輸入檔案為多個,每個小檔案啟動一個處理線程
HADOOP的MAP-REDUCE的做法,是先將檔案split成小分區檔案,然後針對每個分區做計算,最後將每個分區的結果彙總在一起,然而由於HADOOP的調度、叢集穩定性等各種原因,對於MB大小層級的檔案處理,會發現速度非常慢,有時候甚至比單機單線程處理速度還慢,將單機單線程改成多線程,往往會發現令人驚訝的效果提升。
直觀的做法,是使用主線程讀取輸入的單個大檔案,然後將讀取的結果分配給子線程處理,然後主線程做整合,這種方式因為多線程共用了單個檔案的IO,需要加入對檔案的同步機制,最後會發現效能瓶頸在這單個檔案的讀取同步之上。
可以將大檔案分區成小檔案,然後每個檔案分配給單個線程單獨處理,避免線程間的資源同步,每個線程會享用單獨的CPU核、記憶體單元、檔案控制代碼,處理速度能達到最快。
使用這種方式,可以用以下的步驟進行:
1、使用SHELL,將輸入檔案拆分成預定線程數目的份數,存放到一個目錄中;
2、以輸入檔案的目錄路徑作為參數,程式設計語言JAVA/PYTHON讀取該目錄的所有檔案,對於每個檔案啟動一個處理線程,進行處理;
3、SHELL將輸出目錄的所有檔案,使用cat file* > output_file的方式,得到最終的計算結果
Shell
將輸入檔案拆分成多個小檔案,啟動多線程進行處理,輸出結果檔案
function run multitask(){ # 開啟多個非同步線程 SPLITS COUNT=20 # 輸入檔案總數 sourcefile linescount=
cat ${input_file} | wc -l# 計算出拆分的檔案個數 split filelines count=$(( $sourcefile linescount / $SPLITS COUNT )) # 進行檔案拆分 split -l $splitfile linescount -a 3 -d ${input file} ${input
dir}/inputFile_
# 執行JAVA程式$JAVA_CMD -classpath $jar_path "net.crazyant.BackTaskMain" "${input_dir}" "${output_dir}" "${output_err_dir}"# 合并檔案cat ${output_dir}/* > ${output_file}
}
run multitask
## 將輸入檔案拆分成多個小檔案,啟動多線程進行處理,輸出結果檔案#function run_multi_task(){ # 開啟多個非同步線程 SPLITS_COUNT=20 # 輸入檔案總數 source_file_lines_count=`cat ${input_file} | wc -l` # 計算出拆分的檔案個數 split_file_lines_count=$(( $source_file_lines_count / $SPLITS_COUNT )) # 進行檔案拆分 split -l $split_file_lines_count -a 3 -d ${input_file} ${input_dir}/inputFile_ # 執行JAVA程式 $JAVA_CMD -classpath $jar_path "net.crazyant.BackTaskMain" "${input_dir}" "${output_dir}" "${output_err_dir}" # 合并檔案 cat ${output_dir}/* > ${output_file}} run_multi_task
這裡注意,拆分檔案的時候,不能使用split按照大小進行拆分,因為會把輸入檔案中的行截斷;
對應的JAVA程式,則是通過讀取檔案夾中檔案清單的方法,每個檔案單獨啟動一個線程:
Java
public class BackTaskMain { public static void main(String[] args) { String inputDataDir = args[1]; String outputDataDir = args[2]; String errDataDir = args[3]; File inputDir = new File(inputDataDir); File[] inputFiles = inputDir.listFiles(); // 記錄開啟的線程 List threads = new ArrayList(); for (File inputFile : inputFiles) { if (inputFile.getName().equals(".") || inputFile.getName().equals("..")) { continue; } // 針對每個inputFile,產生對應的outputFile和errFile String outputSrcLiceFpath = outputDataDir + "/" + inputFile.getName() + ".out"; String errorOutputFpath = errDataDir + "/" + inputFile.getName() + ".err"; // 建立Runnable BackRzInterface backRzInterface = new BackRzInterface(); backRzInterface.setInputFilePath(inputFile.getAbsolutePath()); backRzInterface.setOutputFilePath(outputSrcLiceFpath); backRzInterface.setErrorOutputFpath(errorOutputFpath); // 建立Thread,啟動線程 Thread singleRunThread = new Thread(backRzInterface); threads.add(singleRunThread); singleRunThread.start(); } for (Thread thread : threads) { try { // 使用thread.join(),等待所有的線程執行完畢 thread.join(); System.out.println(thread.getName() + " has over"); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("proccess all over"); }}
public class BackTaskMain { public static void main(String[] args) { String inputDataDir = args[1]; String outputDataDir = args[2]; String errDataDir = args[3]; FileinputDir = new File(inputDataDir); File[] inputFiles = inputDir.listFiles(); // 記錄開啟的線程 List threads = new ArrayList(); for (FileinputFile : inputFiles) { if (inputFile.getName().equals(".") || inputFile.getName().equals("..")) { continue; } // 針對每個inputFile,產生對應的outputFile和errFile String outputSrcLiceFpath = outputDataDir + "/" + inputFile.getName() + ".out"; String errorOutputFpath = errDataDir + "/" + inputFile.getName() + ".err"; // 建立Runnable BackRzInterfacebackRzInterface = new BackRzInterface(); backRzInterface.setInputFilePath(inputFile.getAbsolutePath()); backRzInterface.setOutputFilePath(outputSrcLiceFpath); backRzInterface.setErrorOutputFpath(errorOutputFpath); // 建立Thread,啟動線程 ThreadsingleRunThread = new Thread(backRzInterface); threads.add(singleRunThread); singleRunThread.start(); } for (Threadthread : threads) { try { // 使用thread.join(),等待所有的線程執行完畢 thread.join(); System.out.println(thread.getName() + " has over"); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("proccess all over"); }}
通過這種方式,將大檔案拆分成小檔案,啟動多個線程,每個線程處理一個小檔案,最終將每個小檔案的結果彙總,就得到了最終產出,效能上卻大幅提升。
若有依賴的資源,可以按線程先複製、拆分、複製,防止依賴的資源成為效能瓶頸
在上面的代碼中,BackRzInterface是每個線程啟動時要使用的Runnable對象,可以看到用的是每次new的方式建立:
// 建立RunnableBackRzInterface backRzInterface = new BackRzInterface();
這樣每個處理線程依賴的BackRzInterface都是獨立的,對這個Runnable代碼的使用不會存在同步問題。
如果多執行緒中需要使用外部資源,最好想辦法使得每個線程單獨使用自己獨佔的資源,相互之間若不會存在衝突,可以實現最大化並發處理。
其他一些例子,比如:
- 多線程用到了字典檔案,那麼方法是首先將字典檔案複製多份,每個線程使用自己獨佔的字典,避免並發同步訪問字典;
- 多線程若需要統一ID發號,可以提前計算出每個輸入檔案的行數,然後依次產生第一個線程需要的ID範圍、第二個線程需要的ID範圍,這些不同的ID範圍也可以分別產生不同的檔案,這樣每個線程會使用各自獨立的ID資源,避免了多個線程單時刻訪問單個ID發號服務,使得發號成為效能瓶頸的可能;
- 多線程如果依賴相同的Service,如果可以每次new對象就每次new,如果Bean都是在Spring中管理,則將Service加上@Scope(“prototype”),或者將對象每次clone一下得到一個新對象,保證最終每個線程使用自己獨佔的對象。
- 盡量使用函數式編程的思想,每個函數都不要產生副作用,不要修改入參,結果只能通過return返回,避免增加代碼同步衝突的可能;
通過以上這些類似的方法,每次將可能需要同步訪問的共用資源,通過複製、分區等手段得到不同份,每個線程單獨訪問自己那一份,避免同步訪問,最終實現效能最優。
避免同步的終極方法:使用多進程進行實現資源隔離
如果將檔案拆分成了多份,依賴的ID、詞典等資源也相應提供了多份,但是發現代碼中存在無法解決的代碼層級同步,該怎麼辦呢?
相對於想盡辦法解決代碼中的同步問題來說,多線程和多進程之間的效能差別微乎其微,我們都知道線程會使用進程的資源,所以導致了線程之間存在競爭進程資源,但是對於進程來說,CPU、記憶體等硬體資源是完全隔離的,這時候將程式運行在多進程而不是多線程,反而能更好的提升效能。
對於一些支援多線程不好的語言,比如PHP,直接用這種多進程計算的方法,速度並不比支援多線程的JAVA、PYTHON語言差:
Shell
要拆分的檔案數,也就是要啟動的多進程數
SPLITS_COUNT=20
input splitsdir="${input dir}splits" output splitsdir="${output dir}splits"
輸入檔案行數
source filelines_count=
cat ${input_file} | wc -l
每個檔案拆分的行數=總行數除以要拆分的檔案個數(也就是對應進程的個數)
split filelines count=$(( $sourcefile linescount / ${SPLITS_COUNT} ))
執行拆分,注意這裡使用-l進行行層級拆分更好
split -l $split filelines count -a 3 -d ${inputfile} ${input splitsdir}/inputfile_
process idx=1 for fname in $(ls ${inputsplits dir}); do inputfpath=${input splitsdir}/$fname ouput fpath=${outputsplits dir}/$fname # 後台執行所有進程 php "/php/main.php" "${inputfpath}" "${ouput fpath}" & (( processidx++ )) done
等待所有後台進程執行結束
wait
合并檔案
cat $output splitsdir/* > ${output_file}
# 要拆分的檔案數,也就是要啟動的多進程數SPLITS_COUNT=20 input_splits_dir="${input_dir}_splits"output_splits_dir="${output_dir}_splits"# 輸入檔案行數source_file_lines_count=`cat ${input_file} | wc -l`# 每個檔案拆分的行數=總行數除以要拆分的檔案個數(也就是對應進程的個數)split_file_lines_count=$(( $source_file_lines_count / ${SPLITS_COUNT} ))# 執行拆分,注意這裡使用-l進行行層級拆分更好split -l $split_file_lines_count -a 3 -d ${input_file} ${input_splits_dir}/inputfile_ process_idx=1for fname in $(ls ${input_splits_dir}); do input_fpath=${input_splits_dir}/$fname ouput_fpath=${output_splits_dir}/$fname # 後台執行所有進程 php "/php/main.php" "${input_fpath}" "${ouput_fpath}" & (( process_idx++ )) done # 等待所有後台進程執行結束wait # 合并檔案cat $output_splits_dir/* > ${output_file}
上述代碼中,使用shell的&符號,可以在後台同時啟動多個進程,使用wait文法,可以實現多線程的Thread.join特性,等待所有的進程執行結束。
總結
對於輸入檔案的大小、計算的複雜度處於單機和叢集計算之間的資料處理,使用並發處理最為合適,但是並發的同步處理卻會降低多線程的效能,這時可以藉助於輸入檔案複製拆分、依賴資源複製拆分切片等方法,實現每個線程處理自己的獨佔資源,從而最大化提升計算速度。而對於一些無法避免的代碼同步衝突邏輯,可以退化為多進程處理資料,藉助於SHELL的後台進程支援,實現進程層級的資源獨佔,最終大幅提升處理效能。