檔案整合的互斥控制基礎 - 檔案鎖與原子性 claim 的最佳實務
檔案整合的互斥控制,在共享資料夾、夜間批次、跨行程整合幾乎一定會出問題。 搜尋時常見的疑問是:光用檔案鎖就夠了嗎?多個 worker 怎麼避免抓到同一份檔案?怎麼避開寫到一半的檔案?
本文以檔案鎖、原子性 claim、temp -> rename、idempotency 為軸整理檔案整合的互斥控制。
1. 先下結論(一句話)
- 檔案整合最重要的,是讓「最終檔名一出現,就已經可以讀取」的狀態成立
- 用檔名或資料夾去區分「產生中 / 已公開 / 處理中 / 已處理」
- 若有多個 worker,讀之前要先以原子方式取得 claim
- lock file 或 OS 層級鎖只是輔助,最後靠 idempotency 接住
簡單說,檔案整合與其說是 互斥控制,不如說是 交接協定 的設計才是核心。 叫一個鎖的函式就能完事是不存在的。
2. 檔案整合會出現的競爭模式(圖)
2.1. 讀到寫到一半的檔
直接對最終檔名開始寫,就會發生這種事故。 JSON 會缺閉括號,CSV 行數不夠,ZIP 就直接壞掉。
sequenceDiagram
participant 送方 as 送出端
participant 共享 as 共享資料夾
participant 收方 as 接收端
送方->>共享: 以最終檔名建立 orders.csv
送方->>共享: 寫入第 1~5000 行中
收方->>共享: 偵測到 orders.csv
收方->>共享: 直接開始讀
Note over 收方: 還沒寫完
送方->>共享: 寫入剩下的
Note over 收方: 行數不足 / 解析失敗 / 只處理部分
2.2. 多個 worker 同時抓同一份檔
「看一下目錄,沒處理就打開」這種流程,很容易讓兩個 worker 都抓住同一份檔案。 重複計算、重複送出就是這樣開始的。
sequenceDiagram
participant W1 as worker1
participant W2 as worker2
participant Dir as incoming
W1->>Dir: 找到 a.csv
W2->>Dir: 找到 a.csv
W1->>Dir: 開始讀取
W2->>Dir: 開始讀取
Note over W1,W2: 同一份輸入被重複處理
2.3. stale lock 讓所有人都卡住
只放一個 lock file 的設計,很容易在異常結束時卡死。 不知道這是誰的 lock、還活不活著、什麼時候失效,後面的人會永遠等下去。
sequenceDiagram
participant A as workerA
participant Lock as lock 檔
participant B as workerB
A->>Lock: 建立 lock
Note over A: 這裡異常結束
B->>Lock: 確認 lock 存在
B->>Lock: 放棄開始處理
B->>Lock: 再等
Note over B,Lock: 無法判定 stale,全員停擺
3. 反模式
3.1. Exists -> Create 兩段檢查
問題在於「確認」與「確保」是兩個獨立操作。 中間會有其他行程插進來,根本沒達到互斥。
sequenceDiagram
participant A as 行程A
participant B as 行程B
participant FS as 檔案系統
A->>FS: 確認是否沒有 lock
B->>FS: 確認是否沒有 lock
FS-->>A: 沒有
FS-->>B: 沒有
A->>FS: 建立 lock
B->>FS: 建立 lock
Note over A,B: 兩邊都被放行
典型的錯誤寫法:
if (!File.Exists(lockPath))
{
File.WriteAllText(lockPath, Environment.ProcessId.ToString());
ProcessFile();
}
需要的是 把「沒有就建立」做成單一操作。
.NET 上可以用 FileMode.CreateNew 系列;POSIX 系用 O_CREAT | O_EXCL 這類原子性建立。
3.2. 直接對最終檔名寫入
若接收端認定「看到那個檔名就能讀」,你一開始對最終檔名寫入就輸了。 不要把 被看到 與 可以被讀 劃等號,才是基本。
flowchart LR
A[看到 final 檔名] --> B[接收端偵測到]
B --> C[送出端還在寫]
C --> D[讀到不完整的資料]
using var writer = OpenForWrite(finalPath); // 這裡 finalPath 就被看到了
foreach (var row in rows)
{
writer.WriteLine(row);
}
這種寫法等於自己把 2.1 的事故召喚出來。
3.3. 檔案大小不變就當完成
這看起來很方便,但其實相當危險。 跨網路的複製、送出端暫停、緩衝、重試,都會讓大小起伏。
sequenceDiagram
participant 送方 as 送出端
participant 共享 as 共享資料夾
participant 收方 as 接收端
送方->>共享: 開始複製 data.zip
送方->>共享: 中途暫停
收方->>共享: 大小 10 秒沒變
Note over 收方: 誤判為完成
收方->>共享: 開始讀取
送方->>共享: 複製恢復
if (currentLength == lastLength && stableSeconds >= 10)
{
return Ready;
}
用 推測 認定完成,在共享資料夾或大檔上很容易被絆倒。 完成應該用 manifest 或 done file 明確 宣告,才會穩定。
3.4. 大家都更新同一個共用檔
大家讀並更新一份 status.csv 或 counter.json,幾乎都是最後寫的人贏。
把檔案整合當作簡易 DB 用,就會從這裡開始吃苦。
sequenceDiagram
participant A as batchA
participant B as batchB
participant F as status.csv
A->>F: 讀到 v1
B->>F: 讀到 v1
A->>F: 寫入 v2-A
B->>F: 寫入 v2-B
Note over F: A 的更新消失
也有 append-only 的變通,但在不同檔案系統或部署型態下意義會變。 需要共用更新的話,這邊不要硬撐用檔案整合會比較好。
3.5. 以為 lock API 萬能
lock API 很重要,但它只有在 所有參與者都遵守同一套約定 時才有效。 跨異質系統時,別太信任它。
補充:
- Linux 的
flock是 advisory lock,不守約定的人照樣能寫 - Windows 的 byte-range lock 在 memory-mapped file 裡會被忽略
- 所以別讓 OS 層級鎖 單獨扛起完成通知或所有權的設計
4. 最佳實務
4.1. 以 temp -> close -> rename / replace 公開
這是王道。 產生中的檔案放在 temp 檔名,close 之後再改名為 final。 接收端只看 final 檔名。
flowchart LR
A[建立唯一的 temp 檔名] --> B[把所有內容寫到 temp]
B --> C[flush / close]
C --> D[在同一資料夾改名/取代為 final]
D --> E[接收端只監看 final]
重點:
- temp 與 final 要在 同一資料夾,至少在 同一個磁碟區/檔案系統
- 在 Windows / .NET 上可以考慮
File.Replace系 - 約定:看到 final 檔名時,內容已完整
如果把 temp 放在別的磁碟,rename 會變成複製、Replace 可能失敗。
這個前提雖不起眼,但非常重要。
4.2. 用 done / manifest 明確標示完整性
除了資料本體,用另一個檔案明確標示「什麼已經完成」,接收端會更穩。 跨異質系統整合時尤其有效。
flowchart TD
A[產生 data.tmp] --> B[公開為 data.csv]
B --> C[產生 data.done / manifest.json]
C --> D[接收端偵測 done / manifest]
D --> E[檢查檔名、大小、雜湊]
manifest 可以放這些欄位:
- 目標檔名
- 大小
- 雜湊
- 紀錄筆數
- 整合 ID / idempotency key
- 產生時間
順序也很重要。
若 done 比本體先出現,那就不是完成通知,而是 事故預告。
4.3. 接收端以原子方式取得 claim
多個 worker 都看同一個 incoming 時,「讀之前先搬到自己名下」最直觀。
只有 rename 成功、把檔從 incoming 移到 processing/<worker>/ 的 worker 才處理。
sequenceDiagram
participant W1 as worker1
participant W2 as worker2
participant IN as incoming
participant PR as processing
W1->>IN: 找到 a.csv
W2->>IN: 找到 a.csv
W1->>PR: rename a.csv
W2->>PR: rename a.csv
Note over W1,W2: 先成功的那一邊取得所有權
運維上,把資料夾也拆開比較好追蹤。
flowchart LR
T[temp] -->|publish| I[incoming]
I -->|claim| P[processing]
P -->|成功| A[archive]
P -->|失敗| E[error]
claim 用的 rename 也需要在同一檔案系統內進行。
4.4. 要用 lock file 就做成 lease
用 lock file 的話,不要只是一個空檔,而是一份 帶有效期限的持有資訊。 不知道誰拿了的 lock,之後一定會吵架。
flowchart TD
L[lock.json] --> A[ownerId]
L --> B[host]
L --> C[pid]
L --> D[acquiredAt]
L --> E[expiresAt]
L --> F[heartbeatAt]
要點:
- 建立要原子
- 以「停止更新」作為 stale 判定依據
- 刪除 原則上只由建立者 做
- 預設會有漏解鎖的情況,要有恢復流程
lock file 終究是 協調用的號牌。 期望它一張票就保證完整性,通常會很辛苦。
4.5. 以 idempotency 為前提
互斥控制很重要,但實際運維上,「偶爾會重複送來」「中途會重跑」是無法歸零的。 最後還是要讓 同一輸入吃第二次也不會壞 的設計發揮作用。
flowchart LR
A[輸入 + idempotency key] --> B{已處理過嗎}
B -- 是 --> C[不重執行,視為成功]
B -- 否 --> D[執行處理]
D --> E[記到已處理帳冊]
例如,為每份接收檔配上整合 ID,記到已處理帳冊。 這樣就算互斥一次失守,結果也不會被重複計算,運維就輕鬆很多。
5. 虛擬碼(節錄)
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/同一 host 時,光
temp -> rename就已經很穩 - 有多個 consumer,就加
incoming -> processing的 claim rename - 跨異質系統、NAS、共享資料夾,建議連 manifest / done 與 idempotency 一起做
- 多個 writer 要更新同一邏輯狀態,別在檔案整合裡硬撐,考慮 DB 或 queue
- OS 層級鎖在同一群應用/同一前提下有效,但不能取代交接協定
最後一條其實也是「撤退判斷」。 有些問題用檔案來處理,本來就會很辛苦。
7. 總結
互斥控制的核心:
- 檔案整合的互斥控制,重點不是呼叫哪個鎖函式,而是決定狀態轉移
- 用檔名或資料夾區分「產生中 / 已公開 / 處理中 / 已處理」,事故會減少
想避免的設計:
Exists -> Create- 直接寫最終檔名
- 等大小穩定
- 大家更新同一共用檔
- 把一切都壓在 lock API 上
實務上有效的對策:
temp -> close -> rename / replacedone/ manifest 明確宣告完整性- 用 claim rename 取得所有權
- 用 lease 與 idempotency 應付失敗
也就是說,檔案整合的訣竅是:不要把「能讀」與「可以讀」當成同一件事。 光是把這兩者分開,那些只在深夜才出現的事故就會少很多。
8. 參考資料
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
Windows 上為什麼應先用事件等待而不是計時器等待 - 避免以約 15.6ms 粒度做輪詢
本文聚焦於 Windows 上短時間 timed wait 為何不可靠,並說明在工作抵達、I/O 完成或停止請求等場景應改採 event 驅動。讀者可學會以 system clock 粒度與排程延遲為線索,挑選 event、semaphore、WaitOnAddress 或...
發生非預期例外時的 checklist - 要讓應用結束還是繼續,先看的判斷表
本文以 C# / .NET 與 Windows 應用為前提,把非預期例外發生後該結束還是繼續的判斷拆成失敗單位、共用狀態、外部副作用、原生邊界四個觀察點,並提供判斷表與典型情境,協助讀者在 catch 之前先判斷是否還能信任應用狀態。
Windows 應用程式開發中遵守最低限度安全性的檢核表
用檢核表形式整理 WPF / WinForms / WinUI / C++ / C# 等 Windows 應用程式發佈前最低限不想漏的安全性要點。涵蓋避免不必要的管理員權限、EXE 與更新物簽章加時間戳、改用 DPAPI 與 Credential Locker、保留 HTT...
把 Generic Host / BackgroundService 帶進桌面應用程式的理由 - 啟動・壽命・graceful shutdown 的整理會輕鬆很多
整理把 .NET Generic Host 與 BackgroundService 帶進 WPF / WinForms 桌面應用程式的理由,把啟動、lifetime、graceful shutdown 集中於入口管理。透過 StartAsync / ExecuteAsync...
FileSystemWatcher 的使用方法與注意事項 - 漏掉、重複通知、完成判定的陷阱
本文整理 FileSystemWatcher 的正確用法。把事件視為跡象而非完成通知,將通知摺疊為重新掃描請求,由傳送端以 temp 後 rename 明示完成,多 worker 以原子性 claim 取得所有權,最後以 full rescan 與 idempotency ...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
技術諮詢 & 設計審查
協助釐清設計方向、架構邊界、生命週期責任,以及既有 Windows 資產的處理方式。