做共享目录实时同步,踩过这些坑

Luca Ju
2026-05-26 / 0 评论 / 11 阅读 / 正在检测是否收录...

项目背景:

我们有一个知识库平台,之前都是用户在平台上上传文档,然后执行解析向量数据库,用于后续执行RAG

现在客户希望可以文档上传到共享目录,系统就可以自动把这些文件同步到知识库。

问题:为什么不用轮询?

最直白的方案是定时扫目录,对比前后两次的文件列表差异。这个方案写起来简单,但有几个硬伤:

  • 延迟高:扫描周期设 5 分钟,用户上传后平均要等 2.5 分钟才能感知到
  • IO 浪费:目录下文件多的时候,每次全量扫一遍磁盘开销不小
  • 无法感知"修改":如果只是文件内容变了但大小没变,很难准确判断

所以决定用 java.nio.file.WatchService 做事件驱动监听。

WatchService 的基本用法

WatchService 本质上是对操作系统底层文件事件通知的封装。Linux 下用的是 inotify,Windows 下是 ReadDirectoryChangesW。

注册一个目录的监听很简单:

WatchService watchService = FileSystems.getDefault().newWatchService();
Path dir = Paths.get("/share");
WatchKey key = dir.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);

然后在一个独立线程里 take() 阻塞等待事件:

WatchKey key = watchService.take();
for (WatchEvent<?> event : key.pollEvents()) {
    // 处理事件
}
key.reset();  // 必须 reset,否则后续事件收不到

坑一:WatchService 不会递归监听子目录

这是最容易被忽略的一点。dir.register(...) 只监听这一层目录,子目录下的文件变更完全收不到。

我们的共享目录结构是 /share/知识库名/文件,层级不固定,用户还可能随时新建子目录。所以必须自己实现递归注册。

方案是在启动时把整个目录树 walk 一遍,每个子目录都注册上:

Files.walkFileTree(root, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE,
    new SimpleFileVisitor<>() {
        @Override
        public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
            register(dir);  // 每个目录都注册监听
            return FileVisitResult.CONTINUE;
        }
    });

同时监听 ENTRY_CREATE 事件,当发现新建的是目录时,立刻给它补上注册:

if (kind == ENTRY_CREATE && Files.isDirectory(child)) {
    registerAll(child);
}

坑二:目录创建时,里面的文件可能"漏事件"

这是个很隐蔽的竞态条件。假设用户通过 cp -r 拷贝一整个目录进来,操作系统产生的事件顺序可能是:

  1. ENTRY_CREATE —— 新目录 /share/A/B
  2. ENTRY_CREATE —— 目录下的文件 file1.txt
  3. ENTRY_CREATE —— 目录下的文件 file2.txt

但如果我们的处理逻辑在第 1 步给 /share/A/B 注册监听时稍微慢了一点,第 2、3 步的事件可能已经被操作系统发出,而我们还没注册上,于是漏掉了。

解决办法是在注册新目录后,主动扫一遍目录里已有的文件,补发创建事件:

if (Files.isDirectory(child)) {
    registerAll(child);
    emitCreateForExistingFiles(child);  // 兜底补发
}
private void emitCreateForExistingFiles(Path dir) {
    try (Stream<Path> pathStream = Files.walk(dir)) {
        pathStream.filter(Files::isRegularFile).forEach(eventHandler::onCreate);
    }
}

坑三:事件防抖

文件写入往往不是原子操作。比如用户拷贝一个大文件,或者用户频繁修改文件,操作系统可能产生一连串的 MODIFY 事件。如果每个事件都触发一次上传,既浪费资源又容易出错。

我们在事件处理器里加了基于时间窗口的去抖:

String debounceKey = eventType + "|" + fullPath;
long now = System.currentTimeMillis();
Long last = debounceMap.get(debounceKey);
if (last != null && now - last < windowMillis) {
    return;  // 同一个文件同类型事件,5 秒内只处理一次
}
debounceMap.put(debounceKey, now);

这里用 ConcurrentHashMap 保证线程安全,key 的设计是"事件类型 + 文件路径",这样创建和修改事件互不干扰。

坑四:应用重启期间的事件丢失

WatchService 是内存态的,应用一重启,期间发生的所有文件变更都感知不到了。这对于生产环境是不可接受的。

我们的补偿方案是快照比对

  1. 每次补偿扫描时,把当前目录下所有文件的路径、大小、最后修改时间文件hash记录下来,存到 db
  2. 下次扫描时加载上次的快照,和当前状态做 diff
  3. 新出现的文件 → 补发 CREATE
  4. 大小或修改时间变化的 → 补发 MODIFY
  5. 快照里有但现在没了的 → 补发 DELETE

快照存 MySQL 而不是本地文件,是为了在集群部署时也能共享状态(虽然当前是单实例,但预留了扩展空间)。

补偿扫描有两个触发时机:

  • 启动时立即执行一次:覆盖重启期间的漏事件
  • 定时执行:默认 5 分钟一次,作为兜底
@Scheduled(fixedDelayString = "${doc.share-sync.reconcile-delay-ms:300000}")
public void scheduledReconcile() {
    reconcile("scheduled");
}

整体架构

整个同步模块拆成了四个组件,职责很清晰:

组件职责
ShareDirWatcher基于 WatchService 的实时监听,递归注册子目录
ShareFileEventHandler事件去抖、路径解析(从文件路径反查知识库ID)、文件处理
ShareDirReconcileJob停机补偿扫描,快照比对补发事件
ShareFileSnapshotStore快照的持久化存储(MySQL)

配置也做了开关控制,方便分阶段上线:

doc:
  share-sync:
    enabled: true
    dry-run: true           # 第一阶段只打日志,不真写
    root-dir: /share
    settle-seconds: 5       # 防抖窗口
    reconcile-delay-ms: 300000

一些没写的细节

  • OVERFLOW 事件:WatchService 可能因事件队列满而丢失事件,此时会收到一个 OVERFLOW 事件。我们的处理是直接跳过,依赖定时补偿扫描来修复。
  • 删除事件的路径ENTRY_DELETE 事件发生时,文件已经没了,ev.context() 只能拿到文件名,不能判断它是不是目录。所以删除事件统一走 onDelete,由下游根据数据库记录判断之前是什么。
  • 重复上传风险: 监听程序和定时补偿程序同时发现新创建了文件,会出现重复上传的问题。处理方案是添加数据库唯一索引,直接从数据库兜底

总结

WatchService 来做文件监听是我目前实现的方案,递归注册、新目录兜底扫描、事件去抖、停机补偿,这几块缺一不可。第一阶段先跑 dry-run,把日志打全、路径解析逻辑验证清楚,第二阶段再放开真实写入,这样上线心里比较有底。

1

评论 (0)

取消