面試必問的分布式鎖,你懂了嗎?
前言
分布式鎖無論是在實際應用,還是面試中,都是經常會遇到的,因此很有必要掌握這個知識點。
今天跟大家一起探討下當前主流的幾種實現方案及其優(yōu)缺點。
正文
為什么需要鎖
原因其實很簡單:因為我們想讓同一時刻只有一個線程在執(zhí)行某段代碼。
因為如果同時出現多個線程去執(zhí)行,可能會帶來我們不想要的結果,可能是數據錯誤,也可能是服務宕機等等。
以淘寶雙11為例,在0點這一刻,如果有幾十萬甚至上百萬的人同時去查看某個商品的詳情,這時候會觸發(fā)商品的查詢,如果我們不做控制,全部走到數據庫去,那是有可能直接將數據庫打垮的。
這個時候一個比較常用的做法就是進行加鎖,只讓1個線程去查詢,其他線程待等待這個線程的查詢結果后,直接拿結果。在這個例子中,鎖用于控制訪問數據庫的流量,最終起到了保護系統的作用。
再舉個例子,某平臺做活動“秒殺茅臺”,假如活動只秒殺1瓶,但是同時有10萬人在同一時刻去搶,如果底層不做控制,有10000個人搶到了,額外的9999瓶平臺就要自己想辦法解決了。此時,我們可以在底層通過加鎖或者隱式加鎖的方式來解決這個問題。
此外,鎖也經常用來解決并發(fā)下的數據安全方面的問題,這里就不一一舉例了。
為什么需要分布式鎖
分布式鎖是鎖的一種,通常用來跟 JVM 鎖做區(qū)別。
JVM 鎖就是我們常說的 synchronized、Lock。
JVM 鎖只能作用于單個 JVM,可以簡單理解為就是單臺服務器(容器),而對于多臺服務器之間,JVM 鎖則沒法解決,這時候就需要引入分布式鎖。
實現分布式鎖的方式
實現分布式鎖的方式其實很多,只要能保證對于搶奪“鎖”的系統來說,這個東西是唯一的,那么就能用于實現分布式鎖。
舉個簡單的例子,有一個 MySQL 數據庫 Order,Order 庫里有個 Lock 表只有一條記錄,該記錄有個狀態(tài)字段 lock_status,默認為0,表示空閑狀態(tài),可以修改為1,表示成功獲取鎖。
我們的訂單系統部署在100臺服務器上,這100臺服務器可以在“同一時刻”對上述的這1條記錄執(zhí)行修改,修改內容都是從0修改為1,但是 MysQL 會保證最終只會有1個線程修改成功。因此,這條記錄其實就可以用于做分布式鎖。
常見實現分布式鎖的方式有:數據庫、Redis、Zookeeper。
這其中又以 Redis 最為常見。
Redis 實現分布式鎖
加鎖
加鎖通常使用 set 命令來實現,偽代碼如下:
set?key?value?PX?milliseconds?NX幾個參數的意義如下:
key、value:鍵值對
PX milliseconds:設置鍵的過期時間為 milliseconds 毫秒。
NX:只在鍵不存在時,才對鍵進行設置操作。SET key value NX 效果等同于 SETNX key value。
PX、expireTime 參數則是用于解決沒有解鎖導致的死鎖問題。因為如果沒有過期時間,萬一程序員寫的代碼有 bug 導致沒有解鎖操作,則就出現了死鎖,因此該參數起到了一個“兜底”的作用。
NX?參數用于保證在多個線程并發(fā) set 下,只會有1個線程成功,起到了鎖的“唯一”性。
解鎖
解鎖需要兩步操作:
1)查詢當前“鎖”是否還是我們持有,因為存在過期時間,所以可能等你想解鎖的時候,“鎖”已經到期,然后被其他線程獲取了,所以我們在解鎖前需要先判斷自己是否還持有“鎖”
2)如果“鎖”還是我們持有,則執(zhí)行解鎖操作,也就是刪除該鍵值對,并返回成功;否則,直接返回失敗。
由于當前 Redis 還沒有原子命令直接支持這兩步操作,所以當前通常是使用 Lua 腳本來執(zhí)行解鎖操作,Redis 會保證腳本里的內容執(zhí)行是一個原子操作。
腳本代碼如下,邏輯比較簡單:
if redis.call("get",KEYS[1]) == ARGV[1]thenreturn redis.call("del",KEYS[1])elsereturn 0end
兩個參數的意義如下:
KEYS[1]:我們要解鎖的 key
ARGV[1]:我們加鎖時的 value,用于判斷當“鎖”是否還是我們持有,如果被其他線程持有了,value 就會發(fā)生變化。
上述方法是 Redis 當前實現分布式鎖的主流方法,可能會有一些小優(yōu)區(qū)別,但是核心都是這個思路。看著好像沒啥毛病,但是真的是這個樣子嗎?讓我們繼續(xù)往下看。

Redis 分布式鎖過期了,還沒處理完怎么辦
為了防止死鎖,我們會給分布式鎖加一個過期時間,但是萬一這個時間到了,我們業(yè)務邏輯還沒處理完,怎么辦?
首先,我們在設置過期時間時要結合業(yè)務場景去考慮,盡量設置一個比較合理的值,就是理論上正常處理的話,在這個過期時間內是一定能處理完畢的。
之后,我們再來考慮對這個問題進行兜底設計。
關于這個問題,目前常見的解決方法有兩種:
1、守護線程“續(xù)命”:額外起一個線程,定期檢查線程是否還持有鎖,如果有則延長過期時間。Redisson 里面就實現了這個方案,使用“看門狗”定期檢查(每1/3的鎖時間檢查1次),如果線程還持有鎖,則刷新過期時間。
2、超時回滾:當我們解鎖時發(fā)現鎖已經被其他線程獲取了,說明此時我們執(zhí)行的操作已經是“不安全”的了,此時需要進行回滾,并返回失敗。
同時,需要進行告警,人為介入驗證數據的正確性,然后找出超時原因,是否需要對超時時間進行優(yōu)化等等。
守護線程續(xù)命的方案有什么問題嗎
Redisson 使用看門狗(守護線程)“續(xù)命”的方案在大多數場景下是挺不錯的,也被廣泛應用于生產環(huán)境,但是在極端情況下還是會存在問題。
問題例子如下:
1、線程1首先獲取鎖成功,將鍵值對寫入 redis 的 master 節(jié)點
2、在 redis 將該鍵值對同步到 slave 節(jié)點之前,master 發(fā)生了故障
3、redis 觸發(fā)故障轉移,其中一個 slave 升級為新的 master
4、此時新的?master 并不包含線程1寫入的鍵值對,因此線程2嘗試獲取鎖也可以成功拿到鎖
5、此時相當于有兩個線程獲取到了鎖,可能會導致各種預期之外的情況發(fā)生,例如最常見的臟數據
解決方法:上述問題的根本原因主要是由于 redis 異步復制帶來的數據不一致問題導致的,因此解決的方向就是保證數據的一致。
當前比較主流的解法和思路有兩種:
1)Redis 作者提出的 RedLock;2)Zookeeper 實現的分布式鎖。
接下來介紹下這兩種方案。
RedLock
首先,該方案也是基于文章開頭的那個方案(set加鎖、lua腳本解鎖)進行改良的,所以 antirez 只描述了差異的地方,大致方案如下。
假設我們有 N?個 Redis 主節(jié)點,例如 N = 5,這些節(jié)點是完全獨立的,我們不使用復制或任何其他隱式協調系統,為了取到鎖,客戶端應該執(zhí)行以下操作:
1、獲取當前時間,以毫秒為單位。
2、依次嘗試從5個實例,使用相同的 key?和隨機值(例如UUID)獲取鎖。當向Redis 請求獲取鎖時,客戶端應該設置一個超時時間,這個超時時間應該小于鎖的失效時間。例如你的鎖自動失效時間為10秒,則超時時間應該在 5-50 毫秒之間。這樣可以防止客戶端在試圖與一個宕機的 Redis 節(jié)點對話時長時間處于阻塞狀態(tài)。如果一個實例不可用,客戶端應該盡快嘗試去另外一個Redis實例請求獲取鎖。
3、客戶端通過當前時間減去步驟1記錄的時間來計算獲取鎖使用的時間。當且僅當從大多數(N/2+1,這里是3個節(jié)點)的Redis節(jié)點都取到鎖,并且獲取鎖使用的時間小于鎖失效時間時,鎖才算獲取成功。
4、如果取到了鎖,其有效時間等于有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。
5、如果由于某些原因未能獲得鎖(無法在至少N/2+1個Redis實例獲取鎖、或獲取鎖的時間超過了有效時間),客戶端應該在所有的Redis實例上進行解鎖(即便某些Redis實例根本就沒有加鎖成功,防止某些節(jié)點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖)。
可以看出,該方案為了解決數據不一致的問題,直接舍棄了異步復制,只使用 master 節(jié)點,同時由于舍棄了 slave,為了保證可用性,引入了 N 個節(jié)點,官方建議是 5。
該方案看著挺美好的,但是實際上我所了解到的在實際生產上應用的不多,主要有兩個原因:1)該方案的成本似乎有點高,需要使用5個實例;2)該方案一樣存在問題。
該方案主要存以下問題:
1)嚴重依賴系統時鐘。如果線程1從3個實例獲取到了鎖,但是這3個實例中的某個實例的系統時間走的稍微快一點,則它持有的鎖會提前過期被釋放,當他釋放后,此時又有3個實例是空閑的,則線程2也可以獲取到鎖,則可能出現兩個線程同時持有鎖了。
2)如果線程1從3個實例獲取到了鎖,但是萬一其中有1臺重啟了,則此時又有3個實例是空閑的,則線程2也可以獲取到鎖,此時又出現兩個線程同時持有鎖了。
針對以上問題其實后續(xù)也有人給出一些相應的解法,但是整體上來看還是不夠完美,所以目前實際應用得不是那么多。
Zookeeper 實現分布式鎖
Zookeeper 的分布式鎖實現方案如下:
1、創(chuàng)建一個鎖目錄 /locks,該節(jié)點為持久節(jié)點
2、想要獲取鎖的線程都在鎖目錄下創(chuàng)建一個臨時順序節(jié)點
3、獲取鎖目錄下所有子節(jié)點,對子節(jié)點按節(jié)點自增序號從小到大排序
4、判斷本節(jié)點是不是第一個子節(jié)點,如果是,則成功獲取鎖,開始執(zhí)行業(yè)務邏輯操作;如果不是,則監(jiān)聽自己的上一個節(jié)點的刪除事件
5、持有鎖的線程釋放鎖,只需刪除當前節(jié)點即可。
6、當自己監(jiān)聽的節(jié)點被刪除時,監(jiān)聽事件觸發(fā),則回到第3步重新進行判斷,直到獲取到鎖。
由于 Zookeeper 保證了數據的強一致性,因此不會存在之前 Redis 方案中的問題,整體上來看還是比較不錯的。
Zookeeper 方案的主要問題在于性能不如 Redis 那么好,當申請鎖和釋放鎖的頻率較高時,會對集群造成壓力,此時集群的穩(wěn)定性可用性能可能又會遭受挑戰(zhàn)。
分布式鎖的選型
當前主流的方案有兩種:
1)Redis 的 set 加鎖+lua 腳本解鎖方案,至于是不是用守護線程續(xù)命可以結合自己的場景去決定,個人建議還是可以使用的。
2)Zookeeper 方案
通常情況下,對于數據的安全性要求沒那么高的,可以采用 Redis 的方案,對數據安全性要求比較高的可以采用 Zookeeper 的方案。
最后
當你的才華還撐不起你的野心的時候,你就應該靜下心來學習,愿你在我這里能有所收獲。
原創(chuàng)不易,如果你覺得本文寫的還不錯,對你有幫助,請通過【點贊】讓我知道,支持我寫出更好的文章。
推薦閱讀
