前言
提到數據一致性、操作原子性,諸如此類的一些與并發有關的詞匯時不知道你第一時間會聯想到什么呢?我相信大多數人可能會想到“鎖”,為什么是鎖呢,這個我不多說,大家心里應該都明白。在單體應用時代,我們使用jvm提供的鎖就可以很好的工作,但是到了分布式應用時代,jvm提供的鎖就行不通了,那么勢必要借助一些跨jvm的臨界資源來支持鎖的相關語義,比如redis,zookeeper等。
步入正題
我今天就來分享下我司基于redis來實現的分布式鎖,2013年投入使用,也算是久經沙場。但是也存在一些設計上的缺陷,這個我后面也會提到,希望大家秉著互相學習的態度文明交流,別一上來就說這不行那不行,還是那句話“適合自己的才是最好的”。
加鎖過程分析
我第一次讀代碼的時候,有這么幾個疑惑:
Q1:為什么不使用 SET key value [expiration EX seconds|PX milliseconds] [NX|XX] 這個指令來實現key的自動過期呢,反而放到應用代碼判斷key是否過期?
A1:我們的分布式鎖開發的時候SET命令還不支持NX、PX,所以才想出這種辦法來實現key過期,NX、PX在2.6.12以后開始支持;
Q2:已經判斷了當前key對應的時間戳已經過期了,為什么還要使用getset再獲取一次呢,直接使用set指令覆蓋不可以嗎?
A2:這里其實牽扯到并發的一些事情,如果直接使用set,那有可能多個客戶端會同時獲取到鎖,如果使用getset然后判斷舊值是否過期就不會有這個問題,設想一下如下場景:
1.C1加鎖成功,不巧的是,這時C1意外的奔潰了,自然就不會釋放鎖;
2.C2,C3嘗試加鎖,這時key已存在,所以C2,C3去判斷key是否已過期,這里假設key已經過期了,所以C2,C3使用set指令去設置值,那兩個都會加鎖成功,這就闖大禍了;如果使用getset指令,然后判斷下返回值是否過期就可以避免這種問題,假如C2跑的快,那C3判斷返回的時間戳已經過期,自然就加鎖失敗;
釋放鎖過程分析
Q1:為什么釋放鎖時還需要判斷key是否過期呢,直接del不是性能更高嗎?
A1:考慮這樣一種場景:
1. C1獲取鎖成功,開始執行自己的操作,不幸的是C1這時被阻塞了;
2. C2這時來獲取鎖,由于C1被阻塞了很長時間,所以key對應的value已經過期了,這時C2通過getset加鎖成功;
3. C1塵封了太久終于被再次喚醒,對于釋放鎖這件事它可是認真的,伴隨著一波del操作,悲劇即將發生;
4. C3來獲取鎖,好家伙,居然一下就成功了,接著就是一波操作猛如虎,接著就是一堆的客訴過來了;
為什么會這樣呢?回想C1被喚醒以后的事情,居然敢直接del,C2活都沒干完呢,鎖就被C1給釋放了,這時C3來直接就加鎖成功,所以為了安全起見C3釋放鎖時得分成兩步:1.判斷value是否已經過期 2.如果已過期直接忽略,如果沒過期就執行del。這樣就真的安全了嗎?安全了嗎?安全了嗎?假如第一步和第二步之間相隔了很久是不是也會出現鎖被其他人釋放的問題呢?是吧?是的!有沒有別的解決辦法呢?聽說借助lua就可以解決這個問題了。
正視自己的缺點
Q1:Redis鎖的過期時間小于業務的執行時間該如何續期?
A1:這個暫時沒有實現,據說有一個叫Redisson的家伙解決了這個問題,我們也有部分業務在使用,未來有可能會切換到Redisson。
Q2:怎么實現的高可用?
A2:我們采用Failover機制,初始化redis鎖的時候會維護一個redis連接池,加鎖或者釋放鎖的時候采用多寫的方式來保障一致性,如果某個節點不可用的時候會自動切換到其他節點,但是這種機制可能會導致多個客戶端同時獲取到鎖的情況,考慮這種情況:
1. C1去redis1加鎖,加鎖成功后會寫到redis2,redis3;
2. C2也去redis1加鎖,但是此時C2到redis1的網絡出現問題,這時C2切換到redis2去加鎖,由于第一步中的redis多寫并不是原子的,所有就有可能導致C2也獲取鎖成功;
針對這種情況,目前有些業務方是通過數據庫唯一索引的方式來規避的,未來會修復這個bug,具體方案目前還沒有。
總結
希望對有些同學能起到幫助,不喜勿噴。
來源:https://biiy.cn/000PZC