如何读取其他进程当前写入的大日志文件

how to read a large log file which other process current write

按天创建日志文件,一个文件约400MB,JVM内存约2GB。 让一个进程以 'a' 模式写一个大日志文件。 我想读取这个文件并能实现一些功能:

  1. 追加读取新写入的数据
  2. jvm重启后我会存储偏移量以恢复读取

这是我的简单实现,不知道时间和内存消耗好不好。我想知道有没有更好的办法解决这个问题

public static void main(String[] args) throws IOException {
    String filePath = "D://test.log";
    long restoreOffset = resotoreOffset();
    RandomAccessFile randomAccessFile = new RandomAccessFile(filePath, "r");
    randomAccessFile.seek(restoreOffset);
    while (true) {
        String line = randomAccessFile.readLine();
        if(line != null) {

            // doSomething(line);

            restoreOffset = randomAccessFile.getFilePointer();

            //storeOffset(restoreOffset);
        }
    }
}

不幸的是,它不是。

此代码有 2 个主要问题。首先我会解决简单的问题,但最重要的是第二点。

编码问题

String line = randomAccessFile.readLine();

此行将字节隐式转换为字符,这通常不是一个好主意,因为字节不是字符,并且从一种转换为另一种需要字符集编码。

这种方法(readLine() 来自英国皇家空军)是一个奇怪的案例——可能是因为 RandomAccessFile 太旧了 API。使用此方法将应用一些奇怪的 ISO-8859-1 esque 字符集编码:它通过将每个字节作为一个完整的字符将字节转换为字符,假设字节代表列出的 unicode 字符,这实际上不是一个合理的编码,只是一个懒惰的程序员。

你的结果是:除非你能保证这个日志文件永远只包含 ASCII 字符,否则这个代码是错误的,readLine 不能完全用过。相反,您将不得不做更多的工作:读取字节直到遇到换行符,然后使用 new String(byteArray, StandardCharsets.UTF_8) 将如此收集的字节转换为字符串,或者使用 ByteBuffer 并应用类似的策略。但是请继续阅读,因为解决第二个问题会自动解决这个问题。

缓冲

现代计算机系统倾向于 'packeting'。您不能真正对单个字节进行操作。以 SSD 为例(尽管这也适用于旋转盘片):实际的 SSD 硬件无法读取单个字节。它只能读取整个块的数据。

因此,当您明确向 OS 请求单个字节时,最终会引发一系列事件,导致 SSD 读取整个块,然后将整个块传递给操作系统,然后它将忽略除您想要的一个字节之外的所有内容,return就是这样。

如果您的代码随后请求下一个字节,我们将再次执行该例程。

因此,如果您从具有 1024 字节块的 SSD 连续读取 1024 字节,通过调用 read() 1024 次会导致 SSD 执行 1024 次读取,而调用 read(byteArr) 一次,向其传递一个 1024 字节的数组,使 SSD 执行单次读取。

是的,这意味着字节数组解决方案实际上快了 1000 倍。

网络也是如此。发送 1 个字节一千次通常比一次发送 1000 个字节慢近 1000 倍; TCP/IP 数据包可以携带大约 1800 字节的数据,因此发送任何小于此的数据几乎不会给您带来任何好处。

RAF 的 readLine() 与第一个(坏的)场景类似:它一次读取一个字节,直到遇到换行符。因此,要读取 100 个字符的字符串,比只知道需要读取 100 个字符并一次性读取它们慢 100 倍。

解决方案

您可能想要完全放弃 RandomAccessFile,它已经很旧了 API。

缓冲的一个主要问题是它要困难得多,除非您事先知道要读取多少字节。在这里,您不知道:您想继续阅读直到遇到换行符,但您不知道我们到达那里需要多长时间。此外,缓冲 APIs 往往只是 return 方便,因此读取的字节数可能比我们要求的少(不过,除非我们到达文件末尾,否则它总是至少读取 1 个字节)。因此,我们需要编写代码来重复读取整个块的数据价值,分析块中的换行符,如果不存在,则继续阅读。

此外,开通渠道等费用高昂。所以,如果你想挖掘所有日志行,编写每次都打开一个新频道的代码是次优的。

怎么样,使用来自 java.nio.file 的较新文件 API:

public class LogLineReader implements AutoCloseable {
  private final byte[] buffer = new byte[1024];
  private final ByteBuffer bb = wrap(buffer);
  private final SeekableByteChannel channel;
  private final Charset charset = StandardCharsets.UTF_8;

  public LogLineReader(Path p) {
    channel = Files.newByteChannel(p, StandardOpenOption.READ);
    channel.position(111L); // you seek to pos 111 in your code...
  }

  @Override public void close() throws IOException {
    channel.close();
  }

  // This code buffers: First, our internal buffer is scanned
  // for a new line. If there is no full line in the buffer,
  // we read bytes from the file and check again until we find one.

  public String readLine() {
    int len = 0;
    if (!channel.isOpen()) return null;

    int scanStart = 0;

    while (true) {
      // Scan through the bytes we have buffered for a newline.

      for (int i = scanStart; i < buffer.position(); i++) {
        if (buffer[i] == '\n') {
          // Found it. Take all bytes up to the new line, turn into
          // a string.
          String res = new String(buffer, 0, i, charset);

          // Copy all bytes from _after_ the newline to the front.
          System.arraycopy(buffer, i + 1, buffer, 0, buffer.position() - i - 1);

          // Adjust the position (which represents how many bytes are buffered).
          buffer.position(buffer.position() - i - 1);
          return res;
        }
      }
      scanStart = buffer.position();

      // If we get here, the buffer is empty or contains no newline.

      if (scanStart == buffer.limit()) {
        throw new IOException("Log line too long");
      }

      int read = channel.read(buffer); // let's fetch more bytes!

      if (read == -1) {
        // we've reached the end of the file.

        if (buffer.position() == 0) return null;
        return new String(buffer, 0, buffer.position(), charset);
      }
    }
  }
}

为了效率,这段代码无法处理长度超过1024的日志行;随意增加这个数字。如果您希望能够读取无限大小的日志行,那么在某些时候,巨大的缓冲区是一个问题。如果必须,您可以编写代码在达到 1024 时调整缓冲区的大小,或者您可以更新此代码以使其继续读取,但只有 return 是具有前 1024 个字符的截断字符串。我会把它留给你作为练习。

注意:我也没有测试这个,但至少它应该给你使用 SeekableByteChannel 的一般要点,以及缓冲区的概念。

使用:

Path p = Paths.get("D://logfile.txt");
try (LogLineReader reader = new LogLineReader(p)) {
  for (String line = reader.readLine(); line != null; line = reader.readLine()) {
    // do something with line
  }
}

您必须确保 LLR 对象已关闭,因此请使用 try-with-resources。