Skip to content
DEE
Database Engineering Essentials

[DEE-456] Redis 分散式鎖 ​

INFO

分散式系統需要協調原語來防止並行程序破壞共享資源。Redis 常被用於分散式鎖定,因為它速度快、廣泛部署且支援原子操作 -- 但基於 Redis 的鎖有重要的安全注意事項,開發人員必須了解。

背景 ​

在分散式系統中,多個程序或服務可能需要獨佔存取共享資源 -- 更新資料庫記錄、寫入檔案、呼叫有速率限制的外部 API,或確保工作僅處理一次。分散式鎖為這些獨立程序提供互斥。

Redis 是分散式鎖定的熱門選擇,因為它提供原子原語(SET NX EX)、亞毫秒延遲,而且通常已是基礎設施的一部分。主要有兩種方式:

  • 單實例鎖:在單一 Redis 節點上使用 SET key value NX EX ttl。簡單、快速,對於偶爾重複執行可容忍的盡力而為協調已足夠。
  • Redlock(多實例):在 N 個獨立 Redis master(通常 5 個)的多數上取得鎖。設計上可以承受任何少數節點的故障。

Redlock 演算法引發了 Martin Kleppmann(分散式系統研究者)與 Salvatore Sanfilippo(antirez,Redis 創建者)之間的知名辯論,討論分散式鎖何時能、何時不能提供正確性保證。

原則 ​

開發人員SHOULD在偶爾重複執行後果可接受的情況下使用單實例 Redis 鎖(SET NX EX)進行盡力而為的協調(例如背景工作的去重、速率限制、可容忍短暫腦裂的 leader 選舉)。

開發人員MUST始終在鎖定 key 上設定 TTL,以防止鎖持有者在未釋放的情況下當機時造成死鎖。

開發人員MUST使用唯一的鎖值(例如 UUID)並在釋放前驗證所有權,以避免刪除其他客戶端的鎖。

開發人員SHOULD NOT在沒有額外 fencing 機制的情況下,依賴 Redlock 進行正確性關鍵的互斥(例如防止重複扣款、金融交易),因為 Redlock 的安全性依賴於時序假設,而真實系統可能違反這些假設。

開發人員SHOULD在受保護資源支援 fencing token 時使用它,因為它提供獨立於鎖定時序的正確性保證。

圖示 ​

範例 ​

單實例鎖:取得與釋放 ​

python
import uuid
import redis
import time

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

LOCK_TTL_SECONDS = 30

def acquire_lock(resource: str, ttl: int = LOCK_TTL_SECONDS) -> str | None:
    """嘗試取得鎖。成功時回傳 token,失敗時回傳 None。"""
    token = str(uuid.uuid4())
    acquired = r.set(f"lock:{resource}", token, nx=True, ex=ttl)
    return token if acquired else None


def release_lock(resource: str, token: str) -> bool:
    """僅在仍擁有鎖時釋放(透過 Lua 進行原子檢查與刪除)。"""
    lua_script = """
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end
    """
    result = r.eval(lua_script, 1, f"lock:{resource}", token)
    return result == 1


# 使用方式
token = acquire_lock("order:42")
if token:
    try:
        process_order(42)  # 獨佔工作
    finally:
        release_lock("order:42", token)
else:
    print("無法取得鎖 -- 另一個 worker 正在處理此訂單")

為什麼要用 Lua 釋放?原子性很重要 ​

沒有 Lua 腳本的話,天真的釋放方式有競態條件:

python
# 不安全 -- 請勿使用
def unsafe_release(resource: str, token: str):
    key = f"lock:{resource}"
    if r.get(key) == token:        # (1) 檢查所有權
        # <-- 鎖可能在此過期,另一個客戶端取得了它
        r.delete(key)              # (2) 刪除 -- 現在刪除的是別人的鎖!

Lua 腳本在 Redis 伺服器上原子執行,消除了這個競態。

Redlock 多實例模式(虛擬碼) ​

python
# Redlock 使用 N 個獨立的 Redis master(通常 5 個,無複寫)。
# 鎖僅在多數節點(N/2 + 1)上持有時才算取得。

QUORUM = 3  # 5 個節點的多數
LOCK_TTL_MS = 30_000
CLOCK_DRIFT_FACTOR = 0.01

def redlock_acquire(nodes: list, resource: str, ttl_ms: int) -> str | None:
    token = str(uuid.uuid4())
    start_ms = current_time_ms()

    acquired_count = 0
    for node in nodes:
        if node.set(f"lock:{resource}", token, nx=True, px=ttl_ms):
            acquired_count += 1

    elapsed_ms = current_time_ms() - start_ms
    drift = ttl_ms * CLOCK_DRIFT_FACTOR + 2  # 時鐘漂移容許值
    validity_ms = ttl_ms - elapsed_ms - drift

    if acquired_count >= QUORUM and validity_ms > 0:
        return token  # 鎖已取得,剩餘有效期為 validity_ms

    # 失敗 -- 釋放所有已取得的鎖
    for node in nodes:
        release_lock_on_node(node, resource, token)
    return None

Fencing token 確保正確性 ​

客戶端 A 取得鎖(fence token = 33)
客戶端 A 暫停(GC、網路延遲)
鎖過期
客戶端 B 取得鎖(fence token = 34)
客戶端 B 以 fence = 34 寫入儲存
客戶端 A 恢復,以 fence = 33 寫入儲存
儲存拒絕寫入:33 < 34(過期的 token)

Fencing token 是隨每次鎖取得而發出的單調遞增數字。受保護資源檢查 token 並拒絕來自過期鎖持有者的操作。即使鎖過早過期,這也能提供正確性保證。

Kleppmann 與 Antirez 的辯論 ​

Redlock 的安全性已被分散式系統社群中兩位受尊敬的聲音公開辯論:

Martin Kleppmann 的批評(2016):Redlock「非驢非馬」-- 對於僅追求效率的鎖來說太重,對於正確性關鍵的鎖來說又太弱。他的核心論點:

  • Redlock 假設有界的網路延遲、有界的程序暫停和大致同步的時鐘。真實系統會違反這些假設(GC 暫停、網路分區、時鐘偏差)。
  • 沒有 fencing token 的話,在取得鎖後暫停的客戶端可以在鎖過期後恢復並破壞資源,而此時另一個客戶端持有鎖。
  • 如果受保護資源支援 fencing token,你不需要 Redlock 的複雜性 -- 單一 Redis 實例就足夠了。

Antirez 的辯護(2016):在取得多數後,Redlock 客戶端會重新檢查是否未超過 TTL。這限制了脆弱視窗。Antirez 也主張 Redlock 的隨機值可以在具有 compare-and-set 語意的系統中作為一種 fencing 形式。

實務指引:對於僅追求效率的鎖(防止重複工作、降低負載),帶 TTL 的單實例 Redis 鎖簡單且足夠。對於正確性關鍵的鎖(防止資料損壞),在受保護資源上使用 fencing token,這適用於任何鎖實作。Redlock 在需要鎖服務本身能承受 Redis 節點故障時有其價值,但它無法在所有故障模式下保證互斥。

常見錯誤 ​

  1. 鎖定 key 未設 TTL(當機時死鎖)。 如果鎖持有者當機或失去連線而未釋放鎖,且未設定 TTL,鎖將永遠被持有。每個鎖MUST有 TTL。選擇一個遠大於預期操作時間、但短到能在可接受視窗內恢復的 TTL。

  2. DEL 時未檢查所有權。 直接呼叫 DEL lock:resource 而未驗證你是否仍持有鎖,可能釋放另一個客戶端的鎖。務必儲存唯一 token 並使用原子檢查與刪除(Lua 腳本或帶比較的 GETDEL)。

  3. 在正確性關鍵系統中依賴 Redlock。 Redlock 的安全性依賴時序假設(有界的網路延遲、有界的程序暫停、有限的時鐘漂移)。如果客戶端暫停足夠長導致鎖過期,Redlock 無法阻止第二個客戶端取得鎖並繼續執行。對於正確性,受保護資源本身必須透過 fencing token 強制執行順序。

  4. 鎖 TTL 短於操作時間。 如果受保護操作的耗時超過鎖 TTL,鎖會在操作仍在執行時過期,另一個客戶端可以取得它。要麼設定有安全餘量的充裕 TTL、實作到期前的鎖延展(續約),或重新設計操作使其更快。

  5. 競爭時的重試風暴。 當多個客戶端競爭同一個鎖並在失敗後立即重試時,會產生瞬間湧入。在重試嘗試之間使用帶隨機抖動的指數退避。

  6. 將 Redlock 與有複寫的 Redis 一起使用。 Redlock 需要 N 個獨立的 Redis master,彼此之間無複寫。與 Redis Sentinel 或 Redis Cluster(使用複寫)一起使用會違反演算法的假設 -- 故障轉移可能導致兩個客戶端同時持有鎖。

相關 DEE ​

  • DEE-450 快取與搜尋總覽
  • DEE-454 Redis 快取資料結構 -- 鎖定所依賴的底層資料類型
  • DEE-455 Redis 持久化(RDB vs AOF) -- 持久化影響跨重啟的鎖持久性

參考資料 ​