我们之前的知识库平台,文档入口都在平台内:用户上传文件,系统解析内容,写入向量库,后面再拿来做 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 用的是"事件类型 + 文件路径"。这样同一个文件的 CREATE 和 MODIFY 不会互相覆盖。
实际工程里,我们还会等文件稳定一小段时间再处理。否则有些文件刚创建出来,还没写完,解析线程就冲上去了。
坑四:应用重启期间一定会丢事件
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 跑一段时间,再逐步放开真实写入。