问题

应用中有时候会有读取日志文件,并做近实时分析的需求(日志监控等)。但是使用类似Log4j的日志框架,日志文件可能会滚动:老的日志文件重命名成其它文件名(比如以日期为后缀),生成一个与老文件同名的新文件,这时候就需要读取日志文件的线程能够正确区分新老文件,并读取相应更新并且不会漏读数据。当然,这个问题的前提是:日志文件本身只会append,而不会在文件中间写入或者删除。本文主要分享下解决这个问题时碰到的一些问题及解决方案。

问题分析

首先我们来看看日志更新这件事有哪些情况可能会出现:

  1. 文件没有更新;
  2. 文件有更新,并且没有新的文件生成;
  3. 文件没有更新,并且有新的文件生成;
  4. 文件有更新,并且有新的文件生成;

也就是我们要解决两个问题:1)检测文件是否有更新,如果有更新,则读取更新;2)检测文件是否有滚动,也就是是否有新文件生成。

检测更新

检测更新有两种可能的方法:

  1. 通过文件更新时间:File.lastModified可以获取;
  2. 通过文件大小:File.length或者 FileChannel.size。

文件更新时间

通过文件更新时间检测更新应该是最直观的方法,但是这个方法有一个小缺陷:更新时间的精度。根据Wiki上的介绍,ext3的更新时间精度是一秒,ext4本身可以返回纳秒级别的更新时间,但是在这两种文件系统中,Java返回的都是秒级别的更新数据。这会导致什么问题呢?如果在1秒内有多次更新,那么有可能无法准确的检测到更新,如:

lastmodified: 1365590117000, size: 10597

lastmodified: 1365590117000, size: 10610

如上所示,是测试中的一个样例,文件大小已变,但是更新时间并没有变化,导致无法检测到更新。当然本身这个问题影响并不大,如果文件后续又有更新,前面的更新还是会读到的。

文件大小

用文件大小来判断一个文件是否有更新应该是最准确:只要文件哪怕更新一个字节,也会立即检测到文件有更新。

  • File.length拿到的永远是当前路径对应文件的大小,如果日志文件滚动,那么它拿到的就是新的日志文件的大小;
  • FileChannel.size则永远拿到当前channel对应物理文件的大小,即使文件滚动,老的文件被重命名,这个size拿到的还是老的文件大小。

文件滚动

在Linux中,inode是一个文件的唯一标识,不管文件是否重命名过。但是在Java中,却不存在这样一个接口来获取inode。在这种情况下,我们怎么来判断是否有文件生成?

  1. 通过文件大小及更新时间(忽略精度问题):上面提到FileChannel.size可以拿到老文件的大小,如果在两次检测之间,这个大小并没有变化,并且文件更新时间有变化(通过File.lastModified获取到,拿到的可能是新文件的更新时间),则可以肯定有新文件生成;
  2. 通过Runtime.exec(或者ProcessBuilder),来调用shell命令“ls -i”来获取文件的inode;
  3. 通过JNI来获取文件的inode。

如果不用获取inode,就可以判断新文件是否生成,那应该是最好的了,可惜并不完美:上述第一种方案的问题是,如果老的文件有更新,并且有新的文件生成,则检测不到已经有新文件生成了。在这种情况下,如果新的文件在滚动之前又有更新,则不会丢失数据(跟lastModified的精度问题类似),否则有可能会丢失数据。在实际使用中,如果检测间隔在毫秒级,这种情况应该很少出现。

那通过Runtime.exec获取inode可以吗,毕竟不用写jni代码。但是实际的情况是:1)每次获取inode花费的时间平均在10毫秒以下,如果检测间隔在100毫秒,还可以接受;2)通过这种方式获取inode不稳定,测试中发现有时候获取一次inode的时间会接近1s;3)Runtime.exec是通过fork一个进程,在新进程中执行shell命令的,万一系统中已经无法创建进程,那就会阻塞我们的检测线程;4)测了一下性能,分别通过Runtime.exec及JNI获取1000次inode,JNI耗时2ms,Runtime.exec耗时8s多,JNI的方式比Runtime.exec快了4000倍。。。

实现

上面把问题基本已经分析清楚,那处理逻辑就比较简单下:

  1. 如果当前文件的inode与上一轮文件的inode不同,则认为有新的文件生成:如果FileChannel.size比上一轮的大,则先读取老文件更新;读取新文件的内容;
  2. 如果当前文件的inode与上一轮文件的inode相同,则没有新文件生成,只需要通过FileChannel.size来判断老文件是否有更新,如果有更新则读取。

NativeLoader

做为一个工具类,如果在使用的时候还要配置一些参数什么的,那么无疑会比较麻烦。最简单的用法应该就是把打包好的jar发布到maven,使用方只要写好依赖,直接使用就行。为此,在打包时,就预先将编译好的动态库打包进去,并且由lib本身来加载动态库。NativeLoader就是完成从jar中加载动态库的功能。

总结

最终代码见:log-tailer,只支持Linux及MacOS。