分布式鎖解決并發(fā)的三種實現(xiàn)方式

轉(zhuǎn)載自(Tank丶Farmer)

在很多場景中,我們?yōu)榱吮WC數(shù)據(jù)的最終一致性,需要很多的技術(shù)方案來支持,比如分布式事務(wù)、分布式鎖等。有的時候,我們需要保證一個方法在同

一時間內(nèi)只能被同一個線程執(zhí)行。在單機(jī)環(huán)境中,Java中其實提供了很多并發(fā)處理相關(guān)的API,但是這些API在分布式場景中就無能為力了。也就是說單

純的Java Api并不能提供分布式鎖的能力。所以針對分布式鎖的實現(xiàn)目前有多種方案:

分布式鎖一般有三種實現(xiàn)方式:1. 數(shù)據(jù)庫鎖;2. 基于Redis的分布式鎖;3. 基于ZooKeeper的分布式鎖。

分布式鎖應(yīng)該是怎么樣的

互斥性 可以保證在分布式部署的應(yīng)用集群中,同一個方法在同一時間只能被一臺機(jī)器上的一個線程執(zhí)行。

這把鎖要是一把可重入鎖(避免死鎖)

不會發(fā)生死鎖:有一個客戶端在持有鎖的過程中崩潰而沒有解鎖,也能保證其他客戶端能夠加鎖

這把鎖最好是一把阻塞鎖(根據(jù)業(yè)務(wù)需求考慮要不要這條)

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

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

數(shù)據(jù)庫鎖

基于數(shù)據(jù)庫表

要實現(xiàn)分布式鎖,最簡單的方式可能就是直接創(chuàng)建一張鎖表,然后通過操作該表中的數(shù)據(jù)來實現(xiàn)了。

當(dāng)我們要鎖住某個方法或資源時,我們就在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。

CREATE TABLE `methodLock` (

? `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',

? `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',

? `desc` varchar(1024) NOT NULL DEFAULT '備注信息',

? `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存數(shù)據(jù)時間,自動生成',

? PRIMARY KEY (`id`),

? UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE

) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';

當(dāng)我們想要鎖住某個方法時,執(zhí)行以下SQL:

insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)

因為我們對method_name做了唯一性約束,這里如果有多個請求同時提交到數(shù)據(jù)庫的話,數(shù)據(jù)庫會保證只有一個操作可以成功,那么我們就可以認(rèn)為

操作成功的那個線程獲得了該方法的鎖,可以執(zhí)行方法體內(nèi)容。

當(dāng)方法執(zhí)行完畢之后,想要釋放鎖的話,需要執(zhí)行以下Sql:

delete from methodLock where method_name ='method_name'

上面這種簡單的實現(xiàn)有以下幾個問題:

1、這把鎖強(qiáng)依賴數(shù)據(jù)庫的可用性,數(shù)據(jù)庫是一個單點,一旦數(shù)據(jù)庫掛掉,會導(dǎo)致業(yè)務(wù)系統(tǒng)不可用。

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

3、這把鎖只能是非阻塞的,因為數(shù)據(jù)的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程并不會進(jìn)入排隊隊列,要想再次獲得鎖就要再次觸發(fā)獲得鎖操作。

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

當(dāng)然,我們也可以有其他方式解決上面的問題。

數(shù)據(jù)庫是單點?搞兩個數(shù)據(jù)庫,數(shù)據(jù)之前雙向同步。一旦掛掉快速切換到備庫上。

沒有失效時間?只要做一個定時任務(wù),每隔一定時間把數(shù)據(jù)庫中的超時數(shù)據(jù)清理一遍。

非阻塞的?搞一個while循環(huán),直到insert成功再返回成功。

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

基于數(shù)據(jù)庫的排它鎖

除了可以通過增刪操作數(shù)據(jù)表中的記錄以外,其實還可以借助數(shù)據(jù)庫中自帶的鎖來實現(xiàn)分布式的鎖。

我們還用剛剛創(chuàng)建的那張數(shù)據(jù)庫表??梢酝ㄟ^數(shù)據(jù)庫的排他鎖來實現(xiàn)分布式鎖。

在查詢語句后面增加for update,數(shù)據(jù)庫會在查詢過程中給數(shù)據(jù)庫表增加排他鎖。當(dāng)某條記錄被加上排他鎖之后,其他線程無法再在該行記錄上增加排他鎖。

我們可以認(rèn)為獲得排它鎖的線程即可獲得分布式鎖,當(dāng)獲取到鎖之后,可以執(zhí)行方法的業(yè)務(wù)邏輯,執(zhí)行完方法之后,再通過以下方法解鎖:

public void unlock(){?

? connection.commit();

}

通過connection.commit()操作來釋放鎖。

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

阻塞鎖? for update語句會在執(zhí)行成功后立即返回,在執(zhí)行失敗時一直處于阻塞狀態(tài),直到成功。

鎖定之后服務(wù)宕機(jī),無法釋放?使用這種方式,服務(wù)宕機(jī)之后數(shù)據(jù)庫會自己把鎖釋放掉。

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

總結(jié):

總結(jié)一下使用數(shù)據(jù)庫來實現(xiàn)分布式鎖的方式,這兩種方式都是依賴數(shù)據(jù)庫的一張表,一種是通過表中的記錄的存在情況確定當(dāng)前是否有鎖存在,另外一種是通過數(shù)據(jù)庫的排他鎖來實現(xiàn)分布式鎖。

**數(shù)據(jù)庫實現(xiàn)分布式鎖的優(yōu)點: **直接借助數(shù)據(jù)庫,容易理解。

**數(shù)據(jù)庫實現(xiàn)分布式鎖的缺點: **會有各種各樣的問題,在解決問題的過程中會使整個方案變得越來越復(fù)雜。

操作數(shù)據(jù)庫需要一定的開銷,性能問題需要考慮。

樂觀鎖

樂觀鎖假設(shè)認(rèn)為數(shù)據(jù)一般情況下不會造成沖突,只有在進(jìn)行數(shù)據(jù)的提交更新時,才會檢測數(shù)據(jù)的沖突情況,如果發(fā)現(xiàn)沖突了,則返回錯誤信息

實現(xiàn)方式:

時間戳(timestamp)記錄機(jī)制實現(xiàn):給數(shù)據(jù)庫表增加一個時間戳字段類型的字段,當(dāng)讀取數(shù)據(jù)時,將timestamp字段的值一同讀出,數(shù)據(jù)每更新一次,timestamp也同步更新。當(dāng)對數(shù)據(jù)做提交更新操作時,檢查當(dāng)前數(shù)據(jù)庫中數(shù)據(jù)的時間戳和自己更新前取到的時間戳進(jìn)行對比,若相等,則更新,否則認(rèn)為是失效數(shù)據(jù)。

若出現(xiàn)更新沖突,則需要上層邏輯修改,啟動重試機(jī)制

同樣也可以使用version的方式。

性能對比

(1) 悲觀鎖實現(xiàn)方式是獨占數(shù)據(jù),其它線程需要等待,不會出現(xiàn)修改的沖突,能夠保證數(shù)據(jù)的一致性,但是依賴數(shù)據(jù)庫的實現(xiàn),且在線程較多時出現(xiàn)等待造成效率降低的問題。一般情況下,對于數(shù)據(jù)很敏感且讀取頻率較低的場景,可以采用悲觀鎖的方式

(2) 樂觀鎖可以多線程同時讀取數(shù)據(jù),若出現(xiàn)沖突,也可以依賴上層邏輯修改,能夠保證高并發(fā)下的讀取,適用于讀取頻率很高而修改頻率較少的場景

(3) 由于庫存回寫數(shù)據(jù)屬于敏感數(shù)據(jù)且讀取頻率適中,所以建議使用悲觀鎖優(yōu)化

基于redis的分布式鎖

相比較于基于數(shù)據(jù)庫實現(xiàn)分布式鎖的方案來說,基于緩存來實現(xiàn)在性能方面會表現(xiàn)的更好一點。而且很多緩存是可以集群部署的,可以解決單點問題。

首先,為了確保分布式鎖可用,我們至少要確保鎖的實現(xiàn)同時滿足以下四個條件:

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

不會發(fā)生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證后續(xù)其他客戶端能加鎖。

具有容錯性。只要大部分的Redis節(jié)點正常運(yùn)行,客戶端就可以加鎖和解鎖。

解鈴還須系鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。

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

第一個為key,我們使用key來當(dāng)鎖,因為key是唯一的。

第二個為value,我們傳的是requestId,很多童鞋可能不明白,有key作為鎖不就夠了嗎,為什么還要用到value?原因就是我們在上面講到可靠性時,分布式鎖要滿足第四個條件解鈴還須系鈴人,通過給value賦值為requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據(jù)。requestId可以使用UUID.randomUUID().toString()方法生成。

第三個為nxxx,這個參數(shù)我們填的是NX,意思是SET IF NOT EXIST,即當(dāng)key不存在時,我們進(jìn)行set操作;若key已經(jīng)存在,則不做任何操作;

第四個為expx,這個參數(shù)我們傳的是PX,意思是我們要給這個key加一個過期的設(shè)置,具體時間由第五個參數(shù)決定。

第五個為time,與第四個參數(shù)相呼應(yīng),代表key的過期時間。

總的來說,執(zhí)行上面的set()方法就只會導(dǎo)致兩種結(jié)果:1. 當(dāng)前沒有鎖(key不存在),那么就進(jìn)行加鎖操作,并對鎖設(shè)置個有效期,同時value表示加鎖的客戶端。2. 已有鎖存在,不做任何操作。

心細(xì)的童鞋就會發(fā)現(xiàn)了,我們的加鎖代碼滿足我們可靠性里描述的三個條件。首先,set()加入了NX參數(shù),可以保證如果已有key存在,則函數(shù)不會調(diào)用成功,也就是只有一個客戶端能持有鎖,滿足互斥性。其次,由于我們對鎖設(shè)置了過期時間,即使鎖的持有者后續(xù)發(fā)生崩潰而沒有解鎖,鎖也會因為到了過期時間而自動解鎖(即key被刪除),不會發(fā)生死鎖。最后,因為我們將value賦值為requestId,代表加鎖的客戶端請求標(biāo)識,那么在客戶端在解鎖的時候就可以進(jìn)行校驗是否是同一個客戶端。由于我們只考慮Redis單機(jī)部署的場景,所以容錯性我們暫不考慮。

錯誤實例:

使用jedis.setnx()和jedis.expire()組合實現(xiàn)加鎖

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {?

? Long result = jedis.setnx(lockKey, requestId);?

if (result == 1) {

? ? ? // 若在這里程序突然崩潰,則無法設(shè)置過期時間,將發(fā)生死鎖? ? ? ? jedis.expire(lockKey, expireTime);?

? ? }

}

setnx()方法作用就是SET IF NOT EXIST,expire()方法就是給鎖加一個過期時間。乍一看好像和前面的set()方法結(jié)果一樣,然而由于這是兩條Redis命令,不具有原子性,如果程序在執(zhí)行完setnx()之后突然崩潰,導(dǎo)致鎖沒有設(shè)置過期時間。那么將會發(fā)生死鎖。網(wǎng)上之所以有人這樣實現(xiàn),是因為低版本的jedis并不支持多參數(shù)的set()方法。

解鎖:

首先獲取鎖對應(yīng)的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖)

總結(jié):

可以使用緩存來代替數(shù)據(jù)庫來實現(xiàn)分布式鎖,這個可以提供更好的性能,同時,很多緩存服務(wù)都是集群部署的,可以避免單點問題。并且很多緩存服務(wù)都提供了可以用來實現(xiàn)分布式鎖的方法,比如redis的setnx方法等。并且,這些緩存服務(wù)也都提供了對數(shù)據(jù)的過期自動刪除的支持,可以直接設(shè)置超時時間來控制鎖的釋放。

使用緩存實現(xiàn)分布式鎖的優(yōu)點

性能好,實現(xiàn)起來較為方便。

使用緩存實現(xiàn)分布式鎖的缺點

通過超時時間來控制鎖的失效時間并不是十分的靠譜。

基于Zookeeper實現(xiàn)分布式鎖

基于zookeeper臨時有序節(jié)點可以實現(xiàn)的分布式鎖。大致思想即為:每個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應(yīng)的指定節(jié)點的目錄下,生成一個唯一的

瞬時有序節(jié)點。 判斷是否獲取鎖的方式很簡單,只需要判斷有序節(jié)點中序號最小的一個。 當(dāng)釋放鎖的時候,只需將這個瞬時節(jié)點刪除即可。同時,其可以避免服務(wù)宕機(jī)導(dǎo)

致的鎖無法釋放,而產(chǎn)生的死鎖問題。

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

鎖無法釋放?使用Zookeeper可以有效的解決鎖無法釋放的問題,因為在創(chuàng)建鎖的時候,客戶端會在ZK中創(chuàng)建一個臨時節(jié)點,一旦客戶端獲取到鎖之后突然掛掉(

Session連接斷開),那么這個臨時節(jié)點就會自動刪除掉。其他客戶端就可以再次獲得鎖。

非阻塞鎖?使用Zookeeper可以實現(xiàn)阻塞的鎖,客戶端可以通過在ZK中創(chuàng)建順序節(jié)點,并且在節(jié)點上綁定監(jiān)聽器,一旦節(jié)點有變化,Zookeeper會通知客戶端,客戶

端可以檢查自己創(chuàng)建的節(jié)點是不是當(dāng)前所有節(jié)點中序號最小的,如果是,那么自己就獲取到鎖,便可以執(zhí)行業(yè)務(wù)邏輯了。

不可重入?使用Zookeeper也可以有效的解決不可重入的問題,客戶端在創(chuàng)建節(jié)點的時候,把當(dāng)前客戶端的主機(jī)信息和線程信息直接寫入到節(jié)點中,下次想要獲取鎖的

時候和當(dāng)前最小的節(jié)點中的數(shù)據(jù)比對一下就可以了。如果和自己的信息一樣,那么自己直接獲取到鎖,如果不一樣就再創(chuàng)建一個臨時的順序節(jié)點,參與排隊。

單點問題?使用Zookeeper可以有效的解決單點問題,ZK是集群部署的,只要集群中有半數(shù)以上的機(jī)器存活,就可以對外提供服務(wù)。

可以直接使用zookeeper第三方庫Curator客戶端,這個客戶端中封裝了一個可重入的鎖服務(wù)。

Zookeeper實現(xiàn)的分布式鎖其實存在一個缺點,那就是性能上可能并沒有緩存服務(wù)那么高。

因為每次在創(chuàng)建鎖和釋放鎖的過程中,都要動態(tài)創(chuàng)建、銷毀瞬時節(jié)點來實現(xiàn)鎖功能。ZK中創(chuàng)建和刪除節(jié)點只能通過Leader服務(wù)器來執(zhí)行,然后將數(shù)據(jù)同不到所有的Follower機(jī)器上。

**使用Zookeeper實現(xiàn)分布式鎖的優(yōu)點: **有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實現(xiàn)起來較為簡單。

**使用Zookeeper實現(xiàn)分布式鎖的缺點 : **性能上不如使用緩存實現(xiàn)分布式鎖。 需要對ZK的原理有所了解。

三種方案的比較

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

從實現(xiàn)的復(fù)雜性角度(從低到高)**: **Zookeeper >= 緩存 > 數(shù)據(jù)庫

**從性能角度(從高到低): **緩存 > Zookeeper >= 數(shù)據(jù)庫

**從可靠性角度(從高到低): **Zookeeper > 緩存 > 數(shù)據(jù)庫

參考:

http://wudashan.cn/2017/10/23/Redis-Distributed-Lock-Implement/

http://www.hollischuang.com/archives/1716

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容