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

我们之前的知识库平台,文档入口都在平台内:用户上传文件,系统解析内容,写入向量库,后面再拿来做 RAG。

后来客户提了一个很实际的诉求:他们已经有一套共享目录的使用习惯,不想每次都打开平台再上传一遍。最好是文件丢到共享目录里,系统自己发现、自己同步到知识库。

听起来像是"监听一个目录"这么简单,但真做起来,坑还挺多。

一开始为什么没用轮询

最直观的方案当然是定时扫目录。

比如每 5 分钟把共享目录完整扫一遍,和上一次的文件列表做 diff:新增的同步,删除的删除,变更的重新解析。

这个方案实现成本低,也容易兜底。但我们没有直接把它当成主链路,主要是几个问题:

  • 延迟不好控制:5 分钟扫一次,用户上传后平均要等 2 分半才能被发现;扫得更频繁,IO 压力又上来了。
  • 目录大了以后不划算:共享目录下文件一多,每次全量遍历都挺浪费。
  • 修改判断不够稳:只看文件大小容易漏,只看修改时间又容易受文件系统和网络盘行为影响。

所以最后的思路是:实时监听做主链路,定时扫描做补偿。

实时监听这块,用的是 java.nio.file.WatchService

WatchService 基本够用,但别只看 Demo

WatchService 是 Java 对操作系统文件事件通知的一层封装。Linux 下通常走 inotify,Windows 下是 ReadDirectoryChangesW。

最简单的写法确实很短:

java 复制代码
WatchService watchService = FileSystems.getDefault().newWatchService();
Path dir = Paths.get("/share");
dir.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);

然后单独起一个线程阻塞等事件:

java 复制代码
WatchKey key = watchService.take();
for (WatchEvent<?> event : key.pollEvents()) {
    // 处理事件
}
key.reset();

这里有个小细节:key.reset() 不能忘。忘了以后,这个目录后续事件就收不到了。

但如果只按这个 Demo 写,放到共享目录同步这种场景里,很快就会踩坑。

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

这是我们第一版最容易想当然的地方。

dir.register(...) 只监听当前这一层目录。比如你监听了 /share,那 /share/A 里面发生了文件变更,默认是收不到的。

而我们的目录结构大概是:

text 复制代码
/share/知识库名/文档
/share/知识库名/子目录/文档

层级不固定,用户还可能随时新建目录。所以启动时必须先把整棵目录树走一遍,把每个子目录都注册上。

java 复制代码
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 后马上补注册:

java 复制代码
if (kind == ENTRY_CREATE && Files.isDirectory(child)) {
    registerAll(child);
}

这一步如果漏了,系统看起来还在正常监听,但新目录下面的文件其实已经静悄悄地漏掉了。

坑二:新目录注册得再快,也可能漏文件

这个问题更隐蔽一点。

假设用户不是一个文件一个文件上传,而是直接 cp -r 拷贝一整个目录进来。操作系统可能先告诉你"新建了目录",然后目录里的文件事件很快就出来了。

理想顺序是:

text 复制代码
1. 收到新目录 /share/A/B
2. 给 /share/A/B 注册监听
3. 收到 B 目录下 file1.txt、file2.txt 的创建事件

但实际情况可能是:

text 复制代码
1. 收到新目录 /share/A/B
2. 还没来得及注册监听
3. file1.txt、file2.txt 的事件已经发生了

这时文件就漏了。

我们的处理比较朴素:发现新目录后,先注册整棵子树,然后主动扫一遍这个新目录,把里面已经存在的文件按"创建事件"补发出去。

java 复制代码
if (Files.isDirectory(child)) {
    registerAll(child);
    emitCreateForExistingFiles(child);
}
java 复制代码
private void emitCreateForExistingFiles(Path dir) {
    try (Stream<Path> pathStream = Files.walk(dir)) {
        pathStream
            .filter(Files::isRegularFile)
            .forEach(eventHandler::onCreate);
    }
}

这个逻辑看起来有点重复,但在目录批量拷贝场景下很有用。我们后来也接受了它可能带来的重复事件,因为下游会做幂等。

坑三:MODIFY 事件会比想象中多

文件写入不是一个瞬间完成的动作。

尤其是大文件、网络共享目录、Office 文档这类场景,系统可能连续抛出很多 MODIFY 事件。如果每个事件都触发一次解析和上传,资源会被打爆,处理结果也不稳定。

所以事件进来以后,我们没有立刻执行重活,而是先做了一层防抖。

java 复制代码
String debounceKey = eventType + "|" + fullPath;
long now = System.currentTimeMillis();
Long last = debounceMap.get(debounceKey);
if (last != null && now - last < windowMillis) {
    return;
}
debounceMap.put(debounceKey, now);

这里的 key 用的是"事件类型 + 文件路径"。这样同一个文件的 CREATEMODIFY 不会互相覆盖。

实际工程里,我们还会等文件稳定一小段时间再处理。否则有些文件刚创建出来,还没写完,解析线程就冲上去了。

坑四:应用重启期间一定会丢事件

WatchService 只负责运行时监听。应用停了,事件就没了,不会帮你补。

这件事在本地测试时不明显,但生产环境一定要考虑:发布重启、机器重启、服务异常退出,这些时间窗口里用户仍然可能往共享目录里放文件。

所以我们加了一套快照比对,专门做补偿。

补偿扫描时,会记录当前目录下文件的这些信息:

  • 文件路径
  • 文件大小
  • 最后修改时间
  • 文件 hash

这些快照存在 MySQL,而不是本地文件。当前虽然是单实例,但后面如果做集群,状态至少不是绑死在某台机器上。

下一次扫描时,把当前快照和上一次快照做 diff:

  • 新出现的文件,补发 CREATE
  • 大小、修改时间或 hash 变化的文件,补发 MODIFY
  • 快照里有、当前目录没有的文件,补发 DELETE

补偿扫描有两个触发点:

  • 服务启动后立刻跑一次,补重启期间的事件
  • 定时跑一次,默认 5 分钟,兜底处理监听漏掉的情况
java 复制代码
@Scheduled(fixedDelayString = "${doc.share-sync.reconcile-delay-ms:300000}")
public void scheduledReconcile() {
    reconcile("scheduled");
}

这也是为什么前面说,我们不是完全不用轮询,而是不把轮询当实时链路。

最后拆成了几个组件

为了后面好排查问题,我们没有把监听、解析、补偿都塞进一个类里,而是拆成了几个比较明确的组件。

组件 职责
ShareDirWatcher 基于 WatchService 做实时监听,负责递归注册子目录
ShareFileEventHandler 做事件防抖、路径解析,以及把文件事件转换成业务处理
ShareDirReconcileJob 做停机补偿和定时快照比对
ShareFileSnapshotStore 把文件快照持久化到 MySQL

配置上也留了开关,方便分阶段上线:

yaml 复制代码
doc:
  share-sync:
    enabled: true
    dry-run: true
    root-dir: /share
    settle-seconds: 5
    reconcile-delay-ms: 300000

第一阶段我们只开 dry-run,先把日志打全:监听到了什么文件、解析出了哪个知识库、会触发什么动作。路径解析和去重逻辑都确认没问题后,再打开真实写入。

还有几个容易忽略的小点

OVERFLOW 事件

WatchService 的事件队列满了以后,可能会收到 OVERFLOW。这说明中间已经有事件丢了。我们的处理是记录日志,然后依赖下一轮补偿扫描修复。

删除事件只能拿到路径,别指望再判断类型

收到 ENTRY_DELETE 时,文件已经没了。你能拿到的通常只是相对路径,不能再通过 Files.isDirectory 判断它之前是文件还是目录。

所以删除事件统一交给下游处理,由数据库里的历史记录判断之前是什么。

重复事件比漏事件更容易接受

监听程序和补偿程序可能同时发现同一个新文件,重复上传是有可能的。这个问题不能只靠内存去重解决,最后还是要靠数据库唯一索引和业务幂等兜底。

在这类同步任务里,我更愿意接受"重复发现一次",也不愿意悄悄漏掉一个文件。

小结

这次做下来,我对 WatchService 的定位更清楚了:它适合做实时感知,但不能单独承担"可靠同步"的全部责任。

真正能上线的方案,至少要补上这几块:

  • 递归注册子目录
  • 新目录创建后的兜底扫描
  • 文件事件防抖和稳定等待
  • 重启后的快照比对补偿
  • 下游幂等和唯一索引兜底

最后我们的方案是:监听负责快,补偿负责稳,数据库负责最终一致性。

这套设计不算复杂,但每一块都少不了。尤其是共享目录这种场景,用户怎么拷文件、网络盘怎么抛事件、服务什么时候重启,都不是代码能完全控制的。能做的就是把主链路和兜底链路都设计清楚,先 dry-run 跑一段时间,再逐步放开真实写入。

相关推荐
阿聪谈架构11 小时前
第12章:高级 RAG 技术 —— 让检索更精准、更全面
人工智能·后端
武子康11 小时前
Java-06 深入浅出 MyBatis 数据库1对1模型实战:从概念到查询实现
java·后端
日月云棠11 小时前
4 AbstractStringBuilder —— 可变字符串的骨架实现
java·后端
日月云棠12 小时前
2 Object —— Java 类体系的根节点
java·后端
瀚高PG实验室12 小时前
开发管理工具打不开No way to find ori gi nal streamhand er for jar protocol
java·数据库·jar·瀚高数据库
woniu_buhui_fei12 小时前
ArrayList核心逻辑
java·开发语言
亦暖筑序12 小时前
Spring AI Alibaba 1.1.2 实战:5种多Agent编排模式完全指南
java·spring boot·ai编程
happyprince12 小时前
05-Hugging Face Transformers 缓存系统深度分析
java·spring·缓存
大数据三康12 小时前
Java静态常量与静态导入:计算圆面积
java·开发语言