2024年11月26日
用 Redis 做分散式鎖:SET NX PX、Token 驗證、續期,以及它保證不了的事
為什麼需要分散式鎖
在單一應用程式裡,要確保同一段程式不會同時被執行,用 lock 或 SemaphoreSlim 就可以了。但部署到 K8s、開了 3 個 Pod 之後,每個 Pod 都是獨立的行程,各自的 lock 只鎖得住自己,管不到其他 Pod。
常見的情境:
- 排程只能跑一次:每天凌晨的結算工作寫在
BackgroundService裡,3 個 Pod 時間一到就同時執行 3 次 - 同一筆資料不能被同時處理:同一張訂單同時收到兩個「付款完成」的通知,兩個 Pod 各處理一次,出貨兩次
- 呼叫有次數限制的外部 API:多個實例同時重新取得 Token,把對方的 Token 刷掉
這時候需要一個所有實例都看得到的地方來放鎖,Redis 是最常見的選擇:快、每個指令都是原子的,而且大部分系統本來就有一台。
第一版:SET NX PX
搶鎖的核心是一個指令:
SET lock:settlement:2024-11-26 {token} NX PX 30000
NX:只有 Key 不存在時才寫入,存在就失敗——誰先寫入誰就拿到鎖PX 30000:30 秒後自動過期——拿到鎖的實例當掉了,鎖也會自己釋放,不會永遠卡住
這兩個參數一定要在同一個指令裡。舊的寫法是先 SETNX 再 EXPIRE,如果在兩個指令之間當掉,這把鎖就永遠沒有過期時間,之後誰都拿不到。
漏洞一:刪到別人的鎖
最直覺的釋放方式是做完工作後 DEL 這個 Key。但看看這個時序:
- Pod A 拿到鎖,過期時間 30 秒
- A 的工作因為資料庫變慢,跑了 40 秒
- 第 30 秒,鎖過期了,Pod B 拿到鎖,開始做同一件事
- 第 40 秒,A 做完了,
DEL這個 Key——刪掉的是 B 的鎖 - Pod C 接著也拿到鎖,現在 B、C 同時在執行
解法是每次取鎖時寫入一個唯一的 Token(例如 GUID),釋放時確認 Token 是自己的才刪除。「讀取比對」和「刪除」必須是原子的,否則在兩者之間鎖剛好過期又被別人拿走,還是會刪錯。所以要用 Lua Script,讓 Redis 一次執行完:
-- 釋放鎖:Token 相同才刪除
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
漏洞二:工作還沒做完,鎖就過期了
Token 解決了「刪錯鎖」,但沒解決上面第 3 步:A 還在做,B 已經拿到鎖了。過期時間設多長都很難拿捏:
- 設太短:工作偶爾變慢,鎖就提前失效
- 設太長:實例當掉時,要等很久才會有別人接手
常見的做法是自動續期(Watchdog):拿到鎖之後啟動一個背景計時器,每隔過期時間的三分之一(例如每 10 秒),確認 Token 還是自己的,再把過期時間延長回 30 秒。工作做完就停止續期並釋放。實例當掉的話,續期也跟著停止,鎖最多 30 秒後過期。
-- 續期:Token 相同才延長
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
在 .NET 裡實作
StackExchange.Redis 已經內建了上面這三個操作:LockTakeAsync 就是 SET NX PX,LockExtendAsync 和 LockReleaseAsync 都會先比對 Token,不用自己寫 Lua。
public async Task<bool> RunWithLockAsync(string resource, Func<CancellationToken, Task> work)
{
var key = $"lock:{resource}";
var token = Guid.NewGuid().ToString("N");
var expiry = TimeSpan.FromSeconds(30);
if (!await _redis.LockTakeAsync(key, token, expiry))
return false; // 別人正在做
using var cts = new CancellationTokenSource();
var watchdog = Task.Run(async () =>
{
while (!cts.IsCancellationRequested)
{
await Task.Delay(expiry / 3, cts.Token);
if (!await _redis.LockExtendAsync(key, token, expiry))
{
// 續期失敗代表鎖已經不是自己的了,通知工作盡快停下來
cts.Cancel();
break;
}
}
});
try
{
await work(cts.Token);
return true;
}
finally
{
cts.Cancel();
try { await watchdog; } catch (OperationCanceledException) { }
await _redis.LockReleaseAsync(key, token);
}
}
使用時:
// 3 個 Pod 同時觸發,只有一個會真的執行
var date = DateTime.Today.ToString("yyyy-MM-dd");
var executed = await _lock.RunWithLockAsync($"settlement:{date}", ct => _settlement.RunAsync(date, ct));
注意 work 收到了 CancellationToken:續期失敗時會被取消,工作要檢查這個 Token 並停下來,不然即使知道鎖已經沒了,程式還是會繼續做下去。
它保證不了的事
補完上面這些,Redis 鎖在大部分情況下都能正常運作。但有幾種情況,它原理上就無法保證:
程序停頓
A 拿到鎖之後,整個行程因為一次很長的 GC、或是 VM 被暫停,停了 40 秒。這段時間 Watchdog 也一起停了,沒辦法續期。鎖過期、B 拿到鎖。A 醒來之後,它不知道自己睡了 40 秒,繼續寫資料庫。
續期只能縮小這個時間窗,不能消除它。
主從切換
Redis 的主從複製是非同步的。A 在 Master 上取得鎖,Master 還沒同步給 Replica 就掛了,Replica 被提升為新的 Master——上面沒有這把鎖,B 馬上就能拿到。
Redis 作者提出的 Redlock 演算法,是向 5 個彼此獨立的 Redis 取鎖,過半數成功才算拿到,以避免單一節點故障。但它依賴「各台機器的時鐘大致準確、停頓不會太久」這類時間上的假設,Martin Kleppmann 在〈How to do distributed locking〉一文中就以程序停頓和時鐘跳動為例,認為它不足以保證正確性。這場討論的結論,對使用者來說很實用:先想清楚你要的是「效率」還是「正確性」。
Fencing Token
如果需要正確性,問題要在被保護的資源那一端解決。做法是每次取鎖時,再拿一個單調遞增的號碼(例如 INCR lock:settlement:fence),寫入資料時帶上這個號碼,資料庫只接受比目前記錄更大的號碼:
UPDATE Settlement
SET Status = 'Done', FenceToken = @token
WHERE SettleDate = @date AND FenceToken < @token;
A 拿到的是 33、B 拿到的是 34。B 先寫入之後,A 醒來帶著 33 想寫入,條件不成立,寫入失敗。這樣即使兩個實例同時以為自己持有鎖,資料也不會被舊的那個覆蓋。
效率還是正確性?
| 目的 | 鎖失效的後果 | 建議 |
|---|---|---|
| 效率:避免重複做同一件事 | 偶爾多做一次,浪費一點資源 | Redis 單節點鎖 + Token + 續期就夠了 |
| 正確性:絕對不能同時做 | 資料錯誤、重複扣款、重複出貨 | Redis 鎖只當第一道防線,資料庫必須有自己的保護 |
需要正確性時,真正的保證應該來自資料庫:
- 唯一索引:結算紀錄以日期為唯一鍵,第二次寫入直接失敗
- 條件更新/樂觀鎖:
WHERE Version = @version、WHERE Status = 'Pending' - 冪等鍵:每個請求帶一個唯一 ID,處理過的就不再處理(點數這類情境可以參考 點數帳本的冪等鍵與條件更新)
Redis 鎖在這裡的角色,是擋掉絕大多數的重複執行,讓資料庫的保護機制很少需要被觸發。
使用情境:多個 Pod 的每日結算排程
- 結算服務部署 3 個 Pod,每個 Pod 的
BackgroundService都會在凌晨 00:05 觸發 - 用
settlement:{日期}當鎖的名稱,只有搶到鎖的 Pod 會執行,其他兩個直接略過 - 執行中的 Pod 透過 Watchdog 每 10 秒續期;Pod 被重啟或當掉,30 秒後鎖過期,下一個排程週期由其他 Pod 接手
- 結算結果寫入時,資料表以結算日期為唯一鍵,並用
Status做條件更新——就算鎖失效、兩個 Pod 都跑了,第二個也寫不進去 - 鎖的取得、續期失敗、釋放都記錄 Log,續期失敗時告警
注意事項
- 鎖的名稱要夠精確:鎖整個「結算」,和鎖「某一天的結算」、「某一個帳號」,平行度完全不同。鎖的範圍越小,越不容易互相等待
- 拿不到鎖時要決定行為:直接略過(排程)、等待重試(處理同一筆資料),還是回傳錯誤(使用者操作)。等待時要有上限,不要無限輪詢
- 不要在鎖裡做太久的事:鎖住的時間越長,被停頓、網路問題影響的機率越高。能拆小就拆小
- 不需要跨實例時不要用:只是要防止同一個 Pod 內的並行,用
SemaphoreSlim就好,多一次 Redis 往返沒有意義 - Redis 掛掉時的行為要先想好:拿不到鎖是「全部都不做」還是「降級成都做、靠資料庫擋」,依業務決定,但要事先決定,而不是出事時才發現
- 排程框架通常有內建:像 Hangfire 這類排程框架本身就有分散式鎖機制,用框架的功能就好,不用自己再包一層
一句話總結
Redis 分散式鎖用 SET key token NX PX 一次完成取鎖與過期,用 Token 確認只刪自己的鎖,用 Watchdog 在工作期間續期;但程序停頓與主從切換讓它原理上無法保證絕對互斥,所以它適合用來「避免重複做事」,真正需要正確性的地方,要靠資料庫的唯一鍵、條件更新或 Fencing Token 來把關。