檔案介接互斥控制的基礎知識 - 檔案鎖定與原子性 claim 的最佳實踐

· 更新日期: · · 檔案介接, 互斥控制, 設計, Windows 開發

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

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

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

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

Go Komura(2026)。〈檔案介接互斥控制的基礎知識 - 檔案鎖定與原子性 claim 的最佳實踐〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616239 https://comcomponent.com/zh-TW/blog/2026/03/07/001-file-integration-locking-best-practices-komurasoft-style/

DOI(最新版本)
10.5281/zenodo.21616239
DOI(此版本)
10.5281/zenodo.22297070

檔案介接的互斥控制,在共用資料夾、夜間批次與跨處理程序介接的場合幾乎一定會出問題。 搜尋時最常見的困擾是:只靠檔案鎖定夠不夠?多個 worker 要怎麼避免撿到同一個檔案?寫到一半的檔案該怎麼避開?

本文以檔案鎖定、原子性 claim、temp -> rename 與 idempotency 為軸,逐一檢視檔案介接的互斥控制。

先把用語統一

這個領域有很多詞是直接沿用英文,意思一直含糊不清的話會讀得很吃力。先把本文中的意思固定下來。

用語 本文中的意思
原子性(atomic) 指中間狀態不會被別人看到的操作。結果只會是成功,或是什麼都沒發生,兩者之一
claim 指確保「這個檔案由我來處理」的處理權。本文主要指的是:只有能把檔案從 incoming rename 到 processing/<worker>/ 的那一方才成為擁有者的形式
原子性 claim 指用單一操作完成這個 claim。確認與確保一旦分開,其他處理程序就能插進中間的空隙(3.1)
lease 帶有效期限的所有權。在 lock file 裡寫下「是誰」「到什麼時候」,期限一到就讓其他 worker 可以接手(4.4)
stale 指持有者已經異常結束,lock 或 claim 卻還留著的狀態。無法判定它是活著還是死了,所有人就都會停下來(2.3)
manifest 與資料本體分開放置、說明內容的檔案。裡面寫上檔名、大小、雜湊、紀錄筆數等,供接收端驗證使用。done 檔案是它的最小版本(4.2)
idempotency(冪等性) 指同一份輸入再處理一次,結果也不會改變的性質(4.5)
advisory lock 只有在所有參與者都遵守約定的前提下才有效的鎖定。作業系統不會強制執行,所以要寫出無視它而照樣讀寫的程式也很容易。Linux 的 flock 屬於這一型
byte-range lock 不是鎖整個檔案,而是只針對指定範圍的鎖定。代表是 Windows 的 LockFileEx,這一種由作業系統強制執行。不過也有例外(3.5)

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

目錄

  1. 先講結論(一句話)
  2. 檔案介接會發生的競爭模式(圖)
    • 2.1. 讀到寫到一半的檔案
    • 2.2. 多個 worker 同時撿到同一個檔案
    • 2.3. stale lock 讓所有人都停住
  3. 反模式
    • 3.1. Exists -> Create 的兩段檢查
    • 3.2. 直接寫入最終檔名
    • 3.3. 檔案大小不再變動就當成完成
    • 3.4. 大家一起更新同一個共用檔案
    • 3.5. 把鎖定 API 當成萬能
  4. 最佳實踐
    • 4.1. 用 temp -> close -> rename / replace 公開
    • 4.2. 用 done / manifest 明示完整性
    • 4.3. 接收端以原子方式取得 claim
    • 4.4. 要靠 lock file 就做成 lease
    • 4.5. 以 idempotency 為前提
  5. 虛擬碼(節錄)
  6. 大致的取捨
  7. 總結
  8. 參考資料

檔案介接這個領域,比起程式碼本身,更容易壞掉的是「交接的約定」。 單元測試會通過,卻只在正式環境的共用資料夾或夜間批次上偶爾壞掉,而且很難重現。這種情況其實相當常見。

原因大多不在檔案 I/O 的 API 本身,而在於下面這三件事沒有講清楚。

  • 什麼時候可以開始讀
  • 處理權在誰手上
  • 失敗的時候要怎麼復原

本文不把檔案介接的互斥控制停在作業系統鎖定的層次,而是當成交接協定來梳理。

另外,本文出現的程式碼已經整理成一套可以建置、執行的範例(函式庫、示範 2 個 worker 的 claim 競爭與 lease 接手的 demo、重現競爭、毀損與 stale lock 的單元測試),公開在 GitHub 上。

file-integration-locking-best-practices-komurasoft-style - komurasoft-blog-samples (GitHub)

1. 先講結論(一句話)

  • 檔案介接最重要的一點,是讓最終檔名一出現,就已經是「可以讀了」的狀態
  • 用檔名或目錄把產生中 / 已公開 / 處理中 / 已處理分開表達
  • 有多個 worker 時,讀取之前要以原子方式取得 claim
  • lock file 與作業系統鎖定只當輔助,最後用 idempotency 接住

換句話說,檔案介接的主體與其說是互斥控制,不如說是交接協定的設計。 呼叫一個鎖定函式就結束,這種事不會發生。

2. 檔案介接會發生的競爭模式(圖)

2.1. 讀到寫到一半的檔案

一開始就直接寫入最終檔名,就會出這種事。 JSON 會少了收尾的括號,CSV 會行數不足,ZIP 則是直接壞掉。

接收端共用資料夾送出端接收端共用資料夾送出端其實還沒寫完行數不足 / 解析失敗 / 只處理到一部分以最終檔名建立 orders.csv正在寫入第 1 行到第 5000 行偵測到 orders.csv直接開始讀取寫入剩下的部分

2.2. 多個 worker 同時撿到同一個檔案

如果流程是「先看清單,沒處理過就打開」,同一個檔案就會被兩個 worker 同時抓住。 重複入帳與重複送出就是從這裡開始的。

incomingworker 2worker 1incomingworker 2worker 1同一份輸入被處理了兩次找到 a.csv找到 a.csv開始讀取開始讀取

2.3. stale lock 讓所有人都停住

只放一個 lock file 的設計,在異常結束時很容易卡住。 不知道這是誰的 lock、持有者還活著沒有、有效到什麼時候,後面的處理就會一直等下去。

worker Block 檔案worker Aworker Block 檔案worker A在這裡異常結束無法判定是不是 stale,所有人都停住建立 lock檢查 lock 是否存在暫時不開始處理繼續等待

3. 反模式

3.1. Exists -> Create 的兩段檢查

問題在於「確認」與「確保」是兩個不同的操作。 其他處理程序可以插進中間,所以根本達不到互斥。

檔案系統處理程序 B處理程序 A檔案系統處理程序 B處理程序 A兩邊都往下走了檢查有沒有 lock檢查有沒有 lock沒有沒有建立 lock建立 lock

典型的錯誤寫法長這樣。

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, Environment.ProcessId.ToString());
    ProcessFile();
}

需要的是把「沒有就建立」變成單一操作。 .NET 用 FileMode.CreateNew 系列,POSIX 系統則用 O_CREAT | O_EXCL 這類原子性建立。

3.2. 直接寫入最終檔名

如果接收端的理解是「看到那個檔名就可以讀」,那麼一開始直接寫入最終檔名的當下就已經輸了。 基本原則是:不要把看得見與可以讀當成同一件事。

看得見 final 檔名接收端偵測到送出端還在寫入讀到不完整的資料
using var writer = OpenForWrite(finalPath); // finalPath 在這裡就被看見了
foreach (var row in rows)
{
    writer.WriteLine(row);
}

這種做法等於自己去招來 2.1 描述的那種出包。

3.3. 檔案大小不再變動就當成完成

這看起來很方便,其實相當危險。 跨網路的複製、送出端暫停、緩衝與重試,都會讓大小很自然地浮動。

接收端共用資料夾送出端接收端共用資料夾送出端誤判為已完成開始複製 data.zip中途暫停大小 10 秒沒有變化開始讀取複製恢復
if (currentLength == lastLength && stableSeconds >= 10)
{
    return Ready;
}

用推測決定完成與否,在共用資料夾或大檔案上就會出事。 完成還是用 manifest 或 done file 明示出來比較穩定。

3.4. 大家一起更新同一個共用檔案

大家一起讀取並更新同一個 status.csv 或 counter.json 的設計,結果幾乎都是最後寫的人贏。 一旦把檔案介接當成簡易資料庫來用,就會從這裡開始難受。

status.csv批次 B批次 Astatus.csv批次 B批次 AA 的更新不見了讀取 v1讀取 v1寫入 v2-A寫入 v2-B

也有改用 append-only 迴避的做法,但它的意義會隨檔案系統與部署形式而浮動。 如果真的需要共用更新,這一段最好不要用檔案介接硬撐。

3.5. 把鎖定 API 當成萬能

鎖定 API 很重要,但它只有在所有參與者都照同一套約定運作時才管用。 在異質系統介接的場合,不要過度信任這一點比較安全。

補充:

  • Linux 的 flock 是 advisory lock,不遵守約定的一方照樣寫得出來
  • Windows 的 byte-range lock 在記憶體對應檔案上會被忽略
  • 也就是說,不要讓作業系統鎖定單獨扛起完成通知與所有權的設計

第二點在 Windows 的規格中有明文記載。Microsoft Learn 的 Locking and Unlocking Byte Ranges in Files 先寫明:其他處理程序存取已被鎖定的範圍時一定會失敗(也就是 Windows 的範圍鎖定不是 advisory,而是由系統強制執行),緊接著就放上一句提醒:使用記憶體對應檔案時,byte-range lock 會被忽略。換句話說,只要對方是透過 CreateFileMapping 碰同一個檔案,我們這邊的鎖定就會被直接穿過去。

在 .NET 上要取得範圍鎖定,用的是 FileStream.Lock / Unlock(Windows 的情況)。

using var stream = new FileStream(
    path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);

// 只把開頭 1 個位元組當成「處理中」的號牌,做互斥鎖定
stream.Lock(0, 1);
try
{
    // 在這裡讀寫資料本體
}
finally
{
    // 關閉之前一定要解除
    stream.Unlock(0, 1);
}

這種寫法在遵守同一套約定的應用程式之間有效。但如同上面所述,對方若是透過記憶體對應存取就沒有效果,而且其他系統本來就不保證會去看這張號牌。所以主體還是第 4 章的交接協定那一側。

4. 最佳實踐

先把第 3 章的反模式與對策之間的對照關係列出來。如果自覺踩到了其中某一項,直接從對應的小節讀起也讀得通。

反模式 會發生什麼 對應的對策
3.1. Exists -> Create 的兩段檢查 確認與確保之間的空隙被插隊,兩個處理程序同時往下走 4.3 以原子方式取得 claim(用 rename 或 FileMode.CreateNew)
3.2. 直接寫入最終檔名 接收端讀到還在寫入中的檔案 4.1 用 temp -> close -> rename / replace 公開
3.3. 檔案大小不再變動就當成完成 把複製的暫停誤判為完成 4.2 用 done / manifest 明示完成
3.4. 大家一起更新同一個共用檔案 被後寫入的一方覆寫,更新消失 用 4.3 把寫入者收斂成一個,用 4.5 吸收重複處理。這樣還不夠就看第 6 章的撤退判斷
3.5. 把鎖定 API 當成萬能 被不守約定的一方,或透過記憶體對應的存取路徑突破 用 4.4 的 lease 形式 lock file,再用 4.5 的 idempotency 接住

4.1. 用 temp -> close -> rename / replace 公開

這是最正統的做法。 產生中的檔案關在 temp 檔名裡,close 之後再切換成 final 檔名。 接收端只看 final 檔名。

建立唯一的 temp 檔名把全部內容寫進 temp執行 flush / close在同一個目錄內 rename / replace 成 final 檔名接收端只監看 final 檔名

重點:

  • temp 與 final 要放在同一個目錄,至少要在同一個磁碟區 / 檔案系統上
  • Windows / .NET 上可以考慮 File.Replace 系列
  • 約定好:final 檔名一出現,內容就已經完成

把 temp 放到另一顆磁碟機上,rename 可能會變成單純的複製,Replace 也可能失敗。 這個前提不起眼,卻非常重要。

透過共用資料夾(SMB)時,下面這 4 點還會再浮動一次。本文的主戰場正好就在這裡,所以另外分開來寫。

  • 在同一個共用資源的同一個目錄內做 rename,是在伺服器端執行的。 所以「看不到中間檔名」這個性質本身仍然成立。反過來說,像 \\server\shareA 到 \\server\shareB 這樣跨越不同共用資源時,就會被當成不同磁碟區;Windows 的 MoveFileEx 在指定 MOVEFILE_COPY_ALLOWED 時,會改用複製加刪除來代替搬移。也就是說它不再是原子操作,中間狀態會被看見。把 temp 與 final、incoming 與 processing 放在同一個共用資源之內這個前提,比在本機時更加重要
  • 只要有人開著檔案,rename 就會失敗。 共用資料夾上會有防毒軟體、搜尋索引器、其他據點的用戶端等我方掌握不到的對象前來存取。publish 與 claim 的 rename,比較實際的做法是把失敗當成正常的分支而不是異常,插入短暫等待之後重試
  • 時間戳記不能當判斷依據。 Microsoft Learn 的 File Times 中寫明,關於檔案時間,唯一保證的只有「在關閉做出變更的控制代碼時會正確寫入」這一點。寫入過程中的最後寫入時間,要等到所有寫入用的控制代碼都關閉之後才會完全更新。粒度也取決於檔案系統:FAT 的最後寫入時間以 2 秒為單位,NTFS 的最後存取時間最多會延遲 1 小時才更新。再加上透過 SMB 時,時間是以伺服器端的時鐘標上去的,只要用戶端與伺服器的時鐘有偏差,「更新後過了 N 分鐘就處理」這種判定也會跟著偏掉。完成判定不用時間或大小,而改用 4.2 的 done / manifest,理由就在這裡
  • 變更通知也會漏掉。 共用資料夾的監控不要只依賴事件通知,搭配定期的目錄列舉會比較穩定。這個主題整理在 FileSystemWatcher 實務指南 - 漏掉通知與重複通知的對策 這篇文章裡

4.2. 用 done / manifest 明示完整性

除了資料本體之外,再用另一個檔案明示「什麼已經完成」,接收端就會比較穩定。 在異質系統介接的場合特別有效。

產生 data.tmp公開成 data.csv建立 data.done / manifest.json接收端偵測到 done / manifest驗證檔名、大小與雜湊

manifest 裡建議放進去的,大致是這些項目。

  • 目標檔名
  • 大小
  • 雜湊
  • 紀錄筆數
  • 介接 ID / idempotency key
  • 產生時間

順序也很重要。 如果 done 比本體先放出去,那就不是完成通知,而是預告等一下要出事。

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

多個 worker 都看同一個 incoming 時,「讀之前先搬到自己名下」是最好懂的做法。 只有把檔案從 incoming rename 到 processing/<worker>/ 成功的 worker 才進行處理。

processingincomingworker 2worker 1processingincomingworker 2worker 1只有先成功的那一方取得所有權找到 a.csv找到 a.csv把 a.csv rename 過去把 a.csv rename 過去

維運上,把目錄也分開會比較好追蹤。

publishclaim成功失敗tempincomingprocessingarchiveerror

claim 用的 rename 同樣以在同一個檔案系統上執行為前提。

4.4. 要靠 lock file 就做成 lease

要用 lock file,就不要只放一個空檔案,而要做成帶有效期限的所有權資訊。 不知道是誰拿走的 lock,之後一定會出爭議。

lock.jsonownerIdhostpidacquiredAtexpiresAtheartbeatAt

重點:

  • 建立要以原子方式進行
  • 以停止更新作為判定 stale 的依據
  • 刪除原則上只由建立者執行
  • 假設一定會有漏解除的情況,事先訂好復原步驟

lock file 終究只是用來協調的號牌。 想靠這一張就連完整性都要完全保證,通常都會很吃力。

4.5. 以 idempotency 為前提

互斥控制很重要,但在實際維運中,「偶爾會重複送來」「中途重跑」是不可能歸零的。 最後真正管用的,是同一份輸入再吃一次也不會壞掉的設計。

是否輸入 + idempotency key是否已處理過不重複執行,直接視為成功執行處理記錄到已處理帳冊

例如,為每一個接收到的檔案配上介接 ID,並記錄到已處理帳冊。 就算互斥被突破了一次,只要結果不會被重複入帳,維運就會輕鬆很多。

5. 虛擬碼(節錄)

接下來出現的 MakeTempPathSameDirectory 與 TryClaimBundleByRename,是為了呈現順序而放的虛構函式名稱。真正能執行的實作放在開頭介紹的那套範例裡。

這段虛擬碼的實作(函式庫、2 個 worker 的 claim 競爭 demo、單元測試) - komurasoft-blog-samples (GitHub)

5.1. 典型的失敗模式

var lockPath = finalPath + ".lock";

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, "");
    using var writer = OpenForWrite(finalPath); // 直接寫入最終檔名
    WritePayload(writer);

    File.Delete(lockPath);
}

問題有三個。

  • Exists 與 WriteAllText 是兩個不同的操作
  • finalPath 從寫入途中就被看見
  • 異常結束時 lock 會殘留

5.2. 正確方向的範例(隨手寫大概是這樣)

var tempPath = MakeTempPathSameDirectory(finalPath);
WritePayload(tempPath);
FlushAndClose(tempPath);

PublishByRenameOrReplace(tempPath, finalPath); // 前提是同一個 FS / 同一個 volume
PublishDoneFile(finalPath + ".done", new
{
    FileName = Path.GetFileName(finalPath),
    Size = GetFileSize(finalPath),
    Hash = ComputeHash(finalPath),
    IdempotencyKey = integrationId
});
if (!TryClaimBundleByRename(baseName, incomingDir, processingDir))
{
    return; // 已被其他 worker 先取得
}

var manifest = ReadDoneFile(Path.Combine(processingDir, baseName + ".done"));
VerifyPayload(Path.Combine(processingDir, baseName), manifest);

if (AlreadyProcessed(manifest.IdempotencyKey))
{
    MoveBundle(processingDir, archiveDir, baseName);
    return;
}

Process(Path.Combine(processingDir, baseName));
RecordProcessed(manifest.IdempotencyKey);
MoveBundle(processingDir, archiveDir, baseName);

這一段重要的不是實作細節,而是順序。 把「寫入」「公開」「取得所有權」「記錄已處理」分開,不要混在一起,就比較不容易壞。

6. 大致的取捨

  • 單一 writer / 單一 reader / 同一台主機的情況,光是 temp -> rename 就已經相當穩定
  • 有多個 consumer 時,加上 incoming -> processing 的 claim rename
  • 異質系統介接、NAS、共用資料夾的情況,連 manifest / done 與 idempotency 都做進去比較安全
  • 多個 writer 要更新同一份邏輯狀態時,不要在檔案介接上硬撐,也把資料庫與佇列納入考慮
  • 作業系統鎖定在同一群應用程式、同一套前提之內有效,但無法取代交接協定

最後一項同時也是撤退判斷。 有些問題用檔案來做,確實就是會很痛苦。

7. 總結

檔案介接的互斥控制,重點不是呼叫哪個鎖定函式,而是決定狀態轉移。這就是本文的骨幹。用檔名或目錄表達產生中 / 已公開 / 處理中 / 已處理,避開 Exists -> Create 的兩段檢查、直接寫入最終檔名、等待大小穩定、共用檔案的相互更新,以及對鎖定 API 的過度信任。在這個基礎上再把 temp -> close -> rename / replace、done / manifest、claim rename、lease 與 idempotency 組合起來,共用資料夾介接出事的機會就能降低相當多。

檔案介接的訣竅,是不要把「讀得到」與「可以讀」當成同一件事。光是把這兩者分開,那種只在半夜才出現的故障就會明顯變少。

8. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

檔案介接的互斥控制只靠鎖定 API 就夠了嗎?
多數情況下並不夠。Linux 的 flock 是 advisory lock,不遵守約定的一方照樣寫得出來;Windows 的 byte-range lock 在使用記憶體對應檔案時會被忽略。作業系統鎖定在同一群應用程式、同一套前提之內確實有效,但只能當成輔助,真正的主體是交接協定的設計:temp -> rename、done/manifest、原子性 claim 與 idempotency。
要怎麼避免別人讀到還在寫入中的檔案?
最正統的做法是用 temp -> close -> rename/replace 公開。產生中的檔案關在 temp 檔名裡,close 之後在同一個目錄內切換成 final 檔名,接收端只看 final 檔名。前提是 temp 與 final 放在同一個目錄,至少要在同一個磁碟區或檔案系統上;並且約定好:只要 final 檔名出現,內容就已經完成。
要怎麼防止多個 worker 同時處理同一個檔案?
在讀取之前先以原子方式取得 claim。具體做法是:只有把檔案從 incoming rename 到 processing/<worker>/ 成功的那個 worker 才進行處理。Exists -> Create 的兩段檢查把「確認」與「確保」拆成兩個操作,中間可以被其他處理程序插進來,因此達不到互斥。需要原子性建立時,就用 .NET 的 FileMode.CreateNew 系列或 POSIX 的 O_CREAT | O_EXCL。
使用 lock file 時有哪些注意事項?
不要只放一個空檔案,而要做成帶有效期限的 lease(所有權資訊),內含 ownerId、host、pid、acquiredAt、expiresAt、heartbeatAt。建立必須是原子操作,以停止更新作為判定 stale 的依據,刪除原則上只由建立者執行,並且要假設一定會有漏解除的情況,事先訂好復原步驟。不要指望單靠一張 lock file 就完全保證完整性,最後用 idempotency 接住的設計在實務上更禁得起考驗。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽