分佈式鎖也算是 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("沒拿到鎖!");
}
});
}
}
}