Redis系列13:分散式鎖實現

2023-05-30 21:01:14

Redis系列1:深刻理解高效能Redis的本質
Redis系列2:資料持久化提高可用性
Redis系列3:高可用之主從架構
Redis系列4:高可用之Sentinel(哨兵模式)
Redis系列5:深入分析Cluster 叢集模式
追求效能極致:Redis6.0的多執行緒模型
追求效能極致:使用者端快取帶來的革命
Redis系列8:Bitmap實現億萬級資料計算
Redis系列9:Geo 型別賦能億級地圖位置計算
Redis系列10:HyperLogLog實現海量資料基數統計
Redis系列11:記憶體淘汰策略
Redis系列12:Redis 的事務機制

1 先來了解下分散式鎖

1.1 什麼是分散式鎖

分散式鎖,即分散式系統中的鎖,我們通過鎖解決 控制共用資源存取 的問題,來保證只有一個執行緒可以存取被保護的資源。

1.2 分散式鎖的實現方案

  • 基於資料庫實現分散式鎖
  • 基於Zookeeper實現分散式鎖
  • 基於Redis實現分散式鎖

等等,本篇基於Redis角度進行討論

1.3 分散式鎖滿足哪些特性

  • 互斥性:在分散式系統下,一個事件在同一個時間內只能被一個執行緒執行,即只能有一個執行緒持有鎖。
  • 安全性:可以方便的獲取鎖和釋放鎖,不產生死鎖情況
  • 過期性:具備鎖失效機制,即可以在時效預期外自動解鎖,防止死鎖
  • 可重入:具備可重入特性(可理解為重新進入,由多於一個任務並
  • 高效能:高效能的獲取鎖與釋放鎖
  • 高可用性:高可用的獲取鎖與釋放鎖

1.4 互斥特性

1.4.1 實現互斥特性

1.4.1.1 SETNX命令

SETNX 是 set if not exists 的縮寫,當且僅當 key 不存在時,則設定 value 給這個key。若給定的 key 已經存在,則 SETNX 不做任何動作。
命令的返回值說明:

  • 1:說明該程序獲得鎖,將 key 的值設為 value
  • 0:說明其他程序已經獲得了鎖,程序不能進入臨界區。

舉例說明:setnx lock.key lock.value

> SETNX lock.user_063105015 1
(integer) 1 # 獲取編號為 063105015 使用者成功

如果已經被獲取過了,則獲取失敗

> SETNX lock.user_063105015 1
(integer) 0 # 獲取編號為 063105015 使用者失敗

1.4.1.2 get命令

獲取key的值,如果存在,則返回;如果不存在,則返回nil

# 獲取成功
> GET lock.user_063105015
"1"

# 獲取失敗
> GET lock.user_123456789
(nil)

1.4.1.3 getset命令

原子的設定值的辦法,對key設定newValue這個值,並且返回key原來的舊值。

# 重置使用者資訊
> getset lock.user_063105015 0
"1"  # 原值為1

# 再次重置
> getset lock.user_063105015 1
"0"  # 原值為0

1.4.1.4 刪除命令,用完之後進行鎖釋放

> DEL lock.user_063105015
(integer) 1

具體執行流程如下:

1.3.1.5 異常導致的鎖釋放問題

可能會因為一些場景,造成鎖無法釋放,如下:

  • 呼叫服務或者使用者端崩潰,無法正確的處理鎖釋放的工作。
  • 業務程式的異常執行,沒有操作釋放鎖的 DEL指令。
    這種情況下,鎖就會一直佔用著,不會被釋放,其他執行緒也無法獲得。所以必須得有個自動釋放鎖的過程。

1.4.2 超時釋放

超時釋放其實就是重置,目的是避免因為各種原因導致的鎖長時間無法釋放。
做法就是我們給鎖加個過期時間(EXPIRE Time):

# 給使用者 063105015 加鎖
> SETNX lock.user_063105015 1 
(integer) 1

# 設定過期時間,到時間沒刪除則自動釋放
> EXPIRE lock.user_063105015 120 # 120秒之後自動釋放
(integer) 1

為了保證執行時的原子性,Redis 官方擴充套件了 SET 命令,既能滿足獲取物件,又能保證設定超時的時間語意。
避免出現了獲取鎖完成之後,執行超時設定失敗微軟無法釋放鎖的情況。保證要麼都成功,要麼都不執行。

# 範例如下:
SET lock.user_063105015 1  NX PX 60000
  • NX:就是Not Exist,表示只有使用者編號為 063105015 不存在的時候才可以 SET 成功,並且只有單個執行緒可以獲取鎖;
  • PX 60000:表示對這個鎖設定一個60s的過期時間。

1.4.3 對鎖進行唯一標識

經常會出現一種情況,就是你獲取到鎖之後,因為各種原因(比如你的服務執行緒故障、網路抖動 等等),沒有執行完成,或者沒有釋放鎖,
這時候鎖也過了 EXPIRE TIME,就自動釋放了。當另外一個執行緒開鎖成功,你的執行緒響應過來了,把人家的鎖給釋放了,這樣就有問題了。
為了避免這種操作,我們要對同一個的鎖做唯一識別碼,在釋放鎖之前,先判斷下是不是自己設定的那個鎖,如下:

# 設定10086專用值
> SET lock.user_063105015 10086 NX PX 60000
OK

# 設定成功,獲取檢查確實是10086
> get lock.user_063105015
"10086"

# 虛擬碼:刪除前進項確認是不是自己加的那個鎖
if ( redis.get("lock.user_063105015").equals("10086")) {
   redis.del("lock.user_063105015");  // 只有對比成功才進行刪除,釋放鎖
 }

1.4.4 實現可重入鎖

可重入鎖可以理解為重新進入,由多於一個任務並行使用,而不必擔心資料錯誤。

  • 可重入性就就保證執行緒能繼續執行,防止在同一執行緒中多次獲取鎖而導致死鎖發生
  • 不可重入就是需要等待鎖釋放之後,再次獲取鎖成功,才能繼續往下執行

這邊說說可重入鎖,比如你執行執行緒的方案a獲取鎖之後,你的a方法後,執行緒繼續執行b方法也需要獲取鎖,如果這時候不可重入,
執行緒就需要等待鎖的釋放,進入爭搶。
這邊的解法就是對執行緒加鎖的鎖值進行增減,同一個執行緒的方法遇到加鎖則鎖值+1,遇到退鎖則鎖值-1,當前僅當鎖值=0的時候,說明這個鎖真正的被釋放了。
Java中的Redisson 類庫就是通過 Redis Hash 來實現可重入鎖。

加鎖的邏輯
我們可以使用 Redis hash 結構實現,key 表示被鎖的共用資源, hash 結構的 fieldKey 的 value 則儲存加鎖的次數。

實現如下( KEYS1 = "lock.user_063105015", ARGV [10000,uuid):
KEYS[1] = key的值
ARGV[1]) = 持有鎖的時間
ARGV[2] = getLockName(threadId) 下面id就算系統在啟動的時候會全域性生成的uuid 來作為當前程序的id,加上執行緒id就是getLockName(threadId)了,可以理解為:程序ID+系統ID = ARGV[2]

# 1 為 true
# 0 為 false

if (redis.call('exists', KEYS[1]) == 0) then 
    redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; 
    end; 
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 
    redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; 
    end; 
return redis.call('pttl', KEYS[1]);

引數說明

  • hincrby :將hash中指定域的值增加給定的數位
  • pexpire:設定key的有效時間以毫秒為單位
  • hexists:判斷field是否存在於hash中
  • pttl:獲取key的有效毫秒數

程式說明

  • Redis exists 命令判斷 lock.user_063105015 鎖是否存在
  • 鎖不存在,hincrby 建立一個鍵為 lock.user_063105015 的 hash 表,鍵為 uuid,初始化值為 0,然後再次加 1,最後設定過期時間。
  • 鎖存在,hexists判斷 lock 對應的 hash 表中是否存在 uuid 鍵,存在則 + 1,並重置過期時間
  • 不符合以上的條件的都走到預設返回