關於分散式鎖的那些事兒
什麼是分散式鎖
通過互斥性質,來保證執行緒對分散式系統中共享資源的有序訪問
說人話:一把鎖,挨個進
分散式鎖的特性
-
互斥(執行緒獨享):即同一時刻只有一個執行緒能夠獲取鎖
-
避免死鎖:獲得鎖的執行緒崩潰後,不會影響後續執行緒獲取鎖,操作共享資源
-
隔離性:A獲取的鎖,不能讓B去解鎖(解鈴還須繫鈴人)
-
原子性:加鎖和解鎖必須保證為原子操作
分散式鎖的實現方式
-
基於Redis
演變過程:
V-1.0:
SETNX:Redis提供了SETNE(SET if Not eXists)命令,表示當Key不存在時,才能設置Value,否則設置失敗(獲取鎖失敗)
DEL KEY:第一步獲取鎖成功,對共享資源操作完後,釋放鎖
問題:
如果業務程式碼出現異常,阻塞或者報錯了,那麼該執行緒就一直持有鎖,不釋放,其他執行緒也永遠獲取不到
————我王霸天得不到的誰也別想得到!V-2.0:
- SETNX+EXPIRE:給鎖上過期時間,假如持有鎖執行緒崩潰了,達到設置的過期時間後,會自動釋放鎖,避免後續執行緒獲取不到鎖!
問題:仍舊會死鎖!SETNX和EXPIRE是兩條命令,Redis單命令是原子操作,但多條命令為非原子操作!
SETNX執行成功,EXPIRE失敗時就會發生死鎖
v-3.0:
- SET(NX+EX)(2.6.12版本之後):獲取鎖,並設置鎖過期時間(
原子操作
)如此,可以說是
徹底解決了死鎖問題
!
那麼還問存在其他問題嗎?
分析分散式鎖的特徵:互斥、死鎖、原子等特性,我們都算是解決了!
但還未考慮隔離性的問題!
場景
- 執行緒A加鎖成功後,去操作共享資源
- 但是因為發生了意外,執行緒A操作的時間超過了鎖過期時間,鎖被釋放了
- 執行緒B進來了,枷鎖成功,去操作共享資源了
- 此時,執行緒A操作完成了,回來釋放鎖,執行緒B的鎖被A釋放(
動了別人的老婆!
)
隔離性帶來的問題:
-
鎖的過期時間設置不合理,導致執行緒A鎖過期,被釋放
-
執行緒A釋放了執行緒B的鎖
分析:
-
執行緒A的過期時間設置不合理,那就換一個合理的時間————對應到現實工作中,就是根據程式設計師的工作經驗,對改值進行較為合理的設置,實在不行,殺了祭天!(不是很可靠)
-
其實很簡單,鎖過期就像去麥當勞喝咖啡喝完了唄,還想喝怎麼辦?續杯!————獲取鎖時,先設置一個過期時間,同時,開啟一個守護執行緒,定時去查看鎖的剩餘存活時間,假如鎖的存活時間快過期了,但業務程式碼還沒執行完,趕緊去給大爺續杯,即重新設置過期時間(看門狗)
-
至於第二個問題,還是那句老話————解鈴還須繫鈴人,加一個業務唯一標識,每個執行緒只能根據業務唯一去釋放自己的鎖,同時,需要注意:判斷是否為自己的鎖和刪除鎖
應為原子操作
!不然仍舊會刪錯鎖!
實現
- Redission的看門狗(基於Netty時間輪演算法實現):
private long lockWatchdogTimeout = 30 * 1000;
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
super(commandExecutor, name);
this.commandExecutor = commandExecutor;
//會獲取看門狗設置的時間,默認為10s檢查一次,鎖過快過期,且業務程式碼還沒執行完,就會給鎖續上這個時間,默認30s
this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
this.pubSub = commandExecutor.getConnectionManager().getSubscribeService().getLockPubSub();
}
private RFuture<Boolean> tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
//如果鎖是永不過期,那麼就按常規方式索取鎖
if (leaseTime != -1) {
return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
}
//否則,會在獲取鎖之後,加一個定時任務,在鎖執行完業務程式碼自行釋放之前,不斷的給所續上過期時間(默認10s檢查一次,每次給鎖續期30s)
RFuture<Boolean> ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
實現具體細節,參見Redission源碼
- 執行緒隔離的問題:
考慮到獲取鎖判斷後,再刪除鎖,這兩個操作必須是原子性的,那麼就需要查看一下Redis的API有沒有提供這兩個操作的原子性操作了
結果發現,沒有!那麼叫考慮第二種方案,在Redis中除了單條命令是原子性的,還有執行Lua腳本
也是原子性操作!
//如果是自己的鎖,則進行刪除,否則返回
if redis.call("GET",KEY[1]) == ARGV[1] then
return redis.call("DEL",KEY[1])
else
return 0
end
總覽
小總結
經過了這幾波優化之後,基於Redis的分散式鎖(
Redis單實例
),可算是安全放心大膽的使用了!悄悄的告訴你,其實我們這些優化過程Redis作者早就想到了,同時,他也提供了較為完善的解決方案,在工作中Redission
可以實現以上所有!
作為技術宅男,要有極客精神(其實就是閑了無聊),有心的人,可能會發現,以上粉色標粗的Redis單實例
字樣,確實!以上分析的分散式鎖適合單節點的Redis實例,如果遇到主從+哨兵的模式基本涼涼
!
涼涼場景:
- 執行緒A在遇到主從架構時,先在Master上加鎖成功
- 此時,還未等加鎖命令SET同步到Slave上,Master就出現問題,宕機了!
- 通過哨兵過半原則,重新選出新的主節點,那麼此時這把鎖在新的主庫上是找不到的!出現新問題了!
為之奈何?
遇到這種情況是不是就完了!芭比Q了!準備提桶跑路了!
親媽解法!
如果一遇到這種問題,就要程式設計師提桶跑路,那麼Redis的作者恐怕在大佬圈是混不下去了!於是,他苦心鑽研,誓死捍衛Redis尊嚴!於是乎它就出世了!————RedLock
要求:
1. 主節點至少5個實例多主部署
2. 由於在需要從節點和哨兵
原理:
1. 加鎖執行緒帶著Expire時間進入,在加鎖前記錄一個開始加鎖時間T1
2. 輪流用相同的key和value在不同的節點上進行加鎖操作,並且必須保證大多數(N/2+1)節點加鎖成功,才算成功
3. 最少(N/2+1)個節點加鎖成功後,記錄當前時間T2
4. 如果T2-T1 < Expire,則加鎖成功,反之失敗
5. 釋放鎖時,要向所有節點
(不管是否在該節點加鎖成功)發送解鎖請求!
6. 此時,鎖的Key真正有效時間為:Expire – (T2-T1)
7. 部署的節點數最好是奇數,以更好的滿足過半原則
疑問:
- 為什麼是N/2+1個節點加鎖?
- 加鎖成功後,計算加鎖耗時的意義?
- 為什麼釋放鎖時,要給所有節點(包括沒有加鎖成功的節點)發送解鎖請求?
分析:
- N/2+1公式為過半原則,這裡的本質時為了容錯,CAP中的P說到,當分散式系統中,如果存在部分故障節點,但大多數節點仍舊正常時,可以認為整個系統仍舊可用
- 假如T2-T1 > Expire 就意味著一定會存在,最早加鎖的節點過期自動解鎖的情況,那麼此時的加鎖節點計數就不再正確!那麼此次加鎖就毫無意義了!(T2-T1為加鎖時間,Expire為過期時間)
- 假設某節點加鎖成功了,但是後續因為其他原因(網路)導致無法從該節點上獲取響應結果,而被判斷為未成功加鎖,如果只給加鎖成功的節點發起解鎖請求,那麼此時該節點是收不到解鎖請求的,就會一直持有,影響後續無法使用
理性看待
其實,Redis作者研究出來的RedLock,在一些極端的情況下是存在風險的,比如:
- N節點的時鐘存在較大偏差時,T2-T1 < Expire的討論就是毫無意義的,依然存在瑣失效的問題,想要解決這個問題,就得需要人工的去維護N節點之間的時鐘趨於一致
- RedLock仍舊解決不了獲得鎖的執行緒客戶端發生長時間GC,導致鎖過期,如果再出現第二個執行緒仍舊可以獲取鎖,此時,就會出現同一時刻兩個執行緒對共享資源同時獲得鎖的矛盾情況,嚴重違反分散式鎖特性中的互斥性
- 因為RedLock無法提供類似fencing token的設計方案,從而推導出RedLock無法保證分散式的正確性
神仙打架局
:
以上觀點來自於業界大佬 Martin 對RedLock的質疑://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
同時,Redis作者 Antirez 也作出了回復://antirez.com/news/101
- 基於Zookeeper
-
利用節點名稱唯一性
原理
- 加鎖時,所有執行緒均在相同的目錄下創建一個文件,誰先創建成功,就代表獲得鎖,否則就代表失敗,只能等待下次
- 當獲取得鎖的執行緒操作完業務程式碼後,會將該文件刪除,同時通知其餘客戶端再次進入競爭
- 在一個路徑下只能創建一個唯一的文件(文件名唯一),但容易引起「
驚群
」效應
-
利用臨時順序節點
原理
- 所有執行緒剛開始都會在ZK中創建自己的臨時節點,由ZK去保證這些節點的順序
- 加鎖時,執行緒會
判斷ZK下的第一個節點是不是自己創建
的,如果是,則加鎖成功,如果不是,加鎖失敗,同時,給自己的上一個節點加一個****節點監聽器
- 當節點監聽器被通知上一個節點被刪除時,當前節點會重新判斷ZK下第一個節點是否是自己創建的,循環2的判斷操作
- 用完鎖後,每個執行緒只能刪除自己創建的臨時節點
- 二者對比
-
效率:ZK鎖遠不如Redis鎖
-
失敗處理:
ZK鎖只需要維護
Watch監聽器
,等待鎖被釋放
Redis鎖則是自旋重試,高並發時耗性能 -
宕機處理:
ZK是根據客戶端上報心跳(長連接),判斷客戶端是否存在(持有鎖),無心跳上報時,會刪除節點(釋放鎖)————
(客戶端長GC時,鎖會被ZK釋放)
Redis則是需要等到過期時間,才會釋放鎖
總結
市面上常見的分散式鎖,基本上都研究了一下,感覺收穫頗豐!當然這些都是理論,光說不練假把式,下一篇就是分散式鎖的實現大合集
!期待!