Redis實現分佈式鎖相關注意事項

Redis實現分佈式鎖相關注意事項

查看了很多關於redis實現分佈式鎖的文章,無疑要設計一個靠譜的分佈式並不太容易,總會出現各類鬼畜的問題;如今就來小述一下,在設計一個分佈式鎖的過程當中,會遇到一些什麼問題java

I. 背景知識

藉助redis來實現分佈式鎖(咱們先考慮單機redis的模式),首先有必要了解下如下幾點:redis

  • 單線程模式
  • setnx : 當不存在時,設置value,並返回1; 不然返回0
  • getset : 設置並獲取原來的值
  • expire : 設置失效時間
  • get : 獲取對應的值
  • del : 刪除
  • ttl : 獲取key對應的剩餘時間,若key沒有設置過超時時間,或者壓根沒有這個key則返回負數(多是-1,-2)
  • watch/unwatch : 事務相關

II. 方案設計

1. 設計思路

獲取鎖:安全

  • 調用 setnx 嘗試獲取鎖,若是設置成功,表示獲取到了鎖
  • 設置失敗,此時須要判斷鎖是否過時
    • 未過時,則表示獲取失敗;循環等待,並再次嘗試獲取鎖
    • 已過時,getset再次設置鎖,判斷是否獲取了鎖(根據返回的值進行判斷,後面給出具體的方案)
    • 若失敗,則從新進入獲取鎖的邏輯

釋放鎖:服務器

  • 一個原則就是確保每一個業務方釋放的是本身的鎖

2. getset的實現方案

網上一種常見的case,主要思路以下網絡

  • setnx 嘗試獲取鎖
  • 失敗,則 get 獲取鎖的value (通常是 uuid_timstamp)
  • 判斷是否過時,若沒有過時,則表示真的獲取失敗
  • 若過時,則採用 getset設置,嘗試獲取鎖

實現代碼以下併發

public class DistributeLock {

    private static final Long OUT_TIME = 30L;

    public String tryLock(Jedis jedis, String key) {
        while (true) {
            String value = UUID.randomUUID().toString() + "_" + System.currentTimeMillis();
            Long ans = jedis.setnx(key, value);
            if (ans != null && ans == 1) { // 獲取鎖成功
                return value;
            }

            // 鎖獲取失敗, 判斷是否超時
            String oldLock = jedis.get(key);
            if (oldLock == null) {
                continue;
            }

            long oldTime = Long.parseLong(oldLock.substring(oldLock.lastIndexOf("_") + 1));
            long now = System.currentTimeMillis();
            if (now - oldTime < OUT_TIME) { // 沒有超時
                continue;
            }

            String getsetOldVal = jedis.getSet(key, value);
            if (Objects.equals(oldLock, getsetOldVal)) { // 返回的正好是上次的值,表示鎖獲取成功
                return value;
            } else { // 表示返回的是其餘業務設置的鎖,趕忙的設置回去
                jedis.set(key, getsetOldVal);
            }
        }
    }

    public void tryUnLock(Jedis jedis, String key, String uuid) {
        String ov = jedis.get(key);
        if (uuid.equals(ov)) { // 只釋放本身的鎖
            jedis.del(key);
        }
    }
}

觀察獲取鎖的邏輯,特別是獲取超時鎖的邏輯,很容易想到有一個問題 getSet 方法會不會致使寫數據混亂的問題,簡單來講就是多個線程同時判斷鎖超時時,執行 getSet設置鎖時,最終獲取鎖的線程,可否保證和redis中的鎖的value相同app

上面的實現方式,一個混亂的case以下:dom

  1. 三個線程a,b,c 都進入到了鎖超時的階段
  2. 線程a, 獲取原始值 oldVal, 並設置 t1
  3. 線程b, 獲取線程a設置的 t1, 並重設爲 t2
  4. 線程c, 獲取線程b設置的 t2, 並重設爲 t3
  5. 線程a,判斷,並正式獲取到鎖
  6. 線程b,判斷失敗,恢復原來鎖的內容爲t1
  7. 線程c, 判斷失敗,恢復原來鎖的內容爲t2
  8. 問題出現了,獲取鎖的線程a,指望所得內容爲t1, 可是實際爲t2; 致使沒法釋放鎖

實際驗證分佈式

在上面的代碼中,配合測試case,加上一些日誌輸出測試

public static String tryLock(Jedis jedis, String key) throws InterruptedException {
    String threadName = Thread.currentThread().getName();
    while (true) {
        String value = threadName + "_" + UUID.randomUUID().toString() + "_" + System.currentTimeMillis();
        Long ans = jedis.setnx(key, value);
        if (ans != null && ans == 1) { // 獲取鎖成功
            return value;
        }

        // 鎖獲取失敗, 判斷是否超時
        String oldLock = jedis.get(key);
        if (oldLock == null) {
            continue;
        }

        long oldTime = Long.parseLong(oldLock.substring(oldLock.lastIndexOf("_") + 1));
        long now = System.currentTimeMillis();
        if (now - oldTime < OUT_TIME) { // 沒有超時
            continue;
        }


        // 強制使全部的線程均可以到這一步
        Thread.sleep(50);
        System.out.println(threadName + " in getSet!");


        // 人工接入,確保t1 獲取到鎖, t2 獲取的是t1設置的內容, t3獲取的是t2設置的內容
        if ("t2".equalsIgnoreCase(threadName)) {
            Thread.sleep(20);
        } else if ("t3".equalsIgnoreCase(threadName)) {
            Thread.sleep(40);
        }

        String getsetOldVal = jedis.getSet(key, value);
        System.out.println(threadName + " set redis value: " + value);

        if (Objects.equals(oldLock, getsetOldVal)) { // 返回的正好是上次的值,表示鎖獲取成功
            System.out.println(threadName + " get lock!");
            if ("t1".equalsIgnoreCase(threadName)) {
                // t1獲取到鎖,強制sleep40ms, 確保線t2,t3也進入了 getSet邏輯
                Thread.sleep(40);
            }
            return value;
        } else { // 表示返回的是其餘業務設置的鎖,趕忙的設置回去
            // 人肉介入,確保t2優先執行,並設置回t1設置的值, t3後執行設置的是t2設置的值
            if ("t3".equalsIgnoreCase(threadName)) {
                Thread.sleep(40);
            } else if ("t2".equalsIgnoreCase(threadName)){
                Thread.sleep(20);
            }
            jedis.set(key, getsetOldVal);
            System.out.println(threadName + " recover redis value: " + getsetOldVal);
        }
    }
}

測試case

@Test
public void testLock() throws InterruptedException {
    // 先無視獲取jedis的方式
    JedisPool jedisPool = cacheWrapper.getJedisPool(0);
    Jedis jedis = jedisPool.getResource();
    String lockKey = "lock_test";

    String old = DistributeLock.tryLock(jedis, lockKey);
    System.out.println("old lock: " + old);

    // 確保鎖超時
    Thread.sleep(40);

    // 建立三個線程
    Thread t1 = new Thread(() -> {
        try {
            Jedis j =jedisPool.getResource();
            DistributeLock.tryLock(j, lockKey);
            System.out.println("t1 >>>> " + j.get(lockKey));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }, "t1");
    Thread t2 = new Thread(() -> {
        try {
            Jedis j =jedisPool.getResource();
            DistributeLock.tryLock(j, lockKey);
            System.out.println("t2 >>>>> " + j.get(lockKey));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }, "t2");
    Thread t3 = new Thread(() -> {
        try {
            Jedis j =jedisPool.getResource();
            DistributeLock.tryLock(j, lockKey);
            System.out.println("t3 >>>>> " + j.get(lockKey));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }, "t3");


    t1.start();
    t2.start();
    t3.start();


    Thread.sleep(10000);
};

部分輸出結果:

main in getSet!
main set redis value: main_d4cc5d69-5027-4550-abe1-10126f057779_1515643763130
main get lock!
old lock: main_d4cc5d69-5027-4550-abe1-10126f057779_1515643763130
t1 in getSet!
t2 in getSet!
t1 set redis value: t1_105974db-7d89-48bf-9669-6f122a3f9fb6_1515643763341
t1 get lock!
t3 in getSet!
t2 set redis value: t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341
t1 >>>> t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341
t3 set redis value: t3_9aa5d755-43b2-43bd-9a0b-2bad13fa31f6_1515643763345
t2 recover redis value: t1_105974db-7d89-48bf-9669-6f122a3f9fb6_1515643763341
t3 recover redis value: t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341

重點關注 t1 >>>> t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341,表示t1線程過去了鎖,可是鎖的內容不是其value,即使t2去恢復,也會被t3給覆蓋


如何解決上面這個問題呢?

上面是典型的併發致使的問題,固然能夠考慮從解決併發問題的角度出發來考慮,一個常見的方式就是加鎖了,思路以下:(不詳細展開了)

  • 在判斷超時以後,加鎖
  • 再次獲取對應的值,判斷是否超時,是則執行上面的操做
  • 不然退出邏輯,繼續循環

這種實現方式,會有如下的問題:

  • getset 這個方法執行,可能致使寫入髒數據
  • 基於服務器時鐘進行超時判斷,要求全部服務器始終一致,不然有坑

3. expire實現方式

相比於前面一種直接將value設置爲時間戳,而後來比對的方法,這裏則直接藉助redis自己的expire方式來實現超時設置,主要實現邏輯相差無幾

public class DistributeExpireLock {

    private static final Integer OUT_TIME = 3;

    public static String tryLock(Jedis jedis, String key) {
        String value = UUID.randomUUID().toString();

        while(true) {
            Long ans = jedis.setnx(key, value);
            if (ans != null && ans == 1) { // 獲取鎖成功
                jedis.expire(key, OUT_TIME); // 主動設置超時時間爲3s
                return value;
            }

            // 獲取失敗,先確認下是否有設置國超是時間
            // 防止鎖的超時時間設置失效,致使一直競爭不到
            if(jedis.ttl(key) < 0) {
                jedis.expire(key, OUT_TIME);
            }
        }
    }


    public static void tryUnLock(Jedis jedis, String key, String uuid) {
        String ov = jedis.get(key);
        if (uuid.equals(ov)) { // 只釋放本身的鎖
            jedis.del(key);
            System.out.println(Thread.currentThread() +" del lock success!");
        } else {
            System.out.println(Thread.currentThread() +" del lock fail!");
        }
    }
}

獲取鎖的邏輯相比以前的,就簡單不少了,接下來則須要簡單的分析下,上面這種實現方式,會不會有坑呢?咱們主要看一下獲取鎖失敗的場景

  • 若是獲取鎖失敗
  • 表示有其餘的業務方已經獲取到了鎖
  • 此時,只能等持有鎖的業務方主動釋放鎖
  • 判斷鎖是否設置了超時時間,若沒有則加一個(防止設置超時時間失敗致使問題)

從上面這個邏輯來看問題不大,可是有個問題,case :

  • 如某個業務方setnx獲取到了鎖,可是由於網絡問題,過了好久才獲取到返回,此時鎖已經失效並被其餘業務方獲取到了,就會出現多個業務方同時持有鎖的場景

III. 小結說明

想基於redis實現一個相對靠譜的分佈式鎖,須要考慮的東西仍是比較多的,並且這種鎖並不太適用於業務要求特別嚴格的地方,如

  • 一個線程持有鎖時,若是發生gc,致使鎖超時失效,可是本身又不知道,此時就會出現多個業務方同時持有鎖的場景
  • 對於鎖超時的場景,須要仔細考慮,是否會出現併發問題
  • 確保只能釋放本身的鎖(以防止釋放了別人的鎖,出現問題)

參考連接

V. 其餘

聲明

盡信書則不如,已上內容,純屬一家之言,因本人能力通常,看法不全,若有問題,歡迎批評指正

掃描關注,java分享

QrCode

相關文章
相關標籤/搜索