分佈式鎖解決併發的三種實現方式

轉載自:分佈式鎖簡單入門以及三種實現方式介紹java

分佈式鎖解決併發的三種實現方式

  • 在不少場景中,咱們爲了保證數據的最終一致性,須要不少的技術方案來支持,好比分佈式事務、分佈式鎖等。有的時候,咱們須要保證一個方法在同 一時間內只能被同一個線程執行。在單機環境中,Java中其實提供了不少併發處理相關的API,可是這些API在分佈式場景中就無能爲力了。也就是說單純的Java Api並不能提供分佈式鎖的能力。因此針對分佈式鎖的實現目前有多種方案:面試

    分佈式鎖通常有三種實現方式:redis

    • 一、 數據庫鎖
    • 二、基於Redis的分佈式鎖
    • 三、基於ZooKeeper的分佈式鎖

分佈式鎖應該是怎麼樣的數據庫

  • 互斥性 能夠保證在分佈式部署的應用集羣中,同一個方法在同一時間只能被一臺機器上的一個線程執行。緩存

  • 這把鎖要是一把可重入鎖(避免死鎖)服務器

  • 不會發生死鎖:有一個客戶端在持有鎖的過程當中崩潰而沒有解鎖,也能保證其餘客戶端可以加鎖微信

  • 這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)多線程

  • 有高可用的獲取鎖和釋放鎖功能架構

  • 獲取鎖和釋放鎖的性能要好併發

一、數據庫鎖

  • 基於數據庫表

    • 要實現分佈式鎖,最簡單的方式可能就是直接建立一張鎖表,而後經過操做該表中的數據來實現了。

    • 當咱們要鎖住某個方法或資源時,咱們就在該表中增長一條記錄,想要釋放鎖的時候就刪除這條記錄。

      image

      當咱們想要鎖住某個方法時,執行如下SQL:

      image

      由於咱們對method_name作了惟一性約束,這裏若是有多個請求同時提交到數據庫的話,數據庫會保證只有一個操做能夠成功,那麼咱們就能夠認爲

      操做成功的那個線程得到了該方法的鎖,能夠執行方法體內容。

      當方法執行完畢以後,想要釋放鎖的話,須要執行如下Sql:

      image
    • 上面這種簡單的實現有如下幾個問題:

      • 一、這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會致使業務系統不可用。

      • 二、這把鎖沒有失效時間,一旦解鎖操做失敗,就會致使鎖記錄一直在數據庫中,其餘線程沒法再得到到鎖。

      • 三、這把鎖只能是非阻塞的,由於數據的insert操做,一旦插入失敗就會直接報錯。沒有得到鎖的線程並不會進入排隊隊列,要想再次得到鎖就要再次觸發得到鎖操做。

      • 四、這把鎖是非重入的,同一個線程在沒有釋放鎖以前沒法再次得到該鎖。由於數據中數據已經存在了。

    • 固然,咱們也能夠有其餘方式解決上面的問題。

      • 數據庫是單點?搞兩個數據庫,數據以前雙向同步。一旦掛掉快速切換到備庫上。

      • 沒有失效時間?只要作一個定時任務,每隔必定時間把數據庫中的超時數據清理一遍。

      • 非阻塞的?搞一個while循環,直到insert成功再返回成功。

      • 非重入的?在數據庫表中加個字段,記錄當前得到鎖的機器的主機信息和線程信息,那麼下次再獲取鎖的時候先查詢數據庫,若是當前機器的主機信息和線程信息在數據庫能夠查到的話,直接把鎖分配給他就能夠了。

  • 基於數據庫的排它鎖

    除了能夠經過增刪操做數據表中的記錄之外,其實還能夠藉助數據庫中自帶的鎖來實現分佈式的鎖。

    咱們還用剛剛建立的那張數據庫表。能夠經過數據庫的排他鎖來實現分佈式鎖。

    在查詢語句後面增長for update,數據庫會在查詢過程當中給數據庫表增長排他鎖。當某條記錄被加上排他鎖以後,其餘線程沒法再在該行記錄上增長排他鎖。

    咱們能夠認爲得到排它鎖的線程便可得到分佈式鎖,當獲取到鎖以後,能夠執行方法的業務邏輯,執行完方法以後,再經過如下方法解鎖:

    咱們能夠認爲得到排它鎖的線程便可得到分佈式鎖,當獲取到鎖以後,能夠執行方法的業務邏輯,執行完方法以後,再經過如下方法解鎖:

    public void unlock(){
          connection.commit();
    }

    經過connection.commit()操做來釋放鎖。

    這種方法能夠有效的解決上面提到的沒法釋放鎖和阻塞鎖的問題。

    • 阻塞鎖? for update語句會在執行成功後當即返回,在執行失敗時一直處於阻塞狀態,直到成功。
    • 鎖定以後服務宕機,沒法釋放?使用這種方式,服務宕機以後數據庫會本身把鎖釋放掉。

    可是仍是沒法直接解決數據庫單點和可重入問題。

  • 總結:

    • 總結一下使用數據庫來實現分佈式鎖的方式,這兩種方式都是依賴數據庫的一張表,一種是經過表中的記錄的存在狀況肯定當前是否有鎖存在,另一種是經過數據庫的排他鎖來實現分佈式鎖。

    • 數據庫實現分佈式鎖的優勢: 直接藉助數據庫,容易理解。

    • 數據庫實現分佈式鎖的缺點: 會有各類各樣的問題,在解決問題的過程當中會使整個方案變得愈來愈複雜。

    • 操做數據庫須要必定的開銷,性能問題須要考慮。

1.一、樂觀鎖

  • 樂觀鎖假設認爲數據通常狀況下不會形成衝突,只有在進行數據的提交更新時,纔會檢測數據的衝突狀況,若是發現衝突了,則返回錯誤信息

  • 實現方式:

    • 時間戳(timestamp)記錄機制實現:給數據庫表增長一個時間戳字段類型的字段,當讀取數據時,將timestamp字段的值一同讀出,數據每更新一次,timestamp也同步更新。當對數據作提交更新操做時,檢查當前數據庫中數據的時間戳和本身更新前取到的時間戳進行對比,若相等,則更新,不然認爲是失效數據。

    • 若出現更新衝突,則須要上層邏輯修改,啓動重試機制

    • 一樣也可使用version的方式。

  • 性能對比

    • 一、悲觀鎖實現方式是獨佔數據,其它線程須要等待,不會出現修改的衝突,可以保證數據的一致性,可是依賴數據庫的實現,且在線程較多時出現等待形成效率下降的問題。通常狀況下,對於數據很敏感且讀取頻率較低的場景,能夠採用悲觀鎖的方式

    • 二、 樂觀鎖能夠多線程同時讀取數據,若出現衝突,也能夠依賴上層邏輯修改,可以保證高併發下的讀取,適用於讀取頻率很高而修改頻率較少的場景

    • 三、 因爲庫存回寫數據屬於敏感數據且讀取頻率適中,因此建議使用悲觀鎖優化

二、基於redis的分佈式鎖

  • 相比較於基於數據庫實現分佈式鎖的方案來講,基於緩存來實如今性能方面會表現的更好一點。並且不少緩存是能夠集羣部署的,能夠解決單點問題。

-首先,爲了確保分佈式鎖可用,咱們至少要確保鎖的實現同時知足如下四個條件:

  • 互斥性。在任意時刻,只有一個客戶端能持有鎖。

  • 不會發生死鎖。即便有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其餘客戶端能加鎖。

  • 具備容錯性。只要大部分的Redis節點正常運行,客戶端就能夠加鎖和解鎖。

  • 解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端本身不能把別人加的鎖給解了。

能夠看到,咱們加鎖就一行代碼:==jedis.set(String key, String value, String nxxx, String expx, int time)==,這個set()方法一共有五個形參:

  • 第一個爲key,咱們使用key來當鎖,由於key是惟一的。

  • 第二個爲value,咱們傳的是requestId,不少童鞋可能不明白,有key做爲鎖不就夠了嗎,爲何還要用到value?緣由就是咱們在上面講到可靠性時,分佈式鎖要知足第四個條件解鈴還須繫鈴人,經過給value賦值爲requestId,咱們就知道這把鎖是哪一個請求加的了,在解鎖的時候就能夠有依據。requestId可使用UUID.randomUUID().toString()方法生成。

  • 第三個爲nxxx,這個參數咱們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,咱們進行set操做;若key已經存在,則不作任何操做;

  • 第四個爲expx,這個參數咱們傳的是PX,意思是咱們要給這個key加一個過時的設置,具體時間由第五個參數決定。

  • 第五個爲time,與第四個參數相呼應,表明key的過時時間。

總的來講,執行上面的set()方法就只會致使兩種結果:1. 當前沒有鎖(key不存在),那麼就進行加鎖操做,並對鎖設置個有效期,同時value表示加鎖的客戶端。2. 已有鎖存在,不作任何操做。

加鎖代碼知足咱們可靠性裏描述的三個條件。首先,set()加入了NX參數,能夠保證若是已有key存在,則函數不會調用成功,也就是隻有一個客戶端能持有鎖,知足互斥性。其次,因爲咱們對鎖設置了過時時間,即便鎖的持有者後續發生崩潰而沒有解鎖,鎖也會由於到了過時時間而自動解鎖(即key被刪除),不會發生死鎖。最後,由於咱們將value賦值爲requestId,表明加鎖的客戶端請求標識,那麼在客戶端在解鎖的時候就能夠進行校驗是不是同一個客戶端。因爲咱們只考慮Redis單機部署的場景,因此容錯性咱們暫不考慮。

錯誤實例

  • 使用jedis.setnx()和jedis.expire()組合實現加鎖
image

setnx()方法做用就是SET IF NOT EXIST,expire()方法就是給鎖加一個過時時間。乍一看好像和前面的set()方法結果同樣,然而因爲這是兩條Redis命令,不具備原子性,若是程序在執行完setnx()以後忽然崩潰,致使鎖沒有設置過時時間。那麼將會發生死鎖。網上之因此有人這樣實現,是由於低版本的jedis並不支持多參數的set()方法。

  • 解鎖:

    • 首先獲取鎖對應的value值,檢查是否與requestId相等,若是相等則刪除鎖(解鎖)

    • 使用緩存實現分佈式鎖的優勢

      • 性能好,實現起來較爲方便。

      • 使用緩存實現分佈式鎖的缺點

      • 經過超時時間來控制鎖的失效時間並非十分的靠譜。

  • 總結:

    • 可使用緩存來代替數據庫來實現分佈式鎖,這個能夠提供更好的性能,同時,不少緩存服務都是集羣部署的,能夠避免單點問題。而且不少緩存服務都提供了能夠用來實現分佈式鎖的方法,好比redis的setnx方法等。而且,這些緩存服務也都提供了對數據的過時自動刪除的支持,能夠直接設置超時時間來控制鎖的釋放。

三、基於Zookeeper實現分佈式鎖

  • 基於zookeeper臨時有序節點能夠實現的分佈式鎖。大體思想即爲:每一個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個惟一的瞬時有序節點。 判斷是否獲取鎖的方式很簡單,只須要判斷有序節點中序號最小的一個。 當釋放鎖的時候,只需將這個瞬時節點刪除便可。同時,其能夠避免服務宕機致使的鎖沒法釋放,而產生的死鎖問題。

  • 完成業務流程後,刪除對應的子節點釋放鎖。

  • 來看下Zookeeper能不能解決前面提到的問題。

    • 鎖沒法釋放?使用Zookeeper能夠有效的解決鎖沒法釋放的問題,由於在建立鎖的時候,客戶端會在ZK中建立一個臨時節點,一旦客戶端獲取到鎖以後忽然掛掉(Session鏈接斷開),那麼這個臨時節點就會自動刪除掉。其餘客戶端就能夠再次得到鎖。
    • 非阻塞鎖?使用Zookeeper能夠實現阻塞的鎖,客戶端能夠經過在ZK中建立順序節點,而且在節點上綁定監聽器,一旦節點有變化,Zookeeper會通知客戶端,客戶端能夠檢查本身建立的節點是否是當前全部節點中序號最小的,若是是,那麼本身就獲取到鎖,即可以執行業務邏輯了。
    • 不可重入?使用Zookeeper也能夠有效的解決不可重入的問題,客戶端在建立節點的時候,把當前客戶端的主機信息和線程信息直接寫入到節點中,下次想要獲取鎖的時候和當前最小的節點中的數據比對一下就能夠了。若是和本身的信息同樣,那麼本身直接獲取到鎖,若是不同就再建立一個臨時的順序節點,參與排隊。
    • 單點問題?使用Zookeeper能夠有效的解決單點問題,ZK是集羣部署的,只要集羣中有半數以上的機器存活,就能夠對外提供服務。
  • 能夠直接使用zookeeper第三方庫Curator客戶端,這個客戶端中封裝了一個可重入的鎖服務。

  • Zookeeper實現的分佈式鎖其實存在一個缺點,那就是性能上可能並無緩存服務那麼高。

    • 由於每次在建立鎖和釋放鎖的過程當中,都要動態建立、銷燬瞬時節點來實現鎖功能。ZK中建立和刪除節點只能經過Leader服務器來執行,而後將數據同不到全部的Follower機器上。
  • 使用Zookeeper實現分佈式鎖的優勢: 有效的解決單點問題,不可重入問題,非阻塞問題以及鎖沒法釋放的問題。實現起來較爲簡單。

  • 使用Zookeeper實現分佈式鎖的缺點 : 性能上不如使用緩存實現分佈式鎖。 須要對ZK的原理有所瞭解。

    image

四、三種方案的比較

  • 從理解的難易程度角度(從低到高): 數據庫 > 緩存 > Zookeeper

  • 從實現的複雜性角度(從低到高): Zookeeper >= 緩存 > 數據庫

  • 從性能角度(從高到低): 緩存 > Zookeeper >= 數據庫

  • 從可靠性角度(從高到低): Zookeeper > 緩存 > 數據庫

在實踐中,固然是從以可靠性爲主。因此首推Zookeeper。

145天以來,Java架構更新了 428個主題,已經有91位同窗加入。微信掃碼關注java架構,獲取Java面試題和架構師相關題目和視頻。上述相關面試題答案,盡在Java架構中。

相關文章
相關標籤/搜索