项目背景:
我们有一个知识库平台,之前都是用户在平台上上传文档,然后执行解析向量数据库,用于后续执行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 拷贝一整个目录进来,操作系统产生的事件顺序可能是:
ENTRY_CREATE—— 新目录/share/A/BENTRY_CREATE—— 目录下的文件file1.txtENTRY_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 是内存态的,应用一重启,期间发生的所有文件变更都感知不到了。这对于生产环境是不可接受的。
我们的补偿方案是快照比对:
- 每次补偿扫描时,把当前目录下所有文件的路径、大小、最后修改时间文件hash记录下来,存到 db
- 下次扫描时加载上次的快照,和当前状态做 diff
- 新出现的文件 → 补发 CREATE
- 大小或修改时间变化的 → 补发 MODIFY
- 快照里有但现在没了的 → 补发 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,把日志打全、路径解析逻辑验证清楚,第二阶段再放开真实写入,这样上线心里比较有底。
评论 (0)