引用本文(DOI: 10.5281/zenodo.21615430)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《FileSystemWatcher 实务指南:应对遗漏通知与重复通知》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615430 https://comcomponent.com/zh-CN/blog/2026/03/10/000-filesystemwatcher-safe-basics/
- DOI(最新版本)
- 10.5281/zenodo.21615430
- DOI(此版本)
- 10.5281/zenodo.22281932
FileSystemWatcher 是在 Windows 上用 .NET 监视文件变化时首先会考虑的 API。它可以通过事件接收文件或目录的创建、修改、删除、重命名,用起来很方便,但如果把 Created 或 Changed 直接当作完成通知来使用,遗漏通知、重复通知、误读写入途中的文件这类问题会相当常见。
本文主要以 Windows 上 .NET 环境下的文件集成为前提,整理 FileSystemWatcher 的使用方法与注意事项。同时也把作为前提的排他控制思路整理成了可以一并参照的形式:文件集成排他控制基础知识 - 文件锁与原子式 claim 的最佳实践。
实际上,在文件复制过程中 Created 确实会先被触发,Changed 也不一定只出现一次。如果短时间内变更过于集中,内部缓冲区会溢出,从而遗漏部分变更。
因此,设计的核心思路是这样的。
- 通知只是触发信号
- 真相在于目录的重新扫描
- 所有权通过原子式 claim 来获取
- 最后用 idempotency 来兜底
正文将按照这一思路,依次梳理把 FileSystemWatcher 集成到文件集成流程中时容易踩坑的地方。
另外,本文中出现的代码已作为一套可构建、可运行的示例(库、可在临时目录上运行的控制台演示,以及实际创建、修改文件来验证事件的单元测试)发布在 GitHub 上。
filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)
目标读者与前提
本文面向在 Windows 上用 .NET 编写“监视接收目录并导入文件”这类处理的开发者。代码示例以 C# / .NET 8 及以上为前提,但思路本身与语言无关。
本文直接沿用上面链接的上一篇文章(文件集成的排他控制)中的术语。claim、idempotency、manifest、bundle 这些词从第 4 章起会不加解释地出现,为了让没读过上一篇的读者也能跟上,这里先各用一行做个汇总。
先要掌握的术语
| 术语 | 含义 |
|---|---|
| claim | 以不会被其他 worker 插队的方式,取得“这个文件由我来处理”的所有权。实现上使用从 incoming/ 到 processing/<worker>/ 的 rename,只有 rename 成功的那一个进程才成为所有者(4.3) |
| idempotency(幂等性) | 同一个对象即使被处理两次以上,结果也和只处理一次相同的性质。既然要以重复通知和重新扫描为前提,最后就得靠它来兜底(4.5) |
| manifest | 与主体数据放在一起、用来说明内容的小文件。写入件数、哈希、IdempotencyKey 等信息后,接收方就能判断“这个是否已经处理过” |
| bundle | 把一次集成的内容打包成一体的单位。将主体 + manifest + 辅助文件放进同一个目录,就可以连目录一起用一次 rename 完成 claim(4.3) |
| full rescan | 不依赖事件,从头重新列举被监视目录,重新梳理出可以处理的对象(4.4) |
| overflow | FileSystemWatcher 的内部缓冲区溢出,丢失单条通知。该情况通过 Error 事件通知出来(2.3) |
| ready | 可以判定为“现在可以读取”的状态。不靠推测,而是依据 final 文件名或 done / manifest 是否存在来判定(4.2) |
目录
- 先说结论(一句话)
- 1.1. 先给出能跑起来的最小代码
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. 正确方向的示例(粗略写出来就是这样)
- 大致的使用区分
- 总结
- 参考资料
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 26 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
1. 先说结论(一句话)
FileSystemWatcher的事件不是 完成通知,而是 变化的迹象Created/Changed/Renamed可能重复触发、以出乎意料的顺序到来,在 overflow 时还会被遗漏- 事件处理程序里不要执行繁重的处理,只 堆积重新扫描请求 会更稳定
- 完成判定的基本做法,是通过
temp -> close -> rename / replace或done/ manifest 来 明确标示 - 如果存在多个 worker,就必须 在读取之前以原子方式获取 claim
InternalBufferSize的调整只是辅助手段。最终起作用的是 full rescan 与 idempotency
总而言之,就是不要把 FileSystemWatcher 当作“真相的历史事件流”来对待。
把通知仅仅留在“差不多该去看看了”这个信号的位置上,系统会更不容易坏。
flowchart TB
accTitle: 本文设计的核心
accDescr: 展示本文设计的核心:通知只作为触发信号,真相靠目录重新扫描来确认,所有权用原子式claim获取,最后用idempotency承接重复。
notif["通知只是触发信号"] --> rescan["真相在于目录的重新扫描"]
rescan --> claim["所有权通过原子式claim获取"]
claim --> idem["最后用idempotency兜底"]
图1:设计的核心。不把事件当作真实的历史,只把它留在“差不多该去看看了”的信号位置上。
1.1. 先给出能跑起来的最小代码
给还没接触过 FileSystemWatcher 的读者,先放一个只覆盖正常路径的最小形态。后面各章要讲的,正是从这 10 行“居然能跑起来”开始的种种陷阱。
// C# / .NET 8 控制台应用。只用来确认通知能否到达的最小形态
using System.IO;
using var watcher = new FileSystemWatcher(@"C:\incoming")
{
Filter = "*.csv",
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite,
};
watcher.Created += (_, e) => Console.WriteLine($"Created: {e.FullPath}");
watcher.Changed += (_, e) => Console.WriteLine($"Changed: {e.FullPath}");
watcher.Renamed += (_, e) => Console.WriteLine($"Renamed: {e.OldFullPath} -> {e.FullPath}");
watcher.Error += (_, e) => Console.WriteLine($"Error: {e.GetException().Message}");
watcher.EnableRaisingEvents = true; // 监视从这里开始
Console.WriteLine("按 Enter 键退出");
Console.ReadLine();
即便是最小形态,先记住下面 3 点也能少走弯路。
- 在设置
EnableRaisingEvents = true之前,一个事件都不会到来。仅仅注册处理程序并不会让它开始工作 watcher的生命周期就是应用的生命周期。局部变量离开作用域被释放后,通知就会在那里停止。如果要常驻运行,就把它放在字段等能一直存活的地方NotifyFilter的默认值是LastWrite | FileName | DirectoryName的组合(见 8. 参考资料中的 FileSystemWatcher.NotifyFilter Property)。明确写出要捕捉哪些变化,日后回头再读时不会犯迷糊
而更重要的是,这段代码只验证了“事件能够到达”这一点。在 Created 触发的时刻是否可以读取文件、有没有遗漏通知,用这种写法都无从得知。真正的正题从这里才开始。
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损坏
图2:即使还在复制途中,Created 也会触发。收到通知就去读,会拿到损坏的数据。
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次,也不一定是这个顺序
图3:即使是普通的保存,事件也会被拆成多条,还会混入外部进程触碰产生的事件。次数和顺序都靠不住。
“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]
图4:通知突发量超过内部缓冲区就会 overflow,单条事件序列的完整性随之崩塌。
这里重要的是,“一旦发生 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 或数据库更新,一旦出现通知突发,就是自己给自己添堵。
flowchart TB
accTitle: 事件处理程序轻重的分水岭
accDescr: 说明事件处理程序应当提出重新扫描请求后立即返回,而在处理程序内部就开始繁重的I/O或数据库更新,会带来读到未完成内容的风险,以及通知突发时处理跟不上的问题。
ev["事件处理程序"] --> light["提出重新扫描请求后立即返回"]
heavy["在处理程序内做繁重I/O或数据库更新"] -.-> raw["读到未完成内容的风险"]
heavy -.-> choke["通知突发时处理跟不上"]
图5:处理程序要轻。不要把完成判定和所有权获取压在事件上。
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;
}
与其在这个方向上死磕,不如每次都重新确认 磁盘上的实物 更可靠。因为在文件集成中重要的是正确找出此刻可以处理的对象,而不是完美再现事件历史。
flowchart TB
accTitle: 还原事件与确认实物的对比
accDescr: 说明从事件序列还原状态的设计会因重复、拆分、overflow和外部干扰而逻辑崩塌,因此每次都重新确认磁盘上的实物、正确找出此刻可以处理的对象更可靠。
ev2["从事件序列还原状态"] -.-> broke["重复、拆分、overflow导致逻辑崩塌"]
disk["每次都确认磁盘上的实物"] --> goal["正确找出可以处理的对象"]
图6:目的不是再现事件历史,而是找出此刻可以处理的对象。
3.3. Changed 停止后就视为完成
这和上一篇里“文件大小不再变化就视为完成”是同一种味道的设计。 看起来很方便,但完成是靠推测决定的。
if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
return Ready;
}
会为此犯难的,比如下面这些情况。
- 大文件的复制中途暂停
- 发送方应用分多个阶段保存
- 网络共享导致通知看起来有延迟
- 外部进程之后又改写了属性或时间戳
完成不要靠 推测,而要 明确标示,这样才更稳定。
flowchart TB
accTitle: 靠安静推测完成的危险
accDescr: 说明把Changed停顿一段时间就视为完成的推测,会因复制暂停、多阶段保存、通知延迟和事后改写属性而误判,因此由发送方明确标示完成会更稳定。
guess["Changed停止就推测为完成"] -.-> c1["复制暂停导致误判"]
guess -.-> c2["多阶段保存、通知延迟导致误判"]
fix["完成由发送方明确标示"] --> stable["不依赖推测,更稳定"]
图7:安静不能作为完成的证据。完成不靠推测,靠明确标示来决定。
3.4. 以为提高 InternalBufferSize 就解决了问题
调整 InternalBufferSize 固然重要,但它不是设计的主体。
- 默认值为
8192字节 - 不能小于
4096字节,也不能超过64 KB - 缓冲区使用的是 non-paged memory,所以并不是越大越无所谓
也就是说,即使调到 64 KB,只要通知的突发量超过它就到此为止。
而且,“是否为完成通知”这个问题一丝一毫都没有得到解决。
在增大缓冲区之前,有些事情应该先做。
- 用
Filter/Filters缩小监视范围 - 把
NotifyFilter收敛到必要的最小范围 - 不要随意把
IncludeSubdirectories设为true - 简化事件处理程序
- 引入 full rescan 和 idempotency
flowchart TB
accTitle: 在扩大缓冲区之前该做的事
accDescr: 说明即使把InternalBufferSize提高到64KB,只要突发量超过它仍会遗漏,因此应先用Filter和NotifyFilter缩小监视范围、简化处理程序、引入full rescan和idempotency这一顺序。
first["应该先着手的事"] --> f1["用Filter和NotifyFilter缩小范围"]
first --> f2["简化处理程序"]
first --> f3["full rescan与idempotency"]
buf["InternalBufferSize的调整"] -.-> aux["只作为最后的辅助手段"]
图8:扩大缓冲区不是设计的主体。先做范围收敛和恢复机制。
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]
图9:无论哪种通知还是 startup,都归并为一种 scan request,再通过重新扫描找出 ready 的候选并尝试 claim。
实现上的要点:
- 事件处理程序里只做到把
dirty = true并发出 signal 这种程度 - 扫描集中到一个 worker 上
- 通知突发时先合并等待 100~300ms 左右,再执行一次扫描
- 如果在扫描过程中又来了新通知,等本次结束后再扫描一次
第三条里的 100~300ms 并不是有规范或官方文档依据的数字,而是来自笔者运维经验的初始值。实际上,先测量下面两项再决定会更可靠。
| 观察项 | 确定方法 |
|---|---|
| 一次扫描所需的时间 | 等待时间如果比它更短,就只会在扫描结束之前不断堆积下一次扫描请求。把与扫描时间相当或更长作为下限的参考 |
| 可接受的检测延迟 | 等待时间会直接变成检测的延迟。如果有“放置后 n 秒以内处理”这样的要求,就把上限压在这个时间的一部分之内 |
例如,一次扫描 50ms 就能结束、检测在 1 秒以内即可,那么这 100~300ms 正好合适。反过来,如果文件数量很多、一次扫描要花上好几秒,那么与其拉长等待时间,不如先重新审视扫描本身的做法(缩小对象范围、只看 done、拆分子目录),效果更好。
这样一来,无论事件来了 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]
图10:发送方把全部内容写入 temp 后 close,用 rename 发布,必要时最后再放置 done / manifest。
虽然和上一篇文章说的一样,但这一点确实非常有效。
把 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: 只有先成功的一方拥有所有权
图11:即使多个 worker 找到同一个候选,也只有 rename 成功的那一个拥有所有权。
正如上一篇文章中也提到的,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 提供的是差分的线索,重新扫描才是一致性的恢复手段”。
flowchart TB
accTitle: 执行full rescan的时机
accDescr: 说明在启动时、收到Error时、重新创建watcher之后,以及作为定期保险的固定间隔这四个时机执行full rescan,可以恢复用事件捕捉不到的变更。
t1["启动时"] --> fr["full rescan"]
t2["收到Error时"] --> fr
t3["重新创建watcher之后"] --> fr
t4["作为定期保险措施"] --> fr
fr --> heal["一致性的恢复"]
图12:watcher 是差分的线索,full rescan 是一致性的恢复。这四个时机务必加上。
4.5. 以 idempotency 为前提
使用 FileSystemWatcher 时,同一个对象会被多次去查看。
这不是 bug,作为设计接受下来反而更稳定。
具体来说,是这样一种做法。
- 在 manifest 中加入
IdempotencyKey - 如果已经处理过,就不再重复执行副作用
- 让已归档 / 已记入数据库 / 已发送这些状态可以互相核对
- 即使执行 full rescan,也只是“再一次安全地查看同一个对象”而已
如果只想靠事件本身做出 exactly-once,会相当吃力。 接受 at-least-once,最后用 idempotency 收尾,在实务中会更稳健。
flowchart TB
accTitle: 以重复为前提的承接方式
accDescr: 说明把同一个对象被多次查看这件事不当作bug而作为设计接受下来,用manifest的IdempotencyKey核对是否已处理从而不重复执行副作用,这样即使重新扫描也是安全的。
multi["同一个对象被多次查看"] --> accept["作为设计接受下来"]
accept --> key["用IdempotencyKey核对是否已处理"]
key --> safe["不重复执行副作用"]
safe --> strong["即使full rescan也只是安全地再看一次"]
图13:不用事件去造 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
flowchart TB
accTitle: 正确方向的处理流程
accDescr: 展示伪代码所表达的一连串流程:把通知归并为scan request,通过扫描找出ready的候选,获取claim,确认idempotency之后再处理与记录并移到archive。
n["把通知归并为scan request"] --> s["通过扫描找出ready"]
s --> c["获取claim"]
c --> i["确认idempotency"]
i --> p["处理并记录后移到archive"]
图14:比起细节 API,这个流程才是主体。事件终究只是 trigger。
FileSystemWatcher 的事件在这里只不过是 trigger 而已。
另外,EnumerateReadyBundles / TryClaimByRename / ReadManifest / AlreadyProcessed 等,是本文为了展示流程而自行命名的函数,并不是 .NET 的标准 API。真正可以构建并运行的形态(库、可在临时目录上运行的控制台演示、验证事件的单元测试),放在开头也提到过的那套示例里。
filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)
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 很方便,但并不是万能的真相探测器。
USN change journal 有什么不同
USN change journal 是 NTFS 以卷为单位保存的变更记录。像 FileSystemWatcher 这样的目录通知,必须在变更发生的那一刻应用正在运行才能接收到;而 change journal 的记录留在卷这一侧,因此应用停止期间发生的变更,之后也能从上次读取的位置(USN)重新读回来。Microsoft 的文档中也把“需要让应用一直运行”列为目录通知的弱点,并把 change journal 作为其规避手段来说明。
另一方面,负担也会增加。
FileSystemWatcher |
USN change journal | |
|---|---|---|
| 监视的单位 | 指定的目录(+ 子目录) | 整个卷。需要的范围要自己缩小 |
| 应用停止期间 | 无从得知。用 full rescan 补齐 | 可以从记录中重新读取 |
| 遗漏 | 由内部缓冲区的 overflow 引起 | 超过日志上限后,旧记录会从前往后被删除 |
| 所需条件 | 只要 .NET 的 API | 卷句柄与 FSCTL_* 调用。创建、删除日志等管理操作需要管理员权限 |
也就是说,当“无法常驻运行”“停止期间的变更也想捕捉”进入需求时,它才成为一个选项。反过来,如果不需要这些,FileSystemWatcher + full rescan 的实现要直白得多。
flowchart TB
accTitle: FileSystemWatcher与USN change journal的区别
accDescr: 说明FileSystemWatcher无法得知应用停止期间的变更、需要用full rescan补齐,而USN change journal的记录留在卷这一侧,可以从上次读取的位置重新读回停止期间的变更这一区别。
fsw["FileSystemWatcher"] -.-> gap["停止期间的变更无从得知"]
gap --> fill["用full rescan补齐"]
usn["USN change journal"] --> keep["记录留在卷这一侧"]
keep --> resume["可以从上次的USN重新读取"]
图15:一旦出现无法常驻运行、停止期间的变更也要捕捉这类需求,change journal 就成为一个选项。
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.NotifyFilter 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
- Creating, Modifying, and Deleting a Change Journal - Win32 apps
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
业务系统的编码设计 ── 商品编码・客户编码的确定方法与校验位
确定商品编码・客户编码等业务系统编码体系的实践指南。整理了有意义编码与无意义流水号的判断表、JAN・Luhn等校验位算法及C#实现、Excel开头零丢失的应对方法,直至位数溢出与迁移。
在桌面应用中使用 .NET Generic Host 与 BackgroundService 的理由
本文整理在 Windows 工具或常驻应用中,如何使用 Generic Host 与 BackgroundService 来梳理启动、定期处理、退出处理、日志、配置与 DI。
多线程实战最佳实践 .NET 篇——增加线程之前必须先定好的事
面向 .NET/C# 梳理防止“线程一开就偶尔崩溃、偶尔卡死”的设计做法,涵盖不自己创建线程而依托 Task、减少共享可变状态、加锁的纪律、用 CancellationToken 设计停止流程,直到 UI 线程的处理方式。
业务应用数据库架构的版本管理 ── 防止「每个客户数据库都不一样」的迁移实践
一份为分散在各客户处的业务应用数据库架构做版本管理的实践指南。整理了 PRAGMA user_version 与前向迁移的 C# 实现、EF Core Migrations・DbUp・自行实现的判断表,以及两阶段发布策略。
WinForms / WPF 应用的 CI/CD 实践 ── 用 GitHub Actions 实现从构建到签名、发布的自动化
一份用 GitHub Actions 搭建 WinForms / WPF 应用 CI/CD 的实务指南。整理了在 windows-latest 上构建+测试的最小 YAML、标签驱动的版本编号、集成 signtool 完成签名,以及按 MSI/MSIX/ClickOnce/...
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
使用 FileSystemWatcher 的文件集成与监视工具,是 Windows 应用开发中实务上非常常见的主题。
技术咨询 & 设计评审
如果希望把防遗漏对策、重新扫描、完成判定作为设计梳理清楚,以技术咨询与设计评审的形式推进会很合适。
常见问题
汇总了咨询这一主题时常见的问题。
- 可以在 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。