分散式鎖的實現原理學習筆記
前言
在分散式場景下,單機的鎖已經沒有辦法滿足控制不同節點對同一資源的併發訪問。
常見的分散式鎖有三種:
- 基於
Mysql
- 基於快取(
redis
、Memcached
) - 基於
ZooKeeper
基於Mysql
實現分散式鎖
核心思想是:在資料庫中建立一個表,表中包含資源名等欄位,並在資源名欄位上建立唯一索引,想要執行某個方法,就使用這個資源名向表中插入資料,成功插入則獲取鎖,執行完成後刪除對應的行資料釋放鎖。
建立表:
DROP TABLE IF EXISTS `resource_lock`; CREATE TABLE `resource_lock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵', `resource_name` varchar(64) NOT NULL COMMENT '資源名字', `desc` varchar(255) NOT NULL COMMENT '備註資訊', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `unq_resource_name` (`resource_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
要想獲取某個資源,則使用該資源向表中插入插入資料。因為對資源驚醒了唯一性約束,因此資料庫會保證只有一個操作成功,成功的操作可以看做拿到了鎖。
想要釋放某個鎖時,利用delete from
刪除對應的行資料即可。
但是這個簡單版本的方法存在一些不足:
- 無法實現阻塞的特性,獲取不到鎖時是直接返回,只能手動實現迴圈獲取
- 無法實現可重入的特性,因此需要再加上
node_info
和count
用來表示節點資訊以及獲取的鎖個數。如果是同一節點,則獲取時鎖的個數加1;釋放時,如果鎖個數大於1,則減1,如果等於1可以直接刪除該行 - 無法實現超時,如果獲取鎖的節點由於出現了宕機,將導致該鎖永遠無法釋放。因此可以新增一列,設定超時時間,並在後臺開啟一個任務進行定期的回收
- 需要資料庫保證可用,否則直接影響分散式鎖的可用性及效能。所以可以通過雙機部署,資料同步,主備切換等。
因此,雖然易於理解,但是實現起來需要考慮很多方面,與基於快取實現的分散式鎖相比效能較低。
基於redis
實現分散式鎖
通常使用setnx key value
來實現,當返回1時表明這個key
不存在,獲取鎖成功,否則為搶鎖失敗。
當任務執行完畢後,使用del key
刪除這個key
,表明已經釋放成功。
看起來很簡便,但是會出現:
問題一:
同樣沒有鎖超時,可能導致某個鎖永遠都不會被釋放掉。因此需要在setnx
使用expires
緊跟其後設定超時。但是這樣帶來第二個問題是,這兩個操作並不是原子性的,可能在還沒執行expires
問題二:
setnx
和expire
操作不具有原子性。redis 2.8
後可以使用set key value ex 5 nx
來支援nx
和ex
是同一原子操作。
問題三:
如果獲取到鎖的A
執行太久導致鎖超時釋放,那麼當B
獲取到鎖後執行過程中,A
執行完了,再執行del key
的操作,那麼可能會將B
的鎖釋放掉。也就是說,釋放掉了不屬於自己的鎖。
可以通過在執行set
時,將自己節點的資訊寫入,例如set lock a_node ex 5 nx
。當釋放鎖時,先get lock
檢視值是不是自己的節點,如果是才能釋放。可是這樣又帶來第四個問題。
問題四:
判斷是否是自己的鎖和釋放鎖的操作不是原子性的了。
- 假設
A
獲取鎖成功,執行完畢後準備釋放鎖 - 首先執行
get
獲取key
的值,是自己的鎖,但是某些原因阻塞了 - 鎖超時了,
B
拿到了鎖 A
從阻塞中恢復,執行del
,又把B
的鎖釋放了
因此釋放鎖的操作必須使用LUA
指令碼實現。
問題五:
如果A
獲取了鎖,但是執行時間很長,鎖提前釋放了,那麼A
接下來對資源操作的安全性將得不到保證。這個可以通過一個守護程序,發現要超時了就延長一下超時時間來解決。
問題六:
如果redis
宕機了,所有客戶端都無法獲得鎖。因此通常會使用Master-slave
機制,但是主從複製是非同步的。因此可能會出現:
A
從master
獲取了鎖master
掛了,key
還沒同步到slave
上slave
升級為master
B
從新的master
獲取到了鎖
於是問題六是針對多redis
節點的,只能使用Redlock
來解決。
Redlock
的大概原理:
- 獲取當前時間
- 依次向
N
個節點執行獲取鎖操作,獲取鎖操作存在超時時間。如果獲取某個節點鎖失敗,應該立即嘗試下一個節點 - 計算遍歷完
N
個節點獲取鎖總共消耗的時間,只有從大於等於N/2+1
個節點中成功獲取到了鎖,並且此時鎖仍然沒有到達超時時間,才認為鎖獲取成功 - 如果最終獲取鎖成功,鎖的有效時間等於最初有效時間減去取鎖成功所所耗的時間
- 如果獲取鎖失敗了,應該向所有節點釋放鎖操作(同單節點相同,使用
LUA
指令碼)
為了避免redis
伺服器短暫失效重新上線導致前一個節點的鎖沒有持久化卻又被下一個節點所獲得,還引入了延遲重啟。即redis
失效後,至少要等到key
過期了,再重啟。
為了防止實際上獲得了鎖,但是卻沒收到redis
的ack
而認為獲得鎖失敗,在釋放時應當對所有節點進行釋放鎖。
由於存在N
個節點,只要能從大部分節點中獲取鎖,就可以視為獲取鎖成功,因此故障轉移時發生的鎖失效問題不存在了。但是程式執行時間過長導致鎖過期的問題仍然沒有解決。
基於ZooKeeper
實現分散式鎖
ZooKeeper
實現分散式鎖的步驟如下:
- 建立一個目錄
mylock
- 執行緒
A
想獲取鎖就在mylock
目錄下建立臨時順序節點; - 獲取
mylock
目錄下所有的子節點,然後獲取比自己編號小的節點,如果不存在,則說明當前執行緒順序號最小,獲得鎖; - 否則監聽比自己編號小1的節點,等待其釋放鎖。
因此基於zk
的分散式鎖,不用考慮超時時間。因為一旦節點發生宕機(心跳檢測),伺服器會自動刪除該節點釋放鎖。同時,使用ZooKeeper
也可以有效的解決不可重入的問題,客戶端在建立節點的時候,把當前客戶端的主機資訊和執行緒資訊直接寫入到節點中,下次想要獲取鎖的時候和當前最小的節點中的資料比對一下就可以了。如果和自己的資訊一樣,那麼自己直接獲取到鎖,如果不一樣就再建立一個臨時的順序節點,參與排隊。
References
: