FileSystemWatcher 实务指南:应对遗漏通知与重复通知
· 小村 豪 · FileSystemWatcher, C#, .NET, Windows开发, 文件集成, 设计
FileSystemWatcher 是在 Windows 上的 .NET 中监视文件变化时首先会考虑的 API。它可以通过事件接收文件或目录的创建、修改、删除、重命名,用起来很方便,但如果把 Created 或 Changed 直接当作完成通知来使用,遗漏通知、重复通知、误读处理中文件等问题会相当常见地发生。
本文主要以 Windows 上 .NET 环境下的文件集成为前提,整理 FileSystemWatcher 的使用方法与注意事项。同时也可以参照作为前提的排他控制思路:文件集成排他控制基础知识 - 文件锁与原子式 claim 的最佳实践。
实际上,在文件复制过程中,Created 确实会先被触发,Changed 也不一定只出现一次。如果短时间内变更过于集中,内部缓冲区会溢出,从而遗漏部分变更。
因此,设计的核心思路是这样的:
- 通知只是触发信号
- 真相在于目录的重新扫描
- 所有权通过原子式 claim 来获取
- 最后用 idempotency(幂等性)来兜底
本文将按照这一思路,依次梳理把 FileSystemWatcher 集成到文件处理流程中时容易踩坑的地方。
此外,本文中出现的代码已作为一套可构建、可运行的示例(包含库、可在临时目录上运行的控制台演示,以及实际创建、修改文件来验证事件的单元测试)发布在 GitHub 上。
filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)
目录
- 先说结论(一句话)
FileSystemWatcher中容易发生的误解模式(图)- 2.1. 把
Created当作完成通知 - 2.2. 相信
Changed的次数和顺序 - 2.3. 内部缓冲区溢出导致变更丢失
- 2.1. 把
- 反模式
- 3.1. 在事件处理程序中直接处理
- 3.2. 试图从事件序列还原真实状态
- 3.3.
Changed停止后就视为完成 - 3.4. 以为提高
InternalBufferSize就能解决问题 - 3.5. 只记录
Error日志却置之不理
- 最佳实践
- 4.1. 把通知归并为「重新扫描请求」
- 4.2. 由发送方明确标示完成条件
- 4.3. 接收方以原子方式获取 claim
- 4.4. 在启动 / overflow / 重新连接时执行 full rescan
- 4.5. 以 idempotency 为前提
- 伪代码(摘录)
- 5.1. 典型的失败模式
- 5.2. 正确方向的示例(简化写法)
- 大致的使用区分
- 总结
- 参考资料
1. 先说结论(一句话)
FileSystemWatcher的事件不是完成通知,而是变化的迹象Created/Changed/Renamed可能会重复触发、以出乎意料的顺序到来,甚至在 overflow 时被遗漏- 事件处理程序不应执行繁重的处理,只堆积重新扫描请求,这样会更稳定
- 完成判定的基本做法是通过
temp -> close -> rename / replace或done/ manifest 来明确标示 - 如果存在多个 worker,就需要在读取之前以原子方式获取 claim
InternalBufferSize的调整只是辅助手段,最终起决定作用的是 full rescan 与 idempotency
总而言之,不要把 FileSystemWatcher 当作「真相的历史事件流」来使用。
把通知仅仅理解为「差不多该去看看了」的信号,会更不容易出问题。
2. 使用 FileSystemWatcher 时容易出现的误解模式(图)
2.1. 把 Created 当作完成通知
这是最容易理解的一个陷阱。在复制或传输过程中,文件 刚被创建的瞬间 就会触发 Created,之后还可能连续出现 一次或多次 Changed。
sequenceDiagram
participant 发送 as 发送方
participant 共享 as watched dir
participant W as FileSystemWatcher
participant 接收 as 接收方
发送->>共享: 创建 orders.csv
共享-->>W: Created
W-->>接收: OnCreated
接收->>共享: 打开并读取 orders.csv
Note over 接收: 还在复制过程中
发送->>共享: 写入剩余内容
共享-->>W: Changed
共享-->>W: Changed
Note over 接收: 行数不足 / JSON 损坏 / ZIP 损坏
Created 只表示「名字已经出现」,并不保证「现在可以读取」。
如果把这两者混为一谈,就相当于换了一条路,重新踩到上一篇文章 2.1 中提到的同一个坑。
2.2. 相信 Changed 的次数和顺序
Changed 不一定只出现一次。即便是移动或保存这样的普通操作,也可能被拆分成多个事件。此外,还可能捕捉到防病毒软件或索引程序触碰文件所产生的事件。
sequenceDiagram
participant App as 执行保存的应用
participant Dir as watched dir
participant AV as AV / indexer
participant W as FileSystemWatcher
App->>Dir: 开始保存 report.xlsx
Dir-->>W: Created
Dir-->>W: Changed
App->>Dir: 从临时文件 rename
Dir-->>W: Renamed
Dir-->>W: Changed
AV->>Dir: 扫描 / 读取属性
Dir-->>W: Changed
Note over W: 不一定只有 1 次,顺序也不固定
「Changed 来一次就算完成」「Renamed 之后就不会再被触碰」这类期待相当危险。
补充说明:
- 文件 rename 时也可能触发
Changed - 如果操作系统无法确定 old/new 的对应关系,
RenamedEventArgs.Name可能会变成null - hidden file 也不会被忽略。以为用隐藏的临时文件名就不会被看到,这种想法是不成立的
- 即使重命名被监视的目录本身,这一变更也不会被通知到
2.3. 内部缓冲区溢出导致变更丢失
FileSystemWatcher 内部有一个缓冲区。如果短时间内变更过于集中,这个缓冲区就会溢出,导致遗漏部分通知。
flowchart LR
A[短时间内发生大量变更] --> B[通知堆积在内部缓冲区]
B --> C{处理速度跟得上吗?}
C -- 是 --> D[依次处理各个事件]
C -- 否 --> E[overflow]
E --> F[Error 事件]
F --> G[不再信任单条历史记录的完整性]
G --> H[对目录执行 full rescan]
这里需要注意的是,「发生 overflow 只会丢失 1 条」的想法并不成立。因为整个事件序列的完整性本身都会变得不可信,所以直接对整体重新检查会更稳妥。
3. 反模式
3.1. 在事件处理程序中直接处理
这种写法把完成判定和所有权获取的责任都压在了事件处理上,负担过重。
watcher.Created += (_, e) =>
{
using var stream = File.OpenRead(e.FullPath);
Import(stream); // 可能还在复制过程中
};
watcher.Error += (_, e) =>
{
Console.WriteLine(e.GetException()); // 仅仅输出而已
};
这里存在两个问题:
- 在
Created触发的时刻,内容可能尚未完整 - 没有针对失败或 overflow 的恢复机制
事件处理程序最好只做「提出重新扫描请求后立即返回」这种程度的工作。 如果在这里就开始进行繁重的 I/O 或数据库更新,一旦出现通知突发,就会自己给自己添堵。
3.2. 试图从事件序列还原真实状态
「在 Created 时加入字典、在 Changed 时更新、在 Deleted 时删除、在 Renamed 时替换键」这种设计看起来很整洁。
但一旦出现重复、拆分、overflow 或外部干扰,逻辑就会渐渐变得站不住脚。
switch (e.ChangeType)
{
case WatcherChangeTypes.Created:
state[e.FullPath] = Pending;
break;
case WatcherChangeTypes.Changed:
state[e.FullPath] = Modified;
break;
case WatcherChangeTypes.Deleted:
state.Remove(e.FullPath);
break;
}
与在这个方向上死磕相比,每次都重新确认 磁盘上的实际状态 会更可靠。因为在文件集成中重要的是,正确找出「此刻可以处理的对象」,而不是完美还原事件历史。
3.3. Changed 停止后就视为完成
这种设计和上一篇文章中「文件大小不再变化就视为完成」的思路如出一辙。 看起来很方便,但实际上是靠猜测来判断完成的。
if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
return Ready;
}
这会带来麻烦的场景,例如:
- 大文件的复制过程中途暂停
- 发送方应用分多个阶段保存
- 网络共享导致通知看起来有延迟
- 外部进程之后又修改了属性或时间戳
完成状态应该 明确标示,而不是靠 推测,这样才更稳定。
3.4. 以为提高 InternalBufferSize 就能解决问题
调整 InternalBufferSize 固然重要,但这并不是设计的核心。
- 默认值为
8192字节 - 不能小于
4096字节,也不能超过64 KB - 缓冲区使用的是 non-paged memory,所以并不是越大越无所谓
也就是说,即使调到 64 KB 上限,只要通知的突发量超过这个数字,还是会失败。
而且,这完全不能解决「是否为完成通知」的问题。
在增大缓冲区之前,应该先做的事情是:
- 用
Filter/Filters缩小监视范围 - 把
NotifyFilter设置到必要的最小范围 - 不要轻易把
IncludeSubdirectories设为true - 简化事件处理程序
- 引入 full rescan 和 idempotency
3.5. 只记录 Error 日志却置之不理
Error 并不是那种「偶尔出现但可以不用管」的通知。
buffer overflow,以及监视本身无法继续的情况,都会体现在这里。
watcher.Error += (_, e) =>
{
_logger.LogError(e.GetException(), "watcher error");
// 如果只做到这里,明明察觉到遗漏却没有恢复机制
};
至少应该做到以下几点:
- 请求执行 full rescan
- 如果监视本身的持续性存疑,也要考虑重新创建 watcher
- 在假设会有遗漏的前提下,让重新处理具备 idempotent(幂等)特性
4. 最佳实践
4.1. 把通知归并为「重新扫描请求」
如果把 Created / Changed / Deleted / Renamed / Error 分别直接对接到各自的业务处理逻辑,整体结构就会变得难以把握。首先应该把它们全部归并为「去查看一下」这一种信号。
flowchart LR
A[Created / Changed / Deleted / Renamed] --> Q[scan request]
B[Error / overflow] --> Q
C[startup] --> Q
Q --> D[重新扫描目录]
D --> E[列举 ready 状态的候选]
E --> F[尝试获取 claim]
实现上的要点:
- 事件处理程序只需要把
dirty设为true并发出 signal - 扫描逻辑集中到一个 worker 中处理
- 通知突发时先合并等待 100~300 毫秒左右,再执行一次扫描
- 如果在扫描过程中又收到新的通知,等本次扫描结束后再扫描一次
这样一来,无论事件来了 5 次还是 50 次,最终要做的事情都可以统一为「查看实际状态,寻找 ready 的对象」。
4.2. 由发送方明确标示完成条件
如果发送方也在自己的控制范围内,与在 FileSystemWatcher 一侧死磕完成判定相比,修改双方约定的协议会更有效。
最稳妥的做法始终是这样:
- 把全部内容写入
temp命名的文件 - 执行
close - 在同一文件系统上执行
rename / replace - 如有需要,最后再放置
done/ manifest
flowchart TD
A[将全部内容写入 data.tmp] --> B[flush / close]
B --> C[rename / replace 为 data.csv]
C --> D[放置 data.done / manifest.json]
D --> E[接收方只需关注 final 文件名或 done 标记]
这和上一篇文章说的一样,但这一点确实非常有效。
把 FileSystemWatcher 理解为「尽早发现已被明确标示的完成状态」的工具,而不是「发明完成状态」的工具,会更贴切。
4.3. 接收方以原子方式获取 claim
即便通过重新扫描发现了 ready 状态的候选对象,如果直接去读取,多个 worker 可能会同时抓取到同一个对象。 因此,在处理之前应该以原子方式先获取 claim。
sequenceDiagram
participant Scan as scanner
participant IN as incoming
participant P1 as processing/worker1
participant P2 as processing/worker2
Scan->>IN: 发现 order-123
Scan->>P1: rename order-123
Scan->>P2: rename order-123
Note over P1,P2: 只有先成功的一方才能获得所有权
正如上一篇文章中提到的那样,incoming -> processing/<worker>/ 的 rename 方式比较直观易懂。
特别是如果把 本体 + manifest + 辅助文件 整合到同一个目录中,就可以按 bundle 为单位来获取 claim,会更加轻松。
incoming/
order-123/
payload.csv
manifest.json
这样一来,只需对 bundle directory 执行一次 rename,就能获得所有权。
4.4. 在启动 / overflow / 重新连接时执行 full rescan
这一点相当重要。
- 应用启动前就已经存在的文件,无法通过事件捕捉到
- 一旦发生 overflow,单条事件序列就会变得难以信任
- 如果涉及网络共享或临时断开,最好假定「期间发生的某些变化」已经遗漏
因此,至少应该在以下时机执行 full rescan:
- 启动时
- 收到
Error时 - 重新创建 watcher 之后
- 作为定期保险措施,按固定间隔执行
这里的理念是:「watcher 提供的是变化的线索,重新扫描才是恢复一致性的手段」。
4.5. 以 idempotency 为前提
使用 FileSystemWatcher 时,同一个对象很可能会被多次查看。
这并不是 bug,而是应该作为设计前提接受下来,这样才会更稳定。
具体来说,可以这样处理:
- 在 manifest 中加入
IdempotencyKey - 如果已经处理过,就不再重复执行副作用
- 让 archive 完成 / 数据库已记录 / 已发送等状态可以互相核对
- 即使执行了 full rescan,也只是「再次安全地查看同一个对象」而已
如果只靠事件本身来实现 exactly-once(精确一次),会相当吃力。 接受 at-least-once(至少一次),最后用 idempotency 来收尾,在实务中会更加稳健。
5. 伪代码(摘录)
5.1. 典型的失败模式
using var watcher = new FileSystemWatcher(incomingDir)
{
Filter = "*.csv",
IncludeSubdirectories = false,
EnableRaisingEvents = true,
InternalBufferSize = 64 * 1024
};
watcher.Created += (_, e) =>
{
// 误以为 Created = 完成通知
ProcessFile(e.FullPath);
};
watcher.Changed += (_, e) =>
{
// 因为会多次触发,姑且再处理一次
ProcessFile(e.FullPath);
};
watcher.Error += (_, e) =>
{
Console.WriteLine(e.GetException());
// 没有恢复机制
};
存在 4 个问题:
- 把
Created/Changed直接与业务处理绑定在一起 - 没有完成判定
- overflow 时没有执行 full rescan
- 没有防止同一文件被反复处理的机制
5.2. 正确方向的示例(简化写法)
private readonly SemaphoreSlim _scanSignal = new(0, int.MaxValue);
private int _scanRequested = 0;
private int _fullRescanRequested = 0;
void OnAnyChange(object? sender, FileSystemEventArgs e)
{
RequestScan(full: false);
}
void OnRenamed(object? sender, RenamedEventArgs e)
{
RequestScan(full: false);
}
void OnError(object? sender, ErrorEventArgs e)
{
Log(e.GetException());
RequestScan(full: true);
}
void RequestScan(bool full)
{
if (full)
{
Interlocked.Exchange(ref _fullRescanRequested, 1);
}
if (Interlocked.Exchange(ref _scanRequested, 1) == 0)
{
_scanSignal.Release();
}
}
async Task ScannerLoopAsync(CancellationToken cancellationToken)
{
RequestScan(full: true); // startup scan
while (!cancellationToken.IsCancellationRequested)
{
await _scanSignal.WaitAsync(cancellationToken);
// 稍微合并一下通知的突发
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
Interlocked.Exchange(ref _scanRequested, 0);
bool full = Interlocked.Exchange(ref _fullRescanRequested, 0) == 1;
foreach (var bundle in EnumerateReadyBundles(incomingDir, full))
{
var claimedPath = Path.Combine(processingDir, bundle.Name);
if (!TryClaimByRename(bundle.Path, claimedPath))
{
continue; // 已被其他 worker 先获取
}
var manifest = ReadManifest(Path.Combine(claimedPath, "manifest.json"));
if (AlreadyProcessed(manifest.IdempotencyKey))
{
MoveToArchive(claimedPath, archiveDir);
continue;
}
ProcessBundle(claimedPath);
RecordProcessed(manifest.IdempotencyKey);
MoveToArchive(claimedPath, archiveDir);
}
if (Volatile.Read(ref _scanRequested) == 1)
{
_scanSignal.Release(); // 不遗漏扫描过程中到来的通知
}
}
}
在这个例子中,重要的不是具体的 API 细节,而是整体流程:
- 把通知归并为 scan request
- 通过扫描找出 ready 的对象
- 获取 claim
- 确认 idempotency
- 处理并记录,然后移动到 archive
FileSystemWatcher 的事件在这里只不过是一个 trigger(触发器)而已。
6. 大致的使用区分
-
单一接收 worker / 自己也能修改发送方的写法 首先采用
temp -> close -> rename加 startup scan。仅凭这一点就已经相当稳定。 -
存在多个接收 worker 在上面的基础上,最好再加入
incoming -> processing的 claim rename。 -
通知频率高、数量多 缩小
Filter/NotifyFilter/IncludeSubdirectories的范围,把事件处理程序精简到最小。之后再考虑调整InternalBufferSize。 -
为 overflow 所困扰 / 不允许出现遗漏 以 full rescan 为前提来设计,如果这样仍然不够,就不要把全部赌注押在
FileSystemWatcher单独一个机制上。如果限定在 Windows 环境,USN change journal 也是一个可选方案。 -
无法控制对方系统的写入方式 与靠猜测来补全完成条件相比,先考虑能否协商出一套公开协议会更安全。如果做不到,就降低保证等级,转向以 idempotent 方式接收的设计。
最后两项其实是相当重要的撤退判断。
FileSystemWatcher 很方便,但并不是万能的真相探测器。
7. 总结
FileSystemWatcher 不能替代完成通知。真相不在事件序列中,而在于此刻磁盘上能看到的实际状态。完成状态应通过 temp -> close -> rename / replace 或 done / manifest 来明确标示,所有权则通过原子式获取 claim 来确定。这才是设计的核心所在。
在 Created 时立即处理、相信 Changed 的次数或顺序、Changed 停止后就视为完成、只靠调大 InternalBufferSize 来安心、看到 Error 却没有恢复机制——这些都是应该避免的设计。取而代之的做法是:把通知归并为重新扫描请求,在启动 / overflow / 重新连接时执行 full rescan,通过 claim rename 获取所有权,并用 idempotency 来兜底重复和重新扫描。
也就是说,在使用 FileSystemWatcher 时,关键在于不要把「收到了事件」和「可以处理了」混为一谈。
只要把这两者分开,那种偶尔才出问题的监视处理故障就会大幅减少。
8. 参考资料
- 本文的示例代码一套(库、演示、单元测试) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/filesystemwatcher-safe-basics
- 相关文章:文件集成排他控制基础知识 - 文件锁与原子式 claim 的最佳实践
- FileSystemWatcher Class (System.IO)
- System.IO.FileSystemWatcher class - .NET
- FileSystemWatcher.InternalBufferSize Property (System.IO)
- FileSystemWatcher.Error Event (System.IO)
- FileSystemWatcher.Created Event (System.IO)
- FileSystemWatcher.Changed Event (System.IO)
- FileSystemWatcher.Renamed Event (System.IO)
- Change Journals - Win32 apps
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
在桌面应用中使用 .NET Generic Host 与 BackgroundService 的理由
本文整理在 Windows 工具或常驻应用中,如何使用 Generic Host 与 BackgroundService 来梳理启动、定期处理、退出处理、日志、配置与 DI。
如何理解 Windows 的会话隔离 ── Session 0・RDP・多用户同时运行
整理 Windows 应用程序开发者容易混淆的「会话」概念。说明服务无法显示 UI 的 Session 0 隔离原因、RDP 连接时会话的行为、命名对象的会话隔离,以及共享 PC・RDS 环境中常见的设计失误,从实务角度解说。
在 C# 中安全调用 Win32 API —— P/Invoke 实务指南(DllImport / LibraryImport / CsWin32)
本文整理在 C# 中通过 P/Invoke 调用 Win32 API 或原生 DLL 时的实务要点:DllImport 与 LibraryImport 的区别、用 CsWin32 自动生成签名、字符串封送的陷阱、用 SafeHandle 管理句柄、SetLastError ...
Windows 的进程间通信该怎么选 ── 命名管道 / TCP / gRPC / 共享内存 / COM 判断表
整理 Windows 应用程序之间应该如何选择通信方式。用判断表整理命名管道、本地 TCP、gRPC、共享内存、文件对接、COM 各自的优势与陷阱,并从实务角度说明 UI+服务分离・32bit/64bit 桥接・权限边界等常见架构,以及命名管道的实现示例。
Windows应用的数据存储位置怎么选 ── SQLite / JSON / 注册表 / Access 判断表
Windows桌面应用的数据该存在哪里、用什么格式保存?本文整理AppData/ProgramData的使用区分,以及SQLite、JSON文件、注册表、Access(.accdb)各自的优势与陷阱,并附判断表,从实务角度讲解防止数据损坏与位数问题等注意事项。
常见问题
汇总了咨询这一主题时常见的问题。
- 可以在 FileSystemWatcher 的 Created 事件中读取文件吗?
- 不可以。Created 只表示「名字已经出现」,并不保证「现在可以读取」。在复制或传输过程中,文件刚被创建的瞬间就会触发 Created,之后还可能连续出现一次或多次 Changed。完成状态应由发送方通过 temp -> close -> rename/replace 或 done/manifest 明确标示,接收方基本上只应关注 final 文件名或 done 标记。
- FileSystemWatcher 会遗漏通知吗?
- 会。内部缓冲区(默认 8192 字节,不能小于 4096 字节,上限为 64KB)一旦溢出,就会遗漏部分通知并触发 Error 事件。一旦发生 overflow,整个事件序列的完整性都会变得不可信,因此对目录进行 full rescan(完全重新扫描)来整体校验是比较安全的做法。建议在启动时、收到 Error 时、重新创建 watcher 之后,以及作为定期保险措施时都执行 full rescan。
- 为什么 Changed 事件会多次触发?
- 因为即便是移动或保存这类普通操作,也可能被拆分成多个事件,而且还会捕捉到防病毒软件或索引程序触碰文件所产生的事件。依赖触发次数或顺序的设计是危险的。稳妥的做法是把通知统一归并为「请求重新扫描」这一种信号,扫描逻辑集中到一个 worker 中处理,并在通知突发时先合并等待 100~300 毫秒左右,再执行一次扫描。
- 增大 InternalBufferSize 能解决遗漏问题吗?
- 不能。即使调大到 64KB 上限,只要通知的突发量超过这个上限,仍然会遗漏,而且这完全不能解决「是否为完成通知」的问题。缓冲区使用的是 non-paged memory,并不是越大越无所谓。正确的顺序应该是:先用 Filter / NotifyFilter 缩小监视范围,重新审视 IncludeSubdirectories,简化事件处理逻辑,再引入 full rescan 与 idempotency。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。