分佈式系列之分佈式鎖

分佈式鎖

在單機多線程環境下,訪問共享資源需要保證操作的原子性,而鎖機制能提供原子性。在分佈式系統下同樣需要鎖,這就是分佈式鎖。與前者不同的是,我們需要一個分佈式的鎖服務。對於分佈式所服務的實現,一般可以用關係型數據庫、Redis 和 ZooKeeper 等實現。以下三個屬性是有效使用分佈式鎖所需的最低保證:

  • 互斥:在任何時候,只有一個客戶端能獲得鎖。
  • 無死鎖:客戶端始終可以獲得鎖。
  • 容錯能力:只要大多數Redis節點都處於運行狀態,客戶端就可以獲取和釋放鎖。

Redis的分佈式鎖服務

單實例正確示範

獲取鎖

SET命令

要獲取鎖,必須遵循以下方法:

SET resource_name my_random_value NX PX 30000								◠‿◠

該SET命令具有原子性,各項參數含義如下:

  • resource_name:key。
  • my_random_value: 必須全局唯一,這個隨機數在釋放鎖時保證釋放鎖操作的安全性。
  • NX:只會在 key 不存在的時候給 key 賦值。
  • PX :通知 Redis 保存這個 key 30000ms。

釋放鎖

DEL命令

釋放鎖,就是把對應的key刪掉,可能會使用DEL,來看看DEL會存在什麼問題:

DEL key																		︶︵︶

在這裏插入圖片描述

問題主要爲:一個客戶端可能會刪除另一個客戶端的鎖。

  1. 客戶端A獲得鎖K,由於被阻塞的時間超過了該鎖的有效時間,Redis自動刪除過期的鎖。
  2. 客戶端B獲得鎖K,在執行任務的過程中,由於客戶端A任務執行完成,刪除鎖。
  3. 在客戶端B未退出之前,客戶端C請求鎖K,請求成功。

相當於兩個線程同時擁有同一個鎖,沒有到達鎖的互斥要求,所以不推薦用del命令刪除鎖。

Lua腳本

基本上,使用隨機值是爲了以安全的方式釋放鎖,並且腳本會告訴Redis:僅當key存在且存儲在key上的值恰好是我期望的值時,才刪除該密鑰。這是通過以下Lua腳本完成的:

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

使用上述腳本時,每個鎖都由一個隨機字符串“簽名”,因此僅當該鎖仍是持有該鎖的客戶端嘗試將其刪除的設置時,該鎖纔會被刪除。

Jedis實現

Jedis使用阻塞的I/O和redis交互,基於上面獲得鎖和釋放鎖的Redis操作提供Jedis實現代碼如下。

																			¯¯__¯¯
//加鎖和過期自動釋放鎖														  
while (!"OK".equals(jedis.set("resource_name", "my_random_value", "NX", "PX", 30000))){
    try {
        //休眠一秒再次嘗試獲取鎖
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        logger.error("#####exception=[{}]", e.getMessage());
    }
}
//執行到這裏證明已經獲得鎖
//do something
//執行完後要釋放鎖
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', 		KEYS[1]) else return 0 end";
//返回刪除key的個數
Object result = jedis.eval(script, Collections.singletonList("resource_name"), 				Collections.singletonList("my_random_value"));

Jedis實現有以下不足:

  • 無法支持重入

  • 如果需要續期,需要增加額外的代碼量

Redisson實現(推薦)

Redission通過Netty支持非阻塞I/O,封裝了鎖的實現,RLock繼承了java.util.concurrent.locks.Lock的接口,讓我們像操作我們的本地Lock一樣去操作Redission的Lock,基於Redission的實現代碼如下:

設置單實例:

config.useSingleServer().setAddress("redis://"120.0.0.1:6379");

代碼實現:

RLock lock = redissonClient.getLock("resource_name");						◠‿◠
//------------------------lock---------------------------------
//① 阻塞
// 獲取鎖並在10秒後自動解鎖
lock.lock(10, TimeUnit.SECONDS);
lock.unlock();
//------------------------lock---------------------------------
//------------------------tryLock------------------------------
//② 阻塞100s後放棄請求鎖
// 等待鎖的獲取時間長達100秒,獲取鎖並在10秒後自動解鎖
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
   try {
     //do something
   } finally {
       lock.unlock();
   }
}
//------------------------tryLock------------------------------

Redisson能夠滿足Jedis上述的不足,而且簡化了代碼量。【注】:lock和tryLock一般根據情況選擇一個使用即可。

重入原理

定義:如果一個線程試圖獲取一個已經由它自己持有的鎖,那個這個請求會成功。

redis中的數據結構:

在這裏插入圖片描述

Redisson使用hash結構存儲鎖的信息,其中hashkey爲UUID(Redisson初始化生成的全局ID)+當前線程ID,hashvalue就是上鎖次數。如果當前線程再次請求鎖時(hashkey的值一樣),hashvalue的值加1。

上鎖邏輯:

在這裏插入圖片描述

續期原理

爲什麼需要續期?

看看下圖這種情況:

在這裏插入圖片描述

在上圖描述的情況中,當客戶端A被阻塞時長超過鎖的有效時間,客戶端B獲得鎖,並更新了資源後退出,但此時客戶端A的阻塞條件被打破,把客戶端B更新的資源覆蓋掉了,造成了數據出錯。只是由於客戶端A持有鎖的有效時間過期造成的,這就需要給鎖續期。

原理:持有鎖的客戶端Redisson實例處於活躍狀態時延長鎖的到期時間,當其崩潰的時候也能防止持有的鎖一直保持,有自動釋放機制。默認情況下,lockWatchdog超時爲30秒,可以通過Config.lockWatchdogTimeout設置進行更改(分佈式鎖的超時時間爲30秒)。

默認情況下,鎖的有效期爲30秒,如果業務再行到 internalLockLeaseTime/3 ,即10秒的時候還沒執行完,就會進行一次續期,重新設置鎖的有效期爲30秒。當遇到宕機時,續期任務無法運行,30秒後鎖自動釋放。

小結

以上介紹的都是同步阻塞式的可重入鎖實現,關閉異步實現和除了可重入鎖以外的其他鎖實現,比如Fair Lock、ReadWriteLock、Semaphore、CountDownLatch等,可參考Redisson官方文檔的第8章。

到此,單實例的分佈式鎖基本功能已實現,前面提到的分佈式鎖所需的最低保證均已滿足,但在線上環境Redis大多數爲集羣模式,那單實例的實現已無法滿足互斥和容錯能力,這時就需要引入RedLock算法。

集羣模式正確示範

基於故障轉移的實現

主從、哨兵等實現的是基於故障轉移的,爲什麼不適用基於故障轉移的實現呢?

當Master宕機後,slaver選舉爲Master之前,無法保證原Master上的鎖已同步到slaver上,因爲Redis複製是異步的

在這裏插入圖片描述

此模型存在明顯的競爭條件:

  1. 客戶端A獲取主服務器中的鎖。
  2. 在將密鑰複製到Slaver之前,Master宕機。
  3. Slaver晉升爲Master。
  4. 客戶端B獲取對相同資源K的鎖定,而該資源K同時被客戶端A鎖定。安全違規!

RedLock(官方推薦)

爲了獲取鎖,客戶端執行以下操作:

  1. 它以毫秒爲單位獲取當前時間。

  2. 它嘗試在所有N個實例中順序使用所有實例中相同的鍵名和隨機值來獲取鎖定。在第2步中,在每個實例中設置鎖定時,客戶端使用的超時時間小於總鎖定自動釋放時間,以便獲取該超時時間。例如,如果自動釋放時間爲10秒,則超時時間可能在5到50毫秒之間。這樣可以防止客戶端長時間與處於故障狀態的Redis節點通信時保持阻塞:如果一個實例不可用,我們應該嘗試與下一個實例儘快通信。

  3. 客戶端通過從當前時間中減去在步驟1中獲得的時間戳,來計算獲取鎖所花費的時間。當且僅當客戶端能夠在大多數實例中獲取鎖時(N/2+1), 並且獲取鎖所花費的總時間小於鎖有效時間,則認爲已獲取鎖

  4. 如果獲取了鎖,則其有效時間被視爲初始有效時間減去在步驟3中計算的經過的時間。

  5. 如果客戶端由於某種原因無法獲取鎖(無法鎖定N/2+1實例或有效期爲負),它將嘗試解鎖所有實例(甚至是它認爲無法鎖定的實例)。

    // 源碼 failedLocksLimit:允許失敗的客戶端數量
    protected int failedLocksLimit() {
        return this.locks.size() - this.minLocksAmount(this.locks);
    }
    
    protected int minLocksAmount(List<RLock> locks) {
        return locks.size() / 2 + 1;
    }
    

代碼實現:

RLock lock1 = redissonClient1.getLock("resource_name");						◠‿◠
RLock lock2 = redissonClient2.getLock("resource_name");
RLock lock3 = redissonClient3.getLock("resource_name");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
//------------------------lock---------------------------------
//① 阻塞
// 獲取鎖並在10秒後自動解鎖
redLock.lock(10, TimeUnit.SECONDS);
redLock.unlock();
//------------------------lock---------------------------------

//------------------------tryLock---------------------------------
//② 阻塞100s後放棄請求鎖
// 等待鎖的獲取時間長達100秒,獲取鎖並在10秒後自動解鎖
try {
    redLock.tryLock(100, 10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
    e.printStackTrace();
} finally {
    redLock.unlock();
}
//------------------------tryLock---------------------------------

【注】:和單實例一樣,lock和tryLock一般根據情況選擇一個使用即可。

至此,已給出RedLock的實現,如果需要了解更多信息,可參考Redis官方算法Redlock

MySQL的分佈式鎖服務

悲觀鎖

在MySQL中新建一張鎖表,加鎖時往數據庫中添加一條記錄,釋放時刪除這條數據。

  • 互斥?

    一個字段,鎖標識,唯一性約束。

  • 超時釋放?

    一個字段,記錄鎖的過期時間,定時任務過期清理。

  • 重入?

    一個字段,記錄當前線程的ID;

  • 非阻塞?

    while循環,直到成功爲止,或者等待超時返回。

我們可以得到如下一張表:

CREATE TABLE `lock_tbale` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `lock_flag` varchar(255) NOT NULL COMMENT '鎖標識',
  `expire_time` datetime DEFAULT NULL COMMENT '過期時間',
  `thread_id` varchar(255) NOT NULL COMMENT '線程id',
  PRIMARY KEY (`id`),
  UNIQUE KEY `lock_flag` (`lock_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

加鎖的SQL語句:

INSERT INTO lock_tbale(lock_flag,expire_time,thread_id) values
('lock_flag','2020-02-04 12:00:00','123456789');

釋放鎖的SQL語句:

DELETE FROM lock_tbale WHERE lock_flag = 'lock_flag';

重入的SQL語句:

SELECT * FROM lock_tbale WHERE thread_id = '123456789' AND lock_flag = 'lock_flag';

查詢到有數據時表示可重入,沒數據時在加鎖。

悲觀鎖有着明顯的缺點,悲觀鎖的實現依賴於數據庫提供的唯一性約束來保存互斥,依賴於代碼邏輯來做阻塞(while循環),當有大量流量請求同一個鎖時,這些流量全部落在MySQL上,這無疑是毀滅性的災難,所以悲觀鎖的實現不太適合高併發場景。

樂觀鎖

樂觀鎖的實現方式是在原來表的基礎上添加一個版本號,更新數據前,先把這個版本號讀出來,更新數據時加上這個版本號條件,只有當數據庫裏的版本號等於查詢出來的版本號時,更新才成功,更新成功後加1,更新語句大概可以寫成下面這樣:

UPDATE table_name SET xxx = #{xxx},version=version+1 where version =#{version};

樂觀鎖也有着一些不知之處:

  • 資源表要添加一個version字段;
  • 如果一個操作涉及多張表,則每張表的版本號都得查一次;

小結

不論是悲觀鎖還是樂觀鎖,用MySQL來實現分佈式鎖都是不推薦的,其性能,可靠性,以及實現的複雜度上都沒有良好的表現,所以,MySQL的實現只能作爲一種備選。

未完待續…

GitHub:https://github.com/pikaxiao/SpringFamily

參考

  1. Redis官方文檔.
  2. Redisson官方文檔.
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章