《我想進大廠》之分布式鎖奪命連環(huán)9問 | 大理版人在囧途
開個頭,這是篇技術文章,但是昨天一天太惡心了,忍不住還是簡單說下昨天的事情。
昨天早上11點飛大理,結果9點鐘要出門的時候發(fā)現(xiàn)密碼鎖壞了,不用密碼都能打開,一邊司機師傅在催著走,一邊連忙打電話給房東和客服找人維修,這是第一。
然后飛機晚點,11點20飛到4點鐘才要落地,下降的過程那叫一個顛簸,我以為都要沒了,這也是第一次暈飛機,簡直快吐了,這是第二。

然后快4點了,飛機總算快要降落了,輪子都快著地了,結果愣是拔起來又起飛了,最后知道是大理8級大風,機長不敢落地。。。這是第三。
最后通知起飛不知道什么時候,要等大理那邊通知,沒有辦法,我們只好下飛機轉高鐵,急急忙忙的一路轉,總算趕上了最后7點前的高鐵,否則就要等到9點以后了,最后一路周轉,9點多總算到了酒店,好在酒店還算行,沒有讓我太過于失望。
這一天搞下來,整個一人在囧途,太累了。好吧,廢話就這么多,文章開始。
說說分布式鎖吧?
對于一個單機的系統(tǒng),我們可以通過synchronized或者ReentrantLock等這些常規(guī)的加鎖方式來實現(xiàn),然而對于一個分布式集群的系統(tǒng)而言,單純的本地鎖已經無法解決問題,所以就需要用到分布式鎖了,通常我們都會引入三方組件或者服務來解決這個問題,比如數(shù)據(jù)庫、Redis、Zookeeper等。
通常來說,分布式鎖要保證互斥性、不死鎖、可重入等特點。
互斥性指的是對于同一個資源,任意時刻,都只有一個客戶端能持有鎖。
不死鎖指的是必須要有鎖超時這種機制,保證在出現(xiàn)問題的時候釋放鎖,不會出現(xiàn)死鎖的問題。
可重入指的是對于同一個線程,可以多次重復加鎖。
那你分別說說使用數(shù)據(jù)庫、Redis和Zookeeper的實現(xiàn)原理?
數(shù)據(jù)庫的話可以使用樂觀鎖或者悲觀鎖的實現(xiàn)方式。
樂觀鎖通常就是數(shù)據(jù)庫中我們會有一個版本號,更新數(shù)據(jù)的時候通過版本號來更新,這樣的話效率會比較高,悲觀鎖則是通過for update的方式,但是會帶來很多問題,因為他是一個行級鎖,高并發(fā)的情況下可能會導致死鎖、客戶端連接超時等問題,一般不推薦使用這種方式。
Redis是通過set命令來實現(xiàn),在2.6.2版本之前,實現(xiàn)方式可能是這樣:

setNX命令代表當key不存在時返回成功,否則返回失敗。
但是這種實現(xiàn)方式把加鎖和設置過期時間的步驟分成兩步,他們并不是原子操作,如果加鎖成功之后程序崩潰、服務宕機等異常情況,導致沒有設置過期時間,那么就會導致死鎖的問題,其他線程永遠都無法獲取這個鎖。
之后的版本中,Redis提供了原生的set命令,相當于兩命令合二為一,不存在原子性的問題,當然也可以通過lua腳本來解決。
set命令如下格式:

key 為分布式鎖的key
value 為分布式鎖的值,一般為不同的客戶端設置不同的值
NX 代表如果要設置的key已存在,則取消設置
EX 代表過期時間為秒,PX則為毫秒,比如上面示例中為10秒過期
Zookeeper是通過創(chuàng)建臨時順序節(jié)點的方式來實現(xiàn)。

當需要對資源進行加鎖時,實際上就是在父節(jié)點之下創(chuàng)建一個臨時順序節(jié)點。 客戶端A來對資源加鎖,首先判斷當前創(chuàng)建的節(jié)點是否為最小節(jié)點,如果是,那么加鎖成功,后續(xù)加鎖線程阻塞等待 此時,客戶端B也來嘗試加鎖,由于客戶端A已經加鎖成功,所以客戶端B發(fā)現(xiàn)自己的節(jié)點并不是最小節(jié)點,就會去取到上一個節(jié)點,并且對上一節(jié)點注冊監(jiān)聽 當客戶端A操作完成,釋放鎖的操作就是刪除這個節(jié)點,這樣就可以觸發(fā)監(jiān)聽事件,客戶端B就會得到通知,同樣,客戶端B判斷自己是否為最小節(jié)點,如果是,那么則加鎖成功
你說改為set命令之后就解決了問題?那么還會不會有其他的問題呢?
雖然set解決了原子性的問題,但是還是會存在兩個問題。
鎖超時問題
比如客戶端A加鎖同時設置超時時間是3秒,結果3s之后程序邏輯還沒有執(zhí)行完成,鎖已經釋放。客戶端B此時也來嘗試加鎖,那么客戶端B也會加鎖成功。
這樣的話,就導致了并發(fā)的問題,如果代碼冪等性沒有處理好,就會導致問題產生。

鎖誤刪除
還是類似的問題,客戶端A加鎖同時設置超時時間3秒,結果3s之后程序邏輯還沒有執(zhí)行完成,鎖已經釋放。客戶端B此時也來嘗試加鎖,這時客戶端A代碼執(zhí)行完成,執(zhí)行釋放鎖,結果釋放了客戶端B的鎖。

那上面兩個問題你有什么好的解決方案嗎?
鎖超時
這個有兩個解決方案。
針對鎖超時的問題,我們可以根據(jù)平時業(yè)務執(zhí)行時間做大致的評估,然后根據(jù)評估的時間設置一個較為合理的超時時間,這樣能一大部分程度上避免問題。 自動續(xù)租,通過其他的線程為將要過期的鎖延長持有時間
鎖誤刪除
每個客戶端的鎖只能自己解鎖,一般我們可以在使用set命令的時候生成隨機的value,解鎖使用lua腳本判斷當前鎖是否自己持有的,是自己的鎖才能釋放。
#加鎖
SET key random_value NX EX 10
#解鎖
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
了解RedLock算法嗎?
因為在Redis的主從架構下,主從同步是異步的,如果在Master節(jié)點加鎖成功后,指令還沒有同步到Slave節(jié)點,此時Master掛掉,Slave被提升為Master,新的Master上并沒有鎖的數(shù)據(jù),其他的客戶端仍然可以加鎖成功。
對于這種問題,Redis作者提出了RedLock紅鎖的概念。
RedLock的理念下需要至少2個Master節(jié)點,多個Master節(jié)點之間完全互相獨立,彼此之間不存在主從同步和數(shù)據(jù)復制。
主要步驟如下:
獲取當前Unix時間 按照順序依次嘗試從多個節(jié)點鎖,如果獲取鎖的時間小于超時時間,并且超過半數(shù)的節(jié)點獲取成功,那么加鎖成功。這樣做的目的就是為了避免某些節(jié)點已經宕機的情況下,客戶端還在一直等待響應結果。舉個例子,假設現(xiàn)在有5個節(jié)點,過期時間=100ms,第一個節(jié)點獲取鎖花費10ms,第二個節(jié)點花費20ms,第三個節(jié)點花費30ms,那么最后鎖的過期時間就是100-(10+20+30),這樣就是加鎖成功,反之如果最后時間<0,那么加鎖失敗 如果加鎖失敗,那么要釋放所有節(jié)點上的鎖
那么RedLock有什么問題嗎?
其實RedLock存在不少問題,所以現(xiàn)在其實一般不推薦使用這種方式,而是推薦使用Redission的方案,他的問題主要如下幾點。
性能、資源
因為需要對多個節(jié)點分別加鎖和解鎖,而一般分布式鎖的應用場景都是在高并發(fā)的情況下,所以耗時較長,對性能有一定的影響。此外因為需要多個節(jié)點,使用的資源也比較多,簡單來說就是費錢。
節(jié)點崩潰重啟
比如有1~5號五個節(jié)點,并且沒有開啟持久化,客戶端A在1,2,3號節(jié)點加鎖成功,此時3號節(jié)點崩潰宕機后發(fā)生重啟,就丟失了加鎖信息,客戶端B在3,4,5號節(jié)點加鎖成功。
那么,兩個客戶端A\B同時獲取到了同一個鎖,問題產生了,怎么解決?
Redis作者建議的方式就是延時重啟,比如3號節(jié)點宕機之后不要立刻重啟,而是等待一段時間后再重啟,這個時間必須大于鎖的有效時間,也就是鎖失效后再重啟,這種人為干預的措施真正實施起來就比較困難了 第二個方案那么就是開啟持久化,但是這樣對性能又造成了影響。比如如果開啟AOF默認每秒一次刷盤,那么最多丟失一秒的數(shù)據(jù),如果想完全不丟失的話就對性能造成較大的影響。
GC、網(wǎng)絡延遲
對于RedLock,Martin Kleppmann提出了很多質疑,我就只舉這樣一個GC或者網(wǎng)絡導致的例子。(這個問題比較多,我就不一一舉例了,心里有一個概念就行了,文章地址:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html)
從圖中我們可以看出,client1線獲取到鎖,然后發(fā)生GC停頓,超過了鎖的有效時間導致鎖被釋放,然后鎖被client2拿到,然后兩個客戶端同時拿到鎖在寫數(shù)據(jù),問題產生。

時鐘跳躍
同樣的例子,假設發(fā)生網(wǎng)絡分區(qū),4、5號節(jié)點變?yōu)橐粋€獨立的子網(wǎng),3號節(jié)點發(fā)生始終跳躍(不管人為操作還是同步導致)導致鎖過期,這時候另外的客戶端就可以從3、4、5號節(jié)點加鎖成功,問題又發(fā)生了。
那你說說有什么好的解決方案嗎?
上面也提到了,其實比較好的方式是使用Redission,它是一個開源的Java版本的Redis客戶端,無論單機、哨兵、集群環(huán)境都能支持,另外還很好地解決了鎖超時、公平非公平鎖、可重入等問題,也實現(xiàn)了RedLock,同時也是官方推薦的客戶端版本。
那么Redission實現(xiàn)原理呢?
加鎖、可重入
首先,加鎖和解鎖都是通過lua腳本去實現(xiàn)的,這樣做的好處是為了兼容老版本的redis同時保證原子性。
KEYS[1]為鎖的key,ARGV[2]為鎖的value,格式為uuid+線程ID,ARGV[1]為過期時間。
主要的加鎖邏輯也比較容易看懂,如果key不存在,通過hash的方式保存,同時設置過期時間,反之如果存在就是+1。
對應的就是hincrby', KEYS[1], ARGV[2], 1這段命令,對hash結構的鎖重入次數(shù)+1。

解鎖
如果key都不存在了,那么就直接返回 如果key、field不匹配,那么說明不是自己的鎖,不能釋放,返回空 釋放鎖,重入次數(shù)-1,如果還大于0那么久刷新過期時間,反之那么久刪除鎖

watchdog
也叫做看門狗,也就是解決了鎖超時導致的問題,實際上就是一個后臺線程,默認每隔10秒自動延長鎖的過期時間。
默認的時間就是internalLockLeaseTime / 3,internalLockLeaseTime默認為30秒。

最后,實際生產中對于不同的場景該如何選擇?
首先,如果對于并發(fā)不高并且比較簡單的場景,通過數(shù)據(jù)庫樂觀鎖或者唯一主鍵的形式就能解決大部分的問題。
然后,對于Redis實現(xiàn)的分布式鎖來說性能高,自己去實現(xiàn)的話比較麻煩,要解決鎖續(xù)租、lua腳本、可重入等一系列復雜的問題。
對于單機模式而言,存在單點問題。
對于主從架構或者哨兵模式,故障轉移會發(fā)生鎖丟失的問題,因此產生了紅鎖,但是紅鎖的問題也比較多,并不推薦使用,推薦的使用方式是用Redission。
但是,不管選擇哪種方式,本身對于Redis來說不是強一致性的,某些極端場景下還是可能會存在問題。
對于Zookeeper的實現(xiàn)方式而言,本身就是保證數(shù)據(jù)一致性的,可靠性更高,所以不存在Redis的各種故障轉移帶來的問題,自己實現(xiàn)也比較簡單,但是性能相比Redis稍差。
不過,實際中我們當然是有啥用啥,老板說用什么就用什么,我才不管那么多。

往期推薦
