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


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

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

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

    一开始为什么没用轮询

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

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

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

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

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

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

    WatchService 基本够用,但别只看 Demo

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

    最简单的写法确实很短:

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

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

    WatchKey key = watchService.take();
    for (WatchEvent event : key.pollEvents()) {
        // 处理事件
    }
    key.reset();
    

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

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

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

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

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

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

    /share/知识库名/文档
    /share/知识库名/子目录/文档
    

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

    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. 收到新目录 /share/A/B
    2. 给 /share/A/B 注册监听
    3. 收到 B 目录下 file1.txt、file2.txt 的创建事件
    

    但实际情况可能是:

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

    这时文件就漏了。

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

    if (Files.isDirectory(child)) {
        registerAll(child);
        emitCreateForExistingFiles(child);
    }
    
    private void emitCreateForExistingFiles(Path dir) {
        try (Stream pathStream = Files.walk(dir)) {
            pathStream
                .filter(Files::isRegularFile)
                .forEach(eventHandler::onCreate);
        }
    }
    

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

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

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

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

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

    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 分钟,兜底处理监听漏掉的情况
    @Scheduled(fixedDelayString = "${doc.share-sync.reconcile-delay-ms:300000}")
    public void scheduledReconcile() {
        reconcile("scheduled");
    }
    

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

    最后拆成了几个组件

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

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

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

    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 跑一段时间,再逐步放开真实写入。

  • 相关阅读:
    Sentry安装配置与使用
    【Vue】使用 transition 标签实现单元素组件的过渡和动画效果(1)
    12 | 使用 Spark SQL执行CURL
    拥抱国产信创化,思迈特与云和恩墨完成产品兼容认证
    智能小区与无线网络技术
    MobPush Android 快速集成
    ANSI/UL 94 5-V级垂直燃烧试验
    React基础语法
    免费在线pdf处理工具:pdf文件压缩;pdf文件转word
    通过webkitAnimationEnd实现轮播动画
  • 原文地址:https://www.cnblogs.com/jucunqi/p/20170622