天天看點

分布式鎖的多種實作方式詳解

作者:财高八鬥Java
分布式鎖的多種實作方式詳解

前言

對多線程有所了解的朋友一般都會熟悉一個概念:鎖。

在多線程并發場景下,要保證在同一時刻隻有一個線程可以操作某個業務、資料或者變量,通常需要使用加鎖機制。比如synchronized或Lock等。

而随着架構演進、業務發展,我們的應用往往都不是隻部署在一台伺服器上,而是使用分布式叢集架構,同時存在多台相同的應用。

比如某電商網站在進行商品銷售時,因為商品的數量是有限的,每個使用者購買一件商品,需要将商品的庫存減1;

但是我們想一下,在“雙十一”這種火爆的活動時,可能會有大量的使用者購買同一件商品,同時對該商品庫存減1,如果不加鎖,則極有可能會造成商品超賣情況。

要保證在這種分布式場景下,共享資料的安全性和一緻性,則需要使用分布式鎖。上面例子中的商品庫存就是共享資料。

什麼是分布式鎖

顧名思義,分布式鎖是指在分布式場景下,保證同一時刻對共享資料隻能被一個應用的一個線程操作。用來保證共享資料的安全性和一緻性。

分布式鎖的多種實作方式詳解

分布式鎖應該滿足哪些要求

現在我們來分析一下,我們要實作一個分布式鎖的話,需要滿足哪些要求呢?

  • 首先最基本的,我們要保證同一時刻隻能有一個應用的一個線程可以執行加鎖的方法,或者說擷取到鎖;(一個應用線程執行)
  • 然後我們這個分布式鎖可能會有很多的伺服器來擷取,是以我們一定要能夠高性能的擷取和釋放;(高性能)
  • 不能因為某一個分布式鎖擷取的服務不可用,導緻所有服務都拿不到或釋放鎖,是以要滿足高可用要求;(高可用)
  • 假設某個應用擷取到鎖之後,一直沒有來釋放鎖,可能服務本身已經挂掉了,不能一直不釋放,導緻其他服務一直擷取不到鎖;(鎖失效機制,防止死鎖)
  • 一個應用如果成功擷取到鎖之後,再次擷取鎖也可以成功;(可重入性)
  • 在某個服務來擷取鎖時,假設該鎖已經被另一個服務擷取,我們要能直接傳回失敗,不能一直等待。(非阻塞特性)

以上是所有分布式鎖要滿足的一些基本要求。

實作方式有哪些

那麼我們可以采取哪種方式來實作分布式鎖呢?目前常見的方式主要有以下三種:

  • 基于資料庫實作
  • 基于ZooKeeper實作
  • 基于Redis實作

接下來我們看看這三種方式具體都是怎樣實作分布式鎖的。

基于資料庫

使用資料庫實作分布式鎖,有兩種方式。

第一種是基于資料庫表實作。

比如我們有如下表來儲存分布式鎖記錄:

CREATE TABLE `methodLock` ( 
    `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',  
    `method_name` varchar(64) NOT NULL COMMENT '鎖定的方法名',
    `desc` varchar(1024) NOT NULL DEFAULT '備注資訊',
    `update_time` timestamp NOT NULL DEFAULT now() ON UPDATE now() COMMENT '儲存時間',  
    PRIMARY KEY (`id`),
    UNIQUE KEY `uidx_method_name` (`method_name `)
) ENGINE=InnoDB COMMENT='分布式鎖定的方法';           

當我們的某個服務要執行某段需要分布式鎖定的方法時,則執行插入語句,在該表中插入一條記錄。

insert into methodLock(method_name,desc) values ('saleProduct','出售産品減庫存');           

因為我們在定義表時,method_name添加了唯一限制,如果在我們插入記錄時,有多個服務都要執行這個操作,那麼資料庫可以保證隻能有一個服務成功,我們認為隻有插入成功的那個服務擷取到了鎖,可以繼續執行該方法。

當方法執行完畢後,需要釋放鎖,則執行一條删除語句,将插入表中的記錄删除。

delete from methodLock where method_name = 'saleProduct';           

另一種是基于資料庫排他鎖。

除了上面的通過插入删除的方式外,借助排它鎖實作分布式鎖。我們可以使用上面方法中的表,對于要使用分布式的方法提前插入一條記錄。

insert into methodLock(method_name,desc) values ('saleProduct','出售産品減庫存');           

在某個應用線程需要對該方法加鎖時,則使用如下語句:

select method_name from methodLock where method_name = 'saleProduct' for update           

在查詢語句後使用for update,資料庫在查詢時給該條記錄添加上排他鎖,其他線程則無法給該條記錄添加鎖。

我們可以當做查詢到資料時,則擷取到分布式鎖,接下來執行方法中的邏輯。在方法執行完畢後,送出事務,鎖會自動釋放。

當然,還可以更簡單一點,不用這麼麻煩建一張表插入資料,而是對要進行分布式鎖定的資料直接加鎖。

比如我們要操作商品庫存,在資料庫中一般會有商品庫存記錄的表,比如叫:t_product_quantity,我們在對某商品減庫存之前,先通過以下SQL查詢出記錄:

select product_no,quantity where product_no = xxx for update           

同樣該查詢會對該産品的庫存記錄添加排它鎖,之後其他線程都不可以對該條記錄加鎖。

接下來我們再對庫存資料操作後,送出事務,鎖會自動釋放;如果操作過程中發生異常,事務復原,也會自動釋放鎖。

使用以上基于資料庫分布式鎖的方式還是挺簡單的。但是我們來回頭看一下,這種方式是否能夠滿足我們上面列出來的分布式鎖應該滿足的要求呢?

  • 一個應用一個線程執行 :heavy_check_mark:
  • 高性能&高可用 因為是基于資料庫實作的,是以高性能和高可用依賴于資料庫,需要多機部署,主從同步、主備切換等。
  • 失效機制 需要手動删除,不具備失效機制。如果要支援失效機制,需要單獨增加定時任務,按照記錄的更新時間定時清除。
  • 可重入性 不具備,因為某線程在擷取成功後,鎖記錄會一直存在,無法再次擷取。 可通過增加字段,記錄占有鎖的應用節點資訊和線程資訊,再次擷取鎖時判斷是否是目前線程擷取的鎖達到可重入的特性。
  • 非阻塞特性 具備,在擷取鎖失敗時,會直接傳回失敗。 但是無法滿足逾時擷取的場景,比如5秒内擷取不到鎖再失敗等。

我們可以發現,這種方式雖然能滿足最基本的分布式鎖能力,但是在實際使用時,還是要針對一些問題做出優化,這些優化将會越來越複雜,并且存在一定的性能問題。是以一般不建議基于資料庫做分布式鎖。

基于ZooKeeper

基于ZooKeeper同樣也能實作分布式鎖,這裡需要先鋪墊一些ZK的基本知識。在ZK中,資料都是存放在資料節點中,資料節點稱為Znode,ZK會将所有的資料都存放在記憶體中,所有的資料構成的資料模型是一個樹狀結構(ZNode Tree),不同層級的節點通過斜杠"/"分割,如/zoo/cat,和檔案系統結構類似。

分布式鎖的多種實作方式詳解

ZNode

在ZK中的資料節點分為以下四種:

持久節點

持久節點是ZK預設的節點類型,建立節點後,不管用戶端與服務端是否斷開連接配接,該節點會一直存在。

臨時節點

可持久節點不同,臨時節點在用戶端與服務端斷開連接配接後,臨時節點會被删除。

分布式鎖的多種實作方式詳解

順序節點

顧名思義,順序節點具有順序,在建立節點時,ZK會根據建立時間給每個節點指定順序編号。

分布式鎖的多種實作方式詳解

臨時順序節點

臨時順序節點是臨時節點和順序節點的結合體,每個節點建立時會指定順序編号,并且在用戶端與ZK服務端斷開時,節點會被删除。

分布式鎖的多種實作方式詳解

ZK分布式鎖實作原理

在ZK中并沒有類似于Lock或Synchronized的API,它實作分布式鎖依賴于臨時順序節點來完成。

擷取鎖

  • 首先需要在ZK中先建立一個持久節點ParentLock表示一個分布式鎖節點。
  • 第一個用戶端來擷取鎖時,就在這個ParentLock節點下建立一個順序臨時節點001-Node,然後檢視ParentLock下所有臨時順序節點,判斷目前建立節點是否在第一位,如果是,表示加鎖成功;
分布式鎖的多種實作方式詳解
  • 之後第二個用戶端來擷取鎖時,同樣在ParentLock節點下建立一個順序臨時節點002-Node,然後判斷自己是否在第一位,因為這是第一位是001-Node,是以這是會向排在自己前面的001-Node注冊一個Watcher,用來監聽001-Node節點,此時該用戶端加鎖失敗,進入等待狀态;
分布式鎖的多種實作方式詳解
  • 當有第三個用戶端來時,同理因為新建立的003-Node不在第一位,于是向排在自己前面的002-Node注冊一個Watcher,以此類推。

有沒有發現,這裡是形成了一個鍊式結構,和JUC中的AQS結構有點相似。

釋放鎖

釋放鎖的場景分兩種,一種是業務處理完畢,正常釋放鎖;還有一種是用戶端與服務端斷開連接配接。

首先正常釋放時,用戶端會顯式地将ZK中的資料節點删除;比如Client 1在業務處理完成時,将001-Node删除。

而用戶端與伺服器斷開連接配接的情況,可能發生在用戶端擷取鎖成功後,執行過程中發生異常,或應用崩潰,或網絡異常等各種原因導緻,這時ZK會自動将對應的Node節點删除。

由于Client 2一直在監聽着001-Node節點,當001-Node節點删除後,Client 2會立刻收到通知,這時Client 2會再次檢視節點清單,判斷自己是否在最前面,如果是,則占有鎖,表示加鎖成功;

當Client 2釋放鎖之後,Client 3采用同樣的方式處理。

以上就是使用ZooKeeper實作分布式鎖的基本原理和過程。整體流程可以簡化為下圖所示。

分布式鎖的多種實作方式詳解

要想在Java中使用ZK,官方有提供API包zkClient,使用時引入zookeeper-3.4.6.jar和 zkclient-0.1.jar即可;

也可使用第三方封裝好的工具包,如Curator、Menagerie等。

通過以上我們可以看出,使用ZooKeeper實作分布式鎖,基本可以全部滿足我們對分布式鎖的要求,需要注意的一點是,一定要使用順序臨時節點,而不是臨時節點,使用臨時節點會存在羊群效應問題。

基于Redis

使用Redis做分布式鎖也是特别常見的一種選擇。并且有多種實作方式。接下來我們逐個講解。

第一種:SETNX+EXPIRE

這種方式可能是多數朋友第一反應就想到的,先通過SETNX擷取到鎖,然後通過EXPIRE指令添加逾時時間。這種方式存在一個很大的問題,就是這兩個指令的操作不是原子操作,需要和Redis互動兩次,用戶端可能會在第一個指令執行完之後就挂掉,導緻沒有設定上逾時時間,那麼這個鎖就一直在那兒了。

為了解決這個問題,于是誕生了第二種方案。

第二種:SETNX+VALUE

這種方式的VALUE值中儲存的是用戶端計算出的過期時間,通過SETNX指令一次性放在Redis中;

public boolean getLock(String key,Long expireTime){
    long expireTime = System.currentTimeMills()+expireTime;
    String value = String.valueOf(expireTime);
    // 加鎖成功
    if(jedis.setnx(key,value)==1){
        return true;
    }
    // 擷取鎖的value
    String currentValueStr = jedis.get(key);
    // 如果過期時間小于系統時間,則表示已過期
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        // 鎖已過期,擷取上一個鎖的過期時間,并設定現在鎖的過期時間
        String oldValueStr = jedis.getSet(key_resource_id, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // 考慮多線程并發的情況,隻有一個線程的設定值和目前值相同,它才可以加鎖
            return true;
        }
    }
    //其他情況,均傳回加鎖失敗
    return false;
}           

這種方式通過value将逾時時間指派,解決了第一種方案的兩次操作不原子性的問題。但是這種方式也有問題:

在鎖過期時,如果多個線程同時來加鎖,可能會導緻多個線程都加鎖成功;

當多個線程都加鎖成功後,因為鎖中沒有加鎖線程的辨別,會導緻多個線程都可以解鎖;

逾時時間是在用戶端計算的,不同的用戶端的時鐘可能會存在差異,導緻在加鎖用戶端沒有逾時的鎖,在另一個用戶端已經逾時。

第三種:使用Lua腳本

同樣是為了解決第一種方案中的原子性問題,我們可以采用Lua腳本,來保證SETNX+EXPIRE操作的原子性。

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

在Java代碼中,使用jedis.eval()執行加鎖。

public boolean getLock(String key,String value){
    String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 "
        + "then redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";   
    Object result = jedis.eval(lua_scripts,Collections.singletonList(key),Collections.singletonList(value));
    return result.equals(1L);
}           

這種方式可以完全避免在加鎖後中斷設定不上逾時時間的問題。也不會存在有時鐘不一緻的問題,和高并發情況下多個線程都加上鎖的問題。但是這種方式就一定沒有問題了嗎?答案是否定的,看下圖。

分布式鎖的多種實作方式詳解

當服務A加鎖成功後,正在執行業務的過程中,鎖過期啦,這時服務A是沒有感覺的;

接着服務B這時來擷取鎖,成功擷取到了;

緊接着,服務A處理完業務了,來釋放鎖,成功釋放掉了,而服務B這時還以為它的鎖還在,在執行代碼。

全亂套了有沒有?以為自己加鎖了,其實你沒加;

以為自己解鎖成功了,其實解的是别人的鎖;

分布式鎖的多種實作方式詳解

這種方案的問題主要是因為兩點:鎖過期釋放,業務沒處理完;鎖沒有唯一身份辨別。

第四種:SET NX PX EX + 唯一辨別

對于誤删鎖的問題,我們可以在加鎖時,由用戶端生成一個唯一ID作為value設定在鎖中,在删除鎖時先進行身份判斷,再删除;加鎖邏輯如下:

public boolean getLock(String key,String uniId,Long expireTime){
    //加鎖
    return jedis.set(key, uniId, "NX", "EX", expireTime) == 1;
}

// 解鎖
public boolean releaseLock(String key,String uniId){
    // 因為get和del操作并不是原子的,是以使用lua腳本
    String lua_script = "if redis.call('get',KEYS[1]) == ARGV[1] then  return redis.call('del',KEYS[1]) 
        +"else return 0  end;";
    Object result = jedis.eval(lua_scripts,Collections.singletonList(key),Collections.singletonList(uniId));
    return result.equals(1L);
}           

這種方式解決了鎖被誤删的問題,但是同樣存在鎖逾時失效,但是業務還未處理完的問題。

第五種:Redission架構

那麼對于鎖過期失效,業務未處理完畢的問題,該如何處理呢?

我們可以在加鎖成功後,啟動一個守護線程,在守護線程中隔一段時間就對鎖的逾時時間再續長一點,直到業務處理完成後,釋放鎖,防止鎖在業務處理完畢之前提前釋放。

而Redission架構就是使用的這種機制,來解決的這個問題。

分布式鎖的多種實作方式詳解

當一個線程去擷取鎖,在加鎖成功的情況下,那麼它已經同Lua腳本将資料儲存在了redis中;

然後在加鎖成功的同時,啟動watch Dog看門狗,每隔10秒檢查是否還持有鎖,如果是則将鎖逾時時間延長。

如果一開始就擷取鎖失敗,則會一直循環擷取。

這種方案的Redis鎖總該沒有問題了吧?格局小了呀我滴朋友,還有問題呢。

分布式鎖的多種實作方式詳解

以上的這些方案,都隻是在Redis單機模式下讨論的方案,如果Redis是采用叢集模式,還會存在一些問題,不過問題不是很離譜,我們來簡單講解一下。

在叢集模式下,一般Master節點會将資料同步到Salve節點,如果我們現在Master節點上加鎖成功,在同步到Salve節點之前,這個Master節點挂了,然後另一台Salve節點更新為Master節點,這時這個節點上并沒有我們的加鎖資料;

此時另一個用戶端線程來擷取相同的鎖,它就會擷取成功,這時在我們的應用中将會有兩個線程同時擷取到這個鎖,這個鎖也就不安全了。

為了解決這個問題,Redis的作者親自出馬了,提出了一種進階的分布式鎖算法,很牛批,叫:RedLock。

第六種:RedLock+Redission

首先這個RedLock的意思并不是“紅色的鎖”,和紅色沒啥關系,而是Redis Distributed Lock,Redis分布式鎖,看見沒,這才是正主,官方出品。

RedLock的核心原理是這樣的:

  • 在Redis叢集中選出多個Master節點,保證這些Master節點不會同時當機;
  • 并且各個Master節點之間互相獨立,資料不同步;
  • 使用與Redis單執行個體相同的方法來加鎖和解鎖。

那麼RedLock到底是如何來保證在有節點當機的情況下,還能安全的呢?

  1. 假設叢集中有N台Master節點,首先,擷取目前時間戳;
  2. 用戶端按照順序使用相同的key,value依次擷取鎖,并且擷取時間要比鎖逾時時間足夠小;比如逾時時間5s,那麼擷取鎖時間最多1s,超過1s則放棄,繼續擷取下一個;
  3. 用戶端通過擷取所有能擷取的鎖之後減去第一步的時間戳,這個時間差要小于鎖逾時時間,并且要至少有N/2 + 1台節點擷取成功,才表示鎖擷取成功,否則算擷取失敗;
  4. 如果成功擷取鎖,則鎖的有效時間是原本逾時時間減去第三不得時間差;
  5. 如果擷取鎖失敗,則要解鎖所有的節點,不管該節點加鎖時是否成功,防止有漏網之魚。

Redssion庫對RedLock方案已經做了實作,如果你的Redis是叢集部署,可以看看使用方法。

參考文檔:redis.io/topics/dist…           

通過以上6種基于Redis的方式,我們該如何選擇呢?可以按以下原則:

  • 如果Redis是單機部署,使用方案五,Redission架構,加鎖時可按場景開啟watch dog機制;解鎖時使用Lua腳本原子删除;
  • 如果是叢集部署,建議采用RedLock方案實作。

總結

本期内容主要跟大家講解了分布式鎖的原理和不同的實作方案,有基于資料庫,ZooKeeper和Redis三種選擇,并且不同的選擇存在不同的一些特征和它的問題,希望通過本文能讓你對分布式鎖有一個比較全面的認識,在實際開發過程中能夠做到“心中有譜,辦事兒不慌”。

繼續閱讀