對於Java中的鎖大家肯定都很熟悉,在Java中synchronized關鍵字和ReentrantLock可重入鎖在我們的代碼中是經常見的,一般我們用其在多線程環境中控制對資源的並發訪問,但是隨着分布式的快速發展,本地的加鎖往往不能滿足我們的需要,在我們的分布式環境中上面加鎖的方法就會失去作用。為了在分布式環境中也能實現本地鎖的效果,人們提出了分布式鎖的概念。
分布式鎖
分布式鎖場景
一般需要使用分布式鎖的場景如下:
- 效率:使用分布式鎖可以避免不同節點重復相同的工作,比如避免重復執行定時任務等;
- 正確性:使用分布式鎖同樣可以避免破壞數據正確性,如果兩個節點在同一條數據上面操作,可能會出現並發問題。
分布式鎖特點
一個完善的分布式鎖需要滿足以下特點:
- 互斥性:互斥是所得基本特性,分布式鎖需要按需求保證線程或節點級別的互斥。;
- 可重入性:同一個節點或同一個線程獲取鎖,可以再次重入獲取這個鎖;
- 鎖超時:支持鎖超時釋放,防止某個節點不可用后,持有的鎖無法釋放;
- 高效性:加鎖和解鎖的效率高,可以支持高並發;
- 高可用:需要有高可用機制預防鎖服務不可用的情況,如增加降級;
- 阻塞性:支持阻塞獲取鎖和非阻塞獲取鎖兩種方式;
- 公平性:支持公平鎖和非公平鎖兩種類型的鎖,公平鎖可以保證安裝請求鎖的順序獲取鎖,而非公平鎖不可以。
分布式鎖的實現
分布式鎖常見的實現有三種實現,下文我們會一一介紹這三種鎖的實現方式:
- 基於數據庫的分布式鎖;
- 基於Redis的分布式鎖;
- 基於Zookeeper的分布式鎖。
基於數據庫的分布式鎖
基於數據庫的分布式鎖可以有不同的實現方式,本文會介紹作者在實際生產中使用的一種數據庫非阻塞分布式鎖的實現方案。
方案概覽
我們上面列舉出了分布式鎖需要滿足的特點,使用數據庫實現分布式鎖也需要滿足這些特點,下面我們來一一介紹實現方法:
- 互斥性:通過數據庫update的原子性達到兩次獲取鎖之間的互斥性;
- 可重入性:在數據庫中保留一個字段存儲當前鎖的持有者;
- 鎖超時:在數據庫中存儲鎖的獲取時間點和超時時長;
- 高效性:數據庫本身可以支持比較高的並發;
- 高可用:可以增加主從數據庫邏輯,提升數據庫的可用性;
- 阻塞性:可以通過看門狗輪詢的方式實現線程的阻塞;
- 公平性:可以添加鎖隊列,不過不建議,實現起來比較復雜。
表結構設計
數據庫的表名為lock,各個字段的定義如下所示:
字段名名稱 | 字段類型 | 說明 |
---|---|---|
lock_key | varchar | 鎖的唯一標識符號 |
lock_time | timestample | 加鎖的時間 |
lock_duration | integer | 鎖的超時時長,單位可以業務自定義,通常為秒 |
lock_owner | varchar | 鎖的持有者,可以是節點或線程的唯一標識,不同可重入粒度的鎖有不同的含義 |
locked | boolean | 當前鎖是否被占有 |
獲取鎖的SQL語句
獲取鎖的SQL語句分不同的情況,如果鎖不存在,那么首先需要創建鎖,並且創建鎖的線程可以獲取鎖:
insert into lock(lock_key,lock_time,lock_duration,lock_owner,locked) values ('xxx',now(),1000,'ownerxxx',true)
如果鎖已經存在,那么就嘗試更新鎖的信息,如果更新成功則表示獲取鎖成功,更新失敗則表示獲取鎖失敗。
update lock set
locked = true,
lock_owner = 'ownerxxxx',
lock_time = now(),
lock_duration = 1000
where
lock_key='xxx' and(
lock_owner = 'ownerxxxx' or
locked = false or
date_add(lock_time, interval lock_duration second) > now())
釋放鎖的SQL語句
當用戶使用完鎖需要釋放的時候,可以直接更新locked標識位為false。
update lock set
locked = false,
where
lock_key='xxx' and
lock_owner = 'ownerxxxx' and
locked = true
看門狗
通過上面的步驟,我們可以實現獲取鎖和釋放鎖,那么看門狗又是做什么的呢?
大家想象一下,如果用戶獲取鎖到釋放鎖之間的時間大於鎖的超時時間,是不是會有問題?是不是可能會出現多個節點同時獲取鎖的情況?這個時候就需要看門狗了,看門狗可以通過定時任務不斷刷新鎖的獲取事件,從而在用戶獲取鎖到釋放鎖期間保持一直持有鎖。
基於Redis的分布式鎖
Redis的Java客戶端Redisson實現了分布式鎖,我們可以通過類似ReentrantLock的加鎖-釋放鎖的邏輯來實現分布式鎖。
RLock disLock = redissonClient.getLock("DISLOCK");
disLock.lock();
try {
// 業務邏輯
} finally {
// 無論如何, 最后都要解鎖
disLock.unlock();
}
Redisson分布式鎖的底層原理
如下圖為Redisson客戶端加鎖和釋放鎖的邏輯:
加鎖機制
從上圖中可以看出來,Redisson客戶端需要獲取鎖的時候,要發送一段Lua腳本到Redis集群執行,為什么要用Lua腳本呢?因為一段復雜的業務邏輯,可以通過封裝在Lua腳本中發送給Redis,保證這段復雜業務邏輯執行的原子性。
Lua源碼分析:如下為Redisson加鎖的lua源碼,接下來我們會對源碼進行分析。
源碼入參:Lua腳本有三個輸入參數:KEYS[1]、ARGV[1]和ARGV[2],含義如下:
- KEYS[1]代表的是加鎖的Key,例如RLock lock = redisson.getLock("myLock")中的“myLock”;
- ARGV[1]代表的就是鎖Key的默認生存時間,默認30秒;
- ARGV[2]代表的是加鎖的客戶端的ID,類似於下面這樣的:8743c9c0-0795-4907-87fd-6c719a6b4586:1。
Lua腳本及加鎖步驟如下代碼塊所示,可以看出其大致原理為:
- 鎖不存在的時候,創建鎖並設置過期時間;
- 鎖存在的時候,如果是重入場景則刷新鎖的過期事件;
- 否則返回加鎖失敗和鎖的過期時間。
-- 判斷鎖是不是存在
if (redis.call('exists', KEYS[1]) == 0) then
-- 添加鎖,並且設置客戶端和初始鎖重入次數
redis.call('hincrby', KEYS[1], ARGV[2], 1);
-- 設置鎖的超時事件
redis.call('pexpire', KEYS[1], ARGV[1]);
-- 返回加鎖成功
return nil;
end;
-- 判斷當前鎖的持有者是不是請求鎖的請求者
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 當前鎖被請求者持有,重入鎖,增加鎖重入次數
redis.call('hincrby', KEYS[1], ARGV[2], 1);
-- 刷新鎖的過期時間
redis.call('pexpire', KEYS[1], ARGV[1]);
-- 返回加鎖成功
return nil;
end;
-- 返回當前鎖的過期時間
return redis.call('pttl', KEYS[1]);
看門狗邏輯
客戶端1加鎖的鎖Key默認生存時間才30秒,如果超過了30秒,客戶端1還想一直持有這把鎖,怎么辦呢?只要客戶端1加鎖成功,就會啟動一個watchdog看門狗,這個后台線程,會每隔10秒檢查一下,如果客戶端1還持有鎖Key,就會不斷的延長鎖Key的生存時間。
釋放鎖機制
如果執行lock.unlock(),就可以釋放分布式鎖,此時的業務邏輯也是非常簡單的。就是每次都對myLock數據結構中的那個加鎖次數減1。
如果發現加鎖次數是0了,說明這個客戶端已經不再持有鎖了,此時就會用:“del myLock”命令,從Redis里刪除這個Key。
而另外的客戶端2就可以嘗試完成加鎖了。這就是所謂的分布式鎖的開源Redisson框架的實現機制。
一般我們在生產系統中,可以用Redisson框架提供的這個類庫來基於Redis進行分布式鎖的加鎖與釋放鎖。
Redisson分布式鎖的缺陷
Redis分布式鎖會有個缺陷,就是在Redis哨兵模式下:
- 客戶端1對某個master節點寫入了redisson鎖,此時會異步復制給對應的slave節點。但是這個過程中一旦發生master節點宕機,主備切換,slave節點從變為了master節點。
- 客戶端2來嘗試加鎖的時候,在新的master節點上也能加鎖,此時就會導致多個客戶端對同一個分布式鎖完成了加鎖。
- 系統在業務語義上一定會出現問題,導致各種臟數據的產生。
這個缺陷導致在哨兵模式或者主從模式下,如果master實例宕機的時候,可能導致多個客戶端同時完成加鎖。
基於Zookeeper的分布式鎖
Zookeeper實現的分布式鎖適用於引入Zookeeper的服務,如下所示,有兩個服務注冊到Zookeeper,並且都需要獲取Zookeeper上的分布式鎖,流程式什么樣的呢?
步驟1
假設客戶端A搶先一步,對ZK發起了加分布式鎖的請求,這個加鎖請求是用到了ZK中的一個特殊的概念,叫做“臨時順序節點”。簡單來說,就是直接在"my_lock"這個鎖節點下,創建一個順序節點,這個順序節點有ZK內部自行維護的一個節點序號。
- 比如第一個客戶端來獲取一個順序節點,ZK內部會生成名稱xxx-000001。
- 然后第二個客戶端來獲取一個順序節點,ZK內部會生成名稱xxx-000002。
最后一個數字都是依次遞增的,從1開始逐次遞增。ZK會維護這個順序。所以客戶端A先發起請求,就會生成出來一個順序節點,如下所示:
客戶端A發起了加鎖請求,會先加鎖的node下生成一個臨時順序節點。因為客戶端A是第一個發起請求,所以節點名稱的最后一個數字是"1"。客戶端A創建完好順序節后,會查詢鎖下面所有的節點,按照末尾數字升序排序,判斷當前節點的是不是第一個節點,如果是第一個節點則加鎖成功。
步驟2
客戶端A都加完鎖了,客戶端B過來想要加鎖了,此時也會在鎖節點下創建一個臨時順序節點,節點名稱的最后一個數字是"2"。
客戶端B會判斷加鎖邏輯,查詢鎖節點下的所有子節點,按序號順序排列,此時第一個是客戶端A創建的那個順序節點,序號為"01"的那個。所以加鎖失敗。加鎖失敗了以后,客戶端B就會通過ZK的API對他的順序節點的上一個順序節點加一個監聽器。ZK天然就可以實現對某個節點的監聽。
步驟3
客戶端A加鎖之后,可能處理了一些代碼邏輯,然后就會釋放鎖。Zookeeper釋放鎖其實就是把客戶端A創建的順序節點zk_random_000001
刪除。
刪除客戶端A的節點之后,Zookeeper會負責通知監聽這個節點的監聽器,也就是客戶端B之前添加監聽器。客戶端B的監聽器知道了上一個順序節點被刪除,也就是排在他之前的某個客戶端釋放了鎖。此時,就會客戶端B會重新嘗試去獲取鎖,也就是獲取鎖節點下的子節點集合,判斷自身是不是第一個節點,從而獲取鎖。
三種鎖的優缺點
基於數據庫的分布式鎖:
- 數據庫並發性能較差;
- 阻塞式鎖實現比較復雜;
- 公平鎖實現比較復雜。
基於Redis的分布式鎖:
- 主從切換的情況下可能出現多客戶端獲取鎖的情況;
- Lua腳本在單機上具有原子性,主從同步時不具有原子性。
基於Zookeeper的分布式鎖:
- 需要引入Zookeeper集群,比較重量級;
- 分布式鎖的可重入粒度只能是節點級別;
參考文檔
我是御狐神,歡迎大家關注我的微信公眾號:wzm2zsd
本文最先發布至微信公眾號,版權所有,禁止轉載!