基於 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
三種方案實現分佈式鎖

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