Redis進階二之分佈式鎖的實現

前言

分佈式系統中,由於多個進程之間會存在操作共享數據的情況下,此時就需要一個協調系統進行各個進程之間的協調,避免多個進程之間同時修改數據導致互相影響的情況。通常可以採用數據庫鎖來實現數據不會再同一時間修改,但是數據庫鎖的悲觀鎖,比較影響整個系統的性能。並且如果修改的數據並非是數據庫中的數據時,通過數據庫鎖就無法實現了。此時就需要一個分佈式鎖來進行分佈式協調。

一、分佈式鎖

高可用的分佈式鎖需要達到以下幾種特性:

1、分佈式系統中,同一個方法在同一時間只能被一個節點上的一個線程執行

2、獲取鎖和釋放鎖性能較高

3、具備鎖失效的機制,避免死鎖

4、可重入性

5、非阻塞性,避免獲取鎖失敗之後一直阻塞

 

二、redis實現分佈式鎖

由於redis是單線程工作的,所以redis天然就具備了同一時間只能有一個線程執行的條件。而redis分佈式鎖根據redis部署方式不同又分成兩個版本,一個是redis單機部署版本,一個是redis集羣部署版本。兩種redis部署方式的分佈式鎖的實現也完全不同

2.1、redis單機模式分佈式鎖

redis的字符串操作API中提供了setnx key value方法,該方法的作用是隻有在key不存在時纔會設置value,redis的分佈式鎖主要就依賴此方法來實現。

方案一:採用命令setnx命令獲取鎖,設置值成功表示獲取鎖成功,如果設置失敗表示key已經存在則表示獲取鎖失敗。通過delete命令刪除key的方式釋放鎖

步驟如下:

1、客戶端執行setnx命令獲取鎖,返回值爲1表示獲取鎖成功,返回爲0表示當前key已經存在獲取鎖失敗

2、獲取到鎖的客戶端執行業務邏輯

3、客戶端執行delete命令刪除key釋放鎖

 

問題:當客戶端執行setnx獲取鎖之後程序異常或崩潰,則無法通過delete命令來釋放鎖,此時就會導致鎖一直無法釋放導致死鎖的情況

 

方案二:採用命令setnx命令獲取鎖,並通過expire命令給key添加過期時間,最後通過delete釋放鎖

步驟如下:

1、客戶端執行setnx命令獲取鎖,返回值爲1表示獲取鎖成功,返回爲0表示當前key已經存在獲取鎖失敗

2、獲取到鎖成功之後執行expire命令給key增加過期時間

3、獲取到鎖的客戶端執行業務邏輯

4、客戶端執行delete命令刪除key釋放鎖

5、如果客戶端異常或崩潰,可以通過key達到過期時間釋放鎖的效果

 

問題:setnx命令和expire命令是兩個命令,所以兩個操作不是原子操作,也就是說可能會存在setnx執行成功而expire執行失敗的情況。

 

方案三:採用set方法加參數的方式達到加鎖和過期時間原子操作

步驟如下:

1、客戶端執行SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]

其中EX表示過期時間單位爲秒,seconds值爲過期時長;PX表示過期時間單位爲毫秒,millisseconds表示過期時長, NX表示只有當key不存在時才設置,XX表示只有當key存在時才設置

如 set test_key test_value PX 5 NX, 則表示設置test_key的值爲test_value,並且設置了5秒的過期時間,同時只有當key不存在時才執行設置操作

2、獲取到鎖的客戶端執行業務邏輯

3、客戶端執行delete命令刪除key釋放鎖

 

問題:由於delete命令沒有做任何校驗,所以會存在誤刪的情況,比如客戶端A執行set方法獲取鎖成功然後執行業務邏輯,由於業務邏輯執行時間較長超過了過期時間,此時key會被刪除,而此時客戶端B執行set方法獲取鎖成功之後,客戶端A的業務邏輯執行完成,開始執行delete操作釋放鎖,此時就會將客戶端B獲取到的鎖給釋放了,從而出現了誤刪鎖的情況。

 

方案四:採用set方法加參數的方式加鎖,在釋放鎖之前需要判斷鎖釋放被當前線程佔用

步驟如下:

1、客戶端執行SET KEY VALUE PX 5 NX進行加鎖

2、獲取到鎖的客戶端執行業務邏輯

3、執行get(key)獲取鎖的值和之前設置過的值進行比較,如果期望的值和實際的值相同,則執行delete命令釋放鎖,如果期望的值和實際的值不相同,則不執行delete命令

 

問題:由於get操作和delete操作是兩個命令,也就是說get和delete操作並非是原子操作,所以理論上還是會出現get操作的時候確實是期望的值,但是在delete之前實際的值已經變成了新的值了,此時還是會出現誤刪的情況。

 

方案五:採用set方法加參數的方式加鎖,採用eval函數原子操作釋放鎖

redis的eval函數是執行一段Lua腳本,執行Lua腳本是原子執行的,所以可以通過eval函數在Lua腳本中判斷當前鎖是否可以釋放並且最終釋放鎖。

步驟如下:

1、客戶端執行SET KEY VALUE PX 5 NX進行加鎖

2、獲取到鎖的客戶端執行業務邏輯

3、執行Lua腳本來刪除key,刪除之前判斷是否是原先的值,只不過eval方法是原子操作。

1  String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return   redis.call('del', KEYS[1]) else return 0 end";
2   Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

 

上面的腳步含義是當前的值是否和期望的值相等,如果相等的話就執行delete操作刪除key。

eval方法是將整個Lua腳本發送給redis執行,eval中的Lua會被當作原子操作交給redis來執行。

在單機部署redis的情況下,方案五可以達到原子性加鎖和原子性釋放鎖的效果,從而可以達到分佈式鎖的效果

 

2.2、redis集羣模式分佈式鎖

在集羣模式下,單機模式的方案五就會存在不可用的風險,比如以下場景:

客戶端A從master節點獲取鎖成功,鎖信息還沒有同步到slave節點就宕機了,此時slave節點升級爲新的master節點,客戶端B從新的master節點獲取鎖成功,就導致同時有兩個客戶端都成功獲取到了鎖。顯然就破壞了分佈式鎖同一時間只能有一個客戶端獲取鎖的原則。

Redis官方提供了一種RedLock算法來實現了集羣模式下的分佈式鎖的實現

2.2.1、RedLock算法

1、客戶端獲取當前Unix時間戳

2、客戶端按順序依次向N個redis節點設置相同的key和唯一的value獲取鎖的操作(獲取鎖的過程和單機模式獲取鎖的方式一樣),且需要設置超時時間,超時時間需要小於鎖過期的時間

3、如果滿足以下幾個條件,那麼就表示獲取鎖成功,否則就表示獲取鎖失敗

a、鎖的可用時間 = (當前時間 - 請求時間) < 鎖的過期時間;

b、超過半數以上的節點都獲取鎖成功,也就是滿足a的要求

4、獲取鎖成功之後,鎖的過期時間應該重新計算,鎖的實際有效時間 = 鎖的過期時間 - 獲取鎖消耗的時間

5、業務邏輯處理完成之後,需要將所有節點發送釋放鎖的命令,因爲獲取鎖失敗的節點可能已經佔鎖成功了,只是由於網絡原因導致的失敗。

 

RedLock理論上還是會存在一定的風險,比如當前有A、B、C三個redis節點,客戶端1從A、B兩個節點獲取鎖成功,C獲取鎖失敗了;然後節點A宕機重啓,且沒有持久化。重啓之後客戶端2從A和C兩個節點獲取鎖成功,就會出現客戶端1和客戶端2同時佔有鎖的情況。

對於這樣的問題可以通過節點持久化來避免,或者宕機之後延遲重啓,延遲時間爲鎖的過期時間,這樣就可以保證重啓之後鎖已經完全被釋放了。

2.2.2、RedLock算法的使用Redisson

目標Redis官方推薦的RedLock算法的實現爲Redisson工具包,Redisson底層通過Netty框架實現,提供了redis不同部署環境下的分佈式鎖的實現。

1、Redisson的使用

a、添加maven依賴

<dependency>
     <groupId>org.redisson</groupId>
     <artifactId>redisson</artifactId>
     <version>3.6.0</version>
</dependency>

 

b、Redisson支持單機模式、主從模式、哨兵模式、集羣模式下的分佈式鎖的實現,只需要通過配置redis節點的主機信息即可。案例如下:

 1         Config config = new Config();
 2         //1.單機模式
 3         config.useSingleServer().setAddress("redis://122.51.172.201");
 4         //2.主從模式
 5         Set<URI> slaveUrls = new HashSet<>();
 6         config.useMasterSlaveServers().setMasterAddress("redis://122.51.172.201").setSlaveAddresses(slaveUrls);
 7         //3.哨兵模式
 8         config.useSentinelServers().addSentinelAddress("redis://122.51.172.201").setMasterName("redis://122.51.172.201");
 9         //4.集羣模式
10         config.useClusterServers().addNodeAddress("redis://122.51.172.201", "redis://122.51.172.201");

 

通過創建Config對象並使用指定的模式,然後添加對應模式下的redis節點信息即可

c、創建Redisson客戶端並獲取和釋放鎖

 1         /**獲取Redisson客戶端 通過靜態方法根據Config配置創建Redisson客戶端*/
 2         RedissonClient client = Redisson.create(config);
 3         /**設置分佈式鎖的key*/
 4         String lockKey = "test_lock_key";
 5         /**獲取分佈式鎖*/
 6         RLock lock = client.getLock(lockKey);
 7 
 8         boolean isLock = false;
 9         try{
10             while (!isLock) {
11                 isLock = lock.tryLock(5, TimeUnit.SECONDS);//嘗試獲取分佈式鎖,有效期爲5秒
12                 if (isLock) {
13                     //獲取鎖成功
14                     System.out.println(Thread.currentThread().getName() + "獲取鎖成功");
15                     //TODO 執行業務邏輯
16                     Thread.sleep(5000L);
17                 } else {
18                     System.out.println(Thread.currentThread().getName() + "獲取鎖失敗");
19                 }
20             }
21         }catch (Exception e){
22             e.printStackTrace();
23         }finally {
24             if(isLock) {
25                 System.out.println(Thread.currentThread().getName() + "釋放鎖");
26                 lock.unlock();//釋放鎖
27             }
28         }

 

可以發現使用起來十分便捷,對於客戶端而言,只需要配置好redis集羣的節點信息即可,通過客戶端獲取RLock對象,然後調用tryLock進行加鎖,調用unlock進行釋放鎖即可。

2.2、Redisson的工作流程

Redisson整體工作流程如下圖示:

 

 

加鎖時,如果採用的是集羣模式,會根據hash選擇指定的節點,執行Lua腳本進行加鎖操作,腳本如下:

"if (redis.call('exists', KEYS[1]) == 0) then " +
    "redis.call('hset', 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]);",

 

通過Lua腳本執行可以保證命令的原子性,上述腳本的意思是:

如果key不存在則通過原子操作向hash中添加key並設置過期時間;

如果key已存在則通過原子操作向hash中的value執行自增操作並重新設置過期時間

該腳本一箇中參數說明:

KEYS[1] : 表示解鎖的key

ARGV[1] : 鎖的key的過期時間

ARGV[2]:加鎖的客戶端唯一ID

1、首先第一個判斷當前的key是否存在,如果存在表示鎖被佔用,如果鎖不存在纔會執行後面邏輯

2、再判斷key對應的hash的value值是否是當前客戶端的唯一ID標識,如果值則加鎖成功,如果不是則加鎖失敗

3、當獲取鎖失敗時會不斷循環嘗試獲取鎖

另外redisson實現的鎖對應的數據結構時hash類型,key是鎖的key,field爲加鎖的客戶端標識,value爲自增的數字,表示重入的次數。

 

釋放鎖是通過自減的方式修改value值,如果值爲0了就直接刪除key即可。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章