檔案整合的互斥控制基礎 - 檔案鎖與原子性 claim 的最佳實務

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

檔案整合的互斥控制,在共享資料夾、夜間批次、跨行程整合幾乎一定會出問題。 搜尋時常見的疑問是:光用檔案鎖就夠了嗎?多個 worker 怎麼避免抓到同一份檔案?怎麼避開寫到一半的檔案?

本文以檔案鎖、原子性 claim、temp -> rename、idempotency 為軸整理檔案整合的互斥控制。

1. 先下結論(一句話)

  • 檔案整合最重要的,是讓「最終檔名一出現,就已經可以讀取」的狀態成立
  • 用檔名或資料夾去區分「產生中 / 已公開 / 處理中 / 已處理」
  • 若有多個 worker,讀之前要先以原子方式取得 claim
  • lock file 或 OS 層級鎖只是輔助,最後靠 idempotency 接住

簡單說,檔案整合與其說是 互斥控制,不如說是 交接協定 的設計才是核心。 叫一個鎖的函式就能完事是不存在的。

2. 檔案整合會出現的競爭模式(圖)

2.1. 讀到寫到一半的檔

直接對最終檔名開始寫,就會發生這種事故。 JSON 會缺閉括號,CSV 行數不夠,ZIP 就直接壞掉。

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

2.2. 多個 worker 同時抓同一份檔

「看一下目錄,沒處理就打開」這種流程,很容易讓兩個 worker 都抓住同一份檔案。 重複計算、重複送出就是這樣開始的。

incomingworker2worker1incomingworker2worker1同一份輸入被重複處理找到 a.csv找到 a.csv開始讀取開始讀取

2.3. stale lock 讓所有人都卡住

只放一個 lock file 的設計,很容易在異常結束時卡死。 不知道這是誰的 lock、還活不活著、什麼時候失效,後面的人會永遠等下去。

workerBlock 檔workerAworkerBlock 檔workerA這裡異常結束無法判定 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.csvcounter.json,幾乎都是最後寫的人贏。 把檔案整合當作簡易 DB 用,就會從這裡開始吃苦。

status.csvbatchBbatchAstatus.csvbatchBbatchAA 的更新消失讀到 v1讀到 v1寫入 v2-A寫入 v2-B

也有 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 檔名。

建立唯一的 temp 檔名把所有內容寫到 tempflush / close在同一資料夾改名/取代為 final接收端只監看 final

重點:

  • temp 與 final 要在 同一資料夾,至少在 同一個磁碟區/檔案系統
  • 在 Windows / .NET 上可以考慮 File.Replace
  • 約定:看到 final 檔名時,內容已完整

如果把 temp 放在別的磁碟,rename 會變成複製、Replace 可能失敗。 這個前提雖不起眼,但非常重要。

4.2. 用 done / manifest 明確標示完整性

除了資料本體,用另一個檔案明確標示「什麼已經完成」,接收端會更穩。 跨異質系統整合時尤其有效。

產生 data.tmp公開為 data.csv產生 data.done / manifest.json接收端偵測 done / manifest檢查檔名、大小、雜湊

manifest 可以放這些欄位:

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

順序也很重要。 若 done 比本體先出現,那就不是完成通知,而是 事故預告。

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

多個 worker 都看同一個 incoming 時,「讀之前先搬到自己名下」最直觀。 只有 rename 成功、把檔從 incoming 移到 processing/<worker>/ 的 worker 才處理。

processingincomingworker2worker1processingincomingworker2worker1先成功的那一邊取得所有權找到 a.csv找到 a.csvrename a.csvrename a.csv

運維上,把資料夾也拆開比較好追蹤。

publishclaim成功失敗tempincomingprocessingarchiveerror

claim 用的 rename 也需要在同一檔案系統內進行。

4.4. 要用 lock file 就做成 lease

用 lock file 的話,不要只是一個空檔,而是一份 帶有效期限的持有資訊。 不知道誰拿了的 lock,之後一定會吵架。

lock.jsonownerIdhostpidacquiredAtexpiresAtheartbeatAt

要點:

  • 建立要原子
  • 以「停止更新」作為 stale 判定依據
  • 刪除 原則上只由建立者 做
  • 預設會有漏解鎖的情況,要有恢復流程

lock file 終究是 協調用的號牌。 期望它一張票就保證完整性,通常會很辛苦。

4.5. 以 idempotency 為前提

互斥控制很重要,但實際運維上,「偶爾會重複送來」「中途會重跑」是無法歸零的。 最後還是要讓 同一輸入吃第二次也不會壞 的設計發揮作用。

輸入 + idempotency key已處理過嗎不重執行,視為成功執行處理記到已處理帳冊

例如,為每份接收檔配上整合 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);
}

三個問題:

  • ExistsWriteAllText 是不同操作
  • 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 / replace
  • done / manifest 明確宣告完整性
  • 用 claim rename 取得所有權
  • 用 lease 與 idempotency 應付失敗

也就是說,檔案整合的訣竅是:不要把「能讀」與「可以讀」當成同一件事。 光是把這兩者分開,那些只在深夜才出現的事故就會少很多。

8. 參考資料

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

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

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

常見問題

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

檔案整合為什麼不能只靠檔案鎖?
lock API 只有在所有參與者都遵守同一套約定時才有效。Linux 的 flock 是 advisory lock,不守約定的行程照樣能寫;Windows 的 byte-range lock 在 memory-mapped file 裡會被忽略。因此檔案整合的核心不是呼叫哪個鎖函式,而是「交接協定」的設計:用檔名或資料夾區分「產生中 / 已公開 / 處理中 / 已處理」等狀態。
怎麼避免讀到寫到一半的檔案?
王道做法是 temp -> close -> rename/replace。產生中的檔案放在 temp 檔名,close 之後再改名為最終檔名,接收端只看最終檔名。重點是 temp 與 final 要在同一資料夾,至少在同一個磁碟區,否則 rename 會變成複製。另外可以用 done 檔或 manifest 明確宣告完整性,內含目標檔名、大小、雜湊、整合 ID 等資訊。
多個 worker 怎麼避免抓到同一份檔案?
讀取之前先以原子方式取得 claim。做法是只有 rename 成功、把檔案從 incoming 移到 processing/<worker>/ 的 worker 才處理,先成功的那一方取得所有權。「確認 lock 不存在再建立」這種 Exists -> Create 兩段檢查無法達到互斥,因為確認與確保是兩個獨立操作,中間會有其他行程插進來;需要把「沒有就建立」做成單一原子操作。
什麼是 idempotency,為什麼檔案整合需要它?
idempotency(冪等性)是讓同一輸入吃第二次也不會壞的設計。實際運維上「偶爾重複送來」「中途重跑」無法歸零,因此為每份接收檔配上整合 ID,記到已處理帳冊,已處理過就不重執行、直接視為成功。這樣就算互斥控制一次失守,結果也不會被重複計算,運維會輕鬆很多。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽