Redis實現分布式鎖
最近看分布式鎖的過程中看到一篇不錯的文章,特地的加工一番自己的理解:
Redis分布式鎖實現的三個核心要素:
1.加鎖
最簡單的方法是使用setnx命令。key是鎖的唯一標識,按業務來決定命名,value為當前線程的線程ID。
比如想要給一種商品的秒殺活動加鎖,可以給key命名為 “lock_sale_ID” 。而value設置成什么呢?我們可以姑且設置成1。加鎖的偽代碼如下:
setnx(key,1)當一個線程執行setnx返回1,說明key原本不存在,該線程成功得到了鎖,當其他線程執行setnx返回0,說明key已經存在,該線程搶鎖失敗。
2.解鎖
有加鎖就得有解鎖。當得到鎖的線程執行完任務,需要釋放鎖,以便其他線程可以進入。釋放鎖的最簡單方式是執行del指令,偽代碼如下:
del(key)釋放鎖之后,其他線程就可以繼續執行setnx命令來獲得鎖。
3.鎖超時
鎖超時是什么意思呢?如果一個得到鎖的線程在執行任務的過程中掛掉,來不及顯式地釋放鎖,這塊資源將會永遠被鎖住,別的線程再也別想進來。
所以,setnx的key必須設置一個超時時間,以保證即使沒有被顯式釋放,這把鎖也要在一定時間后自動釋放。setnx不支持超時參數,所以需要額外的指令,偽代碼如下:
expire(key, 30)綜合起來,我們分布式鎖實現的第一版偽代碼如下:
if (setnx(key, 1) == 1) { expire(key, 30) try { do something ...... } catch () { } finally { del(key) } }
1. setnx和expire的非原子性
設想一個極端場景,當某線程執行setnx,成功得到了鎖:
setnx剛執行成功,還未來得及執行expire指令,節點1 Duang的一聲掛掉了。
if (setnx(key, 1) == 1) { //此處掛掉了..... expire(key, 30) try { do something ...... } catch () { } finally { del(key) } }
這樣一來,這把鎖就沒有設置過期時間,變得“長生不老”,別的線程再也無法獲得鎖了。
怎么解決呢?setnx指令本身是不支持傳入超時時間的,Redis 2.6.12以上版本為set指令增加了可選參數,偽代碼如下:set(key,1,30,NX),這樣就可以取代setnx指令。
2. 超時后使用del 導致誤刪其他線程的鎖
又是一個極端場景,假如某線程成功得到了鎖,並且設置的超時時間是30秒。
如果某些原因導致線程A執行的很慢很慢,過了30秒都沒執行完,這時候鎖過期自動釋放,線程B得到了鎖。
隨后,線程A執行完了任務,線程A接着執行del指令來釋放鎖。但這時候線程B還沒執行完,線程A實際上刪除的是線程B加的鎖。
怎么避免這種情況呢?可以在del釋放鎖之前做一個判斷,驗證當前的鎖是不是自己加的鎖。
至於具體的實現,可以在加鎖的時候把當前的線程ID當做value,並在刪除之前驗證key對應的value是不是自己線程的ID。
加鎖: String threadId = Thread.currentThread().getId() set(key,threadId,30,NX) doSomething..... 解鎖: if(threadId.equals(redisClient.get(key))){ del(key) }
但是,這樣做又隱含了一個新的問題,if判斷和釋放鎖是兩個獨立操作,不是原子性。
我們都是追求極致的程序員,所以這一塊要用Lua腳本來實現:
String luaScript = 'if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end';
redisClient.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId));
這樣一來,驗證和刪除過程就是原子操作了。
3. 出現並發的可能性
還是剛才第二點所描述的場景,雖然我們避免了線程A誤刪掉key的情況,但是同一時間有A,B兩個線程在訪問代碼塊,仍然是不完美的。
怎么辦呢?我們可以讓獲得鎖的線程開啟一個守護線程,用來給快要過期的鎖“續航”。
當過去了29秒,線程A還沒執行完,這時候守護線程會執行expire指令,為這把鎖“續命”20秒。守護線程從第29秒開始執行,每20秒執行一次。
當線程A執行完任務,會顯式關掉守護線程。
另一種情況,如果節點1 忽然斷電,由於線程A和守護線程在同一個進程,守護線程也會停下。這把鎖到了超時的時候,沒人給它續命,也就自動釋放了。
Zookeeper實現分布式緩存
Zookeeper的數據存儲結構就像一棵樹,這棵樹由節點組成,這種節點叫做Znode
。
一、Znode
分為四種類型:
1.持久節點
(PERSISTENT)
默認的節點類型。創建節點的客戶端與zookeeper斷開連接后,該節點依舊存在 。
2.持久節點順序節點
(PERSISTENT_SEQUENTIAL)
所謂順序節點,就是在創建節點時,Zookeeper根據創建的時間順序給該節點名稱進行編號:
3.臨時節點
(EPHEMERAL)
和持久節點相反,當創建節點的客戶端與zookeeper斷開連接后,臨時節點會被刪除:
4.臨時順序節點
(EPHEMERAL_SEQUENTIAL)
顧名思義,臨時順序節點結合和臨時節點和順序節點的特點:在創建節點時,Zookeeper根據創建的時間順序給該節點名稱進行編號;當創建節點的客戶端與zookeeper斷開連接后,臨時節點會被刪除。
二、實現分布式鎖
Zookeeper分布式鎖恰恰應用了臨時順序節點。具體如何實現呢?讓我們來看一看詳細步驟:
1. 獲取鎖
首先,在Zookeeper當中創建一個持久節點ParentLock
。當第一個客戶端想要獲得鎖時,需要在ParentLock
這個節點下面創建一個臨時順序節點 Lock1
。
之后,Client1
查找ParentLock
下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock1
是不是順序最靠前的一個。如果是第一個節點,則成功獲得鎖。
這時候,如果再有一個客戶端 Client2
前來獲取鎖,則在ParentLock
下載再創建一個臨時順序節點Lock2
。
Client2
查找ParentLock
下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock2
是不是順序最靠前的一個,結果發現節點Lock2
並不是最小的。
於是,Client2
向排序僅比它靠前的節點Lock1
注冊Watcher
,用於監聽Lock1
節點是否存在。這意味着Client2
搶鎖失敗,進入了等待狀態。
這時候,如果又有一個客戶端Client3
前來獲取鎖,則在ParentLock
下載再創建一個臨時順序節點Lock3
。
Client3
查找ParentLock
下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock3
是不是順序最靠前的一個,結果同樣發現節點Lock3
並不是最小的。
於是,Client3
向排序僅比它靠前的節點Lock2
注冊Watcher
,用於監聽Lock2
節點是否存在。這意味着Client3
同樣搶鎖失敗,進入了等待狀態。
這樣一來,Client1
得到了鎖,Client2
監聽了Lock1
,Client3
監聽了Lock2
。這恰恰形成了一個等待隊列,很像是Java當中ReentrantLock
所依賴的AQS(AbstractQueuedSynchronizer)
。
2. 釋放鎖
釋放鎖分為兩種情況:
1) 任務完成,客戶端顯示釋放
當任務完成時,Client1
會顯示調用刪除節點Lock1
的指令。
2) 任務執行過程中,客戶端崩潰
獲得鎖的Client1
在任務執行過程中,如果Duang的一聲崩潰,則會斷開與Zookeeper服務端的鏈接。根據臨時節點的特性,相關聯的節點Lock1
會隨之自動刪除。
由於Client2
一直監聽着Lock1
的存在狀態,當Lock1
節點被刪除,Client2
會立刻收到通知。這時候Client2
會再次查詢ParentLock
下面的所有節點,確認自己創建的節點Lock2
是不是目前最小的節點。如果是最小,則Client2
順理成章獲得了鎖。
同理,如果Client2
也因為任務完成或者節點崩潰而刪除了節點Lock2
,那么Cient3
就會接到通知。
最終,Client3
成功得到了鎖。
Zookeeper和Redis分布式鎖的比較
下面的表格總結了Zookeeper和Redis分布式鎖的優缺點: