如何實現一個高效的本地日誌收集程式

bjehp發表於2021-07-24

客戶端在請求資源時,請求會傳送到服務端的業務程式,然後業務程式負責把資源返回給客戶端。在這個過程中,如果我們要對服務端的程式進行優化,那麼分析服務端的日誌是必不可少的。而分析服務端的日誌,首先就需要把服務端的日誌收集起來。那麼如何實現一個高效的本地日誌收集程式,是本文要討論的內容。

"高效" 應該怎麼理解呢?本文把"高效"定義為:在保證讀取日誌吞吐量的同時,儘可能少地佔用伺服器資源。更具體的說,"高效"包含的指標有:CPU消耗、記憶體佔用、可靠性、耗時、吞吐量等因素。

本文將介紹使用 Java 程式語言實現的讀取本地檔案的幾種方式,並分析每種方式的優缺點。最後給出筆者實踐得到的最高效的本地日誌收集程式,供讀者參考。另外由於筆者水平有限,文中有誤之處,歡迎指正。

一、BufferedReader 樸素方式

讀取本地檔案,我們最常用的方式是構建一個 BufferedReader 類,通過它的 readLine() 方法讀取每一行日誌。

如果我們要實現可靠的檔案傳輸功能,就需要定時儲存檔案的當前讀取位置。 這樣可以保證,程式即使在讀檔案過程中停止,程式重啟後,依然可以從檔案上次讀取的位置繼續消費日誌,保障日誌不會被重複或遺漏消費。程式碼如下:

BufferedReader reader =  new BufferedReader(new InputStreamReader(new FileInputStream(filePath)));
while ((line = reader.readLine()) != null){
  recordPosition();   // 記錄讀取位置
  process(line);      // 處理每一行的內容
}

BufferedReader方式的優點是:讀取日誌消耗的記憶體和CPU比較小,吞吐量高。

  • 由於使用了快取,程式會申請一塊固定大小的記憶體作為中轉,不會把整個檔案讀到記憶體,這樣記憶體佔用會比較小,申請的快取預設大小為 8192個位元組。
  • 同樣由於使用到了快取,讀取本地檔案不會逐個位元組讀取,逐個位元組讀取的方式會頻繁地中斷CPU,而是每次讀取一個快取塊的資料,這樣會降低中斷CPU的次數,CPU消耗會很低。
  • 由於使用快取,相比逐個位元組地從檔案讀取內容,以塊方式讀取檔案內容,能大大提高日誌讀取的吞吐量。

BufferedReader方式的缺點是:不支援隨機讀取,在一些場景下耗時比較高。

  • 考慮這樣的場景,程式在檔案讀取過程中異常停止,程式重啟後,BufferedReader方式會從頭開始掃描檔案,直到找到上次檔案讀取的位置,在繼續消費日誌。而查詢檔案某個位置的時間複雜度為O(n),這樣如果檔案很大(超過1GB),且重啟操作比較頻繁,那麼程式會消耗很多無用的操作在掃描日誌上,從而增加日誌處理的耗時。

二、RandomAccessFile 隨機讀取方式

基於上述BufferedReader樸素方式的缺點,我們希望實現隨機讀取日誌的方式。因此我們考慮使用 RandomAccessFile 類,通過它的 readLine() 方法來讀取每一行日誌。

同樣,要實現高可靠的檔案傳輸的功能,也需要定時儲存檔案的當前讀取位置。 實現程式碼如下:

RandomAccessFile raf = new RandomAccessFile(file, "r");
raf.seek(position);   // 定位到檔案的讀取位置
while ((line = raf.readLine()) != null) {
  process(line);      // 處理每一行的內容
}

RandomAccessFile 方式的優點是:支援隨機讀取,讀取日誌消耗記憶體少。

  • 這種方式能夠快速定位到檔案的讀取位置,定位到檔案讀取位置的時間複雜度為 O(1)
  • 該方式讀取本地檔案,會逐個位元組讀取檔案中內容,且不使用快取,記憶體佔用極低。

RandomAccessFile 方式的缺點是:CPU佔用高、吞吐量低。

  • 它的內部實現是通過一個位元組一個位元組地讀取檔案內容,由於每讀一個位元組都會中斷一次CPU,相對於使用快取方式讀取一批資料中斷一次CPU,這種方式中斷CPU次數會更頻繁,造成CPU佔用高。
  • 另外相對於快取按照塊方式讀取檔案內容,這種逐個位元組讀取檔案內容的方式,明顯會降低檔案讀取的吞吐量,檔案讀取效率很低。

三、MappedByteBuffer 記憶體對映檔案方式

RandomAccessFile 隨機讀取方式需要按位元組讀取檔案,這樣讀取檔案的吞吐量會很低。而 BufferedReader 的資料塊快取機制能提高檔案的讀取吞吐量,因此考慮為 RandomAccessFile 新增快取。調研發現 MappedByteBuffer 記憶體對映檔案方式提供了快取機制。

同樣,要實現可靠的檔案傳輸的功能,也需要定時儲存檔案的當前讀取位置。下面程式碼展示了核心的讀檔案處理流程,考慮到更清晰地展示核心處理流程,去掉了儲存檔案的當前讀取位置的邏輯。實現程式碼如下:

RandomAccessFile raf = new RandomAccessFile(file, "r");
FileChannel channel = raf.getChannel();
MappedByteBuffer out = channel.map(FileChannel.MapMode.READ_ONLY, 0, file.length());
byte[] buf = new byte[count];   // buf 陣列用來儲存每一行

while(out.remaining()>0){
      // 解析出每一行
      byte by = out.get();
      ch =(char)by;
      switch(ch){
        case '\n':
          flag = true;
          break;
        case '\r':
          flag = true;
          break;
        default:
          buf[j++] = by;
          break;
      }
      // 讀取的字元超過了buf 陣列的大小,需要動態擴容
      if(flag ==false && j>=count){
        count = count + extra;
        buf = copyOf(buf,count);
      }
      // 處理每一行並初始化環境
      if(flag==true){
        String line = new String(buf, 0, j, StandardCharsets.UTF_8);
        process(line);  	// 處理每一行
        flag = false;
        count = extra;
        buf = new byte[count];
        j =0;
      }
    }

MappedByteBuffer 記憶體對映檔案方式的優點:CPU消耗低、吞吐量高、支援隨機讀取。

  • 這種方式在實現上使用了快取,降低 IO 對 CPU 的中斷次數,這樣 CPU 消耗低,檔案讀取的吞吐量高。
  • 並且底層使用了 RandomAccessFile ,支援檔案內容的隨機讀取,查詢檔案讀取位置的時間複雜度為 O(1).

MappedByteBuffer 方式記憶體佔用高,且對映的檔案有檔案大小限制。

  • 這種方式需要把檔案內容全部讀入記憶體,這樣會消耗伺服器的大量記憶體,記憶體佔用高。

  • 另外這種方式最大對映的檔案大小為 Integer的最大值,即最大支援對映 2GB 的檔案,也就是說只能處理2GB以下的檔案,無法處理超過 2GB 的檔案。

四、ByteBuffer 資料塊快取方式

MappedByteBuffer 記憶體對映檔案方式,需要把檔案內容全部寫入記憶體,而且無法應對傳輸檔案大小超過2GB大小的場景。由此可見,MappedByteBuffer方式的核心缺點在於記憶體佔用的問題。

針對上述缺點,筆者設計了一種 ByteBuffer 資料塊快取方式的解決方案:申請一個資料塊快取,把檔案相應大小的內容裝入快取,該資料塊的快取被消費完後,在往資料塊快取裝入下一部分的檔案內容,然後繼續消費資料塊快取中的資料;如此迴圈,直到把檔案內容全部讀完為止。

資料塊快取

同樣,要實現可靠的檔案傳輸的功能,也需要定時儲存檔案的當前讀取位置。具體實現程式碼如下:

RandomAccessFile raf = new RandomAccessFile(filePath, "r");
FileChannel fc = raf.getChannel();

ByteBuffer buffer = ByteBuffer.allocate(bufferSize);   // 讀取一批日誌申請的位元組快取空間大小
ByteBuffer lineBuffer = ByteBuffer.allocate(lineBufferSize); //每行日誌申請的位元組快取空間大小

int bytesRead = fc.read(buffer);
while (bytesRead != -1 && !fileReaderClosed.get()) {
  currPos = fc.position() - bytesRead;

  buffer.flip();      // 切換為讀模式
  while (buffer.hasRemaining()) {
    byte b = buffer.get();
    currPos++;
    if (b == '\n' || b == '\r') {
      sendLine(lineBuffer);  // 處理日誌
    } else {
      // 若空間不夠則擴容
      if (!lineBuffer.hasRemaining()) {
        lineBuffer = reAllocate(lineBuffer);
      }
      lineBuffer.put(b);
    }
  }
  buffer.clear();    // 清除快取

  bytesRead = fc.read(buffer);   // 寫入快取
}

ByteBuffer 資料塊快取方式的優點是:支援隨機讀取,CPU消耗少,記憶體佔用低,吞吐量高。

  • 這種方式底層使用了RandomAccessFile做檔案掃描,查詢指定位置的字串時間複雜度O(1)
  • 相對於每讀取一個位元組都要中斷一次CPU,通過使用一個位元組快取塊來批量讀取檔案內容的方案,能大大降低呼叫CPU的頻率,減少CPU的消耗。
  • 相對於把整個檔案對映到記憶體,每次把檔案的部分內容對映到記憶體緩衝區,能夠有效減低記憶體佔用,且不受檔案大小的限制。
  • 相對於逐個位元組讀取檔案內容,以快取塊方式讀取能有效提高吞吐量。

總結

本文由淺入深地介紹了四種讀取本地檔案的方式,並分析了每種方式存在的優缺點。通過對每種方式存在的缺點進行探索式改進,最後實現了一種高效的收集本地日誌檔案的方案——ByteBuffer 資料塊快取方式。這四種方式的優缺點對比彙總如下:

吞吐量 CPU消耗 記憶體佔用 時間複雜度(理論值)
BufferedReader 樸素方式 O(n)
RandomAccessFile 隨機讀取方式 O(1)
MappedByteBuffer 記憶體對映檔案方式 O(1)
ByteBuffer 資料塊快取方式 O(1)

這裡需要說明一點,文中提到的可靠性是指在正常情況下的操作,如:啟動、停止操作,日誌消費可做到Exactly-once。但是在異常情況下,如:網路抖動或服務被強制kill,日誌消費可能會出現少量的日誌重複或丟失現象。服務還有待向高可靠的方向演進。

ByteBuffer 資料塊快取方式已應用到本部門的開源專案 Databus 的日誌推送端業務中,其具體的實現為FileSource ,程式碼地址:https://github.com/weibodip/databus/blob/master/src/main/java/com/weibo/dip/databus/source/FileSource.java ,有興趣的同學可以查閱。

相關文章