大家好,我是三友。
在一個分散式系統中,由於涉及到多個例項同時對同一個資源加鎖的問題,像傳統的synchronized、ReentrantLock等單程式情況加鎖的api就不再適用,需要使用分散式鎖來保證多服務例項之間加鎖的安全性。常見的分散式鎖的實現方式有zookeeper和redis等。而由於redis分散式鎖相對於比較簡單,在實際的專案中,redis分散式鎖被用於很多實際的業務場景中。
redis分散式鎖的實現中又以Redisson比較出名,所以本文來著重看一下Redisson是如何實現分散式鎖的,以及Redisson提供了哪些其它的功能。
一、如何保證加鎖的原子性
說到redis的分散式鎖,可能第一時間就想到了setNx命令,這個命令保證一個key同時只能有一個執行緒設定成功,這樣就能實現加鎖的互斥性。但是Redisson並沒有通過setNx命令來實現加鎖,而是自己實現了一套完成的加鎖的邏輯。
Redisson的加鎖使用程式碼如下,接下來會有幾節著重分析一下這段程式碼邏輯背後實現的原理。
先通過RedissonClient,傳入鎖的名稱,拿到一個RLock,然後通過RLock實現加鎖和釋放鎖。
getLock獲得的RLock介面的實現是RedissonLock,所以我們看一下RedissonLock對lock()方法的實現。
lock方法會呼叫過載的lock方法,傳入的leaseTime為-1,呼叫到這個lock方法,之後會呼叫tryAcquire實現加鎖的邏輯。
tryAcquire最後會調到tryAcquireAsync方法,傳入了leaseTime和當前加鎖執行緒的id。tryAcquire和tryAcquireAsync的區別就是tryAcquireAsync是非同步執行,而tryAcquire是同步等待tryAcquireAsync的結果,也就是非同步轉同步的過程。
tryAcquireAsync方法會根據leaseTime是不是-1來判斷使用哪個分支加鎖,其實不論走哪個分支,最後都是呼叫tryLockInnerAsync方法來實現加鎖,只不過是引數不同罷了。但是我們這裡的leaseTime其實就是-1,所以會走下面的分支,儘管傳入到tryAcquireAsync的leaseTime是-1,但是在呼叫tryLockInnerAsync方法傳入的leaseTime引數是internalLockLeaseTime,預設是30s。
tryLockInnerAsync方法。
通過tryLockInnerAsync方法的實現可以看出,最終加鎖是通過一段lua指令碼來實現加鎖的,redis在執行lua指令碼的時候是可以保證加鎖的原子性的,所以Redisson實現加鎖的原子性是依賴lua指令碼來實現的。其實對於RedissonLock這個實現來說,最終實現加鎖的邏輯都是通過tryLockInnerAsync來實現的。
來一張圖總結一下lock方法加鎖的呼叫邏輯。
二、如何通過lua指令碼實現加鎖
通過上面分析可以看出,redis是通過執行lua指令碼來實現加鎖,保證加鎖的原子性。那麼接下來分析一下這段lua指令碼幹了什麼。
其中這段指令碼中的lua指令碼中的引數的意思:
- KEYS[1]:就是鎖的名稱,對於我們的demo來說,就是myLock
- ARGV[1]:就是鎖的過期時間,不指定的話預設是30s
- ARGV[2]:代表了加鎖的唯一標識,由UUID和執行緒id組成。一個Redisson客戶端一個UUID,UUID代表了一個唯一的客戶端。所以由UUID和執行緒id組成了加鎖的唯一標識,可以理解為某個客戶端的某個執行緒加鎖。
那麼這些引數是怎麼傳過去的呢,其實是在這裡。
- getName:方法就是獲取鎖的名稱
- leaseTime:就是傳入的鎖的過期時間,如果指定超時時間就是指定的時間,沒指定預設是30s
- getLockName:就是獲取加鎖的客戶端執行緒的唯一標識。
分析一下這段lua的加鎖的邏輯。
1)先呼叫redis的exists命令判斷加鎖的key存不存在,如果不存在的話,那麼就進入if。不存在的意思就是還沒有某個客戶端的某個執行緒來加鎖,第一次加鎖肯定沒有人來加鎖,於是第一次if條件成立。
2)然後呼叫redis的hincrby的命令,設定加鎖的key和加鎖的某個客戶端的某個執行緒,加鎖次數設定為1,加鎖次數很關鍵,是實現可重入鎖特性的一個關鍵資料。用hash資料結構儲存。hincrby命令完成後就形成如下的資料結構。
myLock:{
"b983c153-7421-469a-addb-44fb92259a1b:1":1
}
3)最後呼叫redis的pexpire的命令,將加鎖的key過期時間設定為30s。
從這裡可以看出,第一次有某個客戶端的某個執行緒來加鎖的邏輯還是挺簡單的,就是判斷有沒有人加過鎖,沒有的話就自己去加鎖,設定加鎖的key,再存一下加鎖的執行緒和加鎖次數,設定一下鎖的過期時間為30s。
畫一張圖來看一下lua指令碼加鎖的邏輯幹了什麼。
至於第二段if是幹什麼的,我們後面再說。
三、為什麼需要設定加鎖key的過期時間
通過上面的加鎖邏輯可以知道,雖然我們沒有手動設定鎖的過期時間,但是Redisson預設會設定一個30s的過期時間,為什麼需要過期時間呢?
主要原因是為了防止死鎖。當某個客戶端獲取到鎖,還沒來得及主動釋放鎖,那麼此時假如客戶端當機了,又或者是釋放鎖失敗了,那麼如果沒有設定過期時間,那麼這個鎖key會一直在,那麼其它執行緒來加鎖的時候會發現key已經被加鎖了,那麼其它執行緒一直會加鎖失敗,就會產生死鎖的問題。
四、如何自動延長加鎖時間
通過上面的分析我們都知道,在加鎖的時候,就算沒有指定鎖的過期時間,Redisson預設也會給鎖設定30s的過期時間,主要是用來防止死鎖。
雖然設定了預設過期時間能夠防止死鎖,但是這也有一個問題,如果在30s內,任務沒有結束,但是鎖已經被釋放了,失效了,一旦有其它執行緒加鎖成功,那麼就完全有可能出現執行緒安全資料錯亂的問題。
所以Redisson對於這種未指定超時時間的加鎖,就實現了一個叫watchdog機制,也就是看門狗機制來自動延長加鎖的時間。
在客戶端通過tryLockInnerAsync方法加鎖成功之後,如果你沒有指定鎖過期的時間,那麼客戶端會起一個定時任務,來定時延長加鎖時間,預設每10s執行一次。所以watchdog的本質其實就是一個定時任務。
最後會定期執行如下的一段lua指令碼來實現加鎖時間的延長。
解釋一下這段lua指令碼中引數的意思,其實是跟加鎖的引數的意思是一樣
- KEYS[1]:就是鎖的名稱,對於我們的demo來說,就是myLock
- ARGV[1]:就是鎖的過期時間
- ARGV[2]:代表了加鎖的唯一標識,b983c153-7421-469a-addb-44fb92259a1b:1。
這段lua指令碼的意思就是判斷來續約的執行緒跟加鎖的執行緒是同一個,如果是同一個,那麼將鎖的過期時間延長到30s,然後返回1,代表續約成功,不是的話就返回0,代表續約失敗,下一次定時任務也就不會執行了。
注意:因為有了看門狗機制,所以說如果你沒有設定過期時間(超時自動釋放鎖的邏輯後面會說)並且沒有主動去釋放鎖,那麼這個鎖就永遠不會被釋放,因為定時任務會不斷的去延長鎖的過期時間,造成死鎖的問題。但是如果發生當機了,是不會造成死鎖的,因為當機了,服務都沒了,那麼看門狗的這個定時任務就沒了,也自然不會去續約,等鎖自動過期了也就自動釋放鎖了,跟上述說的為什麼需要設定過期時間是一樣的。
五、如何實現可重入加鎖
可重入加鎖的意思就是同一個客戶端同一個執行緒也能多次對同一個鎖進行加鎖。
也就是同時可以執行多次 lock方法,流程都是一樣的,最後也會呼叫到lua指令碼,所以可重入加鎖的邏輯最後也是通過加鎖的lua指令碼來實現的。
上面加鎖邏輯的lua指令碼的前段我上面已經說過,下半部分也就是可重入加鎖的邏輯。
下面這段if的意思就是,判斷當前已經加鎖的key對應的加鎖執行緒跟要來加鎖的執行緒是不是同一個,如果是的話,就將這個執行緒對應的加鎖次數加1,也就實現了可重入加鎖,同時返回nil回去。
可重入加鎖成功之後,加鎖key和對應的值可能是這樣。
myLock:{
"b983c153-7421-469a-addb-44fb92259a1b:1":2
}
所以加鎖lua指令碼的第二段if的邏輯其實是實現可重入加鎖的邏輯。
六、如何主動釋放鎖和避免其它執行緒釋放了自己加的鎖
當業務執行完成之後,肯定需要主動釋放鎖,那麼為什麼需要主動釋放鎖呢?
第一,假設你任務執行完,沒有手動釋放鎖,如果沒有指定鎖的超時時間,那麼因為有看門狗機制,勢必會導致這個鎖無法釋放,那麼就可能造成死鎖的問題。
第二,如果你指定了鎖超時時間(鎖超時自動釋放邏輯後面會說),雖然並不會造成死鎖的問題,但是會造成資源浪費的問題。假設你設定的過期時間是30s,但是你的任務2s就完成了,那麼這個鎖還會白白被佔有28s的時間,這28s內其它執行緒都無法成功加鎖。
所以任務完成之後,一定需要主動釋放鎖。
那麼Redisson是如何主動釋放鎖和避免其它執行緒釋放了自己加的鎖?
主動釋放鎖是通過unlock方法來完成的,接下來就分析一下unlock方法的實現。unlock會呼叫unlockAsync,傳入當然釋放執行緒的id,代表了當前執行緒來釋放鎖,unlock其實也是將unlockAsync的非同步操作轉為同步操作。
unlockAsync最後會呼叫RedissonLock的unlockInnerAsync來實現釋放鎖的邏輯。
也是執行一段lua指令碼。
1)先判斷來釋放鎖的執行緒是不是加鎖的執行緒,如果不是,那麼直接返回nil,所以從這裡可以看出,主要是通過一個if條件來防止執行緒釋放了其它執行緒加的鎖。
2)如果來釋放鎖的執行緒是加鎖的執行緒,那麼就將加鎖次數減1,然後拿到剩餘的加鎖次數 counter 變數。
3)如果counter大於0,說明有重入加鎖,鎖還沒有徹底的釋放完,那麼就設定一下鎖的過期時間,然後返回0
4)如果counter沒大於0,說明當前這個鎖已經徹底釋放完了,於是就把鎖對應的key給刪除,然後釋出一個鎖已經釋放的訊息,然後返回1。
七、如何實現超時自動釋放鎖
前面我們說了不指定鎖超時時間的話,那麼會有看門狗執行緒不斷的延長加鎖時間,不會導致鎖超時釋放,自動過期。那麼指定超時時間的話,是如何實現到了指定時間超時釋放鎖的呢?
能夠設定超時自動釋放鎖的方法。
void lock(long leaseTime, TimeUnit unit)
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit)
通過傳入leaseTime引數就可以指定鎖超時的時間。
無論指不指定超時時間,最終其實都會呼叫tryAcquireAsync方法,只不過當不指定超時時間時,leaseTime傳入的是-1,也就是代表不指定超時時間,但是Redisson預設還是會設定30s的過期時間;當指定超時時間,那麼leaseTime就是我們自己指定的時間,最終也是通過同一個加鎖的lua指令碼邏輯。
指定和不指定超時時間的主要區別是,加鎖成功之後的邏輯不一樣,不指定超時時間時,會開啟watchdog後臺執行緒,不斷的續約加鎖時間,而指定超時時間,就不會去開啟watchdog定時任務,這樣就不會續約,加鎖key到了過期時間就會自動刪除,也就達到了釋放鎖的目的。
所以指定超時時間達到超時釋放鎖的功能主要還是通過redis自動過期來實現,因為指定了超時時間,加鎖成功之後就不會開啟watchdog機制來延長加鎖的時間。
在實際專案中,指不指定鎖的超時時間是根據具體的業務來的,如果你能夠比較準確的預估出程式碼執行的時間,那麼可以指定鎖超時釋放時間來防止業務執行錯誤導致無法釋放鎖的問題,如果不能預估出程式碼執行的時間,那麼可以不指定超時時間。
八、如何實現不同執行緒加鎖的互斥
上面我們分析了第一次加鎖邏輯和可重入加鎖的邏輯,因為lua指令碼加鎖的邏輯同時只有一個執行緒能夠執行(redis是單執行緒的原因),所以一旦有執行緒加鎖成功,那麼另一個執行緒來加鎖,前面兩個if條件都不成立,最後通過呼叫redis的pttl命令返回鎖的剩餘的過期時間回去。
這樣,客戶端就根據返回值來判斷是否加鎖成功,因為第一次加鎖和可重入加鎖的返回值都是nil,而加鎖失敗就返回了鎖的剩餘過期時間。
所以加鎖的lua指令碼通過條件判斷就實現了加鎖的互斥操作,保證其它執行緒無法加鎖成功。
所以總的來說,加鎖的lua指令碼實現了第一次加鎖、可重入加鎖和加鎖互斥的邏輯。
九、加鎖失敗之後如何實現阻塞等待加鎖
從上面分析,加鎖失敗之後,會走如下的程式碼。
從這裡可以看出,最終會執行死迴圈(自旋)地的方式來不停地通過tryAcquire方法來嘗試加鎖,直到加鎖成功之後才會跳出死迴圈,如果一直沒有成功加鎖,那麼就會一直旋轉下去,所謂的阻塞,實際上就是自旋加鎖的方式。
但是這種阻塞可能會產生問題,因為如果其它執行緒釋放鎖失敗,那麼這個阻塞加鎖的執行緒會一直阻塞加鎖,這肯定會出問題的。所以有沒有能夠可以指定阻塞的時間,如果超過一定時間還未加鎖成功的話,那麼就放棄加鎖的方法。答案肯定是有的,接著往下看。
十、如何實現阻塞等待一定時間還未加鎖成功就放棄加鎖
超時放棄加鎖的方法
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) boolean tryLock(long time, TimeUnit unit)
通過waitTime引數或者time引數來指定超時時間。這兩個方法的主要區別就是上面的方法支援指定鎖超時時間,下面的方法不支援鎖超時自動釋放。
tryLock(long time, TimeUnit unit)這個方法最後也是呼叫tryLock(long waitTime, long leaseTime, TimeUnit unit)方法的實現。程式碼如下。
其實通過原始碼就可以看出是怎麼實現一定時間之內還未獲取到鎖就放棄加鎖的邏輯,其實相比於一直獲取鎖,主要是加了超時的判斷,如果超時了,那麼就退出迴圈,放棄加鎖,
十一、如何實現公平鎖
1)什麼是公平鎖
所謂的公平鎖就是指執行緒成功加鎖的順序跟執行緒來加鎖的順序是一樣,實現了先來先成功加鎖的特性,所以叫公平鎖。就跟排隊一樣,不插隊才叫公平。
前面幾節講的RedissonLock的實現是非公平鎖,但是裡面的一些機制,比如看門狗都是一樣的。
2)公平鎖和非公平鎖的比較
公平鎖的優點是按序平均分配鎖資源,不會出現執行緒餓死的情況,它的缺點是按序喚醒執行緒的開銷大,執行效能不高。非公平鎖的優點是執行效率高,誰先獲取到鎖,鎖就屬於誰,不會“按資排輩”以及順序喚醒,但缺點是資源分配隨機性強,可能會出現執行緒餓死的情況。
3)如何使用公平鎖?
通過RedissonClient的getFairLock就可以獲取到公平鎖。Redisson對於公平鎖的實現是RedissonFairLock類,通過RedissonFairLock來加鎖,就能實現公平鎖的特性,使用程式碼如下。
RedissonFairLock繼承了RedissonLock,主要是重寫了tryLockInnerAsync方法,也就是加鎖邏輯的方法。
下面來分析一下RedissonFairLock的加鎖邏輯。
這段加鎖的邏輯很長,我就簡單說一下這段lua指令碼幹了啥。
當執行緒來加鎖的時候,如果加鎖失敗了,那麼會將執行緒扔到一個set集合中,這樣就按照加鎖的順序給執行緒排隊,set集合的頭部的執行緒就代表了接下來能夠加鎖成功的執行緒。當有執行緒釋放了鎖之後,其它加鎖失敗的執行緒就會來繼續加鎖,加鎖之前會先判斷一下set集合的頭部的執行緒跟當前要加鎖的執行緒是不是同一個,如果是的話,那就加鎖成功,如果不是的話,那麼就加鎖失敗,這樣就實現了加鎖的順序性。
當然這段lua指令碼還做了一些其它細節的事,這裡就不再贅述。
十二、如何實現讀寫鎖
在實際的業務場景中,其實會有很多讀多寫少的場景,那麼對於這種場景來說,使用獨佔鎖來加鎖,在高併發場景下會導致大量的執行緒加鎖失敗,阻塞,對系統的吞吐量有一定的影響,為了適配這種讀多寫少的場景,Redisson也實現了讀寫鎖的功能。
讀寫鎖的特點:
- 讀與讀是共享的,不互斥
- 讀與寫互斥
- 寫與寫互斥
Redisson使用讀寫鎖的程式碼。
Redisson通過RedissonReadWriteLock類來實現讀寫鎖的功能,通過這個類可以獲取到讀鎖或者寫鎖,所以真正的加鎖的邏輯是由讀鎖和寫鎖實現的。
那麼Redisson是如何具體實現讀寫鎖的呢?
前面說過,加鎖成功之後會在redis中維護一個hash的資料結構,儲存加鎖執行緒和加鎖次數。在讀寫鎖的實現中,會往hash資料結構中多維護一個mode的欄位,來表示當前加鎖的模式。
所以能夠實現讀寫鎖,最主要是因為維護了一個加鎖模式的欄位mode,這樣有執行緒來加鎖的時候,就能根據當前加鎖的模式結合讀寫的特性來判斷要不要讓當前來加鎖的執行緒加鎖成功。
- 如果沒有加鎖,那麼不論是讀鎖還是寫鎖都能加成功,成功之後根據鎖的型別維護mode欄位。
- 如果模式是讀鎖,那麼加鎖執行緒是來加讀鎖的,就讓它加鎖成功。
- 如果模式是讀鎖,那麼加鎖執行緒是來加寫鎖的,就讓它加鎖失敗。
- 如果模式是寫鎖,那麼加鎖執行緒是來加寫鎖的,就讓它加鎖失敗(加鎖執行緒自己除外)。
- 如果模式是寫鎖,那麼加鎖執行緒是來加讀鎖的,就讓它加鎖失敗(加鎖執行緒自己除外)。
十三、如何實現批量加鎖(聯鎖)
批量加鎖的意思就是同時加幾個鎖,只有這些鎖都算加成功了,才是真正的加鎖成功。
比如說,在一個下單的業務場景中,同時需要鎖定訂單、庫存、商品,基於這種需要鎖多種資源的場景中,Redisson提供了批量加鎖的實現,對應的實現類是RedissonMultiLock。
Redisson提供了批量加鎖使用程式碼如下。
Redisson對於批量加鎖的實現其實很簡單,原始碼如下
就是根據順序去依次呼叫傳入myLock1、myLock2、myLock3 加鎖方法,然後如果都成功加鎖了,那麼multiLock就算加鎖成功。
十四、Redis分散式鎖存在的問題
對於單Redis例項來說,如果Redis當機了,那麼整個系統就無法工作了。所以為了保證Redis的高可用性,一般會使用主從或者哨兵模式。但是如果使用了主從或者哨兵模式,此時Redis的分散式鎖的功能可能就會出現問題。
舉個例子來說,假如現在使用了哨兵模式,如圖。
基於這種模式,Redis客戶端會在master節點上加鎖,然後非同步複製給slave節點。
但是突然有一天,因為一些原因,master節點當機了,那麼哨兵節點感知到了master節點當機了,那麼就會從slave節點選擇一個節點作為主節點,實現主從切換,如圖:
這種情況看似沒什麼問題,但是不幸的事發生了,那就是客戶端對原先的主節點加鎖,加成之後還沒有來得及同步給從節點,主節點當機了,從節點變成了主節點,此時從節點是沒有加鎖資訊的,如果有其它的客戶端來加鎖,是能夠加鎖成功的,這不是很坑爹麼。。
那麼如何解決這種問題呢?Redis官方提供了一種叫RedLock的演算法,Redisson剛好實現了這種演算法,接著往下看。
十五、如何實現RedLock演算法
RedLock演算法
在Redis的分散式環境中,我們假設有N個Redis master。這些節點完全互相獨立,不存在主從複製或者其他叢集協調機制。之前我們已經描述了在Redis單例項下怎麼安全地獲取和釋放鎖。我們確保將在每(N)個例項上使用此方法獲取和釋放鎖。在這個樣例中,我們假設有5個Redis master節點,這是一個比較合理的設定,所以我們需要在5臺機器上面或者5臺虛擬機器上面執行這些例項,這樣保證他們不會同時都宕掉。
為了取到鎖,客戶端應該執行以下操作:
- 獲取當前Unix時間,以毫秒為單位。
- 依次嘗試從N個例項,使用相同的key和隨機值獲取鎖。在步驟2,當向Redis設定鎖時,客戶端應該設定一個網路連線和響應超時時間,這個超時時間應該小於鎖的失效時間。例如你的鎖自動失效時間為10秒,則超時時間應該在5-50毫秒之間。這樣可以避免伺服器端Redis已經掛掉的情況下,客戶端還在死死地等待響應結果。如果伺服器端沒有在規定時間內響應,客戶端應該儘快嘗試另外一個Redis例項。
- 客戶端使用當前時間減去開始獲取鎖時間(步驟1記錄的時間)就得到獲取鎖使用的時間。當且僅當從大多數(這裡是3個節點)的Redis節點都取到鎖,並且使用的時間小於鎖失效時間時,鎖才算獲取成功。
- 如果取到了鎖,key的真正有效時間等於有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。
- 如果因為某些原因,獲取鎖失敗(沒有在至少N/2+1個Redis例項取到鎖或者取鎖時間已經超過了有效時間),客戶端應該在所有的Redis例項上進行解鎖(即便某些Redis例項根本就沒有加鎖成功)。
Redisson對RedLock演算法的實現
使用方法如下。
RLock lock1 = redissonInstance1.getLock("lock1"); RLock lock2 = redissonInstance2.getLock("lock2"); RLock lock3 = redissonInstance3.getLock("lock3"); RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3); // 同時加鎖:lock1 lock2 lock3 // 紅鎖在大部分節點上加鎖成功就算成功。 lock.lock(); ... lock.unlock();
RedissonRedLock加鎖過程如下:
- 獲取所有的redisson node節點資訊,迴圈向所有的redisson node節點加鎖,假設節點數為N,例子中N等於5。一個redisson node代表一個主從節點。
- 如果在N個節點當中,有N/2 + 1個節點加鎖成功了,那麼整個RedissonRedLock加鎖是成功的。
- 如果在N個節點當中,小於N/2 + 1個節點加鎖成功,那麼整個RedissonRedLock加鎖是失敗的。
- 如果中途發現各個節點加鎖的總耗時,大於等於設定的最大等待時間,則直接返回失敗。
RedissonRedLock底層其實也就基於RedissonMultiLock實現的,RedissonMultiLock要求所有的加鎖成功才算成功,RedissonRedLock要求只要有N/2 + 1個成功就算成功。
參考:
- [1]https://mp.weixin.qq.com/s/EhucmYblfrRxbAuJTdPlfg
- [2]https://github.com/redisson/redisson/wiki/
- [3]http://redis.cn/topics/distlock.html
如果覺得這篇文章對你有所幫助,還請幫忙點贊、在看、轉發給更多的人,碼字不易,非常感謝!
往期熱門文章推薦
掃碼或者搜尋關注公眾號 三友的java日記 ,及時乾貨不錯過,公眾號致力於通過畫圖加上通俗易懂的語言講解技術,讓技術更加容易學習。