分布式鎖的常見實現(xiàn)思路
點擊上方藍色字體,選擇“標(biāo)星公眾號”
優(yōu)質(zhì)文章,第一時間送達
? 作者?|??Ellen艾倫?
來源 |? urlify.cn/F36JFz
一. 概述
1.1 引言
當(dāng)前參與的項目中會遇到一些線程安全問題,由于業(yè)務(wù)是多節(jié)點部署的,Java的單機的并發(fā)同步手段synchronized和java.util.concurrent包已經(jīng)不太夠用了,這個時候我們需要分布式鎖來保證線程安全問題,所以這里學(xué)習(xí)總結(jié)了幾種分布式鎖的實現(xiàn)思路。
分布式的CAP理論告訴我們?nèi)魏我粋€分布式系統(tǒng)都無法同時滿足一致性(Consistency)、可用性(Availability)和分區(qū)容錯性(Partition tolerance),最多只能同時滿足兩項。一般情況下,都需要犧牲強一致性來換取系統(tǒng)的高可用性,系統(tǒng)往往只需要保證最終一致性,只要這個最終時間是在用戶可以接受的范圍內(nèi)即可。在很多時候,為了保證數(shù)據(jù)的最終一致性,需要很多的技術(shù)方案來支持,比如分布式事務(wù)、分布式鎖等。這里我們主要介紹對象分布式鎖,分布式鎖的的具體實現(xiàn)方案主要如下三種:
基于數(shù)據(jù)庫的實現(xiàn)
基于緩存(redis)的實現(xiàn)
基于zookeeper的實現(xiàn)
1.2 分布式鎖的要求
一個可靠的、高可用的分布式鎖需要滿足以下幾點
互斥性:任意時刻只能有一個客戶端擁有鎖,不能被多個客戶端獲取
安全性:鎖只能被持有該鎖的客戶端刪除,不能被其它客戶端刪除
死鎖避免:獲取鎖的客戶端因為某些原因而宕機,而未能釋放鎖,其它客戶端也就無法獲取該鎖,需要有機制來避免該類問題的發(fā)生
高可用:當(dāng)部分節(jié)點宕機,客戶端仍能獲取鎖或者釋放鎖
二. 基于數(shù)據(jù)庫的實現(xiàn)
2.1 基于數(shù)據(jù)庫實現(xiàn)的樂觀鎖
樂觀鎖的通常是基于數(shù)據(jù)版本號來實現(xiàn)的。比如,有個商品表t_goods,有一個字段left_count用來記錄商品的庫存?zhèn)€數(shù)。在并發(fā)的情況下,為了保證不出現(xiàn)超賣現(xiàn)象,即left_count不為負(fù)數(shù)。樂觀鎖的實現(xiàn)方式為給商品表增加一個版本號字段version,默認(rèn)為0,每修改一次數(shù)據(jù),將版本號加1。
無版本號并發(fā)超賣示例:
--?線程1查詢,當(dāng)前l(fā)eft_count為1,則有記錄
select?*?from?t_goods?where?id?=?10001?and?left_count?>?0
--?線程2查詢,當(dāng)前l(fā)eft_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,產(chǎn)生臟數(shù)據(jù)
update?t_goods?set?left_count?=?left_count?-?1?where?id?=?10001有版本號的樂觀鎖示例:
--?線程1查詢,當(dāng)前l(fā)eft_count為1,則有記錄,當(dāng)前版本號為999
select?left_count,?version?from?t_goods?where?id?=?10001?and?left_count?>?0;
--?線程2查詢,當(dāng)前l(fā)eft_count為1,也有記錄,當(dāng)前版本號為999
select?left_count,?version?from?t_goods?where?id?=?10001?and?left_count?>?0;
--?線程1,更新完成后當(dāng)前的version為1000,update狀態(tài)為1,更新成功
update?t_goods?set?version?=?1000,?left_count?=?left_count-1?where?id?=?10001?and?version?=?999;
--?線程2,更新由于當(dāng)前的version為1000,udpate狀態(tài)為0,更新失敗,再針對相關(guān)業(yè)務(wù)做異常處理
update?t_goods?set?version?=?1000,?left_count?=?left_count-1?where?id?=?10001?and?version?=?999;可以發(fā)現(xiàn),這種和CAS的樂觀鎖機制是類似的,所不同的是CAS的硬件來保證原子性,而這里是通過數(shù)據(jù)庫來保證單條SQL語句的原子性。順帶一提CAS的ABA問題一般也是通過版本號來解決。
2.2 基于數(shù)據(jù)庫實現(xiàn)的排他鎖
基于數(shù)據(jù)庫的排他鎖需要通過數(shù)據(jù)庫的唯一性約束UNIQUE KEY來保證數(shù)據(jù)的唯一性,從而為鎖的獨占性提供基礎(chǔ)。
表結(jié)構(gòu)如下:
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');?如果當(dāng)前SQL執(zhí)行成功代表加鎖成功,如果拋出唯一索引異常(DuplicatedKeyException)則代表加鎖失敗,當(dāng)前鎖已經(jīng)被其他競爭者獲取。
解鎖鎖時刪除該記錄:
delete?from?distribute_lock(unique_mutex)?values('muetx_demo');除了增刪記錄,也可以通過更新state字段來標(biāo)識是否獲取到鎖。
--?獲取鎖
update?distribute_lock?set?state?=?2?where?`unique_mutex`?=?'muetx_demo'?and?state=1;更新之前需要SELECT確認(rèn)鎖在數(shù)據(jù)庫中存在,沒有則創(chuàng)建之。如果創(chuàng)建或更新失敗,則說明這個資源已經(jīng)被別的線程占用了。
2.3 小結(jié)
數(shù)據(jù)庫排他鎖可能出現(xiàn)的問題及解決思路:
沒有失效時間, 一旦解鎖失敗,會導(dǎo)致鎖記錄一直在數(shù)據(jù)庫中,其他線程無法再獲得鎖。
可通過定時任務(wù)清除超時數(shù)據(jù)來解決
是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。
可通過增加字段記錄當(dāng)前主機信息和當(dāng)線程信息,
這個鎖只能是非阻塞的,因為數(shù)據(jù)的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的在線程并不會進入阻塞隊列,需要不停自旋直到獲得鎖,相對耗資源。
總的來說,基于數(shù)據(jù)庫的分布式鎖,能夠滿足一些簡單的需求,好處是能夠少引入依賴,實現(xiàn)較為簡單,缺點是性能較低,且難以滿足復(fù)雜場景下的高并發(fā)需求。
三. 基于redis的實現(xiàn)
3.1 基本實現(xiàn)思路
一個簡單的分布式鎖機制是使用setnx、expire 、del 三個命令的組合來實現(xiàn)的。setnx命令的含義為:當(dāng)且僅當(dāng)key不存在時,value設(shè)置成功,返回1;否則返回0。另外兩個命令,見名知意,就不多做解釋了。
#?加鎖,設(shè)置鎖的唯一標(biāo)識key,返回1說明加鎖成功,返回0加鎖失敗
setnx?key?value
#?設(shè)置鎖超時時間為30s,防止死鎖
expire?key?30
#?解鎖,?刪除鎖
del?key這種思路存在的問題:
setnx和expire的非原子性:如果加鎖之后,服務(wù)器宕機,導(dǎo)致expire和del均執(zhí)行不了,會導(dǎo)致死鎖。
del導(dǎo)致誤刪:A線程超時之后未執(zhí)行完, 鎖過期釋放;B線程獲得鎖,此時A線程執(zhí)行完,執(zhí)行del將B線程的鎖刪除。
鎖過期后引起的并發(fā):A線程超時之后未執(zhí)行完, 鎖過期釋放;B線程獲得鎖,此時A、B線程并發(fā)執(zhí)行會導(dǎo)致線程安全問題。
對應(yīng)的解決思路:
將加鎖和設(shè)置鎖過期時間做成一個原子性操作
在Redis 2.6.12版本之后,set命令增加了NX可選參數(shù),可替代setnx命令;增加了EX可選參數(shù),可以設(shè)置key的同時指定過期時間
或者將兩個操作封裝在lua腳本中,發(fā)送給Redis執(zhí)行,從而實現(xiàn)操作的原子性。
將key的value設(shè)置為線程相關(guān)信息,del釋放鎖之前先判斷一下鎖是不是自己的。(釋放和判斷不是原子性的,需要封裝在lua腳本中)
啟動一個守護線程,在后臺自動給自己的鎖''續(xù)期“,執(zhí)行完成,顯式關(guān)掉守護進程
3.2?redis分布式鎖的缺點
在大型的應(yīng)用中,一般redis服務(wù)都是集群形式部署的,由于Slave同步Master是異步的,所以會出現(xiàn)客戶端A在Master上加鎖,此時Master宕機,Slave沒有完成鎖的同步,Slave變?yōu)镸aster,客戶端B此時可以完成加鎖操作。
為了解決這一問題,官方給出了redlock算法,即使這樣在一些較復(fù)雜的場景下也不能100%保證沒有問題。較復(fù)雜,留待后續(xù)研究。
四. 基于zookeeper的實現(xiàn)
4.1 基本實現(xiàn)思路
zookeeper 是一個開源的分布式協(xié)調(diào)服務(wù)框架,主要用來解決分布式集群中的一致性問題和數(shù)據(jù)管理問題。zookeeper本質(zhì)上是一個分布式文件系統(tǒng),由一群樹狀節(jié)點組成,每個節(jié)點可以存放少量數(shù)據(jù),且具有唯一性。
zookeeper有四種類型的節(jié)點:
持久節(jié)點(PERSISTENT)
默認(rèn)節(jié)點類型,斷開連接仍然存在
持久順序節(jié)點(PERSISTENT_SEQUENTIAL)
在持久節(jié)點的基礎(chǔ)上,增加了順序性。指定創(chuàng)建同名節(jié)點,會根據(jù)創(chuàng)建順序在指定的節(jié)點名稱后面帶上順序編號,以保證節(jié)點具有唯一性和順序性
臨時節(jié)點(EPHEMERAL)
斷開連接后,節(jié)點會被刪除
臨時順序節(jié)點(EPHEMERAL_SEQUENTIAL)
在臨時節(jié)點的基礎(chǔ)上,增加了順序性。
基于zookeeper實現(xiàn)的分布式鎖主要利用了zookeeper臨時順序節(jié)點的特性和事件監(jiān)聽機制。主要思路如下:
創(chuàng)建節(jié)點實現(xiàn)加鎖,通過節(jié)點的唯一性,來實現(xiàn)鎖的互斥
如果使用臨時節(jié)點,節(jié)點創(chuàng)建成功表示獲取到鎖
如果使用臨時順序節(jié)點,客戶端創(chuàng)建的節(jié)點為順序最小節(jié)點,表示獲取到鎖
刪除節(jié)點實現(xiàn)解鎖
通過臨時節(jié)點的斷開連接自動刪除的特性來避免持有鎖的服務(wù)器宕機而導(dǎo)致的死鎖
通過節(jié)點的順序性和事件監(jiān)聽機制,大節(jié)點監(jiān)聽小節(jié)點,形成節(jié)點監(jiān)聽鏈,來實現(xiàn)等待隊列(公平鎖)
其他思路:
不使用監(jiān)聽機制,未獲取到鎖的線程自旋重試或者失敗退出(根據(jù)業(yè)務(wù)決定),可實現(xiàn)非阻塞的樂觀鎖。
不使用臨時順序節(jié)點,而使用臨時節(jié)點,所有客戶端都去監(jiān)聽該臨時節(jié)點,可實現(xiàn)非公平鎖。但是會產(chǎn)生"羊群效應(yīng)",單個事件,引發(fā)多個服務(wù)器響應(yīng),占用服務(wù)器資源和網(wǎng)絡(luò)帶寬,需要根據(jù)業(yè)務(wù)場景選用。
4.2?zookeeper分布式鎖的缺點
zookeeper分布式鎖有著較好的可靠性,但是也有如下缺點:
zookeeper分布式鎖是性能可能沒有redis分布式鎖高,因為每次在創(chuàng)建鎖和釋放鎖的過程中,都要動態(tài)創(chuàng)建、銷毀臨時節(jié)點來實現(xiàn)鎖功能。
使用zookeeper也有可能帶來并發(fā)問題,只是并不常見而已。比如,由于網(wǎng)絡(luò)抖動,客戶端與zk集群的session連接斷了,那么zk以為客戶端掛了,就會刪除臨時節(jié)點,這時候其他客戶端就可以獲取到分布式鎖了。就可能產(chǎn)生并發(fā)問題。這個問題不常見是因為zk有重試機制,一旦zk集群檢測不到客戶端的心跳,就會重試,curator客戶端支持多種重試策略。多次重試之后還不行的話才會刪除臨時節(jié)點。
五 總結(jié)
上面幾種方式,哪種方式都無法做到完美。就像CAP一樣,在復(fù)雜性、可靠性、性能等方面無法同時滿足,所以,根據(jù)不同的應(yīng)用場景選擇最適合自己的才是王道。
從實現(xiàn)的復(fù)雜性角度(從高到低)zookeeper?>=?redis> 數(shù)據(jù)庫
數(shù)據(jù)庫實現(xiàn)的分布式鎖易于理解和實現(xiàn),且不會給項目引入其他依賴。zookeeper和redis需要考慮的情況更多,實現(xiàn)相對較為復(fù)雜,但是都有現(xiàn)成的分布式鎖框架curator和redision,用起來代碼反而可能會更簡潔。
從性能角度(從高到低)redis>zookeeper?> 數(shù)據(jù)庫
redis數(shù)據(jù)存在內(nèi)存,速度很快;zookeeper雖然數(shù)據(jù)也存在內(nèi)存中,但是本身維護節(jié)點的一致性。需要耗費一些性能;數(shù)據(jù)庫則只有索引在內(nèi)存中,數(shù)據(jù)存于磁盤,性能較差。
從可靠性角度(從高到低)zookeeper?>?redis?> 數(shù)據(jù)庫
zookeeper天生設(shè)計定位就是分布式協(xié)調(diào),強一致性,可靠性較高;redis分布式鎖需要較多額外手段去保證可靠性;數(shù)據(jù)庫則較難滿足復(fù)雜場景的需求。
粉絲福利:Java從入門到入土學(xué)習(xí)路線圖
???

?長按上方微信二維碼?2 秒
感謝點贊支持下哈?
