一起來聊聊如何使用Redis實現分散式鎖

2022-03-02 19:00:53
本篇文章給大家帶來了關於中的相關知識,其中主要介紹了分散式鎖的相關問題,我們通常說的執行緒呼叫加鎖和釋放鎖的操作,實際上,一個執行緒呼叫加鎖操作,其實就是檢查鎖變數值是否為0,希望對大家有幫助。

推薦學習:

單機上的鎖和分散式鎖的聯絡與區別

我們先來看下單機上的鎖。

對於在單機上執行的多執行緒程式來說,鎖本身可以用一個變數表示。

  • 變數值為 0 時,表示沒有執行緒獲取鎖;
  • 變數值為 1 時,表示已經有執行緒獲取到鎖了。

我們通常說的執行緒呼叫加鎖和釋放鎖的操作,實際上,一個執行緒呼叫加鎖操作,其實就是檢查鎖變數值是否為 0。如果是 0,就把鎖的變數值設定為 1,表示獲取到鎖,如果不是 0,就返回錯誤資訊,表示加鎖失敗,已經有別的執行緒獲取到鎖了。而一個執行緒呼叫釋放鎖操作,其實就是將鎖變數的值置為 0,以便其它執行緒可以來獲取鎖。
我用一段程式碼來展示下加鎖和釋放鎖的操作,其中,lock 為鎖變數。

acquire_lock(){
  if lock == 0
     lock = 1
     return 1
  else
     return 0
} 
release_lock(){
  lock = 0
  return 1
}

和單機上的鎖類似,分散式鎖同樣可以用一個變數來實現。使用者端加鎖和釋放鎖的操作邏輯,也和單機上的加鎖和釋放鎖操作邏輯一致:加鎖時同樣需要判斷鎖變數的值,根據鎖變數值來判斷能否加鎖成功;釋放鎖時需要把鎖變數值設定為 0,表明使用者端不再持有鎖。
但是,和執行緒在單機上操作鎖不同的是,在分散式場景下,鎖變數需要由一個共用儲存系統來維護,只有這樣,多個使用者端才可以通過存取共用儲存系統來存取鎖變數。相應的,加鎖和釋放鎖的操作就變成了讀取、判斷和設定共用儲存系統中的鎖變數值。

這樣一來,我們就可以得出實現分散式鎖的兩個要求。

要求一:分散式鎖的加鎖和釋放鎖的過程,涉及多個操作。所以,在實現分散式鎖時,我們需要保證這些鎖操作的原子性;
要求二:共用儲存系統儲存了鎖變數,如果共用儲存系統發生故障或宕機,那麼使用者端也就無法進行鎖操作了。在實現分散式鎖時,我們需要考慮保證共用儲存系統的可靠性,進而保證鎖的可靠性。

好了,知道了具體的要求,接下來,我們就來學習下 Redis 是怎麼實現分散式鎖的。

其實,我們既可以基於單個 Redis 節點來實現,也可以使用多個 Redis 節點實現。在這兩種情況下,鎖的可靠性是不一樣的。我們先來看基於單個 Redis 節點的實現方法。

基於單個 Redis 節點實現分散式鎖
作為分散式鎖實現過程中的共用儲存系統,Redis 可以使用鍵值對來儲存鎖變數,再接收和處理不同使用者端傳送的加鎖和釋放鎖的操作請求。那麼,鍵值對的鍵和值具體是怎麼定的呢?
我們要賦予鎖變數一個變數名,把這個變數名作為鍵值對的鍵,而鎖變數的值,則是鍵值對的值,這樣一來,Redis 就能儲存鎖變數了,使用者端也就可以通過 Redis 的命令操作來實現鎖操作。
為了幫助你理解,我畫了一張圖片,它展示 Redis 使用鍵值對儲存鎖變數,以及兩個使用者端同時請求加鎖的操作過程。
在這裡插入圖片描述

可以看到,Redis 可以使用一個鍵值對 lock_key:0 來儲存鎖變數,其中,鍵是 lock_key,也是鎖變數的名稱,鎖變數的初始值是 0。

我們再來分析下加鎖操作。

在圖中,使用者端 A 和 C 同時請求加鎖。因為 Redis 使用單執行緒處理請求,所以,即使使用者端 A 和 C 同時把加鎖請求發給了 Redis,Redis 也會序列處理它們的請求。

我們假設 Redis 先處理使用者端 A 的請求,讀取 lock_key 的值,發現 lock_key 為 0,所以,Redis 就把 lock_key 的 value 置為 1,表示已經加鎖了。緊接著,Redis 處理使用者端 C 的請求,此時,Redis 會發現 lock_key 的值已經為 1 了,所以就返回加鎖失敗的資訊。

剛剛說的是加鎖的操作,那釋放鎖該怎麼操作呢?其實,釋放鎖就是直接把鎖變數值設定為 0。

我還是藉助一張圖片來解釋一下。這張圖片展示了使用者端 A 請求釋放鎖的過程。當用戶端 A 持有鎖時,鎖變數 lock_key 的值為 1。使用者端 A 執行釋放鎖操作後,Redis 將 lock_key 的值置為 0,表明已經沒有使用者端持有鎖了。
在這裡插入圖片描述

因為加鎖包含了三個操作(讀取鎖變數、判斷鎖變數值以及把鎖變數值設定為 1),而這三個操作在執行時需要保證原子性。那怎麼保證原子性呢?

要想保證操作的原子性,有兩種通用的方法,分別是使用 Redis 的單命令操作和使用 Lua 指令碼。那麼,在分散式加鎖場景下,該怎麼應用這兩個方法呢?

我們先來看下,Redis 可以用哪些單命令操作實現加鎖操作。

首先是 SETNX 命令,它用於設定鍵值對的值。具體來說,就是這個命令在執行時會判斷鍵值對是否存在,如果不存在,就設定鍵值對的值,如果存在,就不做任何設定。

舉個例子,如果執行下面的命令時,key 不存在,那麼 key 會被建立,並且值會被設定為 value;如果 key 已經存在,SETNX 不做任何賦值操作。

SETNX key value

對於釋放鎖操作來說,我們可以在執行完業務邏輯後,使用 DEL 命令刪除鎖變數。不過,你不用擔心鎖變數被刪除後,其他使用者端無法請求加鎖了。因為 SETNX 命令在執行時,如果要設定的鍵值對(也就是鎖變數)不存在,SETNX 命令會先建立鍵值對,然後設定它的值。所以,釋放鎖之後,再有使用者端請求加鎖時,SETNX 命令會建立儲存鎖變數的鍵值對,並設定鎖變數的值,完成加鎖。
總結來說,我們就可以用 SETNX 和 DEL 命令組合來實現加鎖和釋放鎖操作。下面的虛擬碼範例顯示了鎖操作的過程,你可以看下。

// 加鎖
SETNX lock_key 1
// 業務邏輯
DO THINGS
// 釋放鎖
DEL lock_key

不過,使用 SETNX 和 DEL 命令組合實現分佈鎖,存在兩個潛在的風險。

第一個風險是,假如某個使用者端在執行了 SETNX 命令、加鎖之後,緊接著卻在操作共用資料時發生了異常,結果一直沒有執行最後的 DEL 命令釋放鎖。因此,鎖就一直被這個使用者端持有,其它使用者端無法拿到鎖,也無法存取共用資料和執行後續操作,這會給業務應用帶來影響。
針對這個問題,一個有效的解決方法是,給鎖變數設定一個過期時間。這樣一來,即使持有鎖的使用者端發生了異常,無法主動地釋放鎖,Redis 也會根據鎖變數的過期時間,在鎖變數過期後,把它刪除。其它使用者端在鎖變數過期後,就可以重新請求加鎖,這就不會出現無法加鎖的問題了。

我們再來看第二個風險。如果使用者端 A 執行了 SETNX 命令加鎖後,假設使用者端 B 執行了 DEL 命令釋放鎖,此時,使用者端 A 的鎖就被誤釋放了。如果使用者端 C 正好也在申請加鎖,就可以成功獲得鎖,進而開始操作共用資料。這樣一來,使用者端 A 和 C 同時在對共用資料進行操作,資料就會被修改錯誤,這也是業務層不能接受的。
為了應對這個問題,我們需要能區分來自不同使用者端的鎖操作,具體咋做呢?其實,我們可以在鎖變數的值上想想辦法。
在使用 SETNX 命令進行加鎖的方法中,我們通過把鎖變數值設定為 1 或 0,表示是否加鎖成功。1 和 0 只有兩種狀態,無法表示究竟是哪個使用者端進行的鎖操作。所以,我們在加鎖操作時,可以讓每個使用者端給鎖變數設定一個唯一值,這裡的唯一值就可以用來標識當前操作的使用者端。在釋放鎖操作時,使用者端需要判斷,當前鎖變數的值是否和自己的唯一標識相等,只有在相等的情況下,才能釋放鎖。這樣一來,就不會出現誤釋放鎖的問題了。

知道了解決方案,那麼,在 Redis 中,具體是怎麼實現的呢?我們再來了解下。
在檢視具體的程式碼前,我要先帶你學習下 Redis 的 SET 命令。

我們剛剛在說 SETNX 命令的時候提到,對於不存在的鍵值對,它會先建立再設定值(也就是「不存在即設定」),為了能達到和 SETNX 命令一樣的效果,Redis 給 SET 命令提供了類似的選項 NX,用來實現「不存在即設定」。如果使用了 NX 選項,SET 命令只有在鍵值對不存在時,才會進行設定,否則不做賦值操作。此外,SET 命令在執行時還可以帶上 EX 或 PX 選項,用來設定鍵值對的過期時間。

舉個例子,執行下面的命令時,只有 key 不存在時,SET 才會建立 key,並對 key 進行賦值。另外,key 的存活時間由 seconds 或者 milliseconds 選項值來決定。

SET key value [EX seconds | PX milliseconds]  [NX]

有了 SET 命令的 NX 和 EX/PX 選項後,我們就可以用下面的命令來實現加鎖操作了。
// 加鎖, unique_value作為使用者端唯一性的標識

SET lock_key unique_value NX PX 10000

其中,unique_value 是使用者端的唯一標識,可以用一個隨機生成的字串來表示,PX 10000 則表示 lock_key 會在 10s 後過期,以免使用者端在這期間發生異常而無法釋放鎖。

因為在加鎖操作中,每個使用者端都使用了一個唯一標識,所以在釋放鎖操作時,我們需要判斷鎖變數的值,是否等於執行釋放鎖操作的使用者端的唯一標識,如下所示:
//釋放鎖 比較unique_value是否相等,避免誤釋放

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

這是使用 Lua 指令碼(unlock.script)實現的釋放鎖操作的虛擬碼,其中,KEYS[1]表示 lock_key,ARGV[1]是當前使用者端的唯一標識,這兩個值都是我們在執行 Lua 指令碼時作為引數傳入的。

最後,我們執行下面的命令,就可以完成鎖釋放操作了。

redis-cli  --eval  unlock.script lock_key , unique_value

你可能也注意到了,在釋放鎖操作中,我們使用了 Lua 指令碼,這是因為,釋放鎖操作的邏輯也包含了讀取鎖變數、判斷值、刪除鎖變數的多個操作,而 Redis 在執行 Lua 指令碼時,可以以原子性的方式執行,從而保證了鎖釋放操作的原子性。

好了,到這裡,你瞭解瞭如何使用 SET 命令和 Lua 指令碼在 Redis 單節點上實現分散式鎖。但是,我們現在只用了一個 Redis 範例來儲存鎖變數,如果這個 Redis 範例發生故障宕機了,那麼鎖變數就沒有了。此時,使用者端也無法進行鎖操作了,這就會影響到業務的正常執行。所以,我們在實現分散式鎖時,還需要保證鎖的可靠性。那怎麼提高呢?這就要提到基於多個 Redis 節點實現分散式鎖的方式了。

基於多個 Redis 節點實現高可靠的分散式鎖
當我們要實現高可靠的分散式鎖時,就不能只依賴單個的命令操作了,我們需要按照一定的步驟和規則進行加解鎖操作,否則,就可能會出現鎖無法運作的情況。「一定的步驟和規則」是指啥呢?其實就是分散式鎖的演演算法。

為了避免 Redis 範例故障而導致的鎖無法運作的問題,Redis 的開發者 Antirez 提出了分散式鎖演演算法 Redlock。

Redlock 演演算法的基本思路,是讓使用者端和多個獨立的 Redis 範例依次請求加鎖,如果使用者端能夠和半數以上的範例成功地完成加鎖操作,那麼我們就認為,使用者端成功地獲得分散式鎖了,否則加鎖失敗。這樣一來,即使有單個 Redis 範例發生故障,因為鎖變數在其它範例上也有儲存,所以,使用者端仍然可以正常地進行鎖操作,鎖變數並不會丟失。

我們來具體看下 Redlock 演演算法的執行步驟。Redlock 演演算法的實現需要有 N 個獨立的 Redis 範例。接下來,我們可以分成 3 步來完成加鎖操作。

第一步是,使用者端獲取當前時間。
第二步是,使用者端按順序依次向 N 個 Redis 範例執行加鎖操作。

這裡的加鎖操作和在單範例上執行的加鎖操作一樣,使用 SET 命令,帶上 NX,EX/PX 選項,以及帶上使用者端的唯一標識。當然,如果某個 Redis 範例發生故障了,為了保證在這種情況下,Redlock 演演算法能夠繼續執行,我們需要給加鎖操作設定一個超時時間。

如果使用者端在和一個 Redis 範例請求加鎖時,一直到超時都沒有成功,那麼此時,使用者端會和下一個 Redis 範例繼續請求加鎖。加鎖操作的超時時間需要遠遠地小於鎖的有效時間,一般也就是設定為幾十毫秒。

第三步是,一旦使用者端完成了和所有 Redis 範例的加鎖操作,使用者端就要計算整個加鎖過程的總耗時。

使用者端只有在滿足下面的這兩個條件時,才能認為是加鎖成功。

  • 條件一:使用者端從超過半數(大於等於 N/2+1)的 Redis 範例上成功獲取到了鎖;
  • 條件二:使用者端獲取鎖的總耗時沒有超過鎖的有效時間。

在滿足了這兩個條件後,我們需要重新計算這把鎖的有效時間,計算的結果是鎖的最初有效時間減去使用者端為獲取鎖的總耗時。如果鎖的有效時間已經來不及完成共用資料的操作了,我們可以釋放鎖,以免出現還沒完成資料操作,鎖就過期了的情況。

當然,如果使用者端在和所有範例執行完加鎖操作後,沒能同時滿足這兩個條件,那麼,使用者端向所有 Redis 節點發起釋放鎖的操作。

在 Redlock 演演算法中,釋放鎖的操作和在單範例上釋放鎖的操作一樣,只要執行釋放鎖的 Lua 指令碼就可以了。這樣一來,只要 N 個 Redis 範例中的半數以上範例能正常工作,就能保證分散式鎖的正常工作了。

所以,在實際的業務應用中,如果你想要提升分散式鎖的可靠性,就可以通過 Redlock 演演算法來實現。

推薦學習:

以上就是一起來聊聊如何使用Redis實現分散式鎖的詳細內容,更多請關注TW511.COM其它相關文章!