第4章:文件监控原理(inotify、WatchService等)

热加载离不开一个核心能力 —— 文件监控。说白了,就是让 SDK 能感知到配置文件的「风吹草动」。文件改了,它得知道;知道以后,才能触发重新加载。这一章,我们就把文件监控的技术底牌翻出来看看。

我在不同平台上都做过文件监控的实现,这里把经验沉淀下来。Linux 的 inotify、Java 的 WatchService、还有 macOS 的 FSEvents,它们各有各的脾气。今天重点讲 inotify 和 WatchService,这两个在服务端场景最常用。

4.1 Linux inotify 机制

inotify 是 Linux 内核提供的文件系统事件通知机制。我在项目中第一次用 inotify,是在为一个 Java SDK 做热加载。当时 WatchService 在 JDK 7 之前还没普及,只能直接调原生的 inotify。

核心概念: inotify 通过 inotify_init() 创建一个文件描述符,然后通过 inotify_add_watch() 注册要监控的目录或文件。内核有变化时,你从那个描述符读事件就行。

事件类型主要有这么几种:

事件宏 含义 触发时机
IN_ACCESS 文件被读取 read() 返回前
IN_MODIFY 文件被修改 write() 返回前
IN_ATTRIB 元数据变化 权限、时间戳等变更时
IN_CLOSE_WRITE 写入后关闭 文件编辑保存后
IN_MOVED_FROM 文件移出 mv 操作目标文件
IN_MOVED_TO 文件移入 mv 操作源文件
IN_DELETE 文件被删除 unlink() 返回前

避坑指南:我曾经踩过一个坑 —— inotify 默认不会递归监控子目录。你只监控了 `/etc/config/`,但人家在 `/etc/config/sub/app.json` 改了,你是收不到事件的。解决方案要么自己递归注册,要么用 fanotify(但权限要求更高)。

看个简单的 C 示例,感受一下 inotify 的使用流程:

#include <sys/inotify.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    int fd = inotify_init();
    if (fd < 0) {
        perror("inotify_init");
        return 1;
    }

    int wd = inotify_add_watch(fd, "/path/to/config",
                               IN_MODIFY | IN_CREATE | IN_DELETE);
    if (wd < 0) {
        perror("inotify_add_watch");
        return 1;
    }

    char buf[4096];
    ssize_t len = read(fd, buf, sizeof(buf));
    // 解析 inotify_event 结构体...
    // 这里拿到事件后通知 SDK 重新加载

    inotify_rm_watch(fd, wd);
    close(fd);
    return 0;
}

个人习惯:我从 read() 返回的数据中一次性读取多个事件,然后用指针遍历。这样可以减少系统调用次数。另外注意 buf 要足够大,否则可能会丢掉事件。

4.2 Java WatchService 设计思路

WatchService 是 JDK 7 引入的,它把底层的 inotify、kqueue(macOS)、ReadDirectoryChanges(Windows)包装成统一的 API。说白了,你的 Java 代码不需要关心跑在什么平台。

我记得第一次用 WatchService 时,有个地方让我很困惑 —— 它为什么不直接给一个回调函数?而是要用一个「拉取」模型?后来想明白了,回调模型在高并发下容易导致线程安全问题。WatchService 的设计是让用户自己控制轮询时机和线程模型。

核心 API 就这几个:

  • Path.register() — 注册要监控的目录,指定关心的事件类型
  • WatchService.poll() — 非阻塞获取事件,没有返回 null
  • WatchService.take() — 阻塞获取事件,有事件才返回
  • WatchKey.pollEvents() — 从 WatchKey 中取出所有待处理事件

使用示例:

Path dir = Paths.get("/etc/sdk/config");
WatchService watcher = FileSystems.getDefault().newWatchService();

dir.register(watcher,
    StandardWatchEventKinds.ENTRY_CREATE,
    StandardWatchEventKinds.ENTRY_MODIFY,
    StandardWatchEventKinds.ENTRY_DELETE);

while (true) {
    WatchKey key = watcher.poll(1, TimeUnit.SECONDS);
    if (key == null) {
        continue;
    }

    for (WatchEvent<?> event : key.pollEvents()) {
        WatchEvent.Kind<?> kind = event.kind();
        Path filename = (Path) event.context();

        if (kind == StandardWatchEventKinds.OVERFLOW) {
            // 事件丢失了,建议全量刷新
            continue;
        }

        // 触发热加载
        if (filename.toString().endsWith(".yml") ||
            filename.toString().endsWith(".json")) {
            sdkReloadConfig(dir.resolve(filename));
        }
    }

    // 必须 reset,否则无法继续接收事件
    boolean valid = key.reset();
    if (!valid) {
        // 目录被删了或不可用
        break;
    }
}

重要:WatchKey 的 reset() 方法必须调用。很多人刚开始用的时候,事件只触发一次就再也不动了,原因就是忘了 reset()。你想想看,不 reset,内核觉得「你还没处理完」,就不给你新事件了。

4.3 两种机制的对比

到底选 inotify 还是 WatchService?我个人的习惯是:Java 项目无脑用 WatchService,除非你需要 Linux 特定的高级特性。C/C++ 项目或者需要极低延迟的场景,直接裸用 inotify。

特性 inotify WatchService
平台 仅 Linux 跨平台(Linux/macOS/Windows)
事件粒度 更细(IN_ACCESS、IN_OPEN 等) 较粗(CREATE、MODIFY、DELETE 等)
递归监控 不支持,需自己扫子树 不支持,需自己扫子树
事件丢失 队列满时可能丢(/proc/sys/fs/inotify/max_user_watches) 产生 OVERFLOW 事件,需手动处理
性能 极低延迟,无额外抽象 有轻微封装开销,但可忽略
易用性 需处理底层文件描述符、epoll 等 Java SPI 方式,代码更整洁

4.4 完整监控流程

文件监控不是「收到事件 → 立即加载」这么简单。你想想看,一个文本编辑器保存文件时,可能会触发 N 次 MODIFY 事件(原子写入或分批写入)。如果你每次收到事件就 reload,那 SDK 可能被冲垮。

我的做法是加一个「去抖」逻辑 —— 收到事件后,启动一个 500ms 的定时器,期间再有事件就重置定时器。定时器到期了,才真正执行热加载。这样能避免频繁重新加载。

下面这张图展示了我在 SDK 中实现的文件监控整体流程:

(框架图)

SDK 文件监控热加载流程 1. 用户修改配置文件 2. 内核产生 inotify 事件 3. WatchService 拉取事件 (poll/take + 遍历 WatchEvent) 4. 去抖合并(500ms延时) 定时器未到期则重置,避免频繁加载 5. SDK 执行参数热加载 ⚠️ 注意事项 • 单个文件修改产生多次事件 • 目录监控不会递归子目录 • 队列溢出产生 OVERFLOW • WatchKey 必须调用 reset() • 原子写入 vs 就地覆盖

核心流程总结:用户修改文件 → 内核产生 inotify 事件 → WatchService 拉取并封装 → 去抖合并防抖 → SDK 执行参数热加载。

4.5 碎片化问题与优化

刚接触文件监控的同学,经常遇到一个问题:你改一个文件,能收到好几个事件。比如 vim 保存是「写入临时文件 → 删除原文件 → 移动临时文件为原文件名」这个过程,会触发 DELETE、CREATE、MODIFY 多种事件。

避坑指南:我曾经遇到过一个非常隐蔽的 bug —— SDK 收到 DELETE 事件后,把配置文件从内存中移除了。结果紧接着一个 CREATE 事件又把它加回来。中间那几百微秒,系统配置是空的,直接导致了业务异常。正确的做法是:收到 DELETE 后标记暂不删除,等待 1-2 秒再看文件是否存在。

还有一个常见的优化点 —— 大量文件同时变更时的批量处理。我个人习惯在 WatchService 的 take() 返回后,一次性把 pollEvents() 里的所有事件收集起来,合并成一个变更集,然后统一触发一次热加载。而不是逐个事件触发。

你看,文件监控原理不难,但在 SDK 里用好它,需要考虑的细节就很多了。事件丢失怎么处理?性能怎么保证?多线程场景下怎么保证一致性?这些才是实战中的难点。后面章节我们会针对这些场景一一展开。