大家好,我是小楼。之前遇到一个文件监控变化的问题,周末刚好有时间研究,整理出来分享给大家。先从一个故障说起,更贴近实际,也能让大家更快的了解背景。有一个分发配置的服务。这个配置服务的实现有点特殊。服务器发送配置给每个服务的本地文件。当然中间有一个agent经过。如果没有agent,本地文件是写不出来的,然后client端的程序去听这个配置文件。文件更改后,重新加载配置。画一个这样的结构图:今天的重点是如何监控(watch)文件变化。我们当时的实现很简单:一个单独的线程,定时去获取文件的最后一次更新时间戳(毫秒)。记录每个文件的最后一次更新时间戳,根据时间戳是否变化来判断文件是否发生变化。从上面的简单描述,我们可以看出这种实现方式存在一些不足:无法实时感知文件变化,感知错误在于轮询文件最后更新时间的间隔。精确到毫秒级别,如果同一毫秒内发生两次变化,而轮询恰好在两次变化的中间,则不会感知到后面的变化,但概率很小。幸运的是,以上两个缺点几乎没有太大影响。但随后出现了严重的线上故障。为什么?因为一个JDKBUG,罪魁祸首贴在这里:BUG详情:https://bugs.java.com/bugdatabase/view_bug.do?bug_id=8177809。在某些JDK版本下,获取文件的最后更新时间戳会丢失毫秒精度,总是返回整秒的时间戳。为了直观感受,我写了一个demo分别在jdk1.8.0_261和jdk_11.0.6上测试(都是MacOs):jdk_1.8.0_261jdk_11.0.6如果在这个BUG的影响下,只要有2处改动同一秒,而读取文件的最后一个时间戳在2次变化之间,2次变化无法被程序感知,相同1秒的概率远大于相同毫秒,所以当然触发了,导致在线失败。这在以前就像是沧海一粟,现在变成了在大海中钓到某条鱼的概率。这个我们也能遇到,真是有点受不了~WatchService——JDK内置的文件变化监控。得知之前的实现有bug,就去搜索Java中如何监听文件变化,找到了WatchService。据说WatchService可以监控一个目录,监控该目录下文件的增、改、删。于是我很快就写了一个demo进行测试:publicstaticvoidwatchDir(Stringdir){Pathpath=Paths.get(dir);尝试(WatchServicewatchService=FileSystems.getDefault().newWatchService()){while(true){WatchKeykey=watchService.take();对于(WatchEvent>watchEvent:key.pollEvents()){if(watchEvent.kind()==StandardWatchEventKinds.ENTRY_CREATE){System.out.println("创建..."+System.currentTimeMillis());}elseif(watchEvent.kind()==StandardWatchEventKinds.ENTRY_MODIFY){System.out.println("修改..."+System.currentTimeMillis());}elseif(watchEvent.kind()==StandardWatchEventKinds.ENTRY_DELETE){System.out.println("删除..."+System.currentTimeMillis());}elseif(watchEvent.kind()==StandardWatchEventKinds.OVERFLOW){System.out.println("溢出..."+System.currentTimeMillis());}}if(!key.reset()){System.out.println("resetfalse");返回;}}}catch(Exceptione){e.printStackTrace();先监听/tmp/file_test目录,然后每隔5毫秒往文件中写入数据。理论上应该可以收到3个事件,但实际上很奇怪。仔细看,收到修改事件的时间是第一次文件修改后的9.5s左右。这很奇怪。先记住,我们看一下WatchService的源码:>>>1652076266609-16520762570979512WatchService原理WatchServicewatchService=FileSystems.getDefault().newWatchService()通过调试发现这里的watchService其实是PollingWatchService的实例,直接看PollingWatchService的实现:PollingWatchService一上来我就创建了一个线程,这让我心里有些忐忑。我看看这个scheduledExecutor用在什么地方:每隔一段时间(默认是10s)去poll一下,这个poll是做什么用的?代码太长,把关键部分删掉:果然,和我们的实现类似,也是读取文件的最后更新时间,根据时间变化发送change事件。也就是说,在某些JDK版本下,他也是有BUG的!这也就解释了为什么上面说的事件监听是在第一个9.5s之后发送的,因为监听注册之后,经过500ms的sleep修改文件,轮询10s,恰好9.5s后拿到第一轮事件。inotify——Linux内核提供的文件监控机制。说到这里,我想到了linux上的tail命令。tail是文件更改时输出文件的末尾。理论上,它也监视文件变化。这首曲子恰好是很久以前听过的。一位技术负责人分享了如何自己实现tail命令。使用的底层技术是inotify。简单的说,inotify是Linux内核提供的一个监控文件变化事件的系统调用。如果以此为基础实现,不就可以绕过JDK的BUG了吗?但是奇怪的是Java为什么不用这个来实现呢?于是又搜索了一下,发现谷歌好像有库,但是被删了,看不到了。转到代码:在github上找到了另外一个:https://github.com/sunmingshi/Jinotify。貌似是native实现,需要自己编译.so文件,比较蛋疼。记得上次这么痛苦,还在折腾Java的unixdomainsocket。我还找到了一个谷歌图书馆。测试还好,放到网上就崩了~不得不说google还是很强大的,JDK提供不了的库,还是去补吧!于是我带着这个问题去问了一个搞JVM开发的朋友,他告诉我Java也可以用inotify!瞬间斗志来了,难道是我测试姿势不对?又翻了一遍Java文档,发现角落里藏着这么一段话:也就是说,不同的平台会使用不同的实现。PollingWatchService是在系统不支持inotify时使用的一种掩饰策略。于是打印出了watchService的类型,Mac上打印为:classsun.nio.fs.PollingWatchServiceonLinux:classsun.nio.fs.LinuxWatchServiceLinuxWatchServicecannotfindthisclassonMac,我猜应该是Mac版本JDK的开发者根本没有打包这段代码。原来我的本地测试走的是底线策略,貌似是孤测。于是写了一个demo,又测试了一遍:thread.setDaemon(false);thread.start();线程.睡眠(500L);for(inti=0;i<3;i++){Stringpath="/tmp/file_test/test";FileWriterfileWriter=newFileWriter(路径);fileWriter.write(i);fileWriter.close();文件文件=新文件(路径);System.out.println(file.lastModified());线程.睡眠(5);}}本地MacLinux可以看到,Linux上可以接收的事件比本地多很多,接收事件的时间也明显更实时。为了更准确的验证是inotify,使用strace来捕捉系统调用。由于JVMfork产生的子进程较多,需要加上-f命令。如果输出太多,可以将它们保存在一个文件中以供进一步分析:strace-f-os.txtJavaFileTime确实使用了inotify系统调用,这再次验证了我们的猜想。如何修复故障?回到最开始的故障,我们如何修复呢?由于分发的文件和读取文件的程序都在我们的控制之下,所以我们绕过了这个bug,给每个文件写了一个版本,可以用文件内容的md5值作为版本,写一个特殊的文件,读取读取时优先加载版本,版本变化时重新加载文件。也许你要问了,为什么不用WatchService呢?我也问了负责人。据说inotify在docker上跑的不是很好,经常丢事件。这不是Java问题。所有语言都存在这个问题,所以一直没有使用。.不过这块找不到相关资料,也无从考证,所以暂时搁置。最后想说一些bug,不踩是很难避免的。只要代码有bug的可能,就一定会暴露出来。这只是时间问题。我们需要在技术上钻研,仔细验证,但不必执着于产品,可以另辟蹊径。
