1、數據庫實現(效率低,不推薦)
2、redis實現(使用redission實現,但是需要考慮思索,釋放問題。繁瑣一些)
3、Zookeeper實現 (使用臨時節點,效率高,失效時間可以控制)
4、Spring Cloud 實現全局鎖(內置的)
數據庫的分佈式鎖
悲觀鎖
- STEP1 - 獲取鎖:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。
- STEP2 - 執行業務邏輯。
- STEP3 - 釋放鎖:COMMIT。
樂觀鎖
加版本號,每次讀出版本號,進行操作時對比版本號
Zookeeper實現分佈式鎖原理
使用zookeeper創建臨時序列節點來實現分佈式鎖,適用於順序執行的程序,大體思路就是創建臨時序列節點,找出最小的序列節點,獲取分佈式鎖,程序執行完成之後此序列節點消失,通過watch來監控節點的變化,從剩下的節點的找到最小的序列節點,獲取分佈式鎖,執行相應處理,依次類推……
redis分佈式鎖
直接用SETNX和DEL實現加解鎖。SETNX 是『SET if Not eXists』,如果不存在,纔會設置
不過直接使用 SETNX 有一個缺陷,我們沒辦法對其設置過期時間,如果加鎖客戶端宕機了,這就導致這把鎖獲取不了。
不過這個問題在 Redis 2.6.12 版本 就可以被完美解決。這個版本增強了 SET 命令,可以通過帶上 NX,EX 命令原子執行加鎖操作,解決上述問題。參數含義如下:
-
EX second :設置鍵的過期時間,單位爲秒
-
NX 當鍵不存在時,進行設置操作,等同與 SETNX 操作
使用 SET 命令實現分佈式鎖只需要一行代碼:
SET lock_name anystring NX EX lock_time
不過這種方式卻存在一個缺陷,可能會發生錯解鎖問題。
假設應用 1 加鎖成功,鎖超時時間爲 30s。由於應用 1 業務邏輯執行時間過長,30 s 之後,鎖過期自動釋放。
這時應用 2 接着加鎖,加鎖成功,執行業務邏輯。這個期間,應用 1 終於執行結束,使用 DEL
成功釋放鎖。
這樣就導致了應用 1 錯誤釋放應用 2 的鎖,另外鎖被釋放之後,其他應用可能再次加鎖成功,這就可能導致業務重複執行。
這時候就可以考慮樂觀鎖的版本號方法
爲了使鎖不被錯誤釋放,我們需要在加鎖時設置隨機字符串,比如 UUID。
SET lock_name uuid NX EX lock_time
釋放鎖時,需要提前獲取當前鎖存儲的值,然後與加鎖時的 uuid 做比較,僞代碼如下:
var value= get lock_name
if value == uuid
// 釋放鎖成功
else
// 釋放鎖失敗
但是以上代碼我們不能通過 Java 代碼運行,因爲無法保證上述代碼原子化執行。要用Lua腳本
lua 代碼可以運行在 Redis 服務器的上下文中,並且整個操作將會被當成一個整體執行,中間不會被其他命令插入。
Redis 可以使用 EVAL 執行 LUA 腳本,而我們可以在 LUA 腳本中執行判斷求值邏輯。EVAL 執行方式如下:
在 Lua 腳本可以使用下面兩個函數執行 Redis 命令:
-
redis.call()
-
redis.pcall()
兩個函數作用法與作用完全一致,只不過對於錯誤的處理方式不一致,感興趣的小夥伴可以具體點擊以下鏈接,查看錯誤處理一章。http://doc.redisfans.com/script/eval.html
EVAL
命令每次執行時都需要發送 Lua 腳本,但是 Redis 並不會每次都會重新編譯腳本。
當 Redis 第一次收到 Lua 腳本時,首先將會對 Lua 腳本進行 sha1 獲取簽名值,然後內部將會對其緩存起來。後續執行時,直接通過 sha1 計算過後簽名值查找已經編譯過的腳本,加快執行速度。
雖然 Redis 內部已經優化執行的速度,但是每次都需要發送腳本,還是有網絡傳輸的成本,如果腳本很大,這其中花在網絡傳輸的時間就會相應的增加。
所以 Redis 又實現了 EVALSHA
命令,原理與 EVAL
一致。只不過 EVALSHA
只需要傳入腳本經過 sha1計算過後的簽名值即可,這樣大大的減少了傳輸的字節大小,減少了網絡耗時。
EVALSHA
命令如下:
evalsha c686f316aaf1eb01d5a4de1b0b63cd233010e63d 1 foo 樓下小黑哥
可以看到,如果之前未執行過 EVAL
命令,直接執行 EVALSHA
將會報錯。
//連接本地的 Redis 服務
Jedis jedis = new Jedis("localhost", 6379);
jedis.auth("1234qwer");
System.out.println("服務正在運行: " + jedis.ping());
String lua_script = "return redis.call('set',KEYS[1],ARGV[1])";
String lua_sha1 = DigestUtils.sha1DigestAsHex(lua_script);
try {
Object evalsha = jedis.evalsha(lua_sha1, Lists.newArrayList("foo"), Lists.newArrayList("樓下小黑哥"));
} catch (Exception e) {
Throwable current = e;
while (current != null) {
String exMessage = current.getMessage();
// 包含 NOSCRIPT,代表該 lua 腳本從未被執行,需要先執行 eval 命令
if (exMessage != null && exMessage.contains("NOSCRIPT")) {
Object eval = jedis.eval(lua_script, Lists.newArrayList("foo"), Lists.newArrayList("樓下小黑哥"));
break;
}
}
}
String foo = jedis.get("foo");
System.out.println(foo);
上面的代碼看起來還是很複雜吧,不過這是使用原生 jedis 的情況下。如果我們使用 Spring Boot 的話,那就沒這麼麻煩了。Spring 組件執行的 Eval
方法內部就包含上述代碼的邏輯。
不過需要注意的是,如果 Spring-Boot 使用 Jedis 作爲連接客戶端,並且使用Redis Cluster 集羣模式,需要使用 2.1.9 以上版本的spring-boot-starter-data-redis,不然執行過程中將會拋出。
優化分佈式鎖
/**
* 非阻塞式加鎖,若鎖存在,直接返回
*
* @param lockName 鎖名稱
* @param request 唯一標識,防止其他應用/線程解鎖,可以使用 UUID 生成
* @param leaseTime 超時時間
* @param unit 時間單位
* @return
*/
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
// 注意該方法是在 spring-boot-starter-data-redis 2.1 版本新增加的,若是之前版本 可以執行下面的方法
return stringRedisTemplate.opsForValue().setIfAbsent(lockName, request, leaseTime, unit);
}
由於setIfAbsent
方法是在 spring-boot-starter-data-redis 2.1 版本新增加,之前版本無法設置超時時間。
解鎖需要使用 Lua 腳本:
-- 解鎖代碼
-- 首先判斷傳入的唯一標識是否與現有標識一致
-- 如果一致,釋放這個鎖,否則直接返回
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
這段腳本將會判斷傳入的唯一標識是否與 Redis 存儲的標示一致,如果一直,釋放該鎖,否則立刻返回。
釋放鎖的方法如下:
/**
* 解鎖
* 如果傳入應用標識與之前加鎖一致,解鎖成功
* 否則直接返回
* @param lockName 鎖
* @param request 唯一標識
* @return
*/
public Boolean unlock(String lockName, String request) {
DefaultRedisScript<Boolean> unlockScript = new DefaultRedisScript<>();
unlockScript.setLocation(new ClassPathResource("simple_unlock.lua"));
unlockScript.setResultType(Boolean.class);
return stringRedisTemplate.execute(unlockScript, Lists.newArrayList(lockName), request);
}
Redis 分佈式鎖的缺陷
無法重入
由於上述加鎖命令使用了 SETNX
,一旦鍵存在就無法再設置成功,這就導致後續同一線程內繼續加鎖,將會加鎖失敗。
如果想將 Redis 分佈式鎖改造成可重入的分佈式鎖,有兩種方案:
-
本地應用使用 ThreadLocal 進行重入次數計數,加鎖時加 1,解鎖時減 1,當計數變爲 0 釋放鎖
-
第二種,使用 Redis Hash 表存儲可重入次數,使用 Lua 腳本加鎖/解鎖
Redis 分佈式鎖集羣問題
redisson 已經實現的 RedLock
簡單的 Redis 分佈式鎖的實現方式還是很簡單的,我們可以直接用 SETNX/DEL 命令實現加解鎖。
不過這種實現方式不夠健壯,可能存在應用宕機,鎖就無法被釋放的問題。
所以我們接着引入以下命令以及 Lua 腳本增強 Redis 分佈式鎖。
摘抄自公衆號程序通事,大家可以去關注學習下。