最全分布式鎖解決方案詳解


一. 概述

1.1 鎖的概念

  1. 在單進程的系統中,當存在多個線程可以同時改變某個變量(可變共享變量)時,就需要對變量或代碼塊做同步,使其在修改這種變量時能夠線性執行消除並發修改變量。Java的單機並發同步手段是synchronized和java.util.concurrent包。

  2. 而同步的本質是通過鎖來實現的。為了實現多個線程在一個時刻同一個代碼塊只能有一個線程可執行,那么需要在某個地方做個標記,這個標記必須每個線程都能看到,當標記不存在時可以設置該標記,其余后續線程發現已經有標記了則等待擁有標記的線程結束同步代碼塊取消標記后再去嘗試設置標記。這個標記可以理解為鎖。

  3. 不同地方實現鎖的方式也不一樣,只要能滿足所有線程都能看得到標記即可。如 Java 中 synchronize 是在對象頭設置標記,Lock 接口的實現類基本上都只是某一個 volitile 修飾的 int 型變量其保證每個線程都能擁有對該 int 的可見性和原子修改,linux 內核中也是利用互斥量或信號量等內存數據做標記。

  4. 除了利用內存數據做鎖其實任何互斥的都能做鎖(只考慮互斥情況),如流水表中流水號與時間結合做冪等校驗可以看作是一個不會釋放的鎖,或者使用某個文件是否存在作為鎖等。只需要滿足在對標記進行修改能保證原子性和內存可見性即可。

   原理:多個訪問方對同一個資源進行操作,需要進行互斥,通常是利用一個這些訪問方同時能夠訪問到的lock來實施互斥的。

場景一

在同一個進程內,多個線程的互斥,我們可以通過加鎖來進行串行化訪問。

步驟:

(1)多個線程同時搶鎖

(2)只一個線程搶到,未搶到的阻塞,或下次再來搶

(3)搶到鎖的線程操作臨界資源

(4)操作完臨界資源后釋放鎖

畫外音:鎖是進程內的一個數據結構,將臨界資源的沖突轉變為對鎖結構的沖突。

 

場景二

在分布式環境下,進程內的鎖結構就無法作用於進程外了,所以多進程情況下怎么進行臨界資源的保護呢?

 

結合進程內鎖的機制,我們可以得出幾點條件:

(1)需要有一個特殊的數據結構,每個進程都能訪問

(2)同時只能一個進程訪問成功

(3)訪問成功的進程可以訪問臨界資源

畫外音:問題的關鍵在於找到同時只有一個進程訪問成功的外部存儲結構。

1.2 分布式場景

分布式的 CAP 理論告訴我們:

任何一個分布式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance),最多只能同時滿足兩項。

目前很多大型網站及應用都是分布式部署的,分布式場景中的數據一致性問題一直是一個比較重要的話題。基於 CAP理論,很多系統在設計之初就要對這三者做出取舍。在互聯網領域的絕大多數的場景中,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證最終一致性。

分布式場景


此處主要指集群模式下,多個相同服務同時開啟.

在許多的場景中,我們為了保證數據的最終一致性,需要很多的技術方案來支持,比如分布式事務分布式鎖等。很多時候我們需要保證一個方法在同一時間內只能被同一個線程執行。在單機環境中,通過 Java 提供的並發 API 我們可以解決,但是在分布式環境下,就沒有那么簡單啦。

  1. 分布式與單機情況下最大的不同在於其不是多線程而是多進程

  2. 多線程由於可以共享堆內存,因此可以簡單的采取內存作為標記存儲位置。而進程之間甚至可能都不在同一台物理機上,因此需要將標記存儲在一個所有進程都能看到的地方。

1.3分布式鎖的概念

  1. 當在分布式模型下,數據只有一份(或有限制),此時需要利用鎖的技術控制某一時刻修改數據的進程數。

  2. 與單機模式下的鎖不僅需要保證進程可見,還需要考慮進程與鎖之間的網絡問題。(我覺得分布式情況下之所以問題變得復雜,主要就是需要考慮到網絡的延時和不可靠。。。一個大坑)

  3. 分布式鎖還是可以將標記存在內存,只是該內存不是某個進程分配的內存而是公共內存如 Redis、Memcache。至於利用數據庫、文件等做鎖與單機的實現是一樣的,只要保證標記能互斥就行。

1.4 設計分布式鎖的目標

  1. 互斥性:任意時刻只能有一個客戶端擁有鎖,不能被多個客戶端獲取,即可以保證在分布式部署的應用集群中,同一個方法在同一時間只能被一台機器-上的一個線程執行。

  2. 這把鎖要是一把可重入鎖(避免死鎖),說白了,獲取鎖的客戶端因為某些原因而宕機,而未能釋放鎖,其它客戶端也就無法獲取該鎖,需要有機制來避免該類問題的發生

  3. 這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)

  4. 這把鎖最好是一把公平鎖(根據業務需求考慮要不要這條)

  5. 有高可用的獲取鎖和釋放鎖功能,當部分節點宕機,客戶端仍能獲取鎖或者釋放鎖

  6. 獲取鎖和釋放鎖的性能要好

1.5 方案匯總

分布式的CAP理論告訴我們任何一個分布式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance),最多只能同時滿足兩項。一般情況下,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證最終一致性,只要這個最終時間是在用戶可以接受的范圍內即可。在很多時候,為了保證數據的最終一致性,需要很多的技術方案來支持,比如分布式事務、分布式鎖等。這里我們主要介紹對象分布式鎖,分布式鎖的的具體實現方案主要如下三種:

  1. 基於數據庫的實現

  2. 基於緩存(redis)的實現

  3. 基於zookeeper的實現

二. 基於數據庫的實現

2.1 基於數據庫實現的樂觀鎖

2.1.1 基於數據版本號來實現

比如,有個商品表t_goods,有一個字段left_count用來記錄商品的庫存個數。在並發的情況下,為了保證不出現超賣現象,即left_count不為負數。樂觀鎖的實現方式為給商品表增加一個版本號字段version,默認為0,每修改一次數據,將版本號加1。

無版本號並發超賣示例:

-- 線程1查詢,當前left_count為1,則有記錄
select * from t_goods where id = 10001 and left_count > 0
-- 線程2查詢,當前left_count為1,也有記錄
select * from t_goods  where id = 10001 and left_count > 0
-- 線程1下單成功庫存減一,修改left_count為0,
update t_goods set left_count = left_count - 1 where id = 10001
-- 線程2下單成功庫存減一,修改left_count為-1,產生臟數據
update t_goods set left_count = left_count - 1 where id = 10001

 

有版本號的樂觀鎖示例:

-- 線程1查詢,當前left_count為1,則有記錄,當前版本號為999
select left_count, version from t_goods where id = 10001 and left_count > 0;
-- 線程2查詢,當前left_count為1,也有記錄,當前版本號為999
select left_count, version from t_goods where id = 10001 and left_count > 0;
-- 線程1,更新完成后當前的version為1000,update狀態為1,更新成功
update t_goods set version = 1000, left_count = left_count-1 where id = 10001 and version = 999;
-- 線程2,更新由於當前的version為1000,udpate狀態為0,更新失敗,再針對相關業務做異常處理
update t_goods set version = 1000, left_count = left_count-1 where id = 10001 and version = 999;

 

可以發現,這種和CAS的樂觀鎖機制是類似的,所不同的是CAS的硬件來保證原子性,而這里是通過數據庫來保證單條SQL語句的原子性。順帶一提CAS的ABA問題一般也是通過版本號來解決。

2.1.2 基於表主鍵唯一做分布式鎖

思路:利用主鍵唯一的特性,如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,那么我們就可以認為操作成功的那個線程獲得了該方法的鎖,當方法執行完畢之后,想要釋放鎖的話,刪除這條數據庫記錄即可。

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

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

  2. 這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖。

  3. 這把鎖只能是非阻塞的,因為數據的 insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。

  4. 這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因為數據中數據已經存在了。

  5. 這把鎖是非公平鎖,所有等待鎖的線程憑運氣去爭奪鎖。

  6. 在 MySQL 數據庫中采用主鍵沖突防重,在大並發情況下有可能會造成鎖表現象。

當然,我們也可以有其他方式解決上面的問題。
  1. 數據庫是單點?搞兩個數據庫,數據之前雙向同步,一旦掛掉快速切換到備庫上。

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

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

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

  5. 非公平的?再建一張中間表,將等待鎖的線程全記錄下來,並根據創建時間排序,只有最先創建的允許獲取鎖。

  6. 比較好的辦法是在程序中生產主鍵進行防重。

2.2 基於數據庫實現的排他鎖

基於數據庫的排他鎖需要通過數據庫的唯一性約束UNIQUE KEY來保證數據的唯一性,從而為鎖的獨占性提供基礎。

表結構如下:

CREATE TABLE `distribute_lock` (
   `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',
   `unique_mutex` varchar(64) NOT NULL COMMENT '需要鎖住的資源或者方法',
   -- `state` tinyint NOT NULL DEFAULT 1 COMMENT '1:未分配;2:已分配
   PRIMARY KEY (`id`),
   UNIQUE KEY `unique_mutex`
);

 

其中,unique_mutex就是我們需要加鎖的對象,需要用UNIQUE KEY來保證此對象唯一。

加鎖時增加一條記錄:

insert into distribute_lock(unique_mutex) values('mutex_demo'); 

如果當前SQL執行成功代表加鎖成功,如果拋出唯一索引異常(DuplicatedKeyException)則代表加鎖失敗,當前鎖已經被其他競爭者獲取。

解鎖鎖時刪除該記錄:

delete from distribute_lock(unique_mutex) values('muetx_demo');

除了增刪記錄,也可以通過更新state字段來標識是否獲取到鎖。

-- 獲取鎖
update distribute_lock set state = 2 where `unique_mutex` = 'muetx_demo' and state=1;

更新之前需要SELECT確認鎖在數據庫中存在,沒有則創建之。如果創建或更新失敗,則說明這個資源已經被別的線程占用了。

2.2.1  基於數據庫排他鎖做分布式鎖

在查詢語句后面增加for update,數據庫會在查詢過程中給數據庫表增加排他鎖 (注意:InnoDB 引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這里我們希望使用行級鎖,就要給要執行的方法字段名添加索引,值得注意的是,這個索引一定要創建成唯一索引,否則會出現多個重載方法之間無法同時被訪問的問題。重載方法的話建議把參數類型也加上。)。當某條記錄被加上排他鎖之后,其他線程無法再在該行記錄上增加排他鎖。

我們可以認為獲得排他鎖的線程即可獲得分布式鎖,當獲取到鎖之后,可以執行方法的業務邏輯,執行完方法之后,通過connection.commit()操作來釋放鎖。

這種方法可以有效的解決上面提到的無法釋放鎖和阻塞鎖的問題。

  1. 阻塞鎖? for update語句會在執行成功后立即返回,在執行失敗時一直處於阻塞狀態,直到成功。

  2. 鎖定之后服務宕機,無法釋放?使用這種方式,服務宕機之后數據庫會自己把鎖釋放掉。

但是還是無法直接解決數據庫單點和可重入問題。

這里還可能存在另外一個問題,雖然我們對方法字段名使用了唯一索引,並且顯示使用 for update 來使用行級鎖。但是,MySQL 會對查詢進行優化,即便在條件中使用了索引字段,但是否使用索引來檢索數據是由 MySQL 通過判斷不同執行計划的代價來決定的,如果 MySQL 認為全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 將使用表鎖,而不是行鎖。如果發生這種情況就悲劇了。。。

還有一個問題,就是我們要使用排他鎖來進行分布式鎖的 lock,那么一個排他鎖長時間不提交,就會占用數據庫連接。一旦類似的連接變得多了,就可能把數據庫連接池撐爆。

2.3 小結

數據庫排他鎖可能出現的問題及解決思路:

  1.沒有失效時間, 一旦解鎖失敗,會導致鎖記錄一直在數據庫中,其他線程無法再獲得鎖。

    可通過定時任務清除超時數據來解決

  2.是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。

    可通過增加字段記錄當前主機信息和當線程信息,

  3.這個鎖只能是非阻塞的,因為數據的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的在線程並不會進入阻塞隊列,需要不停自旋直到獲得鎖,相對耗資源。

總的來說,基於數據庫的分布式鎖,能夠滿足一些簡單的需求,好處是能夠少引入依賴,實現較為簡單,缺點是性能較低,且難以滿足復雜場景下的高並發需求。會有各種各樣的問題(操作數據庫需要一定的開銷,使用數據庫的行級鎖並不一定靠譜,性能不靠譜)

三. 基於redis的實現

3.1 基本實現思路

 一個簡單的分布式鎖機制是使用setnx、expire 、del 三個命令的組合來實現的。setnx命令的含義為:當且僅當key不存在時,value設置成功,返回1;否則返回0。另外兩個命令,見名知意,就不多做解釋了。

# 加鎖,設置鎖的唯一標識key,返回1說明加鎖成功,返回0加鎖失敗
setnx key value
# 設置鎖超時時間為30s,防止死鎖
expire key 30
# 解鎖, 刪除鎖
del key

使用步驟


1、setnx(lockkey, 1) 如果返回 0,則說明占位失敗;如果返回 1,則說明占位成功

2、expire() 命令對 lockkey 設置超時時間,為的是避免死鎖問題。

3、執行完業務代碼后,可以通過 delete 命令刪除 key。

這個方案其實是可以解決日常工作中的需求的,但從技術方案的探討上來說,可能還有一些可以完善的地方。

這種思路存在的問題:

  1. setnx和expire的非原子性:如果加鎖之后,服務器宕機,導致expire和del均執行不了,會導致死鎖。比如,如果在第一步 setnx 執行成功后,在 expire() 命令執行成功前,發生了宕機的現象,那么就依然會出現死鎖的問題.

  2. del導致誤刪:A線程超時之后未執行完, 鎖過期釋放;B線程獲得鎖,此時A線程執行完,執行del將B線程的鎖刪除。

  3. 鎖過期后引起的並發:A線程超時之后未執行完, 鎖過期釋放;B線程獲得鎖,此時A、B線程並發執行會導致線程安全問題。

對應的解決思路:

  1.將加鎖和設置鎖過期時間做成一個原子性操作

在Redis 2.6.12版本之后,set命令增加了NX可選參數,可替代setnx命令;增加了EX可選參數,可以設置key的同時指定過期時間

    或者將兩個操作封裝在lua腳本中,發送給Redis執行,從而實現操作的原子性。

  2.將key的value設置為線程相關信息,del釋放鎖之前先判斷一下鎖是不是自己的。(釋放和判斷不是原子性的,需要封裝在lua腳本中)

  3.啟動一個守護線程,在后台自動給自己的鎖''續期“,執行完成,顯式關掉守護進程

基於 REDIS 的 SETNX()、GET()、GETSET()方法做分布式鎖


這個方案的背景主要是在 setnx() 和 expire() 的方案上針對可能存在的死鎖問題,做了一些優化。

getset()


這個命令主要有兩個參數 getset(key,newValue)。該方法是原子的,對 key 設置 newValue 這個值,並且返回 key 原來的舊值。假設 key 原來是不存在的,那么多次執行這個命令,會出現下邊的效果:

  1. getset(key, “value1”) 返回 null 此時 key 的值會被設置為 value1

  2. getset(key, “value2”) 返回 value1 此時 key 的值會被設置為 value2

  3. 依次類推!

使用步驟


  1. setnx(lockkey, 當前時間+過期超時時間),如果返回 1,則獲取鎖成功;如果返回 0 則沒有獲取到鎖,轉向 2。

  2. get(lockkey) 獲取值 oldExpireTime ,並將這個 value 值與當前的系統時間進行比較,如果小於當前系統時間,則認為這個鎖已經超時,可以允許別的請求重新獲取,轉向 3。

  3. 計算 newExpireTime = 當前時間+過期超時時間,然后 getset(lockkey, newExpireTime) 會返回當前 lockkey 的值currentExpireTime。

  4. 判斷 currentExpireTime 與 oldExpireTime 是否相等,如果相等,說明當前 getset 設置成功,獲取到了鎖。如果不相等,說明這個鎖又被別的請求獲取走了,那么當前請求可以直接返回失敗,或者繼續重試。

  5. 在獲取到鎖之后,當前線程可以開始自己的業務處理,當處理完畢后,比較自己的處理時間和對於鎖設置的超時時間,如果小於鎖設置的超時時間,則直接執行 delete 釋放鎖;如果大於鎖設置的超時時間,則不需要再鎖進行處理。

3.2 redis分布式鎖的缺點

在大型的應用中,一般redis服務都是集群形式部署的,由於Slave同步Master是異步的,所以會出現客戶端A在Master上加鎖,此時Master宕機,Slave沒有完成鎖的同步,Slave變為Master,客戶端B此時可以完成加鎖操作。

為了解決這一問題,官方給出了redlock算法,即使這樣在一些較復雜的場景下也不能100%保證沒有問題。

Redlock 是 Redis 的作者 antirez 給出的集群模式的 Redis 分布式鎖,它基於 N 個完全獨立的 Redis 節點(通常情況下 N 可以設置成 5)。

算法的步驟如下:

1、客戶端獲取當前時間,以毫秒為單位。

2、客戶端嘗試獲取 N 個節點的鎖,(每個節點獲取鎖的方式和前面說的緩存鎖一樣),N 個節點以相同的 key 和 value 獲取鎖。客戶端需要設置接口訪問超時,接口超時時間需要遠遠小於鎖超時時間,比如鎖自動釋放的時間是 10s,那么接口超時大概設置 5-50ms。這樣可以在有 redis 節點宕機后,訪問該節點時能盡快超時,而減小鎖的正常使用。

3、客戶端計算在獲得鎖的時候花費了多少時間,方法是用當前時間減去在步驟一獲取的時間,只有客戶端獲得了超過 3 個節點的鎖,而且獲取鎖的時間小於鎖的超時時間,客戶端才獲得了分布式鎖。

4、客戶端獲取的鎖的時間為設置的鎖超時時間減去步驟三計算出的獲取鎖花費時間。

5、如果客戶端獲取鎖失敗了,客戶端會依次刪除所有的鎖。

使用 Redlock 算法,可以保證在掛掉最多 2 個節點的時候,分布式鎖服務仍然能工作,這相比之前的數據庫鎖和緩存鎖大大提高了可用性,由於 redis 的高效性能,分布式緩存鎖性能並不比數據庫鎖差。

但是,有一位分布式的專家寫了一篇文章《How to do distributed locking》,質疑 Redlock 的正確性。

https://mp.weixin.qq.com/s/1bPLk_VZhZ0QYNZS8LkviA

https://blog.csdn.net/jek123456/article/details/72954106

缺點:

失效時間設置多長時間為好?如何設置的失效時間太短,方法沒等執行完,鎖就自動釋放了,那么就會產生並發問題。如果設置的時間太長,其他獲取鎖的線程就可能要平白的多等一段時間。

四. 基於zookeeper的實現

4.1 基本實現思路

zookeeper 是一個開源的分布式協調服務框架,主要用來解決分布式集群中的一致性問題和數據管理問題。zookeeper本質上是一個分布式文件系統,由一群樹狀節點組成,每個節點可以存放少量數據,且具有唯一性。

zookeeper有四種類型的節點:

   1.持久節點(PERSISTENT)

     默認節點類型,斷開連接仍然存在

   2.持久順序節點(PERSISTENT_SEQUENTIAL)

     在持久節點的基礎上,增加了順序性。指定創建同名節點,會根據創建順序在指定的節點名稱后面帶上順序編號,以保證節點具有唯一性和順序性

   3.臨時節點(EPHEMERAL)

     斷開連接后,節點會被刪除

   4.臨時順序節點(EPHEMERAL_SEQUENTIAL)

     在臨時節點的基礎上,增加了順序性。

 基於zookeeper實現的分布式鎖主要利用了zookeeper臨時順序節點的特性和事件監聽機制。主要思路如下:

  1. 創建節點實現加鎖,通過節點的唯一性,來實現鎖的互斥。
    如果使用臨時節點,節點創建成功表示獲取到鎖如果使用臨時順序節點,客戶端創建的節點為順序最小節點,表示獲取到鎖

  2. 刪除節點實現解鎖

  3. 通過臨時節點的斷開連接自動刪除的特性來避免持有鎖的服務器宕機而導致的死鎖

  4. 通過節點的順序性和事件監聽機制,大節點監聽小節點,形成節點監聽鏈,來實現等待隊列(公平鎖)

其他思路:

  1. 不使用監聽機制,未獲取到鎖的線程自旋重試或者失敗退出(根據業務決定),可實現非阻塞的樂觀鎖。

  2. 不使用臨時順序節點,而使用臨時節點,所有客戶端都去監聽該臨時節點,可實現非公平鎖。但是會產生"羊群效應",單個事件,引發多個服務器響應,占用服務器資源和網絡帶寬,需要根據業務場景選用。

基於 ZooKeeper 做分布式鎖


ZOOKEEPER 鎖相關基礎知識

  1. zk 一般由多個節點構成(單數),采用 zab 一致性協議。因此可以將 zk 看成一個單點結構,對其修改數據其內部自動將所有節點數據進行修改而后才提供查詢服務。

  2. zk 的數據以目錄樹的形式,每個目錄稱為 znode, znode 中可存儲數據(一般不超過 1M),還可以在其中增加子節點。

  3. 子節點有三種類型。序列化節點,每在該節點下增加一個節點自動給該節點的名稱上自增。臨時節點,一旦創建這個 znode 的客戶端與服務器失去聯系,這個 znode 也將自動刪除。最后就是普通節點。

  4. Watch 機制,client 可以監控每個節點的變化,當產生變化會給 client 產生一個事件。

ZK 基本鎖

  1. 原理:利用臨時節點與 watch 機制。每個鎖占用一個普通節點 /lock,當需要獲取鎖時在 /lock 目錄下創建一個臨時節點,創建成功則表示獲取鎖成功,失敗則 watch/lock 節點,有刪除操作后再去爭鎖。臨時節點好處在於當進程掛掉后能自動上鎖的節點自動刪除即取消鎖。

  2. 缺點:所有取鎖失敗的進程都監聽父節點,很容易發生羊群效應,即當釋放鎖后所有等待進程一起來創建節點,並發量很大。

ZK 鎖優化

  • 原理:上鎖改為創建臨時有序節點,每個上鎖的節點均能創建節點成功,只是其序號不同。只有序號最小的可以擁有鎖,如果這個節點序號不是最小的則 watch 序號比本身小的前一個節點 (公平鎖)。

步驟:

1.在 /lock 節點下創建一個有序臨時節點 (EPHEMERAL_SEQUENTIAL)。

2.判斷創建的節點序號是否最小,如果是最小則獲取鎖成功。不是則取鎖失敗,然后 watch 序號比本身小的前一個節點。

3.當取鎖失敗,設置 watch 后則等待 watch 事件到來后,再次判斷是否序號最小。

4.取鎖成功則執行代碼,最后釋放鎖(刪除該節點)。

4.2 zookeeper分布式鎖的缺點

zookeeper分布式鎖有着較好的可靠性,但是也有如下缺點:

  1.zookeeper分布式鎖是性能可能沒有redis分布式鎖高,因為每次在創建鎖和釋放鎖的過程中,都要動態創建、銷毀臨時節點來實現鎖功能。

  2.使用zookeeper也有可能帶來並發問題,只是並不常見而已。比如,由於網絡抖動,客戶端與zk集群的session連接斷了,那么zk以為客戶端掛了,就會刪除臨時節點,這時候其他客戶端就可以獲取到分布式鎖了。就可能產生並發問題。這個問題不常見是因為zk有重試機制,一旦zk集群檢測不到客戶端的心跳,就會重試,curator客戶端支持多種重試策略。多次重試之后還不行的話才會刪除臨時節點。

優點:

有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實現起來較為簡單。

缺點:

性能上可能並沒有緩存服務那么高,因為每次在創建鎖和釋放鎖的過程中,都要動態創建、銷毀臨時節點來實現鎖功能。ZK 中創建和刪除節點只能通過 Leader 服務器來執行,然后將數據同步到所有的 Follower 機器上。還需要對 ZK的原理有所了解。

 

五 基於 Consul 做分布式鎖


DD 寫過類似文章,其實主要利用 Consul 的 Key / Value 存儲 API 中的 acquire 和 release 操作來實現。

文章地址:http://blog.didispace.com/spring-cloud-consul-lock-and-semphore/

六 總結

上面幾種方式,哪種方式都無法做到完美。就像CAP一樣,在復雜性、可靠性、性能等方面無法同時滿足,所以,根據不同的應用場景選擇最適合自己的才是王道。

  1.從實現的復雜性角度(從高到低)zookeeper >= redis> 數據庫

    數據庫實現的分布式鎖易於理解和實現,且不會給項目引入其他依賴。zookeeper和redis需要考慮的情況更多,實現相對較為復雜,但是都有現成的分布式鎖框架curator和redision,用起來代碼反而可能會更簡潔。

  2.從性能角度(從高到低)redis>zookeeper > 數據庫

    redis數據存在內存,速度很快;zookeeper雖然數據也存在內存中,但是本身維護節點的一致性。需要耗費一些性能;數據庫則只有索引在內存中,數據存於磁盤,性能較差。

  3.從可靠性角度(從高到低)zookeeper > redis > 數據庫

    zookeeper天生設計定位就是分布式協調,強一致性,可靠性較高;redis分布式鎖需要較多額外手段去保證可靠性;數據庫則較難滿足復雜場景的需求。

使用分布式鎖的注意事項

1、注意分布式鎖的開銷

2、注意加鎖的粒度

3、加鎖的方式

 

分布式鎖需要具備哪些條件

  1. 獲取鎖和釋放鎖的性能要好

  2. 判斷是否獲得鎖必須是原子性的,否則可能導致多個請求都獲取到鎖

  3. 網絡中斷或宕機無法釋放鎖時,鎖必須被清除,不然會發生死鎖

  4. 可重入一個線程中可以多次獲取同一把鎖,比如一個線程在執行一個帶鎖的方法,該方法中又調用了另一個需要相同鎖的方法,則該線程可以直接執行調用的方法,而無需重新獲得鎖;

  5. 阻塞鎖和非阻塞鎖,阻塞鎖即沒有獲取到鎖,則繼續等待獲取鎖;非阻塞鎖即沒有獲取到鎖后,不繼續等待,直接返回鎖失敗。

分布式鎖實現方式

一、數據庫鎖

一般很少使用數據庫鎖,性能不好並且容易產生死鎖。

  1. 基於MySQL鎖表

該實現方式完全依靠數據庫唯一索引來實現,當想要獲得鎖時,即向數據庫中插入一條記錄,釋放鎖時就刪除這條記錄。這種方式存在以下幾個問題:

(1) 鎖沒有失效時間,解鎖失敗會導致死鎖,其他線程無法再獲取到鎖,因為唯一索引insert都會返回失敗。

(2) 只能是非阻塞鎖,insert失敗直接就報錯了,無法進入隊列進行重試

(3) 不可重入,同一線程在沒有釋放鎖之前無法再獲取到鎖

  1. 采用樂觀鎖增加版本號

根據版本號判斷更新之前有沒有其他線程更新過,如果被更新過,則獲取鎖失敗。

二、緩存鎖

具體實例可以參考我講述Redis的系列文章,里面有完整的Redis分布式鎖實現方案

這里我們主要介紹幾種基於redis實現的分布式鎖:基於setnx、expire兩個命令來實現

基於setnx(set if not exist)的特點,當緩存里key不存在時,才會去set,否則直接返回false。如果返回true則獲取到鎖,否則獲取鎖失敗,為了防止死鎖,我們再用expire命令對這個key設置一個超時時間來避免。但是這里看似完美,實則有缺陷,當我們setnx成功后,線程發生異常中斷,expire還沒來的及設置,那么就會產生死鎖。

解決上述問題有兩種方案

第一種是采用redis2.6.12版本以后的set,它提供了一系列選項

EX seconds – 設置鍵key的過期時間,單位時秒

PX milliseconds – 設置鍵key的過期時間,單位時毫秒

NX – 只有鍵key不存在的時候才會設置key的值

XX – 只有鍵key存在的時候才會設置key的值

第二種采用setnx(localkey,value),get(localkey),getset(localkey,value)實現,大體的實現過程如下:

(1) 線程Asetnx,值為超時的時間戳(t1),如果返回true,獲得鎖。

(2) 線程B用get 命令獲取t1,與當前時間戳比較,判斷是否超時,沒超時false,如果已超時執行步驟3

(3) 計算新的超時時間t2,使用getset命令返回t3(這個值可能其他線程已經修改過),如果t1==t3,獲得鎖,如果t1!=t3說明鎖被其他線程獲取了

(4) 獲取鎖后,處理完業務邏輯,再去判斷鎖是否超時,如果沒超時刪除鎖,如果已超時,不用處理(防止刪除其他線程的鎖)

一.redis命令講解: setnx()命令: setnx的含義就是SET if Not Exists,其主要有兩個參數 setnx(key, value)。

該方法是原子的,如果key不存在,則設置當前key成功,返回1;如果當前key已經存在,則設置當前key失敗,返回0。

get()命令: get(key) 獲取key的值,如果存在,則返回;如果不存在,則返回nil; getset()命令: 這個命令主要有兩個參數 getset(key, newValue)。該方法是原子的,對key設置newValue這個值,並且返回key原來的舊值。 假設key原來是不存在的,那么多次執行這個命令,會出現下邊的效果:

getset(key, "value1") 返回nil 此時key的值會被設置為value1

1.getset(key, "value2") 返回value1 此時key的值會被設置為value2

2.依次類推!

二.具體的使用步驟如下:

1.setnx(lockkey, 當前時間+過期超時時間) ,如果返回1,則獲取鎖成功;如果返回0則沒有獲取到鎖,轉向2。

  1. get(lockkey)獲取值oldExpireTime ,並將這個value值與當前的系統時間進行比較,如果小於當前系統時間,則認為這個鎖已經超時,可以允許別的請求重新獲取,轉向3。

    1. 計算newExpireTime=當前時間+過期超時時間,然后getset(lockkey, newExpireTime) 會返回當前lockkey的值currentExpireTime。

    2. 判斷currentExpireTime與oldExpireTime 是否相等,如果相等,說明當前getset設置成功,獲取到了鎖。如果不相等,說明這個鎖又被別的請求獲取走了,那么當前請求可以直接返回失敗,或者繼續重試。

  2. 在獲取到鎖之后,當前線程可以開始自己的業務處理,當處理完畢后,比較自己的處理時間和對於鎖設置的超時時間,如果小於鎖設置的超時時間,則直接執行delete釋放鎖;如果大於鎖設置的超時時間,則不需要再鎖進行處理。

  1. RedLock算法

redlock算法是redis作者推薦的一種分布式鎖實現方式,算法的內容如下:

(1) 獲取當前時間;

(2) 使用setNx()嘗試從5個相互獨立redis客戶端獲取鎖;

(3) 計算獲取所有鎖消耗的時間,當且僅當客戶端從多數節點獲取鎖,並且獲取鎖的時間小於鎖的有效時間,認為獲得鎖;

(4) 重新計算有效期時間,原有效時間減去獲取鎖消耗的時間;

(5) 刪除所有實例的鎖

redlock算法相對於單節點redis鎖可靠性要更高,但是實現起來條件也較為苛刻。

(1) 必須部署5個節點才能讓Redlock的可靠性更強

(2) 需要請求5個節點才能獲取到鎖,通過Future的方式,先並發向5個節點請求,再一起獲得響應結果,能縮短響應時間,不過還是比單節點redis鎖要耗費更多時間

然后由於必須獲取到5個節點中的3個以上,所以可能出現獲取鎖沖突,即大家都獲得了1-2把鎖,結果誰也不能獲取到鎖,這個問題,redis作者借鑒了raft算法的精髓,通過沖突后從隨機時間開始,可以大大降低沖突時間,但是這問題並不能很好的避免,特別是在第一次獲取鎖的時候,所以獲取鎖的時間成本增加了。

如果5個節點有2個宕機,此時鎖的可用性會極大降低,首先必須等待這兩個宕機節點的結果超時才能返回,另外只有3個節點,客戶端必須獲取到這全部3個節點的鎖才能擁有鎖,難度也加大了。

如果出現網絡分區,那么可能出現客戶端永遠也無法獲取鎖的情況,介於這種情況,下面我們來看一種更可靠的分布式鎖zookeeper鎖。

三、zookeeper分布式鎖

關於zookeeper的分布式鎖實現在之前講述zookeeper的時候已經介紹了。這里不再贅述、

首先我們來了解一下zookeeper的特性,看看它為什么適合做分布式鎖,

zookeeper是一個為分布式應用提供一致性服務的軟件它內部是一個分層的文件系統目錄樹結構,規定統一個目錄下只能有一個唯一文件名。

數據模型:

永久節點:節點創建后,不會因為會話失效而消失

臨時節點:與永久節點相反,如果客戶端連接失效,則立即刪除節點

順序節點:(臨時或者永久的順序節點)與上述兩個節點特性類似,如果指定創建這類節點時,zk會自動在節點名后加一個數字后綴,並且是有序的。

監視器(watcher):

當創建一個節點時,可以注冊一個該節點的監視器,當節點狀態發生改變時,watch被觸發時,ZooKeeper將會向客戶端發送且僅發送一條通知,因為watch只能被觸發一次。

根據zookeeper的這些特性,我們來看看如何利用這些特性來實現分布式鎖:

  1. 創建一個鎖目錄lock

  2. 希望獲得鎖的線程A就在lock目錄下,創建臨時順序節點

  3. 獲取鎖目錄下所有的子節點,然后獲取比自己小的兄弟節點,如果不存在,則說明當前線程順序號最小,獲得鎖

  4. 線程B獲取所有節點,判斷自己不是最小節點,設置監聽(watcher)比自己次小的節點(只關注比自己次小的節點是為了防止發生“羊群效應”)

  5. 線程A處理完,刪除自己的節點,線程B監聽到變更事件,判斷自己是最小的節點,獲得鎖。

小結

在分布式系統中,共享資源互斥訪問問題非常普遍,而針對訪問共享資源的互斥問題,常用的解決方案就是使用分布式鎖,這里只介紹了幾種常用的分布式鎖,分布式鎖的實現方式還有有很多種,根據業務選擇合適的分布式鎖,下面對上述幾種鎖進行一下比較:

數據庫鎖:

優點:直接使用數據庫,使用簡單

缺點:分布式系統大多數瓶頸都在數據庫,使用數據庫鎖會增加數據庫負擔。

緩存鎖:

優點:性能高,實現起來較為方便,在允許偶發的鎖失效情況,不影響系統正常使用,建議采用緩存鎖。

缺點:通過鎖超時機制不是十分可靠,當線程獲得鎖后,處理時間過長導致鎖超時,就失效了鎖的作用。

zookeeper鎖:

優點:不依靠超時時間釋放鎖;可靠性高;系統要求高可靠性時,建議采用zookeeper鎖。

缺點:性能比不上緩存鎖,因為要頻繁的創建節點刪除節點。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM