檔案介接互斥控制的基礎知識 - 檔案鎖定與原子性 claim 的最佳實踐
· 更新日期: · Go Komura · 檔案介接, 互斥控制, 設計, Windows 開發
更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(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
目錄
- 先講結論(一句話)
- 檔案介接會發生的競爭模式(圖)
- 2.1. 讀到寫到一半的檔案
- 2.2. 多個 worker 同時撿到同一個檔案
- 2.3. stale lock 讓所有人都停住
- 反模式
- 3.1.
Exists -> Create的兩段檢查 - 3.2. 直接寫入最終檔名
- 3.3. 檔案大小不再變動就當成完成
- 3.4. 大家一起更新同一個共用檔案
- 3.5. 把鎖定 API 當成萬能
- 3.1.
- 最佳實踐
- 4.1. 用
temp -> close -> rename / replace公開 - 4.2. 用
done/ manifest 明示完整性 - 4.3. 接收端以原子方式取得 claim
- 4.4. 要靠 lock file 就做成 lease
- 4.5. 以 idempotency 為前提
- 4.1. 用
- 虛擬碼(節錄)
- 大致的取捨
- 總結
- 參考資料
檔案介接這個領域,比起程式碼本身,更容易壞掉的是「交接的約定」。 單元測試會通過,卻只在正式環境的共用資料夾或夜間批次上偶爾壞掉,而且很難重現。這種情況其實相當常見。
原因大多不在檔案 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 則是直接壞掉。
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 worker 1
participant W2 as worker 2
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 worker A
participant Lock as lock 檔案
participant B as worker B
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 的設計,結果幾乎都是最後寫的人贏。
一旦把檔案介接當成簡易資料庫來用,就會從這裡開始難受。
sequenceDiagram
participant A as 批次 A
participant B as 批次 B
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. 把鎖定 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 檔名。
flowchart LR
A[建立唯一的 temp 檔名] --> B[把全部內容寫進 temp]
B --> C[執行 flush / close]
C --> D[在同一個目錄內 rename / replace 成 final 檔名]
D --> E[接收端只監看 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 明示完整性
除了資料本體之外,再用另一個檔案明示「什麼已經完成」,接收端就會比較穩定。 在異質系統介接的場合特別有效。
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 時,「讀之前先搬到自己名下」是最好懂的做法。
只有把檔案從 incoming rename 到 processing/<worker>/ 成功的 worker 才進行處理。
sequenceDiagram
participant W1 as worker 1
participant W2 as worker 2
participant IN as incoming
participant PR as processing
W1->>IN: 找到 a.csv
W2->>IN: 找到 a.csv
W1->>PR: 把 a.csv rename 過去
W2->>PR: 把 a.csv rename 過去
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. 虛擬碼(節錄)
接下來出現的 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. 參考資料
- 本文的完整範例程式碼(函式庫、demo、單元測試) - komurasoft-blog-samples (GitHub)
- LockFileEx function (Win32)
- Locking and Unlocking Byte Ranges in Files (Win32)
- Moving and Replacing Files (Win32)
- MoveFileEx function (Win32)
- File Times (Win32)
- FileStream.Lock Method (.NET)
- File.Replace Method (.NET)
- rename — POSIX
- open — POSIX (
O_CREAT | O_EXCL) - flock(2) — Linux manual page
- open(2) — Linux manual page
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
ADR(Architecture Decision Record)入門 ── 在小規模開發中留下「為何採用此設計」的最小做法
程式碼不會說明「為什麼這樣做」。本文說明如何用ADR(Architecture Decision Record)以「1個決定=1個檔案」的Markdown留下設計判斷的理由,並附上範本、該寫/不該寫的判斷表與實例。
Windows 上應優先採用事件等待而非 Sleep(1) 的理由
在 Windows 上,短時間 timed wait 的精度會受 system clock 粒度與排程的影響。本文梳理在等待工作抵達、I/O 完成或停止請求時,為什麼應該改用事件驅動,而不是每隔一段時間輪詢。
非預期例外發生時該結束還是繼續執行的判斷表
發生非預期的例外時,應該讓應用程式結束還是繼續執行?本文從狀態毀損、外部副作用、執行緒與原生邊界的角度梳理判斷方式。
Windows 應用程式開發的最低限度資安檢核表
以檢核表形式整理 WPF / WinForms / WinUI / C++ / C# 業務應用程式在權限、簽署、更新、機密資訊、HTTPS、輸入驗證、DLL 載入與日誌方面的基本要點。
在桌面應用程式使用 .NET Generic Host 與 BackgroundService 的理由
為了在 Windows 工具與常駐型應用程式中梳理啟動、定期處理、終止處理、日誌、設定與 DI,本文整理 Generic Host 與 BackgroundService 該怎麼用。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
在包含共用資料夾介接與夜間批次的 Windows 應用程式開發中,互斥控制的設計會直接影響實作品質。
技術諮詢 & 設計審查
若想先梳理鎖定、原子性 claim 與 idempotency 之間的職責劃分,可以用技術諮詢與設計審查的形式來處理。
常見問題
整理諮詢這個主題時常見的問題。
- 檔案介接的互斥控制只靠鎖定 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 接住的設計在實務上更禁得起考驗。