FileSystemWatcher 實務指南 - 漏掉通知與重複通知的對策

· 更新日期: · · FileSystemWatcher, C#, .NET, Windows 開發, 檔案整合, 設計

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279327)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616250)
初次發布
引用本文(DOI: 10.5281/zenodo.21616249)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈FileSystemWatcher 實務指南 - 漏掉通知與重複通知的對策〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616249 https://comcomponent.com/zh-TW/blog/2026/03/10/000-filesystemwatcher-safe-basics/

DOI(最新版本)
10.5281/zenodo.21616249
DOI(此版本)
10.5281/zenodo.22297078

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.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. startup / overflow / 重新連線時要做 full rescan
    • 4.5. 以 idempotency 為前提
  5. 虛擬碼(摘錄)
    • 5.1. 典型的失敗模式
    • 5.2. 方向正確的例子(草率地寫大概是這樣)
  6. 大致的取捨
  7. 總結
  8. 參考資料

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 26 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

1. 先講結論(一句話)

  • FileSystemWatcher 的事件不是 完成通知,而是 變化的跡象
  • Created / Changed / Renamed 會重複、會以和想像不同的順序抵達,overflow 時還會被漏掉
  • 事件處理常式裡不要做繁重的處理,只堆疊重新掃描請求 會比較穩定
  • 完成判定的基本做法,是用 temp -> close -> rename / replace 或 done / manifest 明示
  • 如果有多個 worker,就必須在讀取前以原子性的方式取得 claim
  • InternalBufferSize 的調整只是輔助。真正管用的是 full rescan 與 idempotency

說到底,就是不要把 FileSystemWatcher 當成「記錄真相的歷史串流」。 通知就停留在「差不多該去看一下了」這個訊號,系統會比較不容易壞。

本文設計的核心通知只停留在契機,真相靠目錄的重新掃描來確認,所有權以原子性的 claim 取得,最後用 idempotency 承接重複,說明本文設計核心的圖。通知只是契機真相是目錄的重新掃描所有權是原子性的 claim最後用 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。

接收端FileSystemWatcherwatched dir傳送端接收端FileSystemWatcherwatched dir傳送端複製其實還沒結束行數不足 / JSON 損毀 / ZIP 損毀建立 orders.csvCreatedOnCreated開啟並讀取 orders.csv寫入剩下的部分ChangedChanged

圖 2: 複製途中 Created 也照樣會飛出來。一收到就去讀,會拿到壞掉的資料。

Created 表示的是「名稱看得到了」,並不保證「已經可以讀了」。 把這兩件事當成同一個意思,等於換一條路踩到前一篇文章 2.1 的同一個坑。

2.2. 相信 Changed 的次數與順序

Changed 不見得只來一次。 像移動或儲存這種一般操作,也可能被拆成多個事件看到。再加上,連防毒軟體或索引程式碰過的部分都會一併收進來。

FileSystemWatcherAV / indexerwatched dir儲存檔案的應用程式FileSystemWatcherAV / indexerwatched dir儲存檔案的應用程式未必只來 1 次,也未必是這個順序開始儲存 report.xlsxCreatedChanged從暫存檔 renameRenamedChanged掃描 / 讀取屬性Changed

圖 3: 一般的儲存動作也會拆成多個事件,還會混進外部處理程序碰過的部分。次數和順序都靠不住。

「Changed 來一次就代表完成」「Renamed 之後就不會再被碰」這類期待,相當危險。

補充:

  • 檔案 rename 也可能飛出 Changed
  • 當 OS 端無法對上 old/new 的對照關係時,RenamedEventArgs.Name 可能會是 null
  • 隱藏檔案也不會被忽略。「因為用的是隱藏的 temp 名稱所以看不到吧」這種想法行不通
  • 就算把正在監控的目錄本身 rename,這個變更也不會被通知

2.3. 內部緩衝區滿出來而遺失變更

FileSystemWatcher 有一個內部緩衝區。 短時間內變更集中時,這裡會滿出來,個別通知就被漏掉。

是否短時間內出現大量變更通知累積在內部緩衝區處理跟得上?依序處理個別事件overflowError 事件不信任個別歷史的完整性對目錄做 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()); // 只是印出來
};

問題有 2 個。

  • 在 Created 的時間點,內容可能還沒寫完
  • 沒有從失敗或 overflow 復原的機制

事件處理常式做到 提出重新掃描請求後立刻返回 剛剛好。 如果在這裡連繁重的 I/O 或資料庫更新都開始做,通知突發時就是自找苦吃。

事件處理常式輕重的分界事件處理常式應該提出重新掃描請求後立刻返回,若在處理常式中連繁重的 I/O 或資料庫更新都開始做,就會同時背上讀到未完成內容的風險與通知突發時處理跟不上的問題的圖。事件處理常式提出重新掃描請求後立刻返回在處理常式中做繁重的 I/O 或資料庫更新讀到未完成內容的風險突發時處理跟不上

圖 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;
}

與其往這個方向硬撐,不如每次都重新確認 磁碟上的實際檔案,這樣更可靠。因為檔案介接真正重要的,是正確找出此時此刻可以處理的對象,而不是漂亮地重現事件歷史。

事件還原與實際檔案確認的對比從事件序列還原狀態的設計會因為重複、拆分、overflow 與外部干擾而前後對不上,因此每次重新確認磁碟上的實際檔案,正確找出此時此刻可以處理的對象更可靠的圖。從事件序列還原狀態重複、拆分、overflow 導致前後對不上每次確認磁碟上的實際檔案正確找出可以處理的對象

圖 6: 目的不是重現事件歷史,而是找出現在可以處理的對象。

3.3. 把「Changed 停下來」當成完成

這和前一篇的「檔案大小不再變動就當成完成」是同一種味道的設計。 看起來很方便,但完成是用推測決定的。

if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
    return Ready;
}

會因此出事的,例如以下這些情況。

  • 大檔案的複製中途暫停
  • 傳送端應用程式分成多個階段儲存
  • 網路共用資料夾上,通知看起來慢了一拍
  • 外部處理程序事後改寫屬性或時間戳

完成用 明示 而不是 推測 來決定,會比較穩定。

以安靜推測完成的危險把 Changed 停下來一段時間就視為完成的推測,會在複製暫停、分段儲存、通知延遲與事後改寫屬性時誤判,因此完成由傳送端明示比較穩定的圖。Changed 停下來就推測為完成複製暫停導致誤判多段儲存、通知延遲導致誤判完成由傳送端明示不依賴推測而穩定

圖 7: 安靜不能當成完成的證據。完成用明示而不是推測來決定。

3.4. 以為調大 InternalBufferSize 就解決了

InternalBufferSize 的調整很重要,但它不是設計的主體。

  • 預設值是 8192 位元組
  • 不能小於 4096 位元組,也不能超過 64 KB
  • 緩衝區使用 non-paged memory,所以並不是愈調大愈沒負擔

也就是說,就算調到 64 KB,通知突發只要超過這個量就沒救了。 而且,「這是不是完成通知」的問題一毫米也沒有被解決。

在調大緩衝區之前,有些事應該先動手。

  • 用 Filter / Filters 縮小監控對象
  • 把 NotifyFilter 收到必要的最小範圍
  • 不要隨手把 IncludeSubdirectories 設成 true
  • 讓事件處理常式保持輕量
  • 加入 full rescan 與 idempotency
調大緩衝區之前該做的事就算把 InternalBufferSize 調到 64 KB,通知突發只要超過就會漏掉,因此先用 Filter 與 NotifyFilter 縮小監控對象,讓處理常式保持輕量,再加入 full rescan 與 idempotency 的順序的圖。應該先動手的事用 Filter 與 NotifyFilter 縮小範圍讓處理常式保持輕量full rescan 與 idempotencyInternalBufferSize 的調整只當成最後的輔助

圖 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 各自直接接到不同的業務處理上,整體會很難看清全貌。先把它們全部摺疊成「去看一下」這一種訊號。

Created / Changed / Deleted / Renamedscan requestError / overflowstartup目錄重新掃描列舉 ready 的候選嘗試取得 claim

圖 9: 不論哪一種通知,連同 startup 都摺疊成一種 scan request,再以重新掃描找出 ready 的候選並嘗試 claim。

實作上的重點:

  • 事件處理常式只做到把 dirty = true 設起來並發出 signal 的程度
  • 掃描集中到一個 worker
  • 突發時先合併 100~300ms 再掃描一次
  • 掃描過程中若有追加通知進來,結束後再掃描一次

第三項的 100~300ms 這個數值,並不是規格或官方文件上有根據的數字,而是作者從維運經驗得出的初始值。實際上,先量測下面 2 項再決定會比較可靠。

要量的東西 決定方式
一次掃描所花的時間 等待時間比它還短的話,只會在掃描結束前又累積起下一次的掃描請求。把與掃描時間相當或更長,當成下限的大致參考值
可以容忍的偵測延遲 等待時間會直接變成偵測的延遲。如果有「放進來之後 n 秒內要處理完」這種需求,就把上限訂在只佔其中一部分的範圍內

舉例來說,一次掃描 50ms 就結束、偵測只要在 1 秒內即可的話,這個 100~300ms 剛好落在範圍內。反過來,如果檔案數量多、一次掃描要花上好幾秒,那麼與其拉長等待時間,不如先重新檢視掃描的做法(縮小對象、只看 done、把子目錄拆開),效果更好。

這樣一來,事件來 5 次還是 50 次,最後要做的事都能統一成「看實際的檔案,找出 ready 的對象」。

4.2. 完成條件由傳送端明示

如果傳送端也在自己掌控之中,與其在 FileSystemWatcher 這一側硬做完成判定,不如修改發布的協定,效果更好。

最正統的做法,還是這個。

  • 把全部內容寫進 temp 名稱的檔案
  • 執行 close
  • 在同一個檔案系統上執行 rename / replace
  • 需要的話,最後再放上 done / manifest
把全部內容寫進 data.tmpflush / closerename / replace 成 data.csv放上 data.done / manifest.json接收端只看 final 名稱或 done

圖 10: 傳送端把全部內容寫進 temp 並 close,以 rename 發布,需要時最後再放上 done / manifest。

這和前一篇文章一樣,但這裡真的很管用。 把 FileSystemWatcher 看成「儘早發現已經被明示的完成」的工具,而不是「發明完成」的工具,理解起來會比較順。

4.3. 接收端以原子性的方式取得 claim

就算重新掃描找到了 ready 的候選,直接去讀的話,多個 worker 會同時抓到同一個。 所以要在處理之前,以原子性的方式取得 claim。

processing/worker2processing/worker1incomingscannerprocessing/worker2processing/worker1incomingscanner只有先成功的那一個持有所有權發現 order-123rename order-123rename order-123

圖 11: 即使多個 worker 找到同一個候選,也只有 rename 成功的那一個持有所有權。

如同前一篇文章提過的,incoming -> processing/<worker>/ 的 rename 最容易理解。 特別是把 本體 + manifest + 輔助檔案 整理進同一個目錄後,就能以 bundle 為單位取得 claim,會輕鬆很多。

incoming/
  order-123/
    payload.csv
    manifest.json

這樣一來,只要把 bundle directory rename 一次,就能取得所有權。

4.4. startup / overflow / 重新連線時要做 full rescan

這一點相當重要。

  • 應用程式啟動前就已經放在那裡的檔案,用事件收不到
  • 一旦發生 overflow,個別事件序列就變得難以信任
  • 牽涉到網路共用資料夾或短暫斷線時,最好以「那段期間的某些東西會漏掉」為前提來看待

所以,至少在下面這些時機加入 full rescan 會比較好。

  • 啟動時
  • 收到 Error 時
  • 重建 watcher 之後緊接著
  • 當成定期的保險,每隔一段固定時間

這裡的想法是:「watcher 是差異的提示,重新掃描是一致性的復原」。

加入 full rescan 的時機在啟動時、收到 Error 時、重建 watcher 之後,以及當成定期保險的固定間隔這四個時機加入 full rescan,藉此把事件收不到的變更復原回來的圖。啟動時full rescan收到 Error 時重建 watcher 之後當成定期的保險一致性的復原

圖 12: watcher 是差異的提示,full rescan 是一致性的復原。這四個時機一定要加進去。

4.5. 以 idempotency 為前提

使用 FileSystemWatcher 時,同一個對象一定會被看好幾次。 這不是 bug,把它當成設計的一部分接受下來,反而比較穩定。

具體來說是這樣。

  • 在 manifest 裡放入 IdempotencyKey
  • 已經處理過的話,就不要重新執行副作用
  • 讓「已 archive/已寫入資料庫/已送出」可以被核對
  • 就算做了 full rescan,也只是「把同一個東西再安全地看一次」

只靠事件想做出 exactly-once,會相當辛苦。 接受 at-least-once,最後用 idempotency 收尾,在實務上更可靠。

以重複為前提的承接方式把同一個對象會被看好幾次這件事當成設計而不是 bug 接受下來,用 manifest 的 IdempotencyKey 核對是否已處理而不重新執行副作用,使得重新掃描也仍然安全的圖。同一個對象會被看好幾次當成設計接受下來用 IdempotencyKey 核對是否已處理不重新執行副作用做了 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
方向正確的處理流程把通知摺疊成 scan request,用掃描找出 ready 的候選,取得 claim,檢查 idempotency 之後才處理與記錄並移動到 archive,說明虛擬碼所呈現的一連串流程的圖。把通知摺疊成 scan request用掃描找出 ready取得 claim檢查 idempotency處理並記錄後移到 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 方式承接的設計。

最後 2 項是相當重要的撤退判斷。 FileSystemWatcher 很方便,但它不是萬能的真相偵測器。

USN change journal 有什麼不同

USN change journal 是 NTFS 以磁碟區為單位保存的變更記錄。像 FileSystemWatcher 這種目錄通知,變更發生的當下應用程式沒有在跑就收不到;而 change journal 的記錄留在磁碟區這一側,所以應用程式停著的那段期間發生的變更,之後也能從上次讀到的位置(USN)重新讀回來。Microsoft 的文件也把「必須讓應用程式一直執行」列為目錄通知的弱點,並說明 change journal 是其迴避方式。

另一方面,負擔也會增加。

  FileSystemWatcher USN change journal
監控的單位 指定的目錄(+子目錄) 整個磁碟區。需要的範圍要自己縮小
應用程式停止的那段期間 無從得知。用 full rescan 補上 可以從記錄重新讀回
漏掉通知 由內部緩衝區的 overflow 造成 超過 journal 的上限時,會從舊的記錄開始消失
需要的東西 只要 .NET 的 API 磁碟區的控制代碼與 FSCTL_* 的呼叫。建立、刪除 journal 等管理操作需要系統管理員權限

也就是說,當「無法常駐執行」「停止期間的變更也想收到」進入需求時,它就是一個選項。反過來,如果不需要這些,FileSystemWatcher + full rescan 的實作會直接了當得多。

FileSystemWatcher 與 USN change journal 的差異FileSystemWatcher 無從得知應用程式停止期間的變更而要用 full rescan 補上,USN change journal 則因為記錄留在磁碟區這一側,可以從上次讀到的位置重新讀回停止期間的變更,說明兩者差異的圖。FileSystemWatcher停止期間的變更無從得知用 full rescan 補上USN change journal記錄留在磁碟區這一側可以從上次的 USN 重新讀回

圖 15: 一旦出現無法常駐執行、停止期間的變更也想收到這類需求,change journal 就成為選項。

7. 總結

FileSystemWatcher 不能拿來代替完成通知。真相不在事件序列裡,而在此刻磁碟上看得到的狀態。完成要用 temp -> close -> rename / replace 或 done / manifest 明示,所有權要以原子性的方式取得 claim 才算數。設計的主體就在這裡。

Created 一來就馬上處理、相信 Changed 的次數與順序、Changed 停下來就當成完成、只靠 InternalBufferSize 就放心、看到 Error 卻不做復原——這些都是想避開的設計。取而代之的是:把通知摺疊成重新掃描請求,在 startup / 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~300ms 再掃描一次,這樣會比較穩定。
調大 InternalBufferSize 就能解決漏掉通知的問題嗎?
不能。就算調到 64KB,通知突發只要超過這個量還是會漏掉,而且「這是不是完成通知」的問題一點也沒有被解決。緩衝區使用 non-paged memory,所以並不是愈調大愈沒負擔。正確的順序是先用 Filter/NotifyFilter 縮小監控對象,重新檢視 IncludeSubdirectories,讓事件處理常式保持輕量,再加入 full rescan 與 idempotency。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽