Redis分佈式鎖——小心求證

Redis分佈式鎖

 

大膽假設,小心求證 —— 前輩們這樣說道。

 

近期在項目中爲了防止惡意併發操作,使用到了分佈式鎖。

幾種常見的方案:

1.Mysql樂觀鎖。

2.緩存

3.zookeeper。

從性能來選擇:

mysql由於要走磁盤io,讀寫性能較差。

redis內存讀寫快。

zookeeper文件系統相對於內存來說還是稍有不足。

實現難度上來說:

mysql和redis都比較簡單,公司也有組件支持。

zookeeper由於公司不支持安裝配置,需要自己搭建集羣,難度較大。

綜上所述,結合一下實際場景,這裏我便使用了redis作爲分佈式鎖實現的工具。

 

先說一下分佈式鎖需要具備哪些特徵:

1.互斥性,在任意時刻,只有一個客戶端能夠持有當前鎖。

2.不會發生死鎖,即使有一個客戶端在拿到鎖之後崩潰沒有主動釋放鎖,也能保證之後的客戶端能正常持有鎖。

3.唯一性,加鎖和解鎖必須是同一個客戶端,A客戶端不能去釋放B客戶端的鎖。

 

網上有許多前輩說過redis實現分佈式鎖是坑很多,那麼我們就一步一步的看下,坑在哪,如何把坑填上吧~

 

第一個坑:

public static boolean lock(Jedis jedis, String key, String requestId, int expireTime) {

if(jedis.setnx(key, requestId) == 1) {

//在這個地方,客戶端崩潰了~ o(╥﹏╥)o

jedis.expire(key, expireTime);

return true;

}

return false;

}

一般的寫法都是通過jedis.setnx()來獲取鎖,之後再對鎖設置過期時間。

這種寫法不滿足分佈式鎖的第二個特性,也就是說如果在如圖所示的地方崩潰了,客戶端獲取鎖之後還沒來得及給鎖設置過期時間,那麼這個鎖就一直在redis中並且不會釋放,導致後續客戶端永遠拿不到鎖。

那麼,我們得想辦法把坑填上去,於是補充了一段判斷,並且把過期時間的設置改成了value值:

    public static boolean lock( String key, long timeout ){

        //先獲取當前系統時間

        long currentTime = System.currentTimeMillis();

        //獲取鎖,並且設置鎖到期時間=(當前時間+過期時間)

        if( jedis.setnx( key, String.valueOf( currentTime + timeout ) ) == 1 ){

            return true;

        }else{

            //如果獲取鎖失敗,再次獲取當前系統時間

            currentTime = System.currentTimeMillis();

            //拿到鎖之前設置的到期時間

            long oldTime = NumberUtils.toLong( jedis.get( key ));

            //判斷當前系統時間是否大於到期時間,如果大於鎖的到期時間,說明鎖本應該被釋放

            if( currentTime > oldTime ){

                //那麼重新給鎖設置到期時間=(當前客戶端的系統時間+過期時間)

                long getSetTime = NumberUtils.toLong( jedis.getSet( key, String.valueOf( currentTime + timeout )));

            //這裏再次判斷當前系統時間是否大於鎖的過期時間

            //假設一下,如果兩個客戶端同時進行了上一步操作,都重新給鎖設置了新的到期時間,A先設置,B在設置,這時A拿到的getSetTime應該是之前的的oldTime,B拿到的getSetTime則爲A設置的新的過期時間。那麼加上這個判斷就能保證只有一個客戶端能真正拿到鎖,返回true。

                if( currentTime > getSetTime ){

                    return true;

                }

            }

        }
        

    return false;

}

當然,這種實現方式的問題在於

1.如果客戶端的系統時間不一致,那麼互斥性也不會滿足,

2.如果redis節點是主從分佈的,由於主從切換是異步同步數據的,所以redis並不能完全的實現鎖的安全性。 舉個例子來說:

  1. A客戶端在master實例上獲得一個鎖。
  2. 在對象鎖key傳送到slave之前,master崩潰掉。
  3. 一個slave被選舉成master。
  4. B客戶端可以獲取到同個key的鎖,但A也已經拿到鎖,導致鎖失效。

由於在公司內部所有的節點都能保證系統時間一致,並且redis節點是集羣分佈的,所以我採用了這種實現方式。

 

第二個坑:

public static void releaseLock(Jedis jedis, String Key) {

//A客戶端能解B客戶端的鎖。

jedis.del(Key);

}

改進一下:

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

//判斷當前客戶端是不是加鎖的客戶端

if (requestId.equals(jedis.get(lockKey))) {

//如果在這一時刻,A客戶端的鎖突然過期了,那麼B客戶端獲得了鎖,A客戶端就釋放了B客戶端的鎖。

jedis.del(lockKey);

}

}

在釋放鎖階段,一定要保證鎖擁有者的唯一性,即只有當前客戶端能釋放自己的鎖。

 

填坑,結合在加鎖階段的填坑方式,保證當前拿到鎖的客戶端不會自動釋放鎖的前提:

public static boolean unlock( String key ){

    if( System.currentTimeMillis() < NumberUtils.toLong( jedis.get( key ) ) && jedis.del( key ) <= 0 ){

        return false;

    }

    return true;

}

由於加鎖填坑的地方並沒有對鎖設置過期時間,而是把時間當做了它的一個value值,那麼就不存在判斷是當前客戶端之後鎖自動釋放的問題。

通過這樣的一個組合方式,能很好的解決redis實現分佈式鎖中間遇到由於原子性的問題導致的各種坑。

 

小彩蛋(後續思考):

當然,針對於redis集羣來實現分佈式鎖現在還有更好的方式,可以直接使用redis官方實現的Redlock,或者通過以下方法加鎖保證設置key和過期時間兩個操作的原子性,並且釋放鎖的時候使用lua代碼交由redis來執行,實現判斷與刪除的原子性:

加鎖:

jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

解鎖:

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

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