天天看點

如何優雅地用Redis實作分布式鎖

什麼是分布式鎖

在學習Java多線程程式設計的時候,鎖是一個很重要也很基礎的概念,鎖可以看做是多線程情況下通路共享資源的一種線程同步機制。這是對于單程序應用而言的,即所有線程都在同一個JVM程序裡的時候,使用Java語言提供的鎖機制可以起到對共享資源進行同步的作用。如果分布式環境下多個不同線程需要對共享資源進行同步,那麼用Java的鎖機制就無法實作了,這個時候就必須借助分布式鎖來解決分布式環境下共享資源的同步問題。分布式鎖有很多種解決方案,今天我們要講的是怎麼使用緩存資料庫Redis來實作分布式鎖。

Redis分布式鎖方案一

使用Redis實作分布式鎖最簡單的方案是在擷取鎖之前先查詢一下以該鎖為key對應的value存不存在,如果存在,則說明該鎖被其他用戶端擷取了,否則的話就嘗試擷取鎖,擷取鎖的方法很簡單,隻要以該鎖為key,設定一個随機的值就行了。比如,我們有一批任務需要由多個分布式線程處理,每個任務都有一個taskId,為了保證每個任務隻被執行一次,在工作線程執行任務之前,先擷取該任務的鎖,鎖的key可以為taskId。是以,擷取鎖的過程可以用如下僞代碼實作:

function boolean getLock(taskId){

if(existsKey(taskId)){
}else{

return false;
setKey(taskId);

return true;
}
}           

上述就是最簡單的擷取鎖的方案了,但是大家可以想想這個方案有什麼問題呢?有沒有什麼潛在的坑?在分析這種方案的優缺點之前,先說一下擷取鎖後我們一般是怎麼使用鎖,并且又是如何釋放鎖的,以Java語言為例,我們一般擷取鎖後會将釋放鎖的代碼放在finally塊中,這樣做的好處是即使在使用鎖的過程中出現異常,也能順利将鎖釋放掉。用僞代碼描述如下:

boolean lock=false;

try{
lcok=getLock(taskId); //擷取鎖

if(lock){
doSomething(); //業務邏輯
}finally{

}
if(lock){

releaseLock(taskId); //釋放鎖

}

}           

其中,getLock方法的僞代碼上文已經給出,releaseLock方法是釋放鎖的方法,在該方案中,隻是簡單地删除掉key,就不給出僞代碼了。

上述使用鎖的代碼咋一看是沒有什麼問題的,學過Java的人都知道,在try...finally...代碼塊中,即使try代碼塊中抛出異常,最終也會執行finally代碼塊,然而這樣就能保證鎖一定會被釋放嗎?考慮這樣一種情況:代碼執行到doSomething()方法的時候,伺服器當機了,這個時候finally代碼塊就沒法被執行了,是以在這種情況下,該鎖不會被正常釋放,在上述案例中,可能會導緻任務漏算。是以,這種方案的第一個問題是會出現鎖無法正常釋放的風險,解決這個問題的方法也很簡單,Redis設定key的時候可以指定一個過期時間,隻要擷取鎖的時候設定一個合理的過期時間,那麼即使伺服器當機了,也能保證鎖被正确釋放。

該方案的另外一個問題是,擷取到的鎖不一定是排他鎖,也就是說同一把鎖同一時間可能被不同用戶端擷取到。仔細分析一下getLock方法,該方法并不是原子性的,當一個用戶端檢查到某個鎖不存在,并在執行setKey方法之前,别的用戶端可能也會檢查到該鎖不存在,并也會執行setKey方法,這樣一來,同一把鎖就有可能被不同的用戶端擷取到了。

既然這種方案有以上缺點,那麼該如何改進呢?且聽我慢慢道來。

Redis分布式鎖方案二

上一小節的方案有2個缺點,一個是擷取的鎖可能無法釋放,另一個是同一把鎖在同一時間可能被不同線程擷取到。通過檢視Redis文檔,可以找到Redis提供了一個隻有在某個key不存在的情況下才會設定key的值的原子指令,該指令也能設定key值過期時間,是以使用該指令,不存在上述方案出現的問題,該指令為:

SET my_key my_value NX PX milliseconds

其中,NX表示隻有當鍵key不存在的時候才會設定key的值,PX表示設定鍵key的過期時間,機關是毫秒。

如此一來,擷取鎖的過程可以用如下僞代碼描述:

function boolean getLock(taskId,timeout){

return setKeyOnlyIfNotExists(taskId,timeout);

}           

其中,setKeyOnlyIfNotExists方法表示的是原子指令SET my_key my_value NX PX milliseconds。

如此一來,擷取鎖的代碼應該就沒什麼問題了,但是這種方案還是會有其他問題。大家再仔細研究下釋放鎖的代碼。因為現在我們設定key的時候也設定了過期時間,是以原來的釋放鎖的代碼現在看來就有問題了。考慮這樣一種情況:用戶端A擷取鎖的時候設定了key的過期時間為2秒,然後用戶端A在擷取到鎖之後,業務邏輯方法doSomething執行了3秒(大于2秒),當執行完業務邏輯方法的時候,用戶端A擷取的鎖已經被Redis過期機制自動釋放了,是以用戶端A在擷取鎖經過2秒之後,該鎖可能已經被其他用戶端擷取到了。當用戶端A執行完doSomething方法之後接下來就是執行releaseLock方法釋放鎖了,由于前面說了,該鎖可能已經被其他用戶端擷取到了,是以這個時候釋放鎖就有可能釋放的是其他用戶端擷取到的鎖。

Redis分布式鎖方案三

既然方案二可能會出現釋放了别的用戶端申請的鎖的問題,那麼該如何進行改進呢?有一個很簡單的方法是,我們設定key的時候,将value設定為一個随機值r,當釋放鎖,也就是删除key的時候,不是直接删除,而是先判斷該key對應的value是否等于先前設定的随機值,隻有當兩者相等的時候才删除該key,由于每個用戶端産生的随機值是不一樣的,這樣一來就不會誤釋放别的用戶端申請的鎖了。新的釋放鎖的方案用僞代碼描述如下:

function void releaseLock(taskId,random_value){

if(getKey(taskId)==random_value){
}

deleteKey(taskId);
}           

其中,getKey方法就是Redis的查詢key值的方法,deleteKey就是Redis的删除key值的方法,在此不給出僞代碼了。

那麼這種方案就沒有問題了嗎?很遺憾地說,這種方案也是有問題的。原因在于上述釋放鎖的操作不是原子性的,不是原子性操作意味着當一個用戶端執行完getKey方法并在執行deleteKey方法之前,也就是在這2個方法執行之間,其他用戶端是可以執行其他指令的。考慮這樣一種情況,在用戶端A執行完getKey方法,并且該key對應的值也等于先前的随機值的時候,接下來用戶端A将會執行deleteKey方法。假設由于網絡或其他原因,用戶端A執行getKey方法之後過了1秒鐘才執行deleteKey方法,那麼在這1秒鐘裡,該key有可能也會因為過期而被Redis清除了,這樣一來另一個用戶端,姑且稱之為用戶端B,就有可能在這期間擷取到鎖,然後接下來用戶端A就執行到deleteKey方法了,如此一來就又出現誤釋放别的用戶端申請的鎖的問題了。

Redis分布式鎖方案四

既然方案三的問題是因為釋放鎖的方法不是原子操作導緻的,那麼我們隻要保證釋放鎖的代碼是原子性的就能解決該問題了。很遺憾的是,查閱Redis開發文檔,并沒有發現相關的原子操作。不過幸運的是,在Redis中執行原子操作不止有通過官方提供的指令的方式,還有另外一種方式,就是Lua腳本。是以,方案三中的釋放鎖的代碼可以用以下Lua腳本來實作:

if redis.call("get",KEYS[1]) == ARGV[1] then

return redis.call("del",KEYS[1])
else
end

 return 0           

其中ARGV[1]表示設定key時指定的随機值。

由于Lua腳本的原子性,在Redis執行該腳本的過程中,其他用戶端的指令都需要等待該Lua腳本執行完才能執行,是以不會出現方案三所說的問題。至此,使用Redis實作分布式鎖的方案就相對完善了。

總結

上述分布式鎖的實作方案中,都是針對單節點Redis而言的,然而在生産環境中,我們使用的通常是Redis叢集,并且每個主節點還會有從節點。由于Redis的主從複制是異步的,是以上述方案在Redis叢集的環境下也是有問題的。關于在Redis叢集中如何優雅地實作分布式鎖,後續再寫文章詳述。

原文釋出時間為:2018-08-28

本文作者:不才黃某

本文來自雲栖社群合作夥伴“

Java架構沉思錄

”,了解相關資訊可以關注“

”。