- File Lock 檔案鎖機制說明
📊 = 包含 Mermaid 互動式圖表
AuthGate CLI 使用檔案鎖(File Lock)機制來防止多個 CLI 進程同時修改 token 檔案時發生資料競爭(race condition)和資料損壞。
當多個 CLI 進程同時運行時,可能會發生以下問題:
sequenceDiagram
participant File as token 檔案
participant A as 進程 A
participant B as 進程 B
Note over File: 初始狀態: token_v1
A->>File: T1: ReadFile
File-->>A: token_v1
B->>File: T2: ReadFile
File-->>B: token_v1
Note over A: T3: 刷新 token
A->>A: token_v1 → token_v2
A->>File: T4: WriteFile
Note over File: token_v2
Note over B: T5: 刷新 token
B->>B: token_v1 → token_v3
B->>File: T6: WriteFile
Note over File: token_v3 ✓
Note over File,B: 結果正確,但浪費了一次刷新操作
結果:最終狀態正確(token_v3),但浪費了一次刷新操作。
sequenceDiagram
participant File as token 檔案
participant A as 進程 A<br/>(clientA)
participant B as 進程 B<br/>(clientB)
Note over File: 初始狀態: {A: old, B: old}
A->>File: T1: ReadFile
File-->>A: {A: old, B: old}
B->>File: T2: ReadFile
File-->>B: {A: old, B: old}
Note over A: T3: 更新 A 的 token
A->>A: {A: old, B: old}<br/>→ {A: new, B: old}
A->>File: T4: WriteFile
Note over File: {A: new, B: old}
Note over B: T5: 更新 B 的 token
B->>B: {A: old, B: old}<br/>→ {A: old, B: new}
B->>File: T6: WriteFile 💥
Note over File: {A: old, B: new}<br/>❌ A 的更新遺失!
Note over File,B: 嚴重資料損壞!
結果:Client A 的新 token 遺失! 這是嚴重的資料損壞問題。
graph TB
subgraph Layer3[Layer 3: Atomic Write 原子性寫入]
L3A[寫入臨時檔案<br/>.tmp]
L3B[原子性重命名<br/>os.Rename]
L3C[錯誤時清理]
end
subgraph Layer2[Layer 2: Stale Lock Detection 殭屍鎖檢測]
L2A[檢查 Lock 時間]
L2B[超過 30 秒?]
L2C[自動清理舊鎖]
end
subgraph Layer1[Layer 1: File Lock 進程級互斥鎖]
L1A[嘗試創建 .lock]
L1B[O_CREATE + O_EXCL]
L1C[寫入 PID]
end
Start([多進程並發訪問]) --> L1A
L1A --> L1B
L1B -->|成功| L1C
L1B -->|失敗| L2A
L2A --> L2B
L2B -->|是| L2C
L2C --> L1A
L2B -->|否| Wait[等待重試]
Wait --> L1A
L1C --> ReadWrite[讀取/修改資料]
ReadWrite --> L3A
L3A --> L3B
L3B --> Release[釋放 Lock]
L3B -.錯誤.-> L3C
Release --> End([完成])
style Layer1 fill:#e1f5ff
style Layer2 fill:#ffffcc
style Layer3 fill:#fff4e1
使用獨立的 .lock 檔案來協調多個進程的訪問:
type fileLock struct {
lockFile *os.File
lockPath string
}
func acquireFileLock(filePath string) (*fileLock, error) {
lockPath := filePath + ".lock"
// 使用 O_CREATE|O_EXCL 原子性創建 lock 檔案
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err == nil {
// 成功獲取鎖
fmt.Fprintf(lockFile, "%d", os.Getpid()) // 寫入 PID 方便調試
return &fileLock{lockFile: lockFile, lockPath: lockPath}, nil
}
// 鎖已被其他進程持有,等待重試...
}特點:
- 跨平台支持(使用標準 Go 檔案操作)
- 原子性創建(
O_EXCL保證排他性) - 自動重試(最多 50 次,每次 100ms,總共 5 秒超時)
- PID 記錄(方便調試)
flowchart TD
Start([acquireFileLock]) --> Init[初始化<br/>重試次數 = 0<br/>最大重試 = 50]
Init --> TryCreate{嘗試創建 .lock<br/>O_CREATE + O_EXCL}
TryCreate -->|成功| WritePID[寫入 PID]
WritePID --> Success([返回 fileLock ✓])
TryCreate -->|失敗: 已存在| CheckStale{檢查是否為<br/>Stale Lock}
CheckStale -->|檔案時間超過 30s| RemoveStale[刪除 Stale Lock]
RemoveStale --> Retry
CheckStale -->|檔案時間小於 30s| Wait[等待 100ms]
Wait --> IncRetry[重試次數 +1]
IncRetry --> CheckRetry{重試次數<br/>小於 50 ?}
CheckRetry -->|是| Retry[重試]
Retry --> TryCreate
CheckRetry -->|否| Timeout([返回錯誤<br/>Timeout])
TryCreate -->|失敗: 其他錯誤| Error([返回錯誤])
style Success fill:#ccffcc
style Timeout fill:#ffcccc
style Error fill:#ffcccc
style CheckStale fill:#ffffcc
處理進程 crash 時遺留的 lock 檔案:
// 檢查 lock 檔案是否超過 30 秒
if info, err := os.Stat(lockPath); err == nil {
age := time.Since(info.ModTime())
if age > 30*time.Second {
// Stale lock,自動清理
os.Remove(lockPath)
continue // 重試獲取鎖
}
}特點:
- 30 秒超時閾值(足夠長以避免誤判,足夠短以快速恢復)
- 自動清理殭屍鎖
- 不需要手動介入
使用 temp file + rename 模式確保寫入操作的原子性:
// 1. 先寫入臨時檔案
tempFile := tokenFile + ".tmp"
os.WriteFile(tempFile, data, 0o600)
// 2. 原子性重命名(覆蓋原檔案)
os.Rename(tempFile, tokenFile) // 這是原子操作!
// 3. 錯誤時清理臨時檔案
if err != nil {
os.Remove(tempFile)
}特點:
- 防止寫入到一半時被讀取(避免讀到損壞的 JSON)
os.Rename()在多數作業系統上是原子操作- 失敗時自動清理臨時檔案
flowchart TD
Start([開始 saveTokens]) --> AcquireLock[獲取 File Lock<br/>acquireFileLock]
AcquireLock --> CreateLock[創建 .lock 檔案<br/>寫入 PID]
CreateLock --> ReadFile[讀取現有檔案<br/>os.ReadFile]
ReadFile --> Unmarshal[解析 JSON<br/>json.Unmarshal]
Unmarshal --> Update[更新資料<br/>storageMap.Tokens += new]
Update --> Marshal[序列化 JSON<br/>json.MarshalIndent]
Marshal --> WriteTmp[寫入臨時檔案<br/>.tmp]
WriteTmp --> Rename[原子性重命名<br/>os.Rename 原子操作]
Rename --> ReleaseLock[釋放 File Lock<br/>lock.release]
ReleaseLock --> DeleteLock[刪除 .lock 檔案]
DeleteLock --> End([完成])
style AcquireLock fill:#e1f5ff
style Rename fill:#fff4e1
style ReleaseLock fill:#e1f5ff
sequenceDiagram
participant A as 進程 A
participant Lock as .lock 檔案
participant File as token 檔案
participant B as 進程 B
A->>Lock: acquireFileLock()
activate Lock
Lock-->>A: 創建成功 ✓
Note over B: 嘗試獲取鎖
B->>Lock: acquireFileLock()
Lock-->>B: 已存在,等待...
A->>File: ReadFile
File-->>A: {A: old, B: old}
Note over A: 修改 A 的 token
A->>A: Update A → new
A->>File: WriteFile (temp)
A->>File: Rename (atomic)
A->>Lock: release()
deactivate Lock
Lock-->>A: 刪除 .lock ✓
Note over B: 鎖已釋放,重試
B->>Lock: acquireFileLock()
activate Lock
Lock-->>B: 創建成功 ✓
B->>File: ReadFile
File-->>B: {A: new, B: old}
Note over B: 修改 B 的 token
B->>B: Update B → new
B->>File: WriteFile (temp)
B->>File: Rename (atomic)
B->>Lock: release()
deactivate Lock
Lock-->>B: 刪除 .lock ✓
Note over A,B: 最終結果:{A: new, B: new} ✓
.authgate-tokens.json # 實際的 token 檔案
.authgate-tokens.json.lock # Lock 檔案(包含持有者的 PID)
.authgate-tokens.json.tmp # 臨時寫入檔案(寫入完成後會被重命名)12345僅包含持有 lock 的進程 PID(方便調試)
maxRetries := 50 // 最多重試 50 次
retryDelay := 100 * time.Millisecond // 每次等待 100ms
// 總超時時間:50 * 100ms = 5 秒staleLockThreshold := 30 * time.Second如果 lock 檔案超過 30 秒沒有更新,視為殭屍鎖並自動清理。
| 測試名稱 | 測試內容 | 通過狀態 |
|---|---|---|
TestFileLock_BasicAcquireRelease |
基本的鎖獲取和釋放 | ✅ |
TestFileLock_ConcurrentAccess |
10 個 goroutines × 5 次操作 = 50 次並發 | ✅ |
TestFileLock_StaleLocksCleanup |
自動清理 30 秒以上的舊鎖 | ✅ |
TestFileLock_BlockedByActiveLock |
第二個進程等待第一個釋放 | ✅ |
TestFileLock_Timeout |
5 秒後超時返回錯誤 | ✅ |
TestSaveTokens_ConcurrentWrites |
10 個不同 client 並發保存 | ✅ |
TestSaveTokens_PreservesOtherClients |
不覆蓋其他 client 的 token | ✅ |
TestFileLock_MultipleReleases |
重複釋放不會 panic | ✅ |
BenchmarkFileLock_AcquireRelease 127μs/op 472 B/op 7 allocs/op
BenchmarkSaveTokens_SingleClient 374μs/op 3758 B/op 43 allocs/op
BenchmarkSaveTokens_ParallelWrites 6.2ms/op 102636 B/op 791 allocs/op解讀:
- 單次鎖操作:~0.1 毫秒(非常快)
- 單 client 保存:~0.4 毫秒(含 JSON 序列化)
- 10 個並發保存:~6.2 毫秒(含等待時間)
# 運行所有測試
go test -v
# 快速測試(跳過超時測試)
go test -short
# 查看測試覆蓋率
go test -cover
# 運行性能測試
go test -bench=. -benchmem所有檔案使用 0600 權限(僅所有者可讀寫):
os.WriteFile(tempFile, data, 0o600)
os.OpenFile(lockPath, ..., 0600)使用 os.OpenFile 的 O_EXCL 標誌防止符號連結攻擊:
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)如果 lockPath 已存在(包括符號連結),操作會失敗。
os.Rename() 在以下作業系統上是原子操作:
- ✅ Linux (ext4, xfs, btrfs)
- ✅ macOS (APFS, HFS+)
- ✅ Windows (NTFS)
flowchart TD
A[進程 A 獲取鎖] --> B[進程 Crash 💥]
B --> C[Lock 檔案遺留<br/>.lock 未清理]
C --> D[進程 B 嘗試獲取鎖]
D --> E{檢查 Lock 時間}
E -->|> 30 秒| F[判定為 Stale Lock]
F --> G[自動清理 .lock]
G --> H[獲取鎖成功]
H --> I[繼續執行 ✓]
style B fill:#ffcccc
style F fill:#ffffcc
style I fill:#ccffcc
flowchart TD
A[進程 A 持有鎖] --> B[執行耗時操作<br/>例如:網路延遲]
B --> C[進程 B 嘗試獲取鎖]
C --> D[重試 1..50 次<br/>每次等待 100ms]
D --> E{等待 5 秒}
E -->|鎖已釋放| F[獲取成功 ✓]
E -->|仍被持有| G[超時返回錯誤]
G --> H{使用者選擇}
H -->|1| I[重試操作]
H -->|2| J[檢查殭屍進程<br/>ps aux]
H -->|3| K[手動刪除 .lock]
style G fill:#ffcccc
style F fill:#ccffcc
// 寫入臨時檔案失敗
if err := os.WriteFile(tempFile, data, 0o600); err != nil {
// 錯誤處理:
// 1. Lock 會自動釋放(defer)
// 2. 臨時檔案不存在或未完成
// 3. 原檔案保持不變(資料安全)
return fmt.Errorf("failed to write temp file: %w", err)
}- Lock 獲取:<1ms
- 檔案讀取:~1-5ms(取決於檔案大小)
- JSON 序列化:~0.1ms
- 檔案寫入:~1-5ms
- Lock 釋放:<1ms
- 總計:~3-12ms
- 等待其他進程:最多 5 秒(超時)
- 其他操作同上
- 總計:~5 秒(超時情況)
假設每次操作 10ms:
無 lock:理論上並發執行(實際會資料損壞)
有 lock:串行執行,10 進程需要 100ms
性能損失:10% 吞吐量換取 100% 資料完整性 ✓❌ 不好的做法:
lock := acquireFileLock(file)
makeAPICall() // 可能很慢!
saveTokens()
lock.release()✅ 好的做法:
// 先完成網路操作
newToken := makeAPICall()
// 只在需要時持有鎖
lock := acquireFileLock(file)
saveTokens(newToken)
lock.release()lock, err := acquireFileLock(file)
if err != nil {
return err
}
defer lock.release() // 確保總是釋放
// ... 進行操作lock, err := acquireFileLock(file)
if err != nil {
if strings.Contains(err.Error(), "timeout") {
// 超時:可能有其他進程卡住
fmt.Println("Warning: Lock timeout, another process may be stuck")
fmt.Println("Try again or remove .lock file manually")
}
return err
}# 清理遺留的臨時檔案(正常情況下不應該有)
find . -name "*.json.tmp" -mtime +1 -delete
find . -name "*.json.lock" -mtime +1 -deleteA: 這是一個簡單的 CLI 工具,使用 JSON 檔案存儲有以下優勢:
- 無需額外依賴(SQLite 需要 CGO)
- 使用者可以直接查看和編輯
- 跨平台兼容性好
- 對於少量 token(通常 <10 個 clients)性能足夠
A: 不完全可靠。NFS 的檔案鎖實作依賴於 lockd/statd 服務,可能有延遲或不一致問題。
建議:
- 本地磁碟:完全支持 ✅
- NFS v4:基本支持(但有延遲)
⚠️ - NFS v3 或更舊:不建議 ❌
A: 這是權衡的結果:
- 太短(例如 5 秒):正常的慢網路可能被誤判為 stale
- 太長(例如 5 分鐘):crash 後恢復太慢
30 秒是一個合理的中間值,對於 token 操作(通常 <1 秒)足夠寬鬆。
A: 可以,但要小心:
# 檢查是否有進程正在使用
lsof .authgate-tokens.json.lock
# 如果沒有進程在使用,可以安全刪除
rm .authgate-tokens.json.lock注意:如果有進程正在持有鎖,刪除可能導致資料競爭。
A: Lock file 方式的優勢:
- ✅ 純 Go 實作,無需 CGO
- ✅ 跨平台一致性(Windows 不支持 flock)
- ✅ 可以記錄額外資訊(PID)
- ✅ 實作簡單,易於調試
flock() 的缺點:
- ❌ 需要
syscall包,不同平台行為不一致 - ❌ Windows 需要特殊處理
- ❌ 進程 crash 時可能不自動釋放(取決於作業系統)
| 版本 | 日期 | 變更內容 |
|---|---|---|
| 1.0 | 2025-01-22 | 初始版本:實作 file lock 機制 |
本文件遵循與 AuthGate 專案相同的授權條款。