如何实现一个高效的本地日志收集程序
客户端在请求资源时,请求会发送到服务端的业务程序,然后业务程序负责把资源返回给客户端。在这个过程中,如果我们要对服务端的程序进行优化,那么分析服务端的日志是必不可少的。而分析服务端的日志,首先就需要把服务端的日志收集起来。那么如何实现一个高效的本地日志收集程序,是本文要讨论的内容。
"高效" 应该怎么理解呢?本文把"高效"定义为:在保证读取日志吞吐量的同时,尽可能少地占用服务器资源。更具体的说,"高效"包含的指标有: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 ,有兴趣的同学可以查阅。