Redis分佈式鎖思考

版權聲明:本文爲博主原創文章,未經博主允許不得轉載。 https://blog.csdn.net/xiangyubobo/article/details/50408872

一般的鎖只能針對單機下同一進程的多個線程,或單機的多個進程。多機情況下,對同一個資源訪問,需要對每臺機器的訪問進程或線程加鎖,這便是分佈式鎖。分佈式鎖可以利用多機的共享緩存(例如redis)實現。redis的命令文檔[1],實現及分析參考文檔[2]。

利用redis的get、setnx、getset、del四個命令可以實現基於redis的分佈式鎖:

  • get key:表示從redis中讀取一個key的value,如果key沒有對應的value,返回nil,如果存儲的值不是字符串類型,返回錯誤
  • setnx key value:表示往redis中存儲一個鍵值對,但只有當key不存在時才成功,返回1;否則失敗,返回0,不改變key的value
  • getset key:將給定 key 的值設爲 value ,並返回 key 的舊值(old value)。當舊值不存在時返回nil,當舊值不爲字符串類型,返回錯誤
  • del key:表示刪除key,當key不存在時不做操作,返回刪除的key數量

關於加鎖思考,循環中:
0、setnx的value是當前機器時間+預估運算時間作爲鎖的失效時間。這是爲了防止獲得鎖的線程掛掉而無法釋放鎖而導致死鎖。
0.1、返回1,證明已經獲得鎖,返回啦
0.2、返回0,獲得鎖失敗,需要檢查鎖超時時間
1、get 獲取到鎖,利用失效時間判斷鎖是否失效。
1.1、取鎖超時時間的時刻可能鎖被刪除釋放,此時並沒有拿到鎖,應該重新循環加鎖邏輯。
2、取鎖超時時間成功
2.1、鎖沒有超時,休眠一下,重新循環加鎖
2.2、鎖超時,但此時不能直接釋放鎖刪除。因爲此時可能多個線程都讀到該鎖超時,如果直接刪除鎖,所有線程都可能刪除上一個刪除鎖又新上的鎖,會有多個線程進入臨界區,產生競爭狀態。
3、此時採用樂觀鎖的思想,用getset再次獲取鎖的舊超時時間。
3.1、如果此時獲得鎖舊超時時間成功
3.1.1、等於上一次獲得的鎖超時時間,證明兩次操作過程中沒有別人動過這個鎖,此時已經獲得鎖
3.1.2、不等於上一次獲得的鎖超時時間,說明有人先動過鎖,獲取鎖失敗。雖然修改了別人的過期時間,但因爲衝突的線程相差時間極短,所以修改後的過期時間並無大礙。此處依賴所有機器的時間一致。
3.2、如果此時獲得鎖舊超時時間失敗,證明當前線程是第一個在鎖失效後又加上鎖的線程,所以也獲得鎖
4、其他情況都沒有獲得鎖,循環setnx吧

關於解鎖的思考:
在鎖的時候,如果鎖住了,回傳超時時間,作爲解鎖時候的憑證,解鎖時傳入鎖的鍵值和憑證。我思考的解鎖時候有兩種寫法:
1、解鎖前get一下鍵值的value,判斷是不是和自己的憑證一樣。但這樣存在一些問題:

  • get時返回nil的可能,此時表示有別的線程拿到鎖並用完釋放
  • get返回非nil,但是不等於自身憑證。由於有getset那一步,當兩個競爭線程都在這個過程中時,存在持有鎖的線程憑證不等於value,而是value是稍慢那一步線程設置的value。

2、解鎖前用憑證判斷鎖是否已經超時,如果沒有超時,直接刪除;如果超時,等着鎖自動過期就好,免得誤刪別人的鎖。但這種寫法同樣存在問題,由於線程調度的不確定性,判斷到刪除之間可能過去很久,並不是絕對意義上的正確解鎖。

一個樣例代碼

public class RedisLock {

    private static final Logger logger = LoggerFactory.getLogger(RedisLock.class);

    //顯然jedis還需要自己配置來初始化
    private Jedis jedis = new Jedis();

    //默認鎖住15秒,盡力規避鎖時間太短導致的錯誤釋放
    private static final long DEFAULT_LOCK_TIME = 15 * 1000;

    //嘗試鎖住一個lock,設置嘗試鎖住的次數和超時時間(毫秒),默認最短15秒
    //成功時返回這把鎖的key,解鎖時需要憑藉鎖的lock和key
    //失敗時返回空字符串
    public String lock(String lock, int retryCount, long timeout) {
        Preconditions.checkArgument(retryCount > 0 && timeout > 0, "retry count <= 0 or timeout <= 0 !");
        Preconditions.checkArgument(retryCount < Integer.MAX_VALUE && timeout < Long.MAX_VALUE - DEFAULT_LOCK_TIME,
                "retry count is too big or timeout is too big!");
        String $lock = Preconditions.checkNotNull(lock) + "_redis_lock";
        long $timeout = timeout + DEFAULT_LOCK_TIME;
        String ret = null;
        //重試一定次數,還是拿不到,就放棄
        try {
            long i, status;
            for (i = 0, status = 0; status == 0 && i < retryCount; ++i) {
                //嘗試加鎖,並設置超時時間爲當前機器時間+超時時間
                if ((status = jedis.setnx($lock, ret = Long.toString(System.currentTimeMillis() + $timeout))) == 0) {
                    //獲取鎖失敗,查看鎖是否超時
                    String time = jedis.get($lock);
                    //在加鎖和檢查之間,鎖被刪除了,嘗試重新加鎖
                    if (time == null) {
                        continue;
                    }
                    //鎖的超時時間戳小於當前時間,證明鎖已經超時
                    if (Long.parseLong(time) < System.currentTimeMillis()) {
                        String oldTime = jedis.getSet($lock, Long.toString(System.currentTimeMillis() + $timeout));
                        if (oldTime == null || oldTime.equals(time)) {
                            //拿到鎖了,跳出循環
                            break;
                        }
                    }
                    try {
                        TimeUnit.MILLISECONDS.sleep(1L);
                    } catch (InterruptedException e) {
                        logger.error("lock key:{} sleep failed!", lock);
                    }
                }
            }
            if (i == retryCount && status == 0) {
                logger.info("lock key:{} failed!", lock);
                return "";
            }
            //給鎖加上過期時間
            jedis.pexpire($lock, $timeout);
            logger.info("lock key:{} succsee!", lock);
            return ret;
        } catch (Exception e) {
            logger.error("redis lock key:{} failed! cached exception: ", lock, e);
            return "";
        }
    }

    //釋放lock的鎖,需要傳入lock和key
    //盡力確保刪除屬於自己的鎖,但是不保證做得到
    public void releaseLock(String lock, String key) {
        String $lock = Preconditions.checkNotNull(lock) + "_redis_lock";
        Preconditions.checkNotNull(key);
        try {
            long timeout = Long.parseLong(key);
            //鎖還沒有超時,鎖還屬於自己可以直接刪除
            //但由於線程運行的不確定性,其實不能完全保證刪除時鎖還屬於自己
            //真正執行刪除操作時,距離上語句判斷可能過了很久
            if (timeout <= System.currentTimeMillis()) {
                jedis.del($lock);
                logger.info("release lock:{} with key:{} success!", lock, key);
            } else {
                logger.info("lock:{} with key:{} timeout! wait to expire", lock, key);
            }
        } catch (Exception e) {
            logger.error("redis release {}  with key:{} failed! cached exception: ", lock, key, e);
        }
    }
}

2、解鎖前用憑證判斷鎖是否已經超時,如果沒有超時,直接刪除;如果超時,等着鎖自動過期就好,免得誤刪別人的鎖。但這種寫法同樣存在問題,由於線程調度的不確定性,判斷到刪除之間可能過去很久,並不是絕對意義上的正確解鎖。對於新版的redis,在set方法中通過兩個參數達到一條命令執行。在舊版的redis中使用pipeline的方式也能達到這個效果。

關於解鎖我只想到這麼多,希望有幫助,歡迎拍磚多交流。

參考鏈接:
[1].http://doc.redisfans.com/
[2].http://blog.csdn.net/ugg/article/details/41894947

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