• 如何实现一个高效的本地日志收集程序


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

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

  • 相关阅读:
    浪潮之巅阅读笔记
    人月神话阅读笔记3
    学习报告
    人月神话阅读笔记2
    学习报告
    第十一周学习总结
    软件杯项目——手写体识别
    第十周学习进度
    第九周学习进度
    《软件架构师的12项修炼》阅读笔记
  • 原文地址:https://www.cnblogs.com/ljhbjehp/p/15055702.html
Copyright © 2020-2023  润新知