1. 程式人生 > >基於 Redis 實現分散式鎖

基於 Redis 實現分散式鎖

什麼是Redis?

Redis通常被稱為資料結構伺服器。這意味著Redis通過一組命令提供對可變資料結構的訪問,這些命令使用帶有TCP套接字和簡單協議的伺服器 - 客戶端模型傳送。因此,不同的程序可以以共享方式查詢和修改相同的資料結構。

Redis中實現的資料結構有一些特殊屬性:

Redis關心將它們儲存在磁碟上,即使它們總是被提供並修改到伺服器記憶體中。這意味著Redis速度很快,但這也是非易失性的。
資料結構的實現強調記憶體效率,因此與使用高階程式語言建模的相同資料結構相比,Redis內部的資料結構可能使用更少的記憶體。
Redis提供了許多在資料庫中自然可以找到的功能,如複製,可調節的永續性級別,群集,高可用性。
另一個很好的例子是將Redis視為memcached的更復雜版本,其中操作不僅僅是SET和GET,而是用於處理複雜資料型別(如Lists,Sets,有序資料結構等)的操作。

Redis 實現分散式鎖

什麼是分散式鎖?

顧名思義,分散式鎖肯定是用在分散式環境下。在分散式環境下,使用分散式鎖的目的也是保證同一時刻只有一個執行緒來修改共享變數,修改共享快取……。

前景:

jdk提供的鎖只能保證執行緒間的安全性,但分散式環境下,各節點之間的執行緒同步執行卻得不到保障,分散式鎖由此誕生。

實現方式有以下幾種:

  1. 基於資料庫實現分散式鎖;
  2. 基於快取(Redis等)實現分散式鎖;
  3. 基於Zookeeper實現分散式鎖;

使用Redis做分散式鎖,相對其他兩種方案效能是最好的。當然也是較複雜的。

設計實現

實現分散式鎖必須要有的可靠性保證如下:

互斥性:相互排斥。在任何給定時刻,只有一個客戶端可以持有鎖。
無死鎖:最終,即使鎖定資源的客戶端崩潰或被分割槽,也始終可以獲取鎖定。
容錯性:容錯,只要大多數Redis節點啟動,客戶端就能夠獲取和釋放鎖。
解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。

與SpringBoot 整合實現Redis 分散式鎖Demo

加鎖程式碼

    /**
     * 獲取鎖.
     *
     * @param key                  the lock 鍵
     * @param requestId            the 隨機唯一標識
     * @param expireMillionSeconds the 過期時間
     * @return the boolean
     */
public boolean lock(String key, String requestId, int expireMillionSeconds) { //獲取redis資源 Jedis jedis = getRedis(); String result = jedis.set(key, requestId, "NX", "PX", expireMillionSeconds); //釋放 recycleRedis(jedis); return "OK".equals(result); }

獲取分散式鎖,就一個方法:jedis.set(String key, String value, String nxxx, String expx, int time),這個set()方法一共有五個形參:

第一個為key,我們使用key來當鎖,因為key是唯一的。  
第二個為value,傳的是requestId,這裡會有疑惑,有key作為鎖不就夠了嗎,為什麼還要用到value?原因就是我們在上面講到可靠性時,分散式鎖要滿足第四個條件解鈴還須繫鈴人,通過給value賦值為requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據。requestId可以使用UUID.randomUUID().toString()方法生成或者其他方式生成的唯一標識。  
第三個為nxxx,這個引數填的是NX,意思是SET IF NOT  EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;
第四個為expx,這個引數傳的是PX,意思是我們要給這個key加一個過期的設定,具體時間由第五個引數決定。  
第五個為time,與第四個引數相呼應,代表key的過期時間。  

也就是,判斷傳入的Key是否存在,不存在則新增,並設定過期時間,如果存在則不做任何操作。

我們發現,我們的加鎖程式碼滿足我們可靠性裡描述的三個條件。首先,set()加入了NX引數,可以保證如果已有key存在,則函式不會呼叫成功,也就是隻有一個客戶端能持有鎖,滿足互斥性。其次,由於我們對鎖設定了過期時間,即使鎖的持有者後續發生崩潰而沒有解鎖,鎖也會因為到了過期時間而自動解鎖(即key被刪除),不會發生死鎖。最後,因為我們將value賦值為requestId,代表加鎖的客戶端請求標識,那麼在客戶端在解鎖的時候就可以進行校驗是否是同一個客戶端。由於我們只考慮Redis單機部署的場景,所以容錯性我們暫不考慮。

另外根據部分場景需要可設計阻塞式的鎖,簡單參考如下:
獲取分散式鎖(阻塞)

    /**
     * 嘗試獲取鎖(阻塞式實現).
     *
     * @param key                  the lock鍵
     * @param requestId            the 隨機生成的唯一標識,
     * @param expireMillionSeconds the 該鎖的過期時間,避免redis宕了出現死鎖
     * @return the boolean
     */
    public boolean tryLock(String key, String requestId, int expireMillionSeconds) {
        Jedis jedis = getRedis();
        long startTime = System.currentTimeMillis();
        while (true) {
            String result = jedis.set(key, requestId, "NX", "PX", expireMillionSeconds);
            if ("OK".equals(result)) {
                recycleRedis(jedis);
                return true;
            }
            try {
                //視情況而定,避免無效迴圈過多
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
            }
            long time = System.currentTimeMillis() - startTime;
            //獲取鎖超時,避免獲取不到鎖出現的問題
            if (time > maxLockTimeout) {
                recycleRedis(jedis);
                return false;
            }
        }
    }

解鎖程式碼

    public final static String REDIS_UNLOCK = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    /**
     * 解鎖.key和value必須兩者都匹配才能刪除,目的是防止誤解別人的鎖
     *
     * @param key       the lock 鍵
     * @param requestId the 對應的唯一標識
     * @return the boolean
     */
    public boolean unlock(String key, String requestId) {
        Jedis jedis = getRedis();
        Object result = jedis.eval(REDIS_UNLOCK, Collections.singletonList(key),
                Collections.singletonList(requestId));
        recycleRedis(jedis);
        return Long.valueOf(1L).equals(result);
    }

上面這段指令碼其實很簡單,首先獲取鎖對應的value值,檢查是否與傳給ARGV[1]的requestId相等,如果相等則刪除鎖(解鎖)。
並且eval命令執行Lua程式碼的時候,Lua程式碼將被當成一個命令去執行,並且直到eval命令執行完成,Redis才會執行其他命令。
那麼為什麼要使用Lua語言來實現呢?因為要確保上述操作是原子性的。關於非原子性會帶來什麼問題?
常見的錯誤示例:
這種解鎖程式碼乍一看也是沒問題,與正確姿勢差不多,唯一區別的是分成兩條命令去執行,程式碼如下:

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {

    // 判斷加鎖與解鎖是不是同一個客戶端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此時,這把鎖突然不是這個客戶端的,則會誤解鎖
        jedis.del(lockKey);
    }

}

如程式碼註釋,問題在於如果呼叫jedis.del()方法的時候,這把鎖已經不屬於當前客戶端的時候會解除他人加的鎖。那麼是否真的有這種場景?答案是肯定的,比如客戶端A加鎖,一段時間之後客戶端A解鎖,在執行jedis.del()之前,鎖突然過期了,
此時客戶端B嘗試加鎖成功,然後客戶端A再執行del()方法,則將客戶端B的鎖給解除了。
相比而言,lua指令碼執行是連貫的,在eval命令未執行完成,Redis是不會執行其他命令,所以就能解決這個問題。

原始碼以上傳GitHub:https://github.com/liaozihong/SpringBoot-Learning/tree/master/SpringBoot-Redis-Distributed-Lock

補充

三種方案的比較
上面幾種方式,哪種方式都無法做到完美。就像CAP一樣,在複雜性、可靠性、效能等方面無法同時滿足,所以,根據不同的應用場景選擇最適合自己的才是王道。

從理解的難易程度角度(從低到高)
資料庫 > 快取 > Zookeeper

從實現的複雜性角度(從低到高)
Zookeeper >= 快取 > 資料庫

從效能角度(從高到低)
快取 > Zookeeper >= 資料庫

從可靠性角度(從高到低)
Zookeeper > 快取 > 資料庫

參考連結
https://redis.io/topics/distlock
http://www.importnew.com/27477.html
三種方案實現分散式鎖