Redis應用篇(眾星追月):分布式鎖
話題引入
大家好,我是小龍。
之前在《吃透Redis系列》專欄發(fā)表了第一篇文章《Redis基礎篇(萬丈高樓平地起):核心底層數(shù)據(jù)結構》簡單介紹了Redis,以及它的內(nèi)部組織形式、核心數(shù)據(jù)結構與大致使用場景。還沒看到得同學可以回過頭看看。
接下來,我將繼續(xù)帶大家深入理解,本文將介紹Redis高頻使用的一個場景——「利用Redis實習分布式鎖」。
想必大家都知道,在遇到并發(fā)問題時,我們通常會使用鎖來解決并發(fā)問題。
這是,有同學可能說:“這個我會,不就用synchronized、Lock這些實現(xiàn)嗎?”
對,你說的不錯。但是你只說對了一半,在「傳統(tǒng)單機部署」的情況下,可以使用Java并發(fā)處理相關的API(如ReentrantLcok或synchronized)進行互斥控制。
但是在「分布式系統(tǒng)」中,由于分布式系統(tǒng)「多線程」、「多進程」并且「分布在不同機器」上,這將使原單機并發(fā)控制鎖策略失效,為了解決這個問題就需要一種「跨JVM的互斥機制」來控制共享資源的訪問,這就得靠分布式鎖啦。
看透鎖本質
在我看來:所有的鎖本身都可以用一個變量來表示。
比如:在「單機上運行」的多線程程序來說。取一個變量,變量為0時,表示沒有線程獲取鎖;變量為1時,表示已經(jīng)有線程獲取鎖。
加鎖:線程調(diào)用加鎖操作,檢查變量是否為0,如果為0,表示沒線程獲取鎖,將變量設置為1,表示獲取鎖;如果不是0,表示其他線程已經(jīng)暫用鎖,獲取鎖失敗。
解鎖:同理。
而分布式環(huán)境下,同樣可以以變量形式理解分布式鎖。
但是,和線程在單機上操作鎖不同的是,在分布式場景下,「鎖變量需要由一個共享存儲系統(tǒng)來維護」,只有這樣,多個客戶端才可以通過訪問共享存儲系統(tǒng)來訪問鎖變量。相應的,「加鎖和釋放鎖的操作就變成了讀取、判斷和設置共享存儲系統(tǒng)中的鎖變量值」。
「可見,滿足分布式鎖的要求」:
- 「鎖操作原子性」:分布式鎖的加鎖和釋放鎖的過程,涉及多個操作。所以,在實現(xiàn)分布式鎖時,我們需要保證這些「鎖操作的原子性」;
- 「鎖的可靠性」:共享存儲系統(tǒng)保存了鎖變量,如果共享存儲系統(tǒng)發(fā)生故障或宕機,那么客戶端也就無法進行鎖操作了。在實現(xiàn)分布式鎖時,我們需要考慮保證「共享存儲系統(tǒng)的可靠性」,進而保證「鎖的可靠性」。
上面我們提到了可以使用一個鎖變量來表示鎖,其實你也可以理解為「占位」。只不過分布式鎖需要把這個坑位拿出來放于「共享」的地方,每個都從「共享處來檢查坑位」。
占位一般是使用 setnx(set if not exists) 指令,只允許被一個客戶端占位。先來先占, 用完了,再調(diào)用 del 指令釋放茅坑。
- //加鎖
- > setnx lock_key 1
- OK
- //業(yè)務邏輯
- >(其他操作)
- //釋放鎖
- > del lock_key
但是有個問題,如果邏輯執(zhí)行到中間出現(xiàn)異常了,可能會導致 del 指令沒有被調(diào)用,這樣就會「陷入死鎖」,鎖永遠得不到釋放。
于是我們在拿到鎖之后,再給鎖加上一個過期時間,這樣即使中間出現(xiàn)異常也可以保證指定時間之后鎖會自動釋放。
- //加鎖
- > setnx lock_key 1
- OK
- > expire lock_key 5
- //業(yè)務邏輯
- >(其他操作)
- //釋放鎖
- > del lock_key
但是以上邏輯還有問題。如果在 setnx 和 expire 之間服務器進程突然掛掉了,可能是因為機器掉電或者是被人為殺掉的,就會導致 expire 得不到執(zhí)行,也會造成死鎖。
這種問題的根源就在于 setnx 和 expire 是兩條指令而不是原子指令。你也許會想到使用事務什么的執(zhí)行,但是這里不行,因為如果 setnx 沒搶到鎖,expire 是不應該執(zhí)行的。
Redis 2.8 版本中作者加入了 set 指令的擴展參數(shù),使得 setnx 和expire 指令可以一起執(zhí)行,徹底解決了分布式鎖的亂象。
- set key value [EX seconds | PX milliseconds] [NX]
除了上述基本常規(guī)的問題,還有這些「你可能沒考慮到的問題」:
超時問題
Redis 的分布式鎖不能解決超時問題,如果在加鎖和釋放鎖間的業(yè)務邏輯執(zhí)行時間太長,以至于超出了鎖的超時限制,就會出現(xiàn)問題(也就是鎖過期了,你的業(yè)務邏輯還沒執(zhí)行完)。
因為這時候鎖過期了,第二個客戶端B重新持有了這把鎖,但是緊接著客戶端A執(zhí)行完了業(yè)務邏輯,就把鎖給釋放了,客戶端C就會在客戶端B邏輯執(zhí)行完之間拿到了鎖。為了避免這個問題,Redis 分布式鎖不要用于較長時間的任務。
為了應對這個問題,我們需要能區(qū)分來自不同客戶端的鎖操作,具體咋做呢 ? 針對于這個問題,我們可以想辦法把命令略加點小技巧??梢栽阪i變量的值上想想辦法。
在使用SETNX命令進行加鎖的方法中,我們通過把鎖變量值設置為1或0,表示是否加鎖成功。1和0只有兩種狀態(tài),無法表示究竟是哪個客戶端進行的鎖操作。
所以,我們在加鎖操作時,可以「讓每個客戶端給鎖變量設置一個唯一值」,這里的唯一值就可以用來標識當前操作的客戶端。
在釋放鎖操作時,客戶端需要判斷,當前「鎖變量的值是否和自己的唯一標識相等」,只有在相等的情況下,才能釋放鎖。這樣一來,就不會出現(xiàn)誤釋放鎖的問題了。
于是,我們的命令可以這樣寫:
- //加鎖,unique_value作為客戶端唯—性的標識
- SET lock_key unique_value NX PX 5000
其中,unique_value 是客戶端的唯一標識,可以用一個隨機生成的字符串來表示,PX 5000則表示 lock_key會在5s后過期,以免客戶端在這期間發(fā)生異常而無法釋放鎖。
因為在加鎖操作中,每個客戶端都使用了一個唯一標識,所以在「釋放鎖操作」時,我們需要「判斷鎖變量的值」,是否等于執(zhí)行釋放鎖操作的客戶端的唯一標識,如下所示,可以使用Lua腳本來保證原子性:
- //釋放鎖比較unique_value是否相等,避免誤釋放
- if redis.call("get" ,KEYS[1])== ARGV[1] then
- return redis.call("del" , KEYS[1])
- else
- return 0
- end
可重入性
可重入性是指線程在持有鎖的情況下再次請求加鎖,如果一個鎖支持同一個線程的多次加鎖,那么這個鎖就是可重入的。比如 Java 語言里有個 ReentrantLock 就是可重入鎖。
Redis 分布式鎖如果要支持可重入,可以對客戶端的 set 方法進行包裝,使用線程的 Threadlocal 變量存儲當前持有鎖的計數(shù)。
此處就不過多介紹,大抵不會問,有興趣可以自己上網(wǎng)查閱看書。
課外補充
上述內(nèi)容,是個基于單個Redis節(jié)點實現(xiàn)分布式鎖。
當我們要實現(xiàn)「高可靠的分布式鎖」時,就不能只依賴單個的命令操作了,我們需要按照一定的步驟和規(guī)則進行加解鎖操作,否則,就可能會出現(xiàn)鎖無法工作的情況。“一定的步驟和規(guī)則”是指啥呢?其實就是分布式鎖的算法。
這里簡單介紹Redlock算法的執(zhí)行步驟。Redlock算法的實現(xiàn)需要有N個獨立的Redis實例。接下來,我們可以分成3步來完成加鎖操作。
1、客戶端獲取當前時間
2、客戶端按照順序在每個Master實例中嘗試獲得鎖。在獲得鎖的過程中,為每一個鎖操作設置一個快速失敗時間(如果想要獲得一個10秒的鎖,那么每一個鎖操作的失敗時間設為5-50ms)。
這樣可以避免客戶端與一個已經(jīng)故障的Master通信占用太長時間,通過快速失敗的方式盡快的與集群中的其他節(jié)點完成鎖操作。
3、客戶端計算出與master獲得鎖操作過程中消耗的時間,「當且僅當Client獲得鎖消耗的時間小于鎖的存活時間,并且在一半以上的master節(jié)點中獲得鎖」。才認為client成功的獲得了鎖。
4、如果已經(jīng)獲得了鎖,「Client執(zhí)行任務的時間窗口是鎖的存活時間減去獲得鎖消耗的時間?!?/p>
5、如果Client獲得鎖的數(shù)量不足一半以上,或獲得鎖的時間超時,那么認為獲得鎖失敗。客戶端「需要嘗試在所有的master節(jié)點中釋放鎖, 即使在第二步中沒有成功獲得該Master節(jié)點中的鎖,仍要進行釋放操作。」