目录文件发现与处理方案总结
1. 问题定义
很多系统都会把本地目录当作输入通道:
- 外部系统把文件投递到指定目录
- 本地服务发现新文件
- 读取、解析、发送或转换
- 成功后归档、备份或删除
这类系统表面上是在做“监听目录新增文件”,但真正的问题通常不是“怎么收到一个创建事件”,而是下面这几个更关键的问题:
- 如何发现应该处理的文件
- 如何避免读到半写入文件
- 如何避免漏处理
- 如何避免重复处理
- 服务重启或宕机后如何恢复
- 新增子目录时如何继续工作
围绕这些问题,常见实现方案主要有三类:
- 基于文件系统事件的实时监听
- 基于定时任务的轮询扫描
- 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 | |
3.2 Hutool SimpleWatcher 的典型用法
Hutool 对 JDK watcher 做了简化封装,使用方式比较直接。
典型结构如下:
1 | |
如果只需要监听一个固定目录中的文件新增,这种方式非常轻量。
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 | |
在 Java 中,这类实现通常会组合:
- Spring
@Scheduled Files.walk(...)- Hutool
FileUtil.loopFiles(...)
4.2 轮询方案的典型实现
一个简化版本通常类似这样:
1 | |
其中 process(file) 一般包括:
- 读取内容
- 发送或解析
- 成功后移动文件
4.3 轮询方案的优点
4.3.1 对新增目录天然友好
只要扫描时能遍历到它,就能处理。
不需要关心目录是什么时候创建的,也不需要为每个新增目录动态注册新的 watcher。
4.3.2 天然具备补偿能力
轮询处理的是“当前还存在的文件”,因此天然支持:
- 启动前遗留文件
- 宕机期间积压文件
- 某次处理失败后的后续重试
只要文件仍然保留在待处理目录,后续扫描就还能看到它。
4.3.3 更符合“目录即任务池”的模型
如果目录本身就是待处理任务池,那么轮询非常贴合这个模型:
1 | |
这个模型对运维和排查也更直观。
4.3.4 实现简单,状态容易理解
轮询的实现通常没有复杂的监听生命周期管理。
它更多依赖:
- 扫描周期
- 文件过滤规则
- 幂等处理
- 成功后迁移
整体链路更容易排查。
4.4 轮询方案的代价
4.4.1 实时性受扫描周期限制
如果每分钟扫描一次,那么最坏情况下文件可能要等接近一分钟才会被处理。
这对高实时性场景可能不可接受。
4.4.2 全量扫描可能带来 IO 压力
如果目录树很大,且扫描策略过于粗放,例如每次都递归整个根目录,那么会产生额外 IO 开销。
因此轮询方案往往需要在下面几项上做优化:
- 缩小扫描范围
- 按业务目录分片扫描
- 限制单次处理数
- 给归档目录做清理
4.4.3 去重与状态控制要设计清楚
轮询天然会重复看到同一个文件,所以必须明确:
- 什么叫“处理成功”
- 成功后如何移出待处理区
- 失败是否保留原文件
- 如何避免重复发送
如果这些语义没处理好,轮询就会变成反复重试或误重复处理。
5. 文件未写完时如何处理
这是 watcher 和轮询都绕不过去的问题。
5.1 方案 A:检查文件大小稳定
最常见的做法是:
- 读取当前文件大小
- 等待一段时间
- 再读取一次
- 如果大小不再变化,则认为写入完成
示例:
1 | |
优点是简单。
缺点是:
- 对超慢写入文件不够稳
- 只能基于经验等待
- 无法彻底证明生产方已经完成
5.2 方案 B:生产方先写临时文件,完成后 rename
这是更推荐的做法。
例如:
- 先写
a.xml.tmp - 写完后原子重命名为
a.xml
消费方只处理正式后缀文件。
优点:
- 语义清晰
- 处理方无需猜测文件是否写完
- 比“检查文件大小稳定”更可靠
如果可以控制生产方,这是首选方案。
5.3 方案 C:文件锁或完成标记
还可以采用:
- 写入完成后生成
.ok文件 - 使用锁文件
- 写入完成后更新数据库状态
这类方案比轮询文件大小更明确,但实现复杂度也更高。
6. 更稳妥的目录处理模型
如果要把这类系统做得更稳,仅仅讨论“是 watcher 还是轮询”还不够。更重要的是把处理模型设计清楚。
推荐的目录状态模型如下:
1 | |
处理流程:
- 扫描
inbox/ - 原子移动文件到
processing/ - 执行业务处理
- 成功后移动到
done/ - 失败后移动到
failed/或回退到inbox/
这个模型比“在原目录直接读完再处理”更稳,原因有几点:
- 能显式表达处理中状态
- 减少并发重复消费风险
- 便于人工排查
- 更容易支持失败重试
如果系统是多实例部署,这种状态目录分层尤其有价值。
7. 更好的方案:Watcher 和轮询结合
实际工程里,一个很常见的更优解不是二选一,而是组合使用。
7.1 混合方案的思路
可以这样设计:
- watcher 负责尽快感知新文件,提升实时性
- 轮询负责兜底扫描,提升补偿能力
即:
1 | |
7.2 混合方案的典型结构
1 | |
这样做的好处是:
- 正常情况下,文件几乎实时处理
- 即使 watcher 漏事件,也还有轮询兜底
- 服务重启后也能补扫描历史文件
如果你既想要实时性,又不想把可靠性全部压在 watcher 上,这是比单独 watcher 更稳的方案。
8. 更好的方案:把目录发现和业务处理解耦
更进一步的做法,是不要把“发现文件”和“执行业务”耦合在同一条线程里。
更稳的结构是:
- 目录发现器负责找文件
- 发现后先写入本地任务表、数据库或内存队列
- 业务处理器异步消费这些任务
即:
1 | |
这种方式的价值在于:
- 文件发现和业务处理失败可以分开治理
- 能更容易做重试、限流、并发控制
- 可以记录完整处理状态
- 比“发现后立刻同步发送”更适合复杂系统
如果系统处理链比较重,例如:
- 发送 MQ
- 调外部接口
- 内容解析耗时长
- 存在多步事务
那么这一层任务化解耦很值得做。
9. 幂等性是所有方案的底层前提
无论使用 watcher、轮询还是混合方案,都不能假设“每个文件只会被处理一次”。
真实系统中经常发生:
- watcher 重复事件
- 轮询重复扫描
- 处理成功但归档失败
- 服务重启后重复补处理
因此必须设计幂等。
常见做法包括:
- 按文件名做去重
- 按文件内容 hash 做去重
- 给每个文件分配业务唯一键
- 在数据库中记录处理状态
如果没有幂等,任何目录处理方案都不稳。
10. 什么时候选 watcher
优先选择 watcher 的场景:
- 目录结构固定
- 输入量可控
- 追求秒级甚至亚秒级响应
- 可以接受额外处理新目录注册和异常恢复
- 对历史遗留文件补偿要求不高,或已有其他补偿机制
典型例子:
- 配置热加载
- 单目录接收器
- 本机实时导入
11. 什么时候选轮询
优先选择轮询的场景:
- 动态目录很多
- 更关心不漏处理
- 服务重启后必须补处理遗留文件
- 输入目录本身就是待处理池
- 可以接受秒级到分钟级延迟
典型例子:
- 批量文件投递
- 共享目录集成
- 文件交换平台
- 归档驱动型流程
12. 什么时候选混合方案
优先选择 watcher + 轮询混合方案的场景:
- 既要尽可能实时
- 又不能接受 watcher 漏事件
- 输入目录重要,且存在服务重启补偿需求
- 有能力维护稍高一点的实现复杂度
这通常是最平衡的生产级选择。
13. 实现时最容易忽略的几个坑
13.1 用 contains() 判断路径
很多实现会直接写:
1 | |
这种判断过于宽松,容易误匹配。
更稳的做法是基于路径层级判断,而不是字符串包含。
13.2 发送失败却仍然归档
这是非常常见的 bug:
- 业务发送失败
- 但代码仍然把文件移出待处理目录
- 结果导致数据实际丢失
一定要把“处理成功”与“归档”严格绑定。
13.3 扫描范围过大
如果每次递归整个根目录,随着归档文件增加,扫描成本会越来越高。
应该尽量只扫待处理目录,避免把归档目录、失败目录也反复扫进去。
13.4 没有处理中状态
如果文件被发现后仍长期留在原目录,多个线程或多个实例都可能重复处理。
至少要有以下机制之一:
- 原子 rename 到
processing - 文件锁
- 数据库抢占
13.5 把目录发现和业务发送绑死在一起
如果发现到文件后立即同步发送 MQ 或调用远程接口,那么一旦业务链路阻塞,目录发现本身也会被拖慢。
更好的做法是先转成任务,再异步处理。
14. 一个更推荐的通用落地方案
如果没有特别极端的实时性要求,一个更稳妥的通用方案通常是:
- 待处理目录使用固定层级结构
- 生产方采用“临时文件写入完成后 rename”为正式文件
- 消费方采用轮询扫描
inbox/ - 发现文件后先原子移动到
processing/ - 处理成功后移动到
done/ - 失败进入
failed/ - 增加数据库幂等表或处理记录
- 可选地再补一个 watcher 作为快路径
这个方案的特点是:
- 行为清晰
- 易于排查
- 容易补偿
- 对重启和异常更友好
在大多数“文件作为系统集成边界”的场景里,这比单纯依赖 watcher 更稳。
15. 最终结论
“监听目录新增文件”本质上不是一个单纯的 API 选型问题,而是一个可靠性设计问题。
如果只从技术表面看:
- watcher 更实时
- 轮询更简单
但从工程角度看,真正决定方案优劣的,是这些能力是否完整:
- 能否处理动态目录
- 能否补偿历史文件
- 能否识别文件写完
- 能否避免重复处理
- 能否在失败后恢复
- 能否支持人工排查
因此:
- 追求低延迟、目录固定时,优先 watcher
- 追求稳定补偿、目录动态变化时,优先轮询
- 追求生产级平衡时,优先考虑 watcher + 轮询 + 状态目录 + 幂等 的组合方案
一句话总结:
目录处理系统最重要的不是“如何尽快收到一个文件事件”,而是“如何稳定地把每个应该处理的文件处理到位”。