首頁 > 軟體

SpringCloud 分散式鎖的多種實現

2022-04-11 19:01:36

前言

今天跟大家探討一下分散式鎖的設計與實現。

  • 分散式鎖概述
  • 資料庫分散式鎖
  • Redis分散式鎖
  • Zookeeper分散式鎖
  • 三種分散式鎖對比

1. 分散式鎖概述

我們的系統都是分散式部署的,日常開發中,秒殺下單、搶購商品等等業務場景,為了防⽌庫存超賣,都需要用到分散式鎖

分散式鎖其實就是,控制分散式系統不同程序共同存取共用資源的一種鎖的實現。如果不同的系統或同一個系統的不同主機之間共用了某個臨界資源,往往需要互斥來防止彼此干擾,以保證一致性。

業界流行的分散式鎖實現,一般有這3種方式:

  • 基於資料庫實現的分散式鎖
  • 基於Redis實現的分散式鎖
  • 基於Zookeeper實現的分散式鎖

2. 基於資料庫的分散式鎖

2.1 資料庫悲觀鎖實現的分散式鎖

可以使用select ... for update 來實現分散式鎖。我們自己的專案,分散式定時任務,就使用類似的實現方案,我給大家來展示個簡單版的哈

表結構如下:

CREATE TABLE `t_resource_lock` (
  `key_resource` varchar(45) COLLATE utf8_bin NOT NULL DEFAULT '資源主鍵',
  `status` char(1) COLLATE utf8_bin NOT NULL DEFAULT '' COMMENT 'S,F,P',
  `lock_flag` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '1是已經鎖 0是未鎖',
  `begin_time` datetime DEFAULT NULL COMMENT '開始時間',
  `end_time` datetime DEFAULT NULL COMMENT '結束時間',
  `client_ip` varchar(45) COLLATE utf8_bin NOT NULL DEFAULT '搶到鎖的IP',
  `time` int(10) unsigned NOT NULL DEFAULT '60' COMMENT '方法生命週期內只允許一個結點獲取一次鎖,單位:分鐘',
  PRIMARY KEY (`key_resource`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin

加鎖lock方法的虛擬碼如下:

@Transcational //一定要加事務
public boolean lock(String keyResource,int time){
   resourceLock = 'select * from t_resource_lock where key_resource ='#{keySource}' for update';
   
   try{
    if(resourceLock==null){
      //插入鎖的資料
      resourceLock = new ResourceLock();
      resourceLock.setTime(time);
      resourceLock.setLockFlag(1);  //上鎖
      resourceLock.setStatus(P); //處理中
      resourceLock.setBeginTime(new Date());
      int count = "insert into resourceLock"; 
      if(count==1){
         //獲取鎖成功
         return true;
      }
      return false;
   }
   }catch(Exception x){
      return false;
   }
   
   //沒上鎖並且鎖已經超時,即可以獲取鎖成功
   if(resourceLock.getLockFlag=='0'&&'S'.equals(resourceLock.getstatus)
    && new Date()>=resourceLock.addDateTime(resourceLock.getBeginTime(,time)){
      resourceLock.setLockFlag(1);  //上鎖
      resourceLock.setStatus(P); //處理中
      resourceLock.setBeginTime(new Date());
      //update resourceLock;
      return true;
   }else if(new Date()>=resourceLock.addDateTime(resourceLock.getBeginTime(,time)){
     //超時未正常執行結束,獲取鎖失敗
     return false;
   }else{
     return false;
   } 
}

解鎖unlock方法的虛擬碼如下:

public void unlock(String v,status){
      resourceLock.setLockFlag(0);  //解鎖
      resourceLock.setStatus(status); S:表示成功,F表示失敗
      //update resourceLock;
      return ;
}

整體流程:

try{
if(lock(keyResource,time)){ //加鎖
   status = process();//你的業務邏輯處理。
 }
} finally{
    unlock(keyResource,status); //釋放鎖
}

其實這個悲觀鎖實現的分散式鎖,整體的流程還是比較清晰的。就是先select ... for update 鎖住主鍵key_resource那個記錄,如果為空,則可以插入一條記錄,如果已有記錄判斷下狀態和時間是否已經超時。這裡需要注意一下哈,必須要加事務哈。

2.2 資料庫樂觀鎖實現的分散式鎖

除了悲觀鎖,還可以用樂觀鎖實現分散式鎖。樂觀鎖,顧名思義,就是很樂觀,每次更新操作,都覺得不會存在並行衝突,只有更新失敗後,才重試。它是基於CAS思想實現的。我以前的公司,扣減餘額就是用這種方案。

搞個version欄位,每次更新修改,都會自增加一,然後去更新餘額時,把查出來的那個版本號,帶上條件去更新,如果是上次那個版本號,就更新,如果不是,表示別人並行修改過了,就繼續重試。

大概流程如下:

查詢版本號和餘額

select version,balance from account where user_id ='666';

假設查到版本號是oldVersion=1.

邏輯處理,判斷餘額

if(balance<扣減金額){
   return;
}

left_balance = balance - 扣減金額;

進行扣減餘額

update account set balance = #{left_balance} ,version = version+1 where version 
= #{oldVersion} and balance>= #{left_balance} and user_id ='666';

大家可以看下這個流程圖哈:

這種方式適合並行不高的場景,一般需要設定一下重試的次數

3.基於Redis實現的分散式鎖

Redis分散式鎖一般有以下這幾種實現方式:

  • setnx + expire
  • setnx + value值是過期時間
  • set的擴充套件命令(set ex px nx)
  • set ex px nx + 校驗唯一隨機值,再刪除
  • Redisson
  • Redisson + RedLock

3.1 setnx + expire

聊到Redis分散式鎖,很多小夥伴反手就是setnx + expire,如下:

if(jedis.setnx(key,lock_value) == 1){ //setnx加鎖
    expire(key,100); //設定過期時間
    try {
        do something  //業務處理
    }catch(){
    }
  finally {
       jedis.del(key); //釋放鎖
    }
}

這段程式碼是可以加鎖成功,但是你有沒有發現問題,加鎖操作和設定超時時間是分開的。假設在執行完setnx加鎖後,正要執行expire設定過期時間時,程序crash掉或者要重啟維護了,那這個鎖就長生不老了,別的執行緒永遠獲取不到鎖啦,所以分散式鎖不能這麼實現

3.2 setnx + value值是過期時間

long expires = System.currentTimeMillis() + expireTime; //系統時間+設定的過期時間
String expiresStr = String.valueOf(expires);

// 如果當前鎖不存在,返回加鎖成功
if (jedis.setnx(key, expiresStr) == 1) {
        return true;
} 
// 如果鎖已經存在,獲取鎖的過期時間
String currentValueStr = jedis.get(key);

// 如果獲取到的過期時間,小於系統當前時間,表示已經過期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {

     // 鎖已過期,獲取上一個鎖的過期時間,並設定現在鎖的過期時間(不瞭解redis的getSet命令的小夥伴,可以去官網看下哈)
    String oldValueStr = jedis.getSet(key, expiresStr);
    
    if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
         // 考慮多執行緒並行的情況,只有一個執行緒的設定值和當前值相同,它才可以加鎖
         return true;
    }
}
        
//其他情況,均返回加鎖失敗
return false;
}

日常開發中,有些小夥伴就是這麼實現分散式鎖的,但是會有這些缺點

  • 過期時間是使用者端自己生成的,分散式環境下,每個使用者端的時間必須同步。
  • 沒有儲存持有者的唯一標識,可能被別的使用者端釋放/解鎖
  • 鎖過期的時候,並行多個使用者端同時請求過來,都執行了jedis.getSet(),最終只能有一個使用者端加鎖成功,但是該使用者端鎖的過期時間,可能被別的使用者端覆蓋。

3.3 set的擴充套件命令(set ex px nx)

這個命令的幾個引數分別表示什麼意思呢?跟大家複習一下:

SET key value [EX seconds] [PX milliseconds] [NX|XX]
  • EX second :設定鍵的過期時間為second秒。
  • PX millisecond :設定鍵的過期時間為millisecond毫秒。
  • NX :只在鍵不存在時,才對鍵進行設定操作。
  • XX :只在鍵已經存在時,才對鍵進行設定操作。
if(jedis.set(key, lock_value, "NX", "EX", 100s) == 1){ //加鎖
    try {
        do something  //業務處理
    }catch(){
  }
  finally {
       jedis.del(key); //釋放鎖
    }
}

這個方案可能存在這樣的問題:

  • 鎖過期釋放了,業務還沒執行完。
  • 鎖被別的執行緒誤刪。

有些夥伴可能會有個疑問,就是鎖為什麼會被別的執行緒誤刪呢?假設並行多執行緒場景下,執行緒A獲得了鎖,但是它沒釋放鎖的話,執行緒B是獲取不到鎖的,所以按道理它是執行不到加鎖下面的程式碼滴,怎麼會導致鎖被別的執行緒誤刪呢?

假設執行緒A和B,都想用key加鎖,最後A搶到鎖加鎖成功,但是由於執行業務邏輯的耗時很長,超過了設定的超時時間100s。這時候,Redis就自動釋放了key鎖。這時候執行緒B就可以加鎖成功了,接下啦,它也執行業務邏輯處理。假設碰巧這時候,A執行完自己的業務邏輯,它就去釋放鎖,但是它就把B的鎖給釋放了。

3.4 set ex px nx + 校驗唯一隨機值,再刪除

為了解決鎖被別的執行緒誤刪問題。可以在set ex px nx的基礎上,加上個校驗的唯一隨機值,如下:

if(jedis.set(key, uni_request_id, "NX", "EX", 100s) == 1){ //加鎖
    try {
        do something  //業務處理
    }catch(){
  }
  finally {
       //判斷是不是當前執行緒加的鎖,是才釋放
       if (uni_request_id.equals(jedis.get(key))) {
          jedis.del(key); //釋放鎖
        }
    }
}

在這裡,判斷當前執行緒加的鎖和釋放鎖不是一個原子操作。如果呼叫jedis.del()釋放鎖的時候,可能這把鎖已經不屬於當前使用者端,會解除他人加的鎖。

一般可以用lua指令碼來包一下。lua指令碼如下:

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

這種方式比較不錯了,一般情況下,已經可以使用這種實現方式。但是還是存在:鎖過期釋放了,業務還沒執行完的問題

3.5 Redisson

對於可能存在鎖過期釋放,業務沒執行完的問題。我們可以稍微把鎖過期時間設定長一些,大於正常業務處理時間就好啦。如果你覺得不是很穩,還可以給獲得鎖的執行緒,開啟一個定時守護執行緒,每隔一段時間檢查鎖是否還存在,存在則對鎖的過期時間延長,防止鎖過期提前釋放。

當前開源框架Redisson解決了這個問題。可以看下Redisson底層原理圖:

只要執行緒一加鎖成功,就會啟動一個watch dog看門狗,它是一個後臺執行緒,會每隔10秒檢查一下,如果執行緒1還持有鎖,那麼就會不斷的延長鎖key的生存時間。因此,Redisson就是使用watch dog解決了鎖過期釋放,業務沒執行完問題

3.6 Redisson + RedLock

前面六種方案都只是基於Redis單機版的分散式鎖討論,還不是很完美。因為Redis一般都是叢集部署的:

如果執行緒一在Redismaster節點上拿到了鎖,但是加鎖的key還沒同步到slave節點。恰好這時,master節點發生故障,一個slave節點就會升級為master節點。執行緒二就可以順理成章獲取同個key的鎖啦,但執行緒一也已經拿到鎖了,鎖的安全性就沒了。

為了解決這個問題,Redis作者antirez提出一種高階的分散式鎖演演算法:Redlock。它的核心思想是這樣的:

部署多個Redis master,以保證它們不會同時宕掉。並且這些master節點是完全相互獨立的,相互之間不存在資料同步。同時,需要確保在這多個master範例上,是與在Redis單範例,使用相同方法來獲取和釋放鎖。

我們假設當前有5個Redis master節點,在5臺伺服器上面執行這些Redis範例。

RedLock的實現步驟:

  • 獲取當前時間,以毫秒為單位。
  • 按順序向5個master節點請求加鎖。使用者端設定網路連線和響應超時時間,並且超時時間要小於鎖的失效時間。(假設鎖自動失效時間為10秒,則超時時間一般在5-50毫秒之間,我們就假設超時時間是50ms吧)。如果超時,跳過該master節點,儘快去嘗試下一個master節點。
  • 使用者端使用當前時間減去開始獲取鎖時間(即步驟1記錄的時間),得到獲取鎖使用的時間。當且僅當超過一半(N/2+1,這裡是5/2+1=3個節點)的Redis master節點都獲得鎖,並且使用的時間小於鎖失效時間時,鎖才算獲取成功。(如上圖,10s> 30ms+40ms+50ms+4m0s+50ms)
  • 如果取到了鎖,key的真正有效時間就變啦,需要減去獲取鎖所使用的時間。
  • 如果獲取鎖失敗(沒有在至少N/2+1個master範例取到鎖,有或者獲取鎖時間已經超過了有效時間),使用者端要在所有的master節點上解鎖(即便有些master節點根本就沒有加鎖成功,也需要解鎖,以防止有些漏網之魚)。

簡化下步驟就是:

  • 按順序向5個master節點請求加鎖
  • 根據設定的超時時間來判斷,是不是要跳過該master節點。
  • 如果大於等於3個節點加鎖成功,並且使用的時間小於鎖的有效期,即可認定加鎖成功啦。
  • 如果獲取鎖失敗,解鎖!

Redisson實現了redLock版本的鎖,有興趣的小夥伴,可以去了解一下哈~

4. Zookeeper分散式鎖

在學習Zookeeper分散式鎖之前,我們複習一下Zookeeper的節點哈。

Zookeeper的節點Znode有四種型別:

  • 持久節點:預設的節點型別。建立節點的使用者端與zookeeper斷開連線後,該節點依舊存在。
  • 持久節點順序節點:所謂順序節點,就是在建立節點時,Zookeeper根據建立的時間順序給該節點名稱進行編號,持久節點順序節點就是有順序的持久節點。
  • 臨時節點:和持久節點相反,當建立節點的使用者端與zookeeper斷開連線後,臨時節點會被刪除。
  • 臨時順序節點:有順序的臨時節點。

Zookeeper分散式鎖實現應用了臨時順序節點。這裡不貼程式碼啦,來講下zk分散式鎖的實現原理吧。

4.1 zk獲取鎖過程

當第一個使用者端請求過來時,Zookeeper使用者端會建立一個持久節點locks。如果它(Client1)想獲得鎖,需要在locks節點下建立一個順序節點lock1.如圖

接著,使用者端Client1會查詢locks下面的所有臨時順序子節點,判斷自己的節點lock1是不是排序最小的那一個,如果是,則成功獲得鎖。

這時候如果又來一個使用者端client2前來嘗試獲得鎖,它會在locks下再建立一個臨時節點lock2

使用者端client2一樣也會查詢locks下面的所有臨時順序子節點,判斷自己的節點lock2是不是最小的,此時,發現lock1才是最小的,於是獲取鎖失敗。獲取鎖失敗,它是不會甘心的,client2向它排序靠前的節點lock1註冊Watcher事件,用來監聽lock1是否存在,也就是說client2搶鎖失敗進入等待狀態。

此時,如果再來一個使用者端Client3來嘗試獲取鎖,它會在locks下再建立一個臨時節點lock3

同樣的,client3一樣也會查詢locks下面的所有臨時順序子節點,判斷自己的節點lock3是不是最小的,發現自己不是最小的,就獲取鎖失敗。它也是不會甘心的,它會向在它前面的節點lock2註冊Watcher事件,以監聽lock2節點是否存在。

4.2 釋放鎖

我們再來看看釋放鎖的流程,Zookeeper的使用者端業務完成或者發生故障,都會刪除臨時節點,釋放鎖。如果是任務完成,Client1會顯式呼叫刪除lock1的指令

如果是使用者端故障了,根據臨時節點得特性,lock1是會自動刪除的

lock1節點被刪除後,Client2可開心了,因為它一直監聽著lock1。lock1節點刪除,Client2立刻收到通知,也會查詢locks下面的所有臨時順序子節點,發下lock2是最小,就獲得鎖。

同理,Client2獲得鎖之後,Client3也對它虎視眈眈,啊哈哈~

  • Zookeeper設計定位就是分散式協調,簡單易用。如果獲取不到鎖,只需新增一個監聽器即可,很適合做分散式鎖。
  • Zookeeper作為分散式鎖也缺點:如果有很多的使用者端頻繁的申請加鎖、釋放鎖,對於Zookeeper叢集的壓力會比較大。

5. 三種分散式鎖對比

5.1 資料庫分散式鎖實現

優點:

簡單,使用方便,不需要引入Redis、zookeeper等中介軟體。

缺點:

  • 不適合高並行的場景
  • db操作效能較差;

5.2 Redis分散式鎖實現

優點:

  • 效能好,適合高並行場景
  • 較輕量級
  • 有較好的框架支援,如Redisson

缺點:

  • 過期時間不好控制
  • 需要考慮鎖被別的執行緒誤刪場景

5.3 Zookeeper分散式鎖實現

缺點:

  • 效能不如redis實現的分散式鎖
  • 比較重的分散式鎖。

優點:

  • 有較好的效能和可靠性
  • 有封裝較好的框架,如Curator

5.4 對比彙總

  • 從效能角度(從高到低)Redis > Zookeeper >= 資料庫;
  • 從理解的難易程度角度(從低到高)資料庫 > Redis > Zookeeper;
  • 從實現的複雜性角度(從低到高)Zookeeper > Redis > 資料庫;
  • 從可靠性角度(從高到低)Zookeeper > Redis > 資料庫。

到此這篇關於SpringCloud 分散式鎖的多種實現的文章就介紹到這了,更多相關SpringCloud 分散式鎖 內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com