客户端在请求资源时,请求会发送到服务端的业务程序,然后业务程序负责把资源返回给客户端。在这个过程中,如果我们要对服务端的程序进行优化,那么分析服务端的日志是必不可少的。而分析服务端的日志,首先就需要把服务端的日志收集起来。那么如何实现一个高效的本地日志收集程序,是本文要讨论的内容。

"高效" 应该怎么理解呢?本文把"高效"定义为:在保证读取日志吞吐量的同时,尽可能少地占用服务器资源。更具体的说,"高效"包含的指标有: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 ,有兴趣的同学可以查阅。