客戶端在請求資源時,請求會發送到服務端的業務程序,然后業務程序負責把資源返回給客戶端。在這個過程中,如果我們要對服務端的程序進行優化,那么分析服務端的日志是必不可少的。而分析服務端的日志,首先就需要把服務端的日志收集起來。那么如何實現一個高效的本地日志收集程序,是本文要討論的內容。
"高效" 應該怎么理解呢?本文把"高效"定義為:在保證讀取日志吞吐量的同時,盡可能少地占用服務器資源。更具體的說,"高效"包含的指標有: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 ,有興趣的同學可以查閱。