面試必問的分佈式鎖,你懂了嗎?

以下文章來源於程序員囧輝 ,作者囧輝

分佈式鎖無論是在實際應用還是面試中都是經常會遇到的,因此很有必要掌握這個知識點。

今天跟大家一起探討下當前主流的幾種實現方案及其優缺點。

**1 **爲什麼需要鎖

原因其實很簡單:因爲我們想讓同一時刻只有一個線程在執行某段代碼。

因爲如果同時出現多個線程去執行,可能會帶來我們不想要的結果,可能是數據錯誤,也可能是服務宕機等等。

以淘寶雙十一爲例,在零點這一刻,如果有幾十萬甚至上百萬的人同時去查看某個商品的詳情,這時候會觸發商品的查詢,如果我們不做控制,全部走到數據庫去,那是有可能直接將數據庫打垮的。

這個時候一個比較常用的做法就是進行加鎖,只讓 1 個線程去查詢,其他線程待等待這個線程的查詢結果後,直接拿結果。在這個例子中,鎖用於控制訪問數據庫的流量,最終起到了保護系統的作用。

再舉個例子,某平臺做活動 “秒殺茅臺”,假如活動只秒殺 1 瓶,但是同時有 10 萬人在同一時刻去搶,如果底層不做控制,有 10000 個人搶到了,額外的 9999 瓶平臺就要自己想辦法解決了。此時,我們可以在底層通過加鎖或者隱式加鎖的方式來解決這個問題。

此外,鎖也經常用來解決併發下的數據安全方面的問題,這裏就不一一舉例了。

2 爲什麼需要分佈式鎖

分佈式鎖是鎖的一種,通常用來跟 JVM 鎖做區別。

JVM 鎖就是我們常說的 synchronized、Lock。

JVM 鎖只能作用於單個 JVM,可以簡單理解爲就是單臺服務器(容器),而對於多臺服務器之間,JVM 鎖則沒法解決,這時候就需要引入分佈式鎖。

3 實現分佈式鎖的方式

實現分佈式鎖的方式其實很多,只要能保證對於搶奪 “鎖” 的系統來說,這個東西是唯一的,那麼就能用於實現分佈式鎖。

舉個簡單的例子,有一個 MySQL 數據庫 Order,Order 庫裏有個 Lock 表只有一條記錄,該記錄有個狀態字段 lock_status,默認爲 0,表示空閒狀態。可以修改爲 1,表示成功獲取鎖。

我們的訂單系統部署在 100 臺服務器上,這 100 臺服務器可以在 “同一時刻” 對上述的這 1 條記錄執行修改,修改內容都是從 0 修改爲 1,但是 MysQL 會保證最終只會有 1 個線程修改成功。因此,這條記錄其實就可以用於做分佈式鎖。

常見實現分佈式鎖的方式有:數據庫、Redis、Zookeeper。這其中又以 Redis 最爲常見。

**4 **Redis 實現分佈式鎖

加鎖

加鎖通常使用 set 命令來實現,僞代碼如下:

set key value PX milliseconds NX

幾個參數的意義如下:

解鎖

解鎖需要兩步操作:

  1. 查詢當前 “鎖” 是否還是我們持有,因爲存在過期時間,所以可能等你想解鎖的時候,“鎖”已經到期,然後被其他線程獲取了,所以我們在解鎖前需要先判斷自己是否還持有“鎖”;

  2. 如果 “鎖” 還是我們持有,則執行解鎖操作,也就是刪除該鍵值對,並返回成功;否則,直接返回失敗。

由於當前 Redis 還沒有原子命令直接支持這兩步操作,所以當前通常是使用 Lua 腳本來執行解鎖操作,Redis 會保證腳本里的內容執行是一個原子操作。

腳本代碼如下,邏輯比較簡單:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

兩個參數的意義如下:

上述方法是 Redis 當前實現分佈式鎖的主流方法,可能會有一些小的區別,但是核心都是這個思路。看着好像沒啥毛病,但是真的是這個樣子嗎?讓我們繼續往下看。

4.1 Redis 分佈式鎖過期了,還沒處理完怎麼辦

爲了防止死鎖,我們會給分佈式鎖加一個過期時間,但是萬一這個時間到了,我們業務邏輯還沒處理完,怎麼辦?

首先,我們在設置過期時間時要結合業務場景去考慮,儘量設置一個比較合理的值,就是理論上正常處理的話,在這個過期時間內是一定能處理完畢的。

之後,我們再來考慮對這個問題進行兜底設計。

關於這個問題,目前常見的解決方法有兩種:

  1. 守護線程 “續命”:額外起一個線程,定期檢查線程是否還持有鎖,如果有則延長過期時間。Redisson 裏面就實現了這個方案,使用 “看門狗” 定期檢查(每 1/3 的鎖時間檢查 1 次),如果線程還持有鎖,則刷新過期時間;

  2. 超時回滾:當我們解鎖時發現鎖已經被其他線程獲取了,說明此時我們執行的操作已經是 “不安全” 的了,此時需要進行回滾,並返回失敗。

同時,需要進行告警,人爲介入驗證數據的正確性,然後找出超時原因,是否需要對超時時間進行優化等等。

4.2 守護線程續命的方案有什麼問題嗎

Redisson 使用看門狗(守護線程)“續命” 的方案在大多數場景下是挺不錯的,也被廣泛應用於生產環境,但是在極端情況下還是會存在問題。

問題例子如下:

  1. 線程 1 首先獲取鎖成功,將鍵值對寫入 redis 的 master 節點;

  2. 在 redis 將該鍵值對同步到 slave 節點之前,master 發生了故障;

  3. redis 觸發故障轉移,其中一個 slave 升級爲新的 master;

  4. 此時新的 master 並不包含線程 1 寫入的鍵值對,因此線程 2 嘗試獲取鎖也可以成功拿到鎖;

  5. 此時相當於有兩個線程獲取到了鎖,可能會導致各種預期之外的情況發生,例如最常見的髒數據。

解決方法:上述問題的根本原因主要是由於 redis 異步複製帶來的數據不一致問題導致的,因此解決的方向就是保證數據的一致。

當前比較主流的解法和思路有兩種:

  1. Redis 作者提出的 RedLock;

  2. Zookeeper 實現的分佈式鎖。

接下來介紹下這兩種方案。

5 RedLock

首先,該方案也是基於文章開頭的那個方案(set 加鎖、Lua 腳本解鎖)進行改良的,所以 antirez 只描述了差異的地方,大致方案如下。

假設我們有 N 個 Redis 主節點,例如 N = 5,這些節點是完全獨立的,我們不使用複製或任何其他隱式協調系統,爲了取到鎖,客戶端應該執行以下操作:

  1. 獲取當前時間,以毫秒爲單位;

  2. 依次嘗試從 5 個實例,使用相同的 key 和隨機值(例如 UUID)獲取鎖。當向 Redis 請求獲取鎖時,客戶端應該設置一個超時時間,這個超時時間應該小於鎖的失效時間。例如你的鎖自動失效時間爲 10 秒,則超時時間應該在 5-50 毫秒之間。這樣可以防止客戶端在試圖與一個宕機的 Redis 節點對話時長時間處於阻塞狀態。如果一個實例不可用,客戶端應該儘快嘗試去另外一個 Redis 實例請求獲取鎖;

  3. 客戶端通過當前時間減去步驟 1 記錄的時間來計算獲取鎖使用的時間。當且僅當從大多數(N/2+1,這裏是 3 個節點)的 Redis 節點都取到鎖,並且獲取鎖使用的時間小於鎖失效時間時,鎖纔算獲取成功;

  4. 如果取到了鎖,其真正有效時間等於初始有效時間減去獲取鎖所使用的時間(步驟 3 計算的結果)。

  5. 如果由於某些原因未能獲得鎖(無法在至少 N/2 + 1 個 Redis 實例獲取鎖、或獲取鎖的時間超過了有效時間),客戶端應該在所有的 Redis 實例上進行解鎖(即便某些 Redis 實例根本就沒有加鎖成功,防止某些節點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖)。

可以看出,該方案爲了解決數據不一致的問題,直接捨棄了異步複製,只使用 master 節點,同時由於捨棄了 slave,爲了保證可用性,引入了 N 個節點,官方建議是 5。

該方案看着挺美好的,但是實際上我所瞭解到的在實際生產上應用的不多,主要有兩個原因:

  1. 該方案的成本似乎有點高,需要使用 5 個實例;

  2. 該方案一樣存在問題。

該方案主要存以下問題:

  1. 嚴重依賴系統時鐘。如果線程 1 從 3 個實例獲取到了鎖。但是這 3 個實例中的某個實例的系統時間走的稍微快一點,則它持有的鎖會提前過期被釋放,當他釋放後,此時又有 3 個實例是空閒的,則線程 2 也可以獲取到鎖,則可能出現兩個線程同時持有鎖了。

  2. 如果線程 1 從 3 個實例獲取到了鎖,但是萬一其中有 1 臺重啓了,則此時又有 3 個實例是空閒的,則線程 2 也可以獲取到鎖,此時又出現兩個線程同時持有鎖了。

針對以上問題其實後續也有人給出一些相應的解法,但是整體上來看還是不夠完美,所以目前實際應用得不是那麼多。

6 Zookeeper 實現分佈式鎖

Zookeeper 的分佈式鎖實現方案如下:

  1. 創建一個鎖目錄 /locks,該節點爲持久節點;

  2. 想要獲取鎖的線程都在鎖目錄下創建一個臨時順序節點;

  3. 獲取鎖目錄下所有子節點,對子節點按節點自增序號從小到大排序;

  4. 判斷本節點是不是第一個子節點:如果是,則成功獲取鎖,開始執行業務邏輯操作;如果不是,則監聽自己的上一個節點的刪除事件;

  5. 持有鎖的線程釋放鎖,只需刪除當前節點即可;

  6. 當自己監聽的節點被刪除時,監聽事件觸發,則回到第 3 步重新進行判斷,直到獲取到鎖。

由於 Zookeeper 保證了數據的強一致性,因此不會存在之前 Redis 方案中的問題,整體上來看還是比較不錯的。

Zookeeper 方案的主要問題在於性能不如 Redis 那麼好,當申請鎖和釋放鎖的頻率較高時,會對集羣造成壓力,此時集羣的穩定性可用性能可能又會遭受挑戰。

7 分佈式鎖的選型

當前主流的方案有兩種:

  1. Redis 的 set 加鎖 + Lua 腳本解鎖方案,至於是不是用守護線程續命可以結合自己的場景去決定,個人建議還是可以使用的;

  2. Zookeeper 方案

通常情況下,對於數據的安全性要求沒那麼高的,可以採用 Redis 的方案,對數據安全性要求比較高的可以採用 Zookeeper 的方案。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/2UHp3jtkbp4mFUlybIsYEQ