Redis分佈式鎖的實現方式

前言

分佈式鎖通常有3中實現方式:redis

  1. 數據庫樂觀鎖;
  2. 基於Redis的分佈式鎖;
  3. 基於ZooKeeper的分佈式鎖。

如下將詳細介紹如何正確地實現Redis分佈式鎖。數據庫


可靠性

首先,爲了確保分佈式鎖的可用,咱們至少要確保鎖的實現的同時,要知足如下四個條件:多線程

  • 互斥性。在任意時刻,只有一個客戶端持有鎖。
  • 不會發生死鎖。即便一個客戶端在持有鎖的期間發生崩潰而沒有主動釋放鎖,也能保證後續其餘客戶端能加鎖。
  • 具備容錯性。只要大部分的 Redis 節點正常運行,客戶端就能夠加鎖和解鎖。
  • 解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端本身不能把別人加的鎖給解了。

 代碼實現

 組件依賴

  首先咱們經過 Maven 引入 Jedis 開源組件,在 pom.xml 文件加入如下代碼:併發

1 <dependency>
2     <groupId>redis.clientsgroupId</groupId>
3     <artifactId>jedisartifactId</artifactId>
4     <version>2.9.0version</version>
5 </dependency>

 加鎖代碼

  先放代碼,後面再解釋爲何這樣實現:dom

 1 public class RedisTool {
 2 
 3     private static final String LOCK_SUCCESS = "OK";
 4     private static final String SET_IF_NOT_EXIST = "NX";
 5     private static final String SET_WITH_EXPIRE_TIME = "PX";
 6     /**
 7      * 嘗試獲取分佈式鎖
 8      * @param jedis Redis客戶端
 9      * @param lockKey 鎖
10     * @param requestId 請求標識
11     * @param expireTime 超期時間
12     * @return 是否獲取成功
13     */
14     public static boolean tryGetDistributedLock(Jedis jedis, 
15                     String lockKey,String requestId, int expireTime) {
16 
17         String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, 
18                         SET_WITH_EXPIRE_TIME, expireTime);
19 
20         if (LOCK_SUCCESS.equals(result)) {
21             return true;
22         }
23         return false;
24     }
25 }

能夠看到,加鎖就一行代碼:分佈式

1 jedis.set(String key, String value, String nxxx, String expx, int time);

這個 set( ) 方法一共5個形參:函數

  第一個是 key,咱們使用 key來當鎖,由於 key 是惟一的。spa

  第二個是value,入參是 requestId,不少人不明白,有 key 做爲鎖不就夠了嗎,爲什麼還要用到value?線程

  由於咱們上面講到的可靠性分佈式鎖要知足第4個條件:解鈴還須繫鈴人,經過給 value 賦值爲 requestId,咱們就知道這把鎖是哪一個請求加的,在解鎖的時候能夠有依據。code

  requestId 可使用UUID.random().toString() 方法生成。

  第三個事nxxx,這個參數 咱們填的是NX,意識 set if not exist,即當 key 不存在時,咱們進行 set 操做;若 key 已經存在,則不作任何操做;

  第四個是expx,這個參數咱們傳的是PX,意思是咱們要給這個 key 加一個過時的設置,具體時間由第五個參數決定;

  第五個是time,於第四個參數相呼應,表明 key 的過時時間。

總的來講,執行上面的 set() 方法就只有兩種結果:

  1. 當前沒有鎖(key不存),那麼就進行加鎖操做,並對鎖設置個有效時間,同時 value 表示加鎖的客戶;
  2. 已經存在鎖,不作任何操做。

細心查看就會發現,咱們加鎖的代碼知足可靠性裏面描述的三個條件。

  首先,set() 加入了 NX 參數,能夠保證若是已有 key 存在,則函數不會調用成功,也就是隻有一個客戶端能持有鎖,知足互斥性

  其次,因爲咱們對鎖設置了過時時間,即便鎖的持有者後續發生崩潰而沒有解鎖,鎖也會由於到了過時時間而自動解鎖(即key被刪除),不會發生死鎖

  最後,由於咱們將 value 賦值 requestId,表明加鎖的客戶標識,那麼在客戶端解鎖時就能夠進行校驗是不是同一個客戶端,即解鈴還須繫鈴人。

  因爲咱們只考慮 Redis 單機部署的場景,因此容錯性暫不考慮。

 錯誤示例一

  比較常見的錯誤示例就是使用 jedis.setnx() 和 jedis.expire() 組合實現加鎖。代碼以下:

1 public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
2 
3     Long result = jedis.setnx(lockKey, requestId);
4     if (result == 1) {
5         // 若在這裏程序忽然崩潰,則沒法設置過時時間,將發生死鎖
6         jedis.expire(lockKey, expireTime);
7     }
8 }

  setnx() 方法的做用就是 set if not exist,expire() 方法就是給鎖加一個過時時間。

  咋一看好像和前面的 set() 方法結果同樣,然而因爲這是兩條 Redis 命令,不具備原子性,若是程序在執行完 setnx()以後忽然崩潰,致使沒有設置過時時間,那麼將會發生死鎖。

  網上之因此有人這麼實現,是由於低版本的 Jedis 並不支持多參數的 set() 方法。

 錯誤示例二

  這種錯誤示例就是比較難發現問題,並且實現也比較複雜。實現思路:使用 jedis.setnx( key, value) 命令實現加鎖,其中 key 是鎖,value 是鎖的過時時間。

  執行過程:

  1. 經過 setnx( ) 方法蠶食加鎖,若是當前鎖不存在,返回加鎖成功。
  2. 若是鎖已經存在,則獲取鎖的過時時間,和當前時間比較,若是鎖已通過期,則設置新的過時時間,返回加鎖成功。

  代碼以下:

 1 public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
 2 
 3     long expires = System.currentTimeMillis() + expireTime;
 4     String expiresStr = String.valueOf(expires);
 5 
 6     // 若是當前鎖不存在,返回加鎖成功
 7     if (jedis.setnx(lockKey, expiresStr) == 1) {
 8         return true;
 9     }
10     // 若是鎖存在,獲取鎖的過時時間
11     String currentValueStr = jedis.get(lockKey);
12     if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
13         // 鎖已過時,獲取上一個鎖的過時時間,並設置如今鎖的過時時間
14         String oldValueStr = jedis.getSet(lockKey, expiresStr);
15         if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
16         // 考慮多線程併發的狀況,只有一個線程的設置值和當前值相同,它纔有權利加鎖
17             return true;
18         }
19     }
20     // 其餘狀況,一概返回加鎖失敗
21     return false;
22 }

  這段代碼的問題在哪裏?

  1. 因爲是客戶端本身生產過時時間,因此須要強制要求分佈式下每一個客戶端的時間必須同步。
  2. 當鎖過時的時候,若是多個客戶同時執行 jedis.setnx(k, v) 方法,那麼雖然只有一個客戶端能夠加鎖,可是這個客戶端的鎖過時時間可能會被其餘客戶端覆蓋。
  3. 鎖不具有擁有者標識,即任何客戶端均可以解鎖。

解鎖代碼

 正確姿式

  先展現代碼,再解釋爲何這樣實現:

 1 public class RedisTool {
 2     private static final Long RELEASE_SUCCESS = 1L;
 3     /**
 4      * 釋放分佈式鎖
 5      * @param jedis Redis客戶端
 6      * @param lockKey 鎖
 7      * @param requestId 請求標識
 8      * @return 是否釋放成功
 9      */
10     public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
11         String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
12         Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
13 
14         if (RELEASE_SUCCESS.equals(result)) {
15             return true;
16         }
17         return false;
18     }
19 }

  能夠看到,解鎖只須要兩行代碼就能夠搞定了。

  第一行,寫的是一個簡單的 Lua 腳本代碼;

  第二行,咱們將 Lua 代碼傳到 jedis.eval( ) 方法裏,並使參數 KEYS[1] 賦值爲lockKey,ARGV[1] 賦值爲 requestId,eval() 方法是將 Lua代碼交給 Redis 服務端執行。

  那麼這段代碼的功能是什麼呢?

  很簡單,首先獲取鎖對應的 value 值,檢查是否與 requestId 相等,若是相等則刪除鎖(解鎖)。

  那麼爲何要使用Lua語言來講實現呢?

  由於要確保上述操做是原子性的。關於非原子性會帶來的問題,能夠閱讀【解鎖代碼 - 錯誤示例二】。

  那麼爲何執行 eval() 方法能夠確保原子性?

  源於 Redis 的特性,下面是官網對 eval 命令的部分解釋:簡單來講,就是在 eval 命令執行Lua代碼的時候,Lua代碼將被當成一個命令去執行,而且直到 eval 命令執行完成,Redis 纔會執行其餘命令。

 錯誤示例一

  最多見的解鎖代碼就是直接使用 jedis.del() 方法刪除鎖,這種不先判斷鎖的擁有者就直接解鎖的方法,會致使任何客戶端均可以隨時進行解鎖,即便這把鎖不是它的。

1 public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
2     jedis.del(lockKey);
3 }

 錯誤示例二

  這種解鎖代碼咋一看也是沒有問題的,與正確姿式差很少,惟一區別的是分紅兩條命令去執行。代碼以下:

1 public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
2     // 判斷加鎖與解鎖是否是同一個客戶端
3     if (requestId.equals(jedis.get(lockKey))) {
4         // 若在此時,這把鎖忽然不是這個客戶端的,則會誤解鎖
5         jedis.del(lockKey);
6     }
7 }

  如代碼的註釋,問題在於若是調用 jedis.del() 方法的時候,這把鎖已經不屬於當前客戶端的時候,會解鎖他人加的鎖。

  是否有這種場景?

  固然有的。如客戶端A加鎖,一段時間後客戶端A解鎖,在執行 jedis.del() 方法以前,鎖忽然過時了,此時客戶端B嘗試加鎖成功,而後客戶端A再執行 jedis.del() 方法,則會將客戶端B的鎖給解除。


總結

  想要經過 Redis實現分佈式鎖並不難,只要保證能知足可靠性裏的四個條件。

  若是項目中Redis是多機部署,那麼能夠嘗試使用 Redisson 實現分佈式鎖。

相關文章
相關標籤/搜索