Skip to content

Instantly share code, notes, and snippets.

@appleboy
Last active January 22, 2026 10:26
Show Gist options
  • Select an option

  • Save appleboy/8c19a225b753782c7c24966fbce8d7b8 to your computer and use it in GitHub Desktop.

Select an option

Save appleboy/8c19a225b753782c7c24966fbce8d7b8 to your computer and use it in GitHub Desktop.

File Lock 檔案鎖機制說明

目錄

📊 = 包含 Mermaid 互動式圖表

概述

AuthGate CLI 使用檔案鎖(File Lock)機制來防止多個 CLI 進程同時修改 token 檔案時發生資料競爭(race condition)和資料損壞。

問題背景

為什麼需要 File Lock?

當多個 CLI 進程同時運行時,可能會發生以下問題:

場景 1:同一個 Client ID 的多個進程

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: 結果正確,但浪費了一次刷新操作
Loading

結果:最終狀態正確(token_v3),但浪費了一次刷新操作。

場景 2:不同 Client ID 的多個進程(更嚴重!)

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: 嚴重資料損壞!
Loading

結果: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
Loading

Layer 1: File Lock(進程級互斥鎖)

使用獨立的 .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
Loading

Layer 2: Stale Lock Detection(殭屍鎖檢測)

處理進程 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 秒超時閾值(足夠長以避免誤判,足夠短以快速恢復)
  • 自動清理殭屍鎖
  • 不需要手動介入

Layer 3: Atomic Write(原子性寫入)

使用 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() 在多數作業系統上是原子操作
  • 失敗時自動清理臨時檔案

完整流程

1. Token 保存流程

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
Loading

2. 並發場景處理

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} ✓
Loading

實作細節

檔案結構

.authgate-tokens.json      # 實際的 token 檔案
.authgate-tokens.json.lock # Lock 檔案(包含持有者的 PID)
.authgate-tokens.json.tmp  # 臨時寫入檔案(寫入完成後會被重命名)

Lock 檔案格式

12345

僅包含持有 lock 的進程 PID(方便調試)

重試機制參數

maxRetries := 50                   // 最多重試 50 次
retryDelay := 100 * time.Millisecond  // 每次等待 100ms
// 總超時時間:50 * 100ms = 5 秒

Stale Lock 閾值

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

安全性考量

1. 權限設置

所有檔案使用 0600 權限(僅所有者可讀寫):

os.WriteFile(tempFile, data, 0o600)
os.OpenFile(lockPath, ..., 0600)

2. 防止符號連結攻擊

使用 os.OpenFileO_EXCL 標誌防止符號連結攻擊:

lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)

如果 lockPath 已存在(包括符號連結),操作會失敗。

3. 原子性保證

os.Rename() 在以下作業系統上是原子操作:

  • ✅ Linux (ext4, xfs, btrfs)
  • ✅ macOS (APFS, HFS+)
  • ✅ Windows (NTFS)

故障處理

場景 1:進程 Crash

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
Loading

場景 2:超時

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
Loading

場景 3:磁碟空間不足

// 寫入臨時檔案失敗
if err := os.WriteFile(tempFile, data, 0o600); err != nil {
    // 錯誤處理:
    // 1. Lock 會自動釋放(defer)
    // 2. 臨時檔案不存在或未完成
    // 3. 原檔案保持不變(資料安全)
    return fmt.Errorf("failed to write temp file: %w", err)
}

效能影響

Best Case(無競爭)

  • Lock 獲取:<1ms
  • 檔案讀取:~1-5ms(取決於檔案大小)
  • JSON 序列化:~0.1ms
  • 檔案寫入:~1-5ms
  • Lock 釋放:<1ms
  • 總計:~3-12ms

Worst Case(有競爭)

  • 等待其他進程:最多 5 秒(超時)
  • 其他操作同上
  • 總計:~5 秒(超時情況)

多進程吞吐量

假設每次操作 10ms:

無 lock:理論上並發執行(實際會資料損壞)
有 lock:串行執行,10 進程需要 100ms

性能損失:10% 吞吐量換取 100% 資料完整性 ✓

最佳實踐

1. 避免長時間持有鎖

❌ 不好的做法:

lock := acquireFileLock(file)
makeAPICall()  // 可能很慢!
saveTokens()
lock.release()

✅ 好的做法:

// 先完成網路操作
newToken := makeAPICall()

// 只在需要時持有鎖
lock := acquireFileLock(file)
saveTokens(newToken)
lock.release()

2. 使用 defer 確保釋放

lock, err := acquireFileLock(file)
if err != nil {
    return err
}
defer lock.release()  // 確保總是釋放

// ... 進行操作

3. 處理超時錯誤

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
}

4. 定期清理臨時檔案

# 清理遺留的臨時檔案(正常情況下不應該有)
find . -name "*.json.tmp" -mtime +1 -delete
find . -name "*.json.lock" -mtime +1 -delete

常見問題 (FAQ)

Q1: 為什麼不使用資料庫?

A: 這是一個簡單的 CLI 工具,使用 JSON 檔案存儲有以下優勢:

  • 無需額外依賴(SQLite 需要 CGO)
  • 使用者可以直接查看和編輯
  • 跨平台兼容性好
  • 對於少量 token(通常 <10 個 clients)性能足夠

Q2: File lock 在 NFS 上是否可靠?

A: 不完全可靠。NFS 的檔案鎖實作依賴於 lockd/statd 服務,可能有延遲或不一致問題。

建議

  • 本地磁碟:完全支持 ✅
  • NFS v4:基本支持(但有延遲)⚠️
  • NFS v3 或更舊:不建議 ❌

Q3: 30 秒的 stale lock 閾值是否太長?

A: 這是權衡的結果:

  • 太短(例如 5 秒):正常的慢網路可能被誤判為 stale
  • 太長(例如 5 分鐘):crash 後恢復太慢

30 秒是一個合理的中間值,對於 token 操作(通常 <1 秒)足夠寬鬆。

Q4: 可以手動刪除 .lock 檔案嗎?

A: 可以,但要小心:

# 檢查是否有進程正在使用
lsof .authgate-tokens.json.lock

# 如果沒有進程在使用,可以安全刪除
rm .authgate-tokens.json.lock

注意:如果有進程正在持有鎖,刪除可能導致資料競爭。

Q5: 為什麼選擇 lock file 而不是 flock()?

A: Lock file 方式的優勢:

  • ✅ 純 Go 實作,無需 CGO
  • ✅ 跨平台一致性(Windows 不支持 flock)
  • ✅ 可以記錄額外資訊(PID)
  • ✅ 實作簡單,易於調試

flock() 的缺點:

  • ❌ 需要 syscall 包,不同平台行為不一致
  • ❌ Windows 需要特殊處理
  • ❌ 進程 crash 時可能不自動釋放(取決於作業系統)

相關資源

變更歷史

版本 日期 變更內容
1.0 2025-01-22 初始版本:實作 file lock 機制

授權

本文件遵循與 AuthGate 專案相同的授權條款。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment