天天看點

使用Redis實作微服務分布式鎖

作者:解道Jdon

Redis 以其高性能和支援高讀/寫 QPS 的能力而聞名,這是作為分布式鎖服務的後備存儲非常理想的屬性。此外,Redis 本身也支援 Lua 腳本。開源社群中有很多基于 Redis 的分布式鎖的實作。總體而言,基于 Redis 的分布式鎖比基于 MySQL 的分布式鎖性能更高。下面我們來看幾個使用 Redis 建構分布式鎖的例子。

具有單個 Redis 執行個體的分布式鎖

實作 1. 使用内置的 SETNX

SETNX 表示如果不存在則設定。如果鍵不存在,此指令設定鍵值對。否則,它是無操作的。Redis 鍵值對可以用來表示鎖。如果存在密鑰,則意味着用戶端持有鎖。任何key都可以用作共享資源的鎖。假設我們定義了一個名為 的鎖lock_name,當嘗試擷取鎖時,我們可以使用以下指令:

SETNX lock_name true
           

這裡我們嘗試設定一個 kv 對,鍵是鎖名lock_name,值是任意值true。通常,鎖名可以是任何有效的 Redis 變量名,并且值是任意的,因為我們隻對密鑰是否存在感興趣。如果此指令成功,則擷取鎖。否則意味着鎖被其他用戶端持有,目前用戶端應該在一段時間後重試。

使用Redis實作微服務分布式鎖

當我們用完共享資源想要釋放鎖時,我們可以簡單地使用 DEL 指令删除 Redis 中的鍵。

DEL lock_name
           

SETNX 確定分布式鎖的排他性lock_name——任何時候隻有一個用戶端可以持有鎖。然而,這個簡單的實作對于失敗是不可靠的。例如,如果持有鎖的用戶端由于網絡分區或程序退出而沒有響應,則無法正确釋放鎖。死鎖發生,因為其他用戶端也無法擷取此鎖。這種故障模式在分布式系統中很常見,是以我們需要使用更健壯的實作。

實作 2. 使用内置 SET (NX EX)

避免上述死鎖的常用方法是為鎖設定一個 TTL。一旦 key 過期,Redis 會自動删除(即 TTL 後自動釋放鎖),即使持有鎖的用戶端無法釋放鎖。由于 SETNX 不支援直接設定 TTL,是以需要額外的 EXPIRE 指令。從概念上講,擷取鎖的工作流程應該是這樣的:

SETNX lock_name arbitrary_lock_value
EXPIRE lock_name 10
           

這種方法的問題在于 SETNX 和 EXPIRE 不是原子操作,而是兩個獨立的操作。SETNX 總是有可能成功而 EXPIRE 失敗。

Redis 原生支援帶有一組選項的 SET 指令,例如 SET (key, value, NX, EX, timeout),允許對 SETNX 和 EXPIRE 進行原子操作。擷取/釋放鎖:

SET lock_name arbitrary_lock_value NX EX 10 # acquire the lock
# ... do something to the shared resource
DEL lock_name # release the lock
           

在上面的指令中,NX 與 SETNX 中的含義相同,而 EX 10 表示 TTL 為 10 秒。

我們有一個基于Redis的分布式鎖服務,它提供了排他性,并且可以在用戶端失敗時自動釋放鎖。然而,對于一個最小可行的鎖服務,我們應該考慮另一種失敗模式:

假設用戶端A持有鎖,而A需要比平時更長的時間來完成任務,key已經TTL過期了,鎖被自動釋放了。這時,另一個用戶端B有可能通過再次向Redis寫入k-v對而成功獲得相同的鎖。

在這種情況下,用戶端A仍然認為它持有該鎖,是以在任務完成後,用戶端A試圖删除Redis中的k-v條目并成功了。本質上,用戶端A删除了其他用戶端持有的鎖,這在實際生産環境中可能是災難性的。

實施 3. SET (NX EX) + 在鎖定釋放前檢查唯一的用戶端 ID

設定key時,用戶端應将唯一的用戶端 ID 添加到 kv 對。在删除key之前,用戶端應該檢查這個 ID 以确定它是否仍然持有鎖。如果 ID 不比對,則表示該鎖被其他用戶端持有,目前用戶端不應删除該key。從概念上講,工作流程是:

SET lock_name client_id NX EX 10 # acquire the lock
# ... do something to the shared resource
# check if client_id matches stored value in k-v pair
IF client_id == GET lock_name
    DEL lock_name # release the lock
           

同樣,IF 條件和 DEL 應該是原子的,但它們實際上是兩個獨立的操作。當用戶端嘗試釋放鎖時,需要這種擷取-比較-删除操作。在這種情況下,我們可以使用 Lua 腳本将這些指令包裝成一個原子操作。幾乎所有 Redis 分布式鎖的實作都包含類似于以下的 Lua 腳本片段:

// 如果來自 Redis GET 操作的值等于傳入的值 // 從參數,則删除鍵if redis.call("get", "lock_name") == ARGV[1] 
// if the value from Redis GET operation equals the value passed in // from argument, then delete the key
if redis.call("get", "lock_name") == ARGV[1]
  then
    return redis.call("del", "lock_name")
  else
    return 0
end
           

這裡 ARGV[1] 是一個輸入參數。之前client_id擷取鎖的時候設定的,這裡應該傳入。client_id可以是用戶端的有意義的辨別符,或者隻是一個 UUID 。

使用Redis實作微服務分布式鎖

此實作滿足以下 3 個功能要求。

  1. 由 SET 中的 NX 選項保證互斥
  2. TTL 機制在用戶端失敗時自動釋放鎖,還使用唯一的 client_id 來確定不允許用戶端釋放其他用戶端持有的任意鎖。
  3. 用于擷取和釋放鎖的 API

實作4.開源解決方案Redisson

在上述解決方案中,我們實作了get-compare-set,以避免意外釋放其他用戶端持有的鎖(即從Redis中删除相應的kv對)。我們還需要解決一個額外的問題:假設用戶端 A 持有鎖,并且需要比平時更長的時間來完成共享資源上的任務。但是,如果鎖是由于 TTL 自動釋放的,而用戶端 A 實際上仍然需要鎖怎麼辦?

一種簡單的方法是将 TTL 設定得足夠長。實際上,考慮到分布式鎖服務通常服務的大量異構用戶端,并且每個用戶端在擷取鎖後都有其獨特的業務邏輯要處理,是以很難将 TTL 設定為“正确”。

更通用的解決方案是,一旦用戶端持有鎖,它就會啟動一個守護線程來定期檢查鎖是否存在。如果是這樣,守護線程将重置 TTL 以防止鎖自動釋放。這種政策有時被稱為租賃政策,意思是一個鎖隻租給一個具有固定租期長度的用戶端,并且在租約到期之前,如果仍然需要鎖,用戶端應該更新租約。例如,開源解決方案Redisson 就采用了這種政策。這是 Redisson 中看門狗守護程序的進階示意圖:

使用Redis實作微服務分布式鎖

一旦獲得鎖,就會啟動 WatchDog 守護線程。此背景線程定期檢查用戶端是否仍持有鎖并相應地重置 TTL。這種政策有助于防止過早的鎖釋放。

帶 Redis 叢集的分布式鎖

簡單回顧一下,我們現在有一個基于單個 Redis 執行個體的分布式鎖服務。Redis 内置指令 SET (NX EX) 用于原子擷取鎖并設定 TTL,而 Lua 腳本用于原子釋放鎖。使用唯一的用戶端 ID 和 get-compare-set 邏輯,以便用戶端無法釋放其他用戶端持有的任意鎖。此外,守護線程用于在背景更新鎖租約。

現在唯一的弱點是 Redis 執行個體本身,這是一個單點故障。系統可以處理的最大鎖擷取/釋放 QPS 也受到單個 Redis 執行個體的 CPU/記憶體的限制。為了提高可用性和可擴充性,通常使用 Redis 叢集。Redis叢集的更多細節請參考之前的一篇文章Redis叢集如何實作高可用和資料持久化。 然而,叢集的使用會引入更多我們需要考慮的故障模式。讓我們來看看這裡的主要問題。請繼續關注下一篇文章,我們将深入探讨解決方案。

複制滞後和上司者故障轉移

我們來看看這個叢集中leader failover導緻的故障場景:

  • 用戶端從 Redis 上司者執行個體擷取了鎖。
  • 由于複制滞後,代表上司者執行個體鎖定的 kv 對尚未同步到跟随者執行個體。
  • 上司者失敗,并觸發故障轉移過程,将追随者執行個體之一提升為新上司者。
  • 未同步的 kv 對将丢失。對于請求新上司者的其他用戶端,就好像這些鎖仍然可用。結果,多個用戶端可以成功擷取同一個鎖,違反了排他性要求。

為了解決這個問題,Redis 的發明者提出了 RedLock 算法。

Redis 代理可能不支援 Lua

正如深入 Redis 叢集:分片算法和架構中所讨論的,分片Redis 叢集 + 代理通常用于實際生産環境。在這樣的 Redis 架構中,用戶端将直接與代理對話,而不是與底層的 Redis 叢集對話。代理計算密鑰的哈希值,并确定應由哪個 Redis 執行個體處理任務。不過,并非所有代理都支援 Lua 腳本。

使用Redis實作微服務分布式鎖

在這種情況下,我們需要使用代理支援的語言來實作我們自己的原子 get-compare-set 操作。例如,redislock是 Redis 上分布式鎖的開源 Golang 實作。

原文:https://www.jdon.com/62897

繼續閱讀