FileSystemWatcher 实务指南:应对遗漏通知与重复通知

· · FileSystemWatcher, C#, .NET, Windows开发, 文件集成, 设计

FileSystemWatcher 是在 Windows 上的 .NET 中监视文件变化时首先会考虑的 API。它可以通过事件接收文件或目录的创建、修改、删除、重命名,用起来很方便,但如果把 CreatedChanged 直接当作完成通知来使用,遗漏通知、重复通知、误读处理中文件等问题会相当常见地发生。

本文主要以 Windows 上 .NET 环境下的文件集成为前提,整理 FileSystemWatcher 的使用方法与注意事项。同时也可以参照作为前提的排他控制思路:文件集成排他控制基础知识 - 文件锁与原子式 claim 的最佳实践

实际上,在文件复制过程中,Created 确实会先被触发,Changed 也不一定只出现一次。如果短时间内变更过于集中,内部缓冲区会溢出,从而遗漏部分变更。

因此,设计的核心思路是这样的:

  • 通知只是触发信号
  • 真相在于目录的重新扫描
  • 所有权通过原子式 claim 来获取
  • 最后用 idempotency(幂等性)来兜底

本文将按照这一思路,依次梳理把 FileSystemWatcher 集成到文件处理流程中时容易踩坑的地方。

此外,本文中出现的代码已作为一套可构建、可运行的示例(包含库、可在临时目录上运行的控制台演示,以及实际创建、修改文件来验证事件的单元测试)发布在 GitHub 上。

filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)

目录

  1. 先说结论(一句话)
  2. FileSystemWatcher 中容易发生的误解模式(图)
    • 2.1. 把 Created 当作完成通知
    • 2.2. 相信 Changed 的次数和顺序
    • 2.3. 内部缓冲区溢出导致变更丢失
  3. 反模式
    • 3.1. 在事件处理程序中直接处理
    • 3.2. 试图从事件序列还原真实状态
    • 3.3. Changed 停止后就视为完成
    • 3.4. 以为提高 InternalBufferSize 就能解决问题
    • 3.5. 只记录 Error 日志却置之不理
  4. 最佳实践
    • 4.1. 把通知归并为「重新扫描请求」
    • 4.2. 由发送方明确标示完成条件
    • 4.3. 接收方以原子方式获取 claim
    • 4.4. 在启动 / overflow / 重新连接时执行 full rescan
    • 4.5. 以 idempotency 为前提
  5. 伪代码(摘录)
    • 5.1. 典型的失败模式
    • 5.2. 正确方向的示例(简化写法)
  6. 大致的使用区分
  7. 总结
  8. 参考资料

1. 先说结论(一句话)

  • FileSystemWatcher 的事件不是完成通知,而是变化的迹象
  • Created / Changed / Renamed 可能会重复触发、以出乎意料的顺序到来,甚至在 overflow 时被遗漏
  • 事件处理程序不应执行繁重的处理,只堆积重新扫描请求,这样会更稳定
  • 完成判定的基本做法是通过 temp -> close -> rename / replacedone / manifest 来明确标示
  • 如果存在多个 worker,就需要在读取之前以原子方式获取 claim
  • InternalBufferSize 的调整只是辅助手段,最终起决定作用的是 full rescan 与 idempotency

总而言之,不要把 FileSystemWatcher 当作「真相的历史事件流」来使用。 把通知仅仅理解为「差不多该去看看了」的信号,会更不容易出问题。

2. 使用 FileSystemWatcher 时容易出现的误解模式(图)

2.1. 把 Created 当作完成通知

这是最容易理解的一个陷阱。在复制或传输过程中,文件 刚被创建的瞬间 就会触发 Created,之后还可能连续出现 一次或多次 Changed

接收方FileSystemWatcherwatched dir发送方接收方FileSystemWatcherwatched dir发送方还在复制过程中行数不足 / JSON 损坏 / ZIP 损坏创建 orders.csvCreatedOnCreated打开并读取 orders.csv写入剩余内容ChangedChanged

Created 只表示「名字已经出现」,并不保证「现在可以读取」。 如果把这两者混为一谈,就相当于换了一条路,重新踩到上一篇文章 2.1 中提到的同一个坑。

2.2. 相信 Changed 的次数和顺序

Changed 不一定只出现一次。即便是移动或保存这样的普通操作,也可能被拆分成多个事件。此外,还可能捕捉到防病毒软件或索引程序触碰文件所产生的事件。

FileSystemWatcherAV / indexerwatched dir执行保存的应用FileSystemWatcherAV / indexerwatched dir执行保存的应用不一定只有 1 次,顺序也不固定开始保存 report.xlsxCreatedChanged从临时文件 renameRenamedChanged扫描 / 读取属性Changed

Changed 来一次就算完成」「Renamed 之后就不会再被触碰」这类期待相当危险。

补充说明:

  • 文件 rename 时也可能触发 Changed
  • 如果操作系统无法确定 old/new 的对应关系,RenamedEventArgs.Name 可能会变成 null
  • hidden file 也不会被忽略。以为用隐藏的临时文件名就不会被看到,这种想法是不成立的
  • 即使重命名被监视的目录本身,这一变更也不会被通知到

2.3. 内部缓冲区溢出导致变更丢失

FileSystemWatcher 内部有一个缓冲区。如果短时间内变更过于集中,这个缓冲区就会溢出,导致遗漏部分通知。

短时间内发生大量变更通知堆积在内部缓冲区处理速度跟得上吗?依次处理各个事件overflowError 事件不再信任单条历史记录的完整性对目录执行 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 分别直接对接到各自的业务处理逻辑,整体结构就会变得难以把握。首先应该把它们全部归并为「去查看一下」这一种信号。

Created / Changed / Deleted / Renamedscan requestError / overflowstartup重新扫描目录列举 ready 状态的候选尝试获取 claim

实现上的要点:

  • 事件处理程序只需要把 dirty 设为 true 并发出 signal
  • 扫描逻辑集中到一个 worker 中处理
  • 通知突发时先合并等待 100~300 毫秒左右,再执行一次扫描
  • 如果在扫描过程中又收到新的通知,等本次扫描结束后再扫描一次

这样一来,无论事件来了 5 次还是 50 次,最终要做的事情都可以统一为「查看实际状态,寻找 ready 的对象」。

4.2. 由发送方明确标示完成条件

如果发送方也在自己的控制范围内,与在 FileSystemWatcher 一侧死磕完成判定相比,修改双方约定的协议会更有效。

最稳妥的做法始终是这样:

  • 把全部内容写入 temp 命名的文件
  • 执行 close
  • 在同一文件系统上执行 rename / replace
  • 如有需要,最后再放置 done / manifest
将全部内容写入 data.tmpflush / closerename / replace 为 data.csv放置 data.done / manifest.json接收方只需关注 final 文件名或 done 标记

这和上一篇文章说的一样,但这一点确实非常有效。 把 FileSystemWatcher 理解为「尽早发现已被明确标示的完成状态」的工具,而不是「发明完成状态」的工具,会更贴切。

4.3. 接收方以原子方式获取 claim

即便通过重新扫描发现了 ready 状态的候选对象,如果直接去读取,多个 worker 可能会同时抓取到同一个对象。 因此,在处理之前应该以原子方式先获取 claim。

processing/worker2processing/worker1incomingscannerprocessing/worker2processing/worker1incomingscanner只有先成功的一方才能获得所有权发现 order-123rename order-123rename order-123

正如上一篇文章中提到的那样,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 / replacedone / manifest 来明确标示,所有权则通过原子式获取 claim 来确定。这才是设计的核心所在。

Created 时立即处理、相信 Changed 的次数或顺序、Changed 停止后就视为完成、只靠调大 InternalBufferSize 来安心、看到 Error 却没有恢复机制——这些都是应该避免的设计。取而代之的做法是:把通知归并为重新扫描请求,在启动 / overflow / 重新连接时执行 full rescan,通过 claim rename 获取所有权,并用 idempotency 来兜底重复和重新扫描。

也就是说,在使用 FileSystemWatcher 时,关键在于不要把「收到了事件」和「可以处理了」混为一谈。 只要把这两者分开,那种偶尔才出问题的监视处理故障就会大幅减少。

8. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

可以在 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表