分散式鎖的多種實現方式,你瞭解嗎?

程式設計師生態圈發表於2018-09-07

為什麼要使用分散式鎖?

在網際網路中很多場景下,我們為了保證資料的一致性,需要保證同一個方法,在同一時間,只能有一個執行緒在執行。這在單機環境中,我們有很多辦法實現,在java.util.concurrent包下,java提供了很多併發相關API,但這些API在分散式場景下就無能為力了。

 

常見的幾種方案?

基於資料庫的鎖

基於快取的鎖(Redis、Memcached)

基於分散式演算法的鎖(Zookeeper)

 

使用Mysql實現:

可以使用Mysql中的悲觀鎖或者排他鎖來實現,具體步驟如下:

1、建立一張資料庫表,用於儲存鎖記錄

2、方法開始執行時,執行一條insert語句插入到鎖記錄表中,將要鎖定的資源作為主鍵或者唯一性索引插入,這個主鍵或者唯一性索引可以保證,同一時刻,只有一個執行緒執行成功

3、方法執行完畢後,刪除這條資料

 

使用上面Mysql實現,有如下幾個問題:

1、資料庫是單點的,當資料庫掛掉,會造成服務不可用

2、不能設定鎖的超時時間

3、這把鎖是不可重入的,同一個執行緒在沒有釋放鎖之前,無法再獲取該鎖

 

使用Zookeeper實現:

此種方式不太常用,效能也比較低,但理論上也是最安全的,可以使用Curator框架實現,使用其中InterProcessMutex類可以非常方便的實現分散式鎖

 

使用Redis實現:

這種方式最為常見,具體步驟如下:

1、方法開始執行時,通過如下命令,向Redis獲取一個鎖

SET resource_name my_random_value NX PX 30000

NX表示,只有當resource_name對應的key不存在時,才能SET成功,這保證了只有第一個請求的客戶端才能獲得鎖,而其它客戶端在鎖被釋放之前都無法獲得鎖。

PX 30000 是一個自動過期時間,客戶端可以根據自己的業務常見,選擇合適的過期時間。

上面的命令如果執行成功,則客戶端成功獲取到了鎖;而如果上面的命令執行失敗,則說明獲取鎖失敗。

2、方法執行完畢後,可以通過如下lua指令碼刪除鎖

local v = redis.call('GET', KEYS[1]);

local r= 0;

if v == ARGV[1] then

   r =redis.call('DEL',KEYS[1]);

end

return r

 

使用上面Redis實現,有如下幾個問題:

1、必須設定超時時間,假如沒有設定超時時間,當一個程式獲取到鎖後,他崩潰了,或者因為網路問題,導致它再也無法和Redis通訊了,那麼它會一直持有這個鎖,而其他的客戶端永遠無法獲取到這個鎖

2、這個my_random_value是很有必要的,它保證了一個客戶端釋放的鎖,一定是自己的鎖

2、加鎖的過程,依然不支援可重入性,如果想實現可重入性,可以將 MAC地址 + jvm程式ID + 執行緒ID 作為my_random_value設定進快取

3、依然是單點的,當redis掛掉,會導致服務不可用,假如給這個Redis掛一個Slave,但由於Redis的服務是非同步的,會喪失鎖的安全性

4、釋放鎖的過程使用lua指令碼,是為了保證原子性,網上有人將加鎖過程分為兩步執行,先使用SETNX命令加鎖,再使用PEXPIRE命令設定鎖的超時時間,將這兩步放在lua指令碼中,也是為了保證原子性

5、超時時間應該設定成多少呢?假如方法執行時間過長,超過了設定的超時時間,當Redis已經自動刪除的key,而方法依然在執行,這可能會導致程式出現發生不一致性,出現嚴重BUG,這看起來是個兩難的問題

 

使用Redlock演算法解決單點問題:

針對於上面第3個問題,Redis的作者antirez提出了一個更安全的實現,稱為Redlock,算是對於實現分散式鎖的官方指導規範,因為在此之前,很多人使用Redis鎖,都是基於單節點的,而Redlock是基於多節點(都是Master)的一種實現。

Redlock簡單直白的說,就是採用N(通常是5)個獨立的redis節點,同時setnx,如果多數節點成功,就拿到了鎖,這樣就可以允許少數(2個或以下)的節點掛掉了。釋放鎖的過程則比較簡單,向所有節點發起釋放鎖的請求,無論這個節點之前有沒有成功加鎖,這一點很重要,因為可能這個幾點已經成功加鎖,但是在返回給客戶端的時候,響應包丟失了。

在Redis官網上,有Redlock演算法的詳細過程。

在實現開發過程中,我們可以使用redisson框架中的RedissionClient類來實現Redlock演算法的分散式鎖。

Redlock看似完美,假如我們有5個節點(A、B、C、D、E),假設發生瞭如下情況:

1、service1成功鎖住了其中的3個節點A、B、C,而D和E沒有鎖住

2、節點C突然間發生了崩潰,所有的快取全部丟失,沒有持久化下來

3、將C節點進行重啟

4、service2成功鎖住了其中的3個節點C、D、E,獲取鎖成功

這樣,service1和service2就獲取到了同一把鎖。

當然我們可以通過修改redis配置,將每次修改資料都進行fsync,持久化起來,但這會嚴重降低效能。

為了解決這一問題,Redis作者antirez又提出了延遲重啟(delayed restarts)的概念。辦法很簡單,也就是說,當其中一個節點崩潰後,先不立即重啟它,而是等待一段時間再重啟,等裡面的快取全部都自動過期。這樣的話,這個節點在重啟前,所參與的鎖全部都會過期,它在重啟後就不會對以前的鎖造成影響。

 

超時時間應該如何設定?

大多數情況下,我們應該根據業務場景給出一個超時時間,這個超時時間可能是一個經驗值,也可能是經過嚴格測試計算出來的。

但假如我們的方法執行時間過長,擔心超時時間小於方法的執行時間,在加鎖時,不想設定超時時間,而是讓程式自動檢測,當方法執行完畢後,再釋放鎖,應該怎麼做呢?

我們下次再來分析這個問題,有不同意見和想法的歡迎進群交流:236283328

 

相關文章