目录文件发现与处理方案总结

1. 问题定义

很多系统都会把本地目录当作输入通道:

  • 外部系统把文件投递到指定目录
  • 本地服务发现新文件
  • 读取、解析、发送或转换
  • 成功后归档、备份或删除

这类系统表面上是在做“监听目录新增文件”,但真正的问题通常不是“怎么收到一个创建事件”,而是下面这几个更关键的问题:

  • 如何发现应该处理的文件
  • 如何避免读到半写入文件
  • 如何避免漏处理
  • 如何避免重复处理
  • 服务重启或宕机后如何恢复
  • 新增子目录时如何继续工作

围绕这些问题,常见实现方案主要有三类:

  1. 基于文件系统事件的实时监听
  2. 基于定时任务的轮询扫描
  3. Watcher 和轮询结合的混合方案

本文只讨论实现方案本身,不依赖任何具体项目背景。

2. 设计这类系统时要先明确的判断维度

在决定用什么方案之前,先不要急着选技术栈,应该先回答几个问题。

2.1 你追求的是“实时性”还是“最终不漏”

如果业务要求文件一落盘就立刻处理,实时监听优先。

如果业务更在意:

  • 文件迟一点处理没关系
  • 但绝对不能漏
  • 服务重启后要能补处理

那么轮询往往更合适。

2.2 输入目录是否会动态变化

如果目录结构固定,例如始终只有一个稳定目录,那么 watcher 非常直接。

如果运行过程中会不断新增:

  • 企业目录
  • 日期目录
  • 分区目录
  • 子业务目录

那么单纯 watcher 往往会遇到“新目录没有被纳入监听”的问题。

2.3 文件是一次性原子落盘,还是长时间写入

如果文件是先写临时名,写完后再原子 rename,处理最简单。

如果文件是直接写入目标文件名,而且写入过程持续几秒到几分钟,那么无论是 watcher 还是轮询,都必须解决“文件还没写完就被处理”的问题。

2.4 目录是否承担“待处理队列”的角色

有些系统中,目录只是触发源。

有些系统中,目录本身就是事实上的待处理任务池:

  • 文件还在目录里,表示还没完成
  • 文件被移动走,表示处理完成

后一种模型更适合用轮询或混合方案,因为它天然带有可见的状态。

3. 方案一:基于 Watcher 的实时监听

3.1 基本思路

实时监听依赖操作系统的文件系统事件机制。

在 Java 生态里,常见实现包括:

  • JDK WatchService
  • Hutool WatchMonitor / SimpleWatcher
  • Apache Commons IO 的文件监控组件

其核心流程通常是:

1
2
3
4
5
6
注册目录监听
-> 收到 create / modify / move 事件
-> 过滤目标文件
-> 等待文件可读或写入稳定
-> 执行业务处理
-> 成功后归档

3.2 Hutool SimpleWatcher 的典型用法

Hutool 对 JDK watcher 做了简化封装,使用方式比较直接。

典型结构如下:

1
2
3
4
5
6
7
8
9
10
File dir = FileUtil.file(basePath);
WatchMonitor monitor = WatchMonitor.create(dir, Integer.MAX_VALUE, WatchMonitor.ENTRY_CREATE);
monitor.setWatcher(new SimpleWatcher() {
@Override
public void onCreate(WatchEvent<?> event, Path currentPath) {
String fullPath = currentPath + File.separator + event.context();
// 过滤、等待写完、处理文件
}
});
monitor.start();

如果只需要监听一个固定目录中的文件新增,这种方式非常轻量。

3.3 Watcher 方案的优点

  • 实时性好
  • 平时资源消耗低
  • 不需要定时扫描整个目录树
  • 对单目录、小规模输入非常合适

如果场景是:

  • 目录固定
  • 文件量适中
  • 对秒级触发敏感
  • 服务始终在线

那么 watcher 往往是最自然的选择。

3.4 Watcher 方案的典型难点

3.4.1 新增子目录可能无法自动覆盖

这是 watcher 方案里最常见的问题之一。

很多实现只会监听“注册当下已经存在的目录”。如果运行过程中又新建了子目录,新目录里的文件未必会自动被监听到。

这会导致你不得不增加额外逻辑:

  • 监听目录创建事件
  • 识别这是一个新子目录
  • 再给它单独注册 watcher

一旦补注册失败,后续这个目录里的文件可能就彻底漏掉。

3.4.2 只能感知事件,不天然保证补偿

watcher 擅长回答的问题是:

“刚才发生了什么事件?”

但业务真正关心的往往是:

“现在还有哪些文件没处理?”

如果服务启动前文件就已经存在,或者服务宕机期间发生了文件投递,仅依赖 watcher 无法天然补偿这些文件。

3.4.3 文件创建事件不代表文件已经写完

收到 create 事件时,文件可能只是刚被创建:

  • 文件内容还没写完
  • 文件大小还在增长
  • 生产方还没 flush / close

如果立即读取,极容易得到半截数据。

所以 watcher 不等于“立刻可处理”。

3.4.4 对异常恢复更敏感

watcher 依赖一条持续在线的事件链路。

如果出现以下情况:

  • 监听器未正确初始化
  • 线程异常退出
  • 新目录漏注册
  • 系统重启期间事件丢失

系统就需要额外恢复机制。

4. 方案二:基于轮询的目录扫描

4.1 基本思路

轮询方案不关心“刚刚发生过什么事件”,而是周期性查看目录当前状态。

典型流程如下:

1
2
3
4
5
6
定时执行扫描
-> 递归列出目标目录中的文件
-> 过滤符合条件的文件
-> 判断文件是否写入完成
-> 执行业务处理
-> 成功后移动到归档目录

在 Java 中,这类实现通常会组合:

  • Spring @Scheduled
  • Files.walk(...)
  • Hutool FileUtil.loopFiles(...)

4.2 轮询方案的典型实现

一个简化版本通常类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Scheduled(fixedRate = 60_000)
public void scan() {
List<File> files = FileUtil.loopFiles(basePath);
for (File file : files) {
if (!file.getName().toUpperCase().endsWith(".XML")) {
continue;
}
if (!isReady(file)) {
continue;
}
process(file);
}
}

其中 process(file) 一般包括:

  • 读取内容
  • 发送或解析
  • 成功后移动文件

4.3 轮询方案的优点

4.3.1 对新增目录天然友好

只要扫描时能遍历到它,就能处理。

不需要关心目录是什么时候创建的,也不需要为每个新增目录动态注册新的 watcher。

4.3.2 天然具备补偿能力

轮询处理的是“当前还存在的文件”,因此天然支持:

  • 启动前遗留文件
  • 宕机期间积压文件
  • 某次处理失败后的后续重试

只要文件仍然保留在待处理目录,后续扫描就还能看到它。

4.3.3 更符合“目录即任务池”的模型

如果目录本身就是待处理任务池,那么轮询非常贴合这个模型:

1
2
文件在目录中 = 任务未完成
文件移走 = 任务完成

这个模型对运维和排查也更直观。

4.3.4 实现简单,状态容易理解

轮询的实现通常没有复杂的监听生命周期管理。

它更多依赖:

  • 扫描周期
  • 文件过滤规则
  • 幂等处理
  • 成功后迁移

整体链路更容易排查。

4.4 轮询方案的代价

4.4.1 实时性受扫描周期限制

如果每分钟扫描一次,那么最坏情况下文件可能要等接近一分钟才会被处理。

这对高实时性场景可能不可接受。

4.4.2 全量扫描可能带来 IO 压力

如果目录树很大,且扫描策略过于粗放,例如每次都递归整个根目录,那么会产生额外 IO 开销。

因此轮询方案往往需要在下面几项上做优化:

  • 缩小扫描范围
  • 按业务目录分片扫描
  • 限制单次处理数
  • 给归档目录做清理

4.4.3 去重与状态控制要设计清楚

轮询天然会重复看到同一个文件,所以必须明确:

  • 什么叫“处理成功”
  • 成功后如何移出待处理区
  • 失败是否保留原文件
  • 如何避免重复发送

如果这些语义没处理好,轮询就会变成反复重试或误重复处理。

5. 文件未写完时如何处理

这是 watcher 和轮询都绕不过去的问题。

5.1 方案 A:检查文件大小稳定

最常见的做法是:

  1. 读取当前文件大小
  2. 等待一段时间
  3. 再读取一次
  4. 如果大小不再变化,则认为写入完成

示例:

1
2
3
4
5
private boolean isReady(File file) throws InterruptedException {
long size = file.length();
Thread.sleep(1000);
return size == file.length();
}

优点是简单。

缺点是:

  • 对超慢写入文件不够稳
  • 只能基于经验等待
  • 无法彻底证明生产方已经完成

5.2 方案 B:生产方先写临时文件,完成后 rename

这是更推荐的做法。

例如:

  • 先写 a.xml.tmp
  • 写完后原子重命名为 a.xml

消费方只处理正式后缀文件。

优点:

  • 语义清晰
  • 处理方无需猜测文件是否写完
  • 比“检查文件大小稳定”更可靠

如果可以控制生产方,这是首选方案。

5.3 方案 C:文件锁或完成标记

还可以采用:

  • 写入完成后生成 .ok 文件
  • 使用锁文件
  • 写入完成后更新数据库状态

这类方案比轮询文件大小更明确,但实现复杂度也更高。

6. 更稳妥的目录处理模型

如果要把这类系统做得更稳,仅仅讨论“是 watcher 还是轮询”还不够。更重要的是把处理模型设计清楚。

推荐的目录状态模型如下:

1
2
3
4
inbox/       待处理目录
processing/ 处理中目录
done/ 成功归档目录
failed/ 失败目录

处理流程:

  1. 扫描 inbox/
  2. 原子移动文件到 processing/
  3. 执行业务处理
  4. 成功后移动到 done/
  5. 失败后移动到 failed/ 或回退到 inbox/

这个模型比“在原目录直接读完再处理”更稳,原因有几点:

  • 能显式表达处理中状态
  • 减少并发重复消费风险
  • 便于人工排查
  • 更容易支持失败重试

如果系统是多实例部署,这种状态目录分层尤其有价值。

7. 更好的方案:Watcher 和轮询结合

实际工程里,一个很常见的更优解不是二选一,而是组合使用。

7.1 混合方案的思路

可以这样设计:

  • watcher 负责尽快感知新文件,提升实时性
  • 轮询负责兜底扫描,提升补偿能力

即:

1
2
watcher = 快路径
polling = 补偿路径

7.2 混合方案的典型结构

1
2
3
4
5
6
收到 watcher 事件
-> 立即尝试处理

定时轮询
-> 扫描所有仍留在待处理目录中的文件
-> 补处理 watcher 漏掉或失败的文件

这样做的好处是:

  • 正常情况下,文件几乎实时处理
  • 即使 watcher 漏事件,也还有轮询兜底
  • 服务重启后也能补扫描历史文件

如果你既想要实时性,又不想把可靠性全部压在 watcher 上,这是比单独 watcher 更稳的方案。

8. 更好的方案:把目录发现和业务处理解耦

更进一步的做法,是不要把“发现文件”和“执行业务”耦合在同一条线程里。

更稳的结构是:

  1. 目录发现器负责找文件
  2. 发现后先写入本地任务表、数据库或内存队列
  3. 业务处理器异步消费这些任务

即:

1
2
3
4
5
目录发现
-> 生成任务
-> 任务持久化
-> 异步消费任务
-> 文件归档

这种方式的价值在于:

  • 文件发现和业务处理失败可以分开治理
  • 能更容易做重试、限流、并发控制
  • 可以记录完整处理状态
  • 比“发现后立刻同步发送”更适合复杂系统

如果系统处理链比较重,例如:

  • 发送 MQ
  • 调外部接口
  • 内容解析耗时长
  • 存在多步事务

那么这一层任务化解耦很值得做。

9. 幂等性是所有方案的底层前提

无论使用 watcher、轮询还是混合方案,都不能假设“每个文件只会被处理一次”。

真实系统中经常发生:

  • watcher 重复事件
  • 轮询重复扫描
  • 处理成功但归档失败
  • 服务重启后重复补处理

因此必须设计幂等。

常见做法包括:

  • 按文件名做去重
  • 按文件内容 hash 做去重
  • 给每个文件分配业务唯一键
  • 在数据库中记录处理状态

如果没有幂等,任何目录处理方案都不稳。

10. 什么时候选 watcher

优先选择 watcher 的场景:

  • 目录结构固定
  • 输入量可控
  • 追求秒级甚至亚秒级响应
  • 可以接受额外处理新目录注册和异常恢复
  • 对历史遗留文件补偿要求不高,或已有其他补偿机制

典型例子:

  • 配置热加载
  • 单目录接收器
  • 本机实时导入

11. 什么时候选轮询

优先选择轮询的场景:

  • 动态目录很多
  • 更关心不漏处理
  • 服务重启后必须补处理遗留文件
  • 输入目录本身就是待处理池
  • 可以接受秒级到分钟级延迟

典型例子:

  • 批量文件投递
  • 共享目录集成
  • 文件交换平台
  • 归档驱动型流程

12. 什么时候选混合方案

优先选择 watcher + 轮询混合方案的场景:

  • 既要尽可能实时
  • 又不能接受 watcher 漏事件
  • 输入目录重要,且存在服务重启补偿需求
  • 有能力维护稍高一点的实现复杂度

这通常是最平衡的生产级选择。

13. 实现时最容易忽略的几个坑

13.1 用 contains() 判断路径

很多实现会直接写:

1
2
3
if (fullPath.contains("SendXml")) {
...
}

这种判断过于宽松,容易误匹配。

更稳的做法是基于路径层级判断,而不是字符串包含。

13.2 发送失败却仍然归档

这是非常常见的 bug:

  • 业务发送失败
  • 但代码仍然把文件移出待处理目录
  • 结果导致数据实际丢失

一定要把“处理成功”与“归档”严格绑定。

13.3 扫描范围过大

如果每次递归整个根目录,随着归档文件增加,扫描成本会越来越高。

应该尽量只扫待处理目录,避免把归档目录、失败目录也反复扫进去。

13.4 没有处理中状态

如果文件被发现后仍长期留在原目录,多个线程或多个实例都可能重复处理。

至少要有以下机制之一:

  • 原子 rename 到 processing
  • 文件锁
  • 数据库抢占

13.5 把目录发现和业务发送绑死在一起

如果发现到文件后立即同步发送 MQ 或调用远程接口,那么一旦业务链路阻塞,目录发现本身也会被拖慢。

更好的做法是先转成任务,再异步处理。

14. 一个更推荐的通用落地方案

如果没有特别极端的实时性要求,一个更稳妥的通用方案通常是:

  1. 待处理目录使用固定层级结构
  2. 生产方采用“临时文件写入完成后 rename”为正式文件
  3. 消费方采用轮询扫描 inbox/
  4. 发现文件后先原子移动到 processing/
  5. 处理成功后移动到 done/
  6. 失败进入 failed/
  7. 增加数据库幂等表或处理记录
  8. 可选地再补一个 watcher 作为快路径

这个方案的特点是:

  • 行为清晰
  • 易于排查
  • 容易补偿
  • 对重启和异常更友好

在大多数“文件作为系统集成边界”的场景里,这比单纯依赖 watcher 更稳。

15. 最终结论

“监听目录新增文件”本质上不是一个单纯的 API 选型问题,而是一个可靠性设计问题。

如果只从技术表面看:

  • watcher 更实时
  • 轮询更简单

但从工程角度看,真正决定方案优劣的,是这些能力是否完整:

  • 能否处理动态目录
  • 能否补偿历史文件
  • 能否识别文件写完
  • 能否避免重复处理
  • 能否在失败后恢复
  • 能否支持人工排查

因此:

  • 追求低延迟、目录固定时,优先 watcher
  • 追求稳定补偿、目录动态变化时,优先轮询
  • 追求生产级平衡时,优先考虑 watcher + 轮询 + 状态目录 + 幂等 的组合方案

一句话总结:

目录处理系统最重要的不是“如何尽快收到一个文件事件”,而是“如何稳定地把每个应该处理的文件处理到位”。


目录文件发现与处理方案总结
http://blog.jingxiang.ltd/2026/04/15/目录扫描与文件监听方案总结/
作者
yemangran
发布于
2026年4月15日
许可协议