摘要: 1. 本文背景 很多行業的資訊系統中,例如金融行業的資訊系統,相當多的資料互動工作是通過傳統的文字檔案進行互動的。此外,很多系統的業務日誌和系統日誌由於各種原因並沒有進入ELK之類的日誌分析系統,也是以文字檔案的形式存在的。
- 本文背景
很多行業的資訊系統中,例如金融行業的資訊系統,相當多的資料互動工作是通過傳統的文字檔案進行互動的。此外,很多系統的業務日誌和系統日誌由於各種原因並沒有進入ELK之類的日誌分析系統,也是以文字檔案的形式存在的。隨著資料量的指數級增長,對超大文字檔案的分析越來越成為挑戰。好在阿里雲的MaxCompute產品從2.0版本開始正式支援了直接讀取並分析儲存在OSS上的文字檔案,可以用結構化查詢的方式去分析非結構化的資料。
本文對使用MaxCompute分析OSS文字資料的實踐過程中遇到的一些問題和優化經驗進行了總結。作為前提,讀者需要詳細瞭解MaxCompute讀取OSS文字資料的一些基礎知識,對這篇官方文件 《訪問 OSS 非結構化資料》最好有過實踐經驗。本文所描述的內容主要是針對這個文件中提到的自定義Extractor做出的一些適配和優化。
- 場景實踐
2.1 場景一:分析zip壓縮後的文字檔案
場景說明
很多時候我們會對歷史的文字資料進行壓縮,然後上傳到OSS上進行歸檔,那麼如果要對這部分資料匯入MaxCompute進行離線分析,我們可以自定義Extractor讓MaxCompute直接讀取OSS上的歸檔檔案,避免了把歸檔檔案下載到本地、解壓縮、再上傳回OSS這樣冗長的鏈路。
實現思路
如 《訪問 OSS 非結構化資料》文件中所述,MaxCompute讀取OSS上的文字資料本質上是讀取一個InputStream流,那麼我們只要構造出適當的歸檔位元組流,就可以直接獲取這個InputStream中的資料了。
以Zip格式的歸檔檔案為例,我們可以參考 DataX 中關於讀取OSS上Zip檔案的原始碼,構造一個Zip格式的InputStream,程式碼見 ZipCycleInputStream.java 。構造出這個Zip格式的InputStream後,在自定義Extractor中獲取檔案流的部分就可以直接使用了,例如:
private BufferedReader moveToNextStream() throws IOException {
SourceInputStream stream = inputs.next();
// ......
ZipCycleInputStream zipCycleInputStream = new ZipCycleInputStream(stream);
return new BufferedReader(new InputStreamReader(zipCycleInputStream, "UTF-8"), 8192);
// ......
}
優化經驗
大家可能知道,MaxCompute中進行批量計算的時候,可以通過設定 odps.stage.mapper.split.size 這個引數來調整資料分片的大小,從而影響到執行計算任務的Mapper的個數,在一定程度上提高Mapper的個數可以增加計算的並行度,進而提高計算效率 (但也不是說Mapper個數越多越好,因為這樣可能會造成較長時間的資源等待,或者可能會造成長尾的後續Reducer任務,反而降低整體的計算效率) 。
同樣道理,對OSS上的文字檔案進行解析的時候,也可以通過設定 odps.sql.unstructured.data.split.size 這個引數來達到調整Mapper個數的目的 (注意這個引數可能需要提工單開通使用許可權):
set odps.sql.unstructured.data.split.size=16;
上述設定的含義是,將OSS上的檔案拆分為若干個16M左右大小的分片,讓MaxCompute盡力做到每個分片啟動一個Mapper任務進行計算——之所以說是“盡力做到”,是因為MaxCompute預設不會對單個檔案進行拆分及分片處理(除非設定了其他引數,我們後面會講到),也就是說,如果把單個分片按照上面的設定為16M,而OSS上某個檔案大小假設為32M,則MaxCompute仍然會把這個檔案整體(即32M)的資料量作為一個分片進行Mapper任務計算。
注意點
我們在這個場景中處理的是壓縮後的檔案,而InputStream處理的位元組量大小是不會因壓縮而變小的。舉個例子,假設壓縮比為1:10,則上述這個32M的壓縮檔案實際代表了320M的資料量,即MaxCompute會把1個Mapper任務分配給這320M的資料量進行處理;同理假設壓縮比為1:20,則MaxCompute會把1個Mapper任務分配給640M的資料量進行處理,這樣就會較大的影響計算效率。因此,我們需要根據實際情況調整分片引數的大小,並儘量把OSS上的壓縮檔案大小控制在一個比較小的範圍內,從而可以靈活配置分片引數,否則分片引數的值會因為檔案太大並且檔案不會被拆分而失效。
2.2 場景二:過濾文字檔案中的特定行
場景說明
對於一些業務資料檔案,特別是金融行業的資料交換檔案,通常會有檔案頭或檔案尾的設定要求,即檔案頭部的若干行資料是一些後設資料資訊,真正要分析的業務資料需要把這些元資訊的行過濾掉,只分析業務資料部分的行,否則執行結構化查詢的SQL語句的時候必然會造成任務失敗。
實現思路
在 《訪問 OSS 非結構化資料》文件中提到的 程式碼示例 中,對 readNextLine() 方法進行一些改造,對讀取的每一個檔案,即每個 currentReader 讀取下一行的時候,記錄下來當前處理的行數,用這個行數判斷是否到達了業務資料行,如果未到業務資料行,則繼續讀取下一條記錄,如果已經到達資料行,則將該行內容返回處理;而當跳轉到下一個檔案的時候,將 該行數值重置。
程式碼示例:
private String readNextLine() throws IOException {
if (firstRead) {
firstRead = false;
currentReader = moveToNextStream();
if (currentReader == null) {
return null;
}
}
// 讀取行級資料
while (currentReader != null) {
String line = currentReader.readLine();
if (line != null) {
if (currentLine < dataLineStart) { // 若當前行小於資料起始行,則繼續讀取下一條記錄
currentLine++;
continue;
}
if (!"EOF".equals(line)) { // 若未到達檔案尾則將該行內容返回,若到達檔案尾則直接跳到下個檔案
return line;
}
}
currentReader = moveToNextStream();
currentLine = 1;
}
return null;
}
此處 dataLineStart 表示業務資料的起始行,可以通過 DataAttributes 在建立外部表的時候從外部作為引數傳入。當然也可以隨便定義其他邏輯來過濾掉特定行,比如本例中的對檔案尾的“EOF”行進行了簡單的丟棄處理。
2.3 場景三:忽略文字中的空行
場景說明
在 《訪問 OSS 非結構化資料》文件中提到的 程式碼示例 中,已可以應對大多數場景下的文字資料處理,但有時候在業務資料文字中會存在一些空行,這些空行可能會造成程式的誤判,因此我們需要忽略掉這些空行,讓程式繼續分析處理後面有內容的行。
實現思路
類似於上述 場景二 ,只需要判斷為空行後,讓程式繼續讀取下一行文字即可。
程式碼示例:
public Record extract() throws IOException {
String line = readNextLine();
if (line == null) {
return null;// 返回null標誌已經讀取完成
}
while ("".equals(line.trim()) || line.length() == 0 || line.charAt(0) == `
` // 遇到空行則繼續處理
|| line.charAt(0) == `
`) {
line = readNextLine();
if (line == null)
return null;
}
return textLineToRecord(line);
}
2.4 場景四:選擇OSS上資料夾下的部分檔案進行處理
場景說明
閱讀 《訪問 OSS 非結構化資料》文件可知,一張MaxCompute的外部表連線的是OSS上的一個資料夾(嚴格來說OSS沒有“資料夾”這個概念,所有物件都是以Object來儲存的,所謂的資料夾其實就是在OSS建立的一個位元組數為0且名稱以“/”結尾的物件。MaxCompute建立外部表時連線的是OSS上這樣的以“/”結尾的物件,即連線一個“資料夾”),在處理外部表時,預設會對該資料夾下 所有的檔案 進行解析處理。該資料夾下所有的檔案集合即被封裝為 InputStreamSet ,然後通過其 next() 方法來依次獲得每一個InputStream流、即每個檔案流。
但有時我們可能會希望只處理OSS上資料夾下的 部分 檔案,而不是全部檔案,例如只分析那些檔名中含有“2018_”字樣的檔案,表示只分析2018年以來的業務資料檔案。
實現思路
在獲取到每一個InputStream的時候,通過 SourceInputStream 類的 getFileName() 方法獲取正在處理的檔案流所代表的檔名,然後可以通過正規表示式等方式判斷該檔案流是否為所需要處理的檔案,如果不是則繼續呼叫 next() 方法來獲取下一個檔案流。
程式碼示例:
private BufferedReader moveToNextStream() throws IOException {
SourceInputStream stream = null;
while ((stream = inputs.next()) != null) {
String fileName = stream.getFileName();
System.out.println("========inputs.next():" + fileName + "========");
if (patternModel.matcher(fileName).matches()) {
System.out.println(String
.format("- match fileName:[%s], pattern:[%s]", fileName, patternModel
.pattern()));
ZipCycleInputStream zipCycleInputStream = new ZipCycleInputStream(stream);
return new BufferedReader(new InputStreamReader(zipCycleInputStream, "UTF-8"), 8192);
} else {
System.out.println(String.format(
"-- discard fileName:[%s], pattern:[%s]", fileName, patternModel.pattern()));
continue;
}
}
return null;
}
本例中的 patternModel 為通過 DataAttributes 在建立外部表的時候從外部作為引數傳入的正則規則。
寫到這裡可能有讀者會問,如果一個資料夾下有很多檔案,比如上萬個檔案,整個遍歷一遍後只選擇一小部分檔案進行處理這樣的方式會不會效率太低了?其實大可不必擔心,因為相對於MaxCompute對外部表執行批量計算的過程,迴圈遍歷檔案流的時間消耗是非常小的,通常情況下是不會影響批量計算任務的。
2.5 場景五:針對單個大檔案進行拆分
場景說明
在 場景一 中提到,要想提高計算效率,我們需要調整 odps.sql.unstructured.data.split.size 引數值來增加Mapper的並行度,但是對於單個大檔案來講,MaxCompute預設是不進行拆分的,也就是說OSS上的單個大檔案只會被分配給一個Mapper任務進行處理,如果這個檔案非常大的話,處理效率將會及其低下,我們需要一種方式來實現對單個檔案進行拆分,使其可以被多個Mapper任務進行並行處理。
實現思路
仍然是要依靠調整 odps.sql.unstructured.data.split.size 引數來增加Mapper的並行度,並且設定 odps.sql.unstructured.data.single.file.split.enabled 引數來允許拆分單個檔案 (同odps.sql.unstructured.data.split.size,該引數也可能需要提工單申請使用許可權) ,例如:
set odps.sql.unstructured.data.split.size=128;
set odps.sql.unstructured.data.single.file.split.enabled=true;
設定好這些引數後,就需要編寫特定的Reader類來進行單個大檔案的拆分了。
核心的思路是,根據 odps.sql.unstructured.data.split.size 所設定的值,大概將檔案按照這個大小拆分開,但是拆分點極大可能會切在一條記錄的中間,這時就需要調整位元組數,向前或向後尋找換行符,來保證最終的切分點落在一整條記錄的尾部。具體的實現細節相對來講比較複雜,可以參考在 《訪問 OSS 非結構化資料》文件中提到的 程式碼示例 來進行分析。
注意點
在計算位元組數的過程中,可能會遇到非英文字元造成計算切分點的位置計算不準確,進而出現讀取的位元組流仍然沒有把一整行覆蓋到的情況。這需要針對含有非英文字元的文字資料做一些特殊處理。
程式碼示例:
@Override
public int read(char[] cbuf, int off, int len) throws IOException {
if (this.splitReadLen >= this.splitSize) {
return -1;
}
if (this.splitReadLen + len >= this.splitSize) {
len = (int) (this.splitSize - this.splitReadLen);
}
int readSize = this.internalReader.read(cbuf, off, len);
int totalBytes = 0;
for (char ch : cbuf) {
String str = String.valueOf(ch);
byte[] bytes = str.getBytes(charset);
totalBytes += bytes.length;
}
this.splitReadLen += totalBytes;
return readSize;
}
- 其他建議
在編寫自定義Extractor的程式中,適當加入System.out作為日誌資訊輸出,這些日誌資訊會在MaxCompute執行時輸出在LogView的檢視中,對於除錯過程和線上問題排查過程非常有幫助。
上文中提到通過調整 odps.sql.unstructured.data.split.size 引數值來適當提高Mapper任務的並行度,但是並行度並不是越高越好,具體什麼值最合適是與OSS上的檔案大小、總資料量、MaxCompute產品自身的叢集狀態緊密聯絡在一起的,需要多次除錯,並且可能需要與 odps.stage.reducer.num、odps.sql.reshuffle.dynamicpt、odps.merge.smallfile.filesize.threshold 等引數配合使用才能找到最優值。並且由於MaxCompute產品自身的叢集狀態也是很重要的因素,可能今天申請500個Mapper資源是很容易的事情,過幾個月就變成經常需要等待很長時間才能申請到,這就需要持續關注任務的執行時間並及時調整引數設定。
外部表的讀取和解析是依靠Extractor對文字的解析來實現的,因此在執行效率上是遠不能和MaxCompute的普通表相比的,所以在需要頻繁讀取和分析OSS上的文字檔案的情況下,建議將OSS檔案先 INSERT OVERWRITE 到MaxCompute中欄位完全對等的一張普通表中,然後針對普通表進行分析計算,這樣通常會獲得更好的計算效率。