Redis 做分佈式鎖


分佈式鎖也算是 Redis 中比較常見的使用場景。
問題場景:

例如一個簡單的用戶操作,一個線程去修改用戶的狀態,首先從數據庫中讀出用戶的狀態,然後在內存中進行修改、修改完成後,再存回去。在單線程中,這個操作沒有任何問題,但是在多線程中,由於讀取、修改、存這是三個操作,不是原子操作,所以在多線程中,這樣會出現數據紊亂的問題。

對於這種問題,我們可以使用分佈式鎖來限制程序的併發執行。

1. 基本用法

分佈式鎖實現的思路很簡單,就是進來一個線程先佔位,當後面的線程進來操作時,發現已經有人佔位了,就會放棄本次操作或者稍後再試。

在 Redis 中,佔位一般使用 setnx 指令,先進來的線程先佔位,線程的操作執行完成之後,在調用 del 指令釋放位子。

根據上面的思路,構建代碼如下:

public class LockTest {
    public static void main(String[] args) {
        Redis redis = new Redis();
        redis.execute(jedis->{
            Long setnx = jedis.setnx("k1", "v1");
            if (setnx == 1) {
                // 無人佔位
                jedis.set("name","javaboy");
                System.out.println(jedis.get("name"));
                // 釋放資源
                jedis.del("k1");
            } else {
                System.out.println("有人佔位,停止/暫緩操作!");
            }
        });
    }
}

上面的代碼存在一個小問題,如果代碼業務在執行的過程中拋異常或者掛掉了,這樣會導致 del 指令沒有被調用,這樣,k1 無法釋放,後面來的請求全部被堵塞在這裏,鎖也永遠得不到釋放。

要解決這個問題,我們可以給鎖添加一個過期時間,確保鎖在一定的時間之後,能夠得到釋放。改進後的代碼如下:

public class LockTest {
    public static void main(String[] args) {
        Redis redis = new Redis();
        redis.execute(jedis->{
            Long setnx = jedis.setnx("k1", "v1");
            if (setnx == 1) {
                // 給鎖添加一個過期時間,防止應用在運行過程中拋出異常導致鎖無法及時得到釋放
                jedis.expire("k1",5);
                // 無人佔位
                jedis.set("name","javaboy");
                System.out.println(jedis.get("name"));
                // 釋放資源
                jedis.del("k1");
            } else {
                System.out.println("有人佔位,停止/暫緩操作!");
            }
        });
    }
}

這樣改造之後,還有一個問題,就是在獲取鎖和設置過期時間之間如果服務器突然掛掉【比如突然斷電】,這個時候鎖被佔用,無法及時得到釋放,也會造成死鎖,因爲獲取鎖和設置過期時間是兩個操作,不具備原子性

爲了解決這個問題,從 Redis 2.8 開始,setnx 和 expire 可以通過一個命令來一起執行了,我們對上述代碼再做改進:

public class LockTest {
    public static void main(String[] args) {
        Redis redis = new Redis();
        redis.execute(jedis -> {
            // setnx 和 expire 合二爲一使用
            String set = jedis.set("k1", "v1", new SetParams().nx().ex(5));
            if (null != set && "OK".equals(set)) {
                // 無人佔位
                jedis.set("name", "javaboy");
                System.out.println(jedis.get("name"));
                // 釋放資源
                jedis.del("k1");
            } else {
                System.out.println("有人佔位,停止/暫緩操作!");
            }
        });
    }
}

2. 解決超時問題

爲了防止業務代碼在執行的時候拋出異常,我們要給每一個鎖添加一個超時時間,超時之後,鎖會被自動釋放,但是這也帶來了一個新的問題:

如果要執行的業務非常耗時間,可能會出現紊亂。舉個列子:第一個線程首先獲取到鎖,然後開始執行業務代碼,但是業務代碼比較耗時,執行了8秒,這樣會在第一個線程的任務還未完成就會被釋放掉,此時第二個線程開始獲取到鎖執行,在第二個線程剛剛執行了3秒,第一個線程也執行完了,此時第一個線程會釋放鎖,但是注意,它釋放的是第二個線程的鎖,釋放之後,第三個進程進來。

對於這兩個問題,我們可以從兩個角度入手:

  • 儘量避免在獲取鎖之後,執行耗時操作
  • 可以在鎖上面做文章,將鎖的 value 設置爲一個隨機字符串,每次釋放鎖的時候,都去比較隨機字符串是否一致,如果一致,再去釋放,否則不釋放。

對於第二種解決方案,由於釋放鎖的時候,要去查看鎖的 value,第二步比較 value 的值是否正確,第三步釋放鎖,有三個步驟,很明顯三個步驟不具備原子性,爲了解決這個問題,我們得引入 Lua 腳本。

Lua 腳本的優勢:

  • 使用方便,Redis 中內置了對 Lua 腳本的支持
  • Lua 腳本可以在 Redis 服務端原子的執行多個 Redis 命令
  • 由於網絡在很大程度上會影響到 Redis 性能,而使用 Lua 腳本可以讓多個命令一次執行,可以有效解決網絡給 Redis 帶來的性能問題

在 Redis 中,使用 Lua 基本,大致上兩種思路:

  • 提前在 Redis 服務端寫好 Lua 腳本,然後在 Java 客戶端去調用腳本(推薦)。
  • 可以直接在 Java 端寫 Lua 腳本,寫好之後,需要執行時,每次將腳本發送到 Redis 上去執行。

首先在 Redis 服務端創建 Lua 腳本,內容如下:

if redis.call("get",KEYS[1])==ARGV[1] then
	return redis.call("del",KEYS[1])
else 
	return 0
end

接下來,可以給 Lua 腳本求一個 SHA1 和,命令如下:

cat lua/redis_lock.lua | redis-cli -a javaboy script load --pipe

在這裏插入圖片描述

script load 這個命令會在 Redis 服務器緩存 Lua 腳本,並返回腳本內容的 SHA1 校驗和,然後在 Java 端調用時,傳入 SHA1 校驗和作爲參數,這樣 Redis 服務端就知道執行哪個腳本了。

接下來,在 Java 客戶端調用這個腳本。

public class LockTest {
    public static void main(String[] args) {
        Redis redis = new Redis();
        for (int i = 0; i < 2; i++) {
            redis.execute(jedis -> {
                // 1.先獲取一個隨機字符串
                String value = UUID.randomUUID().toString();
                // 2.獲取鎖
                String set = jedis.set("k1", value, new SetParams().nx().ex(5));
                // 3.判斷是否成功拿到鎖
                if (null != set && "OK".equals(set)) {
                    // 4.具體的業務操作
                    jedis.set("name", "javaboy");
                    System.out.println(jedis.get("name"));
                    // 5.釋放鎖
                    jedis.evalsha("b8059ba43af6ffe8bed3db65bac35d452f8115d8"
                            , Arrays.asList("k1"), Arrays.asList(value));
                } else {
                    System.out.println("沒拿到鎖!");
                }
            });
        }
    }
}

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