從架構(gòu)思維角度分析分布式鎖方案
1 介紹
前面的文章我們介紹了分布式系統(tǒng)和它的CAP原理:一致性(Consistency)、可用性(Availability)和分區(qū)容錯(cuò)性(Partition tolerance)。
參考這篇《分布式事務(wù)CAP兩階段提交及三階段提交詳解》
我們知道,一個(gè)分布式系統(tǒng)無(wú)法同時(shí)滿足三個(gè)特性,所以在設(shè)計(jì)系統(tǒng)之初,就有一個(gè)特性要被妥協(xié)和犧牲,因?yàn)榉謪^(qū)容錯(cuò)性的不可或缺性,一般我們的選擇是AP或者CP,這就要求我們要么舍棄強(qiáng)一致性,要么舍棄高可用。
為了達(dá)到數(shù)據(jù)的一致性,或者說(shuō)至少達(dá)到數(shù)據(jù)的最終一致性,我們需要一些額外的方法來(lái)保證,比如分布式事務(wù),分布式鎖等等。
2 關(guān)于分布式鎖
在單體系統(tǒng)中,我們經(jīng)常會(huì)遇到很多高并發(fā)的場(chǎng)景,比如熱點(diǎn)數(shù)據(jù)、熱點(diǎn)緩存,短時(shí)間會(huì)有大量的請(qǐng)求進(jìn)行訪問(wèn),當(dāng)多個(gè)線程同時(shí)訪問(wèn)共享資源的時(shí)候,就可能產(chǎn)生數(shù)據(jù)不一致的情況。
為了保證操作的順序性、原子性,所以我們需要輔助,比如在線程間中加鎖,當(dāng)某個(gè)線程得到資源的時(shí)候,就對(duì)當(dāng)前的資源進(jìn)行加鎖,等完成操作之后,進(jìn)行釋放,其他線程就可以繼續(xù)使用了。
Java在多線程實(shí)現(xiàn)中,專門提供了一些鎖機(jī)制來(lái)保障線程的互斥同步(synchronized/ReentrantLock)等。
1 synchronized(object:this){ 2 // todo 業(yè)務(wù)邏輯 3 } 4 ==================================== 5 Lock lock = new ReentrantLock(); 6 Condition condition = lock.newCondition(); 7 lock.lock(); 8 try { 9 while(這邊是條件表達(dá)式) { 10 condition.wait(); 11 // todo 業(yè)務(wù)邏輯 12 } 13 } finally { 14 lock.unlock(); 15 }
這種方式對(duì)于同一個(gè)module里面的操作是沒(méi)什么問(wèn)題,但是在分布式系統(tǒng)中,就沒(méi)什么用了,比如很典型的支付場(chǎng)景、跨行轉(zhuǎn)賬場(chǎng)景,均屬于多系統(tǒng)之間的資源操作。
所以,為了解決這個(gè)問(wèn)題,我們就必須引入分布式鎖,來(lái)保障多個(gè)不同系統(tǒng)對(duì)共享資源進(jìn)行互斥訪問(wèn)。
分布式鎖需要解決的問(wèn)題一般包含如下:
1、排他性:分布式部署的應(yīng)用集群中,同一個(gè)方法在同一時(shí)間只能被一臺(tái)機(jī)器上的一個(gè)線程執(zhí)行。
2、避免死鎖:鎖在執(zhí)行一段有限的時(shí)間之后,會(huì)被釋放(正常釋放或異常導(dǎo)致自動(dòng)釋放),并且可以被重入,即當(dāng)前線程可重復(fù)獲取。
3、高可用/高性能:獲取鎖和釋放鎖具備高可用;獲取和釋放鎖的性能優(yōu)良。
3 分布式鎖的實(shí)現(xiàn)方案
分布式鎖的實(shí)現(xiàn),比較常見的方案有3種:
1、基于數(shù)據(jù)庫(kù)實(shí)現(xiàn)分布式鎖
2、基于緩存(Redis或其他類型緩存)實(shí)現(xiàn)分布式鎖
3、基于Zookeeper實(shí)現(xiàn)分布式鎖
這三種方案,從實(shí)現(xiàn)的復(fù)雜度上來(lái)看,從1到3難度依次遞增。而且并不是每種解決方案都是完美的,它們都有各自的特性,還是需要根據(jù)實(shí)際的場(chǎng)景進(jìn)行抉擇的。
3.1 基于數(shù)據(jù)庫(kù)實(shí)現(xiàn)
3.1.1 樂(lè)觀鎖的實(shí)現(xiàn)方式
樂(lè)觀鎖機(jī)制其實(shí)就是在數(shù)據(jù)庫(kù)表中引入一個(gè)版本號(hào)(version)字段來(lái)實(shí)現(xiàn)。如下,再表上添加了一個(gè)version字段,并且設(shè)置為bigint類型:
1 CREATE TABLE `t_pay` ( 2 `id` BIGINT ( 20 ) NOT NULL AUTO_INCREMENT, 3 `pay_id` BIGINT (8) NOT NULL COMMENT '支付id', 4 `pay_count` BIG (<strong>8</strong>) DEFAULT 0 not NULL COMMENT '支付次數(shù)', 5 `balance` DECIMAL (6,2) DEFAULT 0 not NULL COMMENT '總額度', 6 `version` BIGINT (10) DEFAULT 0 NOT NULL COMMENT '版本號(hào)', 7 PRIMARY KEY ( `id` ) 8 ) ENGINE = INNODB AUTO_INCREMENT = 137587 DEFAULT CHARSET = utf8 COMMENT = '用戶支付信息表';
在每次進(jìn)行數(shù)據(jù)庫(kù)表之前先查詢一下當(dāng)前記錄信息,然后執(zhí)行更新語(yǔ)句并且讓指定字段進(jìn)行自增,即 version = version+1 (因?yàn)镸ySQL同一張表只支持一個(gè)自增鍵,這邊已經(jīng)被id用了)。
修改完將新的數(shù)據(jù)與新的version更新到數(shù)據(jù)表中,更新的同時(shí)檢查目前數(shù)據(jù)庫(kù)里version值是不是之前的那個(gè)version,如果是,則正常更新。
如果不是,則更新失敗,說(shuō)明在這個(gè)執(zhí)行間隙有其它的進(jìn)程去更新過(guò)數(shù)據(jù)了,這時(shí)候如果強(qiáng)行更新進(jìn)去,支付次數(shù)和總額度就不對(duì)了。操作如下:
1 -- 先查詢數(shù)據(jù)信息 2 select pay_id,pay_count,balance,version from t_pay where id= #{id} 3 -- 判斷當(dāng)前表中的version 是否與剛才查出的version一致,是的話正常更新 4 update t_pay set pay_count=paycount + 1, balance = balance + '具體消費(fèi)額度' ,version = version+1 where id=#{id} and version= #{version};
根據(jù)返回修改記錄條數(shù)來(lái)判斷當(dāng)前更新是否生效,如果改動(dòng)的是0條數(shù)據(jù),說(shuō)明version發(fā)生了變更,導(dǎo)致改動(dòng)無(wú)效,這時(shí)候可以根據(jù)自己業(yè)務(wù)邏輯來(lái)判斷是否回滾事務(wù)。
下面圖例分析一下:
舉例如圖,你跟你老婆用同一個(gè)賬戶在支付,你支付燃?xì)赓M(fèi),你老婆夠買手表,如果沒(méi)有鎖機(jī)制,在并發(fā)的情況下,可能會(huì)出現(xiàn)同時(shí)被扣25和8000,導(dǎo)致最終余額的不正確。
但是如果使用樂(lè)觀鎖機(jī)制,當(dāng)兩個(gè)請(qǐng)求同時(shí)到達(dá)的時(shí)候,需要獲取到賬號(hào)信息包括版本號(hào)信息,不管是A操作(支付燃?xì)赓M(fèi))還是B操作(購(gòu)買手表),都會(huì)將版本號(hào)加1,即version=2,
那么另外一個(gè)操作執(zhí)行的時(shí)候,發(fā)現(xiàn)當(dāng)前版本號(hào)變成了2,不再是之前讀取的 1,則更新失敗。
通過(guò)上面這個(gè)例子可以看出來(lái),使用「樂(lè)觀鎖」機(jī)制,必須得滿足:
a)鎖服務(wù)要有遞增的版本號(hào) version
b)每次更新數(shù)據(jù)的時(shí)候都必須先判斷版本號(hào)對(duì)不對(duì),然后再寫入新的版本號(hào)
3.1.2 悲觀鎖的實(shí)現(xiàn)方式
悲觀鎖也叫作排它鎖,在MySQL中是基于 for update 語(yǔ)法來(lái)實(shí)現(xiàn)加鎖的,下面用偽代碼來(lái)演示,例如:
1 // 鎖定的方法 2 public boolean lock(){ 3 connection.setAutoCommit(false) 4 while(true){ 5 result = 6 select * from t_pay where 7 id = 100 for update; 8 if(result){ 9 // 結(jié)果不為空, 10 // 則說(shuō)明獲取到了鎖 11 return true; 12 } 13 // 沒(méi)有獲取到鎖,繼續(xù)獲取 14 sleep(1000); 15 } 16 return false; 17 } 18 19 // 釋放鎖 20 connection.commit();
上面的示例中,user表中,id是主鍵,通過(guò) for update 操作,數(shù)據(jù)庫(kù)在查詢的時(shí)候就會(huì)給這條記錄加上排它鎖。(需要注意的是,在InnoDB中只有檢索字段加了索引的,才會(huì)是行級(jí)鎖,否者是表級(jí)鎖,所以這個(gè)id字段要加索引),
當(dāng)這條記錄加上排它鎖之后,其它線程是無(wú)法操作這條記錄的。
那么,這樣的話,我們就可以認(rèn)為獲得了排它鎖的這個(gè)線程是擁有了分布式鎖,然后就可以執(zhí)行我們想要做的業(yè)務(wù)邏輯,當(dāng)邏輯完成之后,再調(diào)用上述釋放鎖的語(yǔ)句即可。
3.1.3 數(shù)據(jù)庫(kù)鎖的優(yōu)缺點(diǎn)
直接使用數(shù)據(jù)庫(kù),容易理解、操作簡(jiǎn)單。
但是會(huì)有各種各樣的問(wèn)題,在解決問(wèn)題的過(guò)程中會(huì)使整個(gè)方案變得越來(lái)越復(fù)雜。操作數(shù)據(jù)庫(kù)需要一定的開銷,性能問(wèn)題需要考慮,特別是高并發(fā)場(chǎng)景下。
使用數(shù)據(jù)庫(kù)的行級(jí)鎖并不一定靠譜,尤其是當(dāng)我們的鎖表并不大的時(shí)候。
3.2基于Redis實(shí)現(xiàn)
3.2.1 基于緩存實(shí)現(xiàn)分布式鎖
相比較于基于數(shù)據(jù)庫(kù)實(shí)現(xiàn)分布式鎖的方案來(lái)說(shuō),基于緩存來(lái)實(shí)現(xiàn)在性能方面會(huì)表現(xiàn)的更好一點(diǎn)。類似Redis可以多集群部署的,解決單點(diǎn)問(wèn)題。
基于Redis實(shí)現(xiàn)的鎖機(jī)制,主要是依賴redis自身的原子操作,例如:
1 # 判斷是否存在,不存在設(shè)值,并提供自動(dòng)過(guò)期時(shí)間 2 SET key value NX PX millisecond 3 4 # 刪除某個(gè)key 5 DEL key [key …]
NX:只在在鍵不存在時(shí),才對(duì)鍵進(jìn)行設(shè)置操作,SET key value NX 效果等同于 SETNX key value
PX millisecond:設(shè)置鍵的過(guò)期時(shí)間為millisecond毫秒,當(dāng)超過(guò)這個(gè)時(shí)間后,設(shè)置的鍵會(huì)自動(dòng)失效
如果需要把上面的支付業(yè)務(wù)實(shí)現(xiàn),則需要改寫如下:
1 # 設(shè)置賬戶Id為17124的賬號(hào)的值為1,如果不存在的情況下,并設(shè)置過(guò)期時(shí)間為500ms 2 SET pay_id_17124 1 NX PX 500 3 4 # 進(jìn)行刪除 5 DEL pay_id_17124
上述代碼示例是指,
當(dāng)redis中不存在pay_key這個(gè)鍵的時(shí)候,才會(huì)去設(shè)置一個(gè)pay_key鍵,鍵的值為 1,且這個(gè)鍵的存活時(shí)間為500ms。
當(dāng)某個(gè)進(jìn)程設(shè)置成功之后,就可以去執(zhí)行業(yè)務(wù)邏輯了,等業(yè)務(wù)邏輯執(zhí)行完畢之后,再去進(jìn)行解鎖。而解鎖之前或者自動(dòng)過(guò)期之前,其他進(jìn)程是進(jìn)不來(lái)的。
實(shí)現(xiàn)鎖機(jī)制的原理是:這個(gè)命令是只有在某個(gè)key不存在的時(shí)候,才會(huì)執(zhí)行成功。那么當(dāng)多個(gè)進(jìn)程同時(shí)并發(fā)的去設(shè)置同一個(gè)key的時(shí)候,就永遠(yuǎn)只會(huì)有一個(gè)進(jìn)程成功。
解鎖很簡(jiǎn)單,只需要?jiǎng)h除這個(gè)key就可以了。
另外,針對(duì)redis集群模式的分布式鎖,可以采用redis的Redlock機(jī)制。
需要注意的是,如何設(shè)置恰當(dāng)?shù)某瑫r(shí)時(shí)間,如果設(shè)置的失效時(shí)間太短,方法沒(méi)等執(zhí)行完,鎖就自動(dòng)釋放了,那么就會(huì)產(chǎn)生并發(fā)問(wèn)題。如果設(shè)置的時(shí)間太長(zhǎng),其他獲取鎖的線程就要多等一段時(shí)間。這個(gè)問(wèn)題使用數(shù)據(jù)庫(kù)實(shí)現(xiàn)分布式鎖同樣存在。
總結(jié):可以使用緩存來(lái)代替數(shù)據(jù)庫(kù)來(lái)實(shí)現(xiàn)分布式鎖,會(huì)提供更好的性能,同時(shí),很多緩存服務(wù)都是集群部署的,可以避免單點(diǎn)問(wèn)題。
并且很多緩存服務(wù)都提供了可以用來(lái)實(shí)現(xiàn)分布式鎖的方法,比如redis的setnx方法。并且,緩存服務(wù)也都提供了對(duì)數(shù)據(jù)的過(guò)期自動(dòng)刪除的支持,可以直接設(shè)置超時(shí)時(shí)間來(lái)控制鎖的釋放。
3.2.2緩存實(shí)現(xiàn)分布式鎖的優(yōu)缺點(diǎn)
優(yōu)點(diǎn)是性能好,實(shí)現(xiàn)起來(lái)較為方便。缺點(diǎn)是通過(guò)超時(shí)時(shí)間來(lái)控制鎖的失效時(shí)間并不是十分的靠譜。
3.3 基于Zookeeper實(shí)現(xiàn)
3.3.1 實(shí)現(xiàn)過(guò)程
基于zookeeper臨時(shí)有序節(jié)點(diǎn)可以實(shí)現(xiàn)分布式鎖。
其原理如下:
1、每個(gè)請(qǐng)求的客戶端,都去Zookeeper上的某個(gè)指定節(jié)點(diǎn)的目錄下(比如是對(duì)某個(gè)對(duì)象的操作),去生成一個(gè)唯一的臨時(shí)有序節(jié)點(diǎn)。
2、然后判斷自己是否是這些有序節(jié)點(diǎn)中序號(hào)最小的一個(gè),如果是,則算是獲取了鎖。
3、如果不是最小序號(hào),則說(shuō)明沒(méi)有獲取到鎖,那么就需要在序列中找到比自己小的那個(gè)節(jié)點(diǎn),對(duì)其注冊(cè)事件監(jiān)聽(調(diào)用exits()方法確認(rèn)節(jié)點(diǎn)在不在)。比如下面圖中,client-3 生成 node-3,并監(jiān)聽node-2。
4、當(dāng)監(jiān)聽到這個(gè)節(jié)點(diǎn)被刪除了,那就再去判斷一次自己當(dāng)初創(chuàng)建的節(jié)點(diǎn)是否變成了序列中最小的。如果是,則獲取鎖,如果不是,則重復(fù)上述步驟。
1 //創(chuàng)建子節(jié)點(diǎn) 2 private String createSaNode() throws KeeperException, InterruptedException { 3 // 如果根節(jié)點(diǎn)不存在,則創(chuàng)建根節(jié)點(diǎn) 4 Stat stat = zk.exists(ZNODE, false); 5 if (stat == null) { 6 zk.create(ZNODE, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); 7 } 8 9 String hostName = System.getenv("HOSTNAME"); 10 // 創(chuàng)建EPHEMERAL_SEQUENTIAL類型節(jié)點(diǎn) 11 String saPath = zk.create(ZNODE + "/" + SA_NODE_PREFIX, 12 hostName.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, 13 CreateMode.EPHEMERAL_SEQUENTIAL); 14 return saPath; 15 }
完整的實(shí)現(xiàn)方案可以參考:ZooKeeper開發(fā)實(shí)際應(yīng)用案例實(shí)戰(zhàn)
根據(jù)上訴的步驟,Zookeeper實(shí)際解決了如下問(wèn)題:
鎖無(wú)法釋放?使用Zookeeper可以有效的解決鎖無(wú)法釋放的問(wèn)題,因?yàn)樵趧?chuàng)建鎖的時(shí)候,客戶端會(huì)在ZK中創(chuàng)建一個(gè)臨時(shí)節(jié)點(diǎn),一旦客戶端獲取到鎖之后突然掛掉(Session連接斷開),那么這個(gè)臨時(shí)節(jié)點(diǎn)就會(huì)自動(dòng)刪除掉。其他客戶端就可以再次獲得鎖。
非阻塞鎖?使用Zookeeper可以實(shí)現(xiàn)阻塞的鎖,客戶端可以通過(guò)在ZK中創(chuàng)建順序節(jié)點(diǎn),并且在節(jié)點(diǎn)上綁定監(jiān)聽器,一旦節(jié)點(diǎn)有變化,Zookeeper會(huì)通知客戶端,客戶端可以檢查自己創(chuàng)建的節(jié)點(diǎn)是不是當(dāng)前所有節(jié)點(diǎn)中序號(hào)最小的,如果是,那么自己就獲取到鎖,便可以執(zhí)行業(yè)務(wù)邏輯了。
不可重入?使用Zookeeper也可以有效的解決不可重入的問(wèn)題,客戶端在創(chuàng)建節(jié)點(diǎn)的時(shí)候,把當(dāng)前客戶端的主機(jī)信息和線程信息直接寫入到節(jié)點(diǎn)中,下次想要獲取鎖的時(shí)候和當(dāng)前最小的節(jié)點(diǎn)中的數(shù)據(jù)比對(duì)一下就可以了。如果和自己的信息一樣,那么自己直接獲取到鎖,如果不一樣就再創(chuàng)建一個(gè)臨時(shí)的順序節(jié)點(diǎn),參與排隊(duì)。
單點(diǎn)問(wèn)題?使用Zookeeper可以有效的解決單點(diǎn)問(wèn)題,ZK是集群部署的,只要集群中有半數(shù)以上的機(jī)器存活,就可以對(duì)外提供服務(wù)。
下面圖例說(shuō)明:
Locker Object 是對(duì)需要競(jìng)爭(zhēng)的資源進(jìn)行持久的節(jié)點(diǎn),下面的node-1到node-n 就是上面說(shuō)的有序子節(jié)點(diǎn),由不同進(jìn)程的client去創(chuàng)建。
當(dāng)進(jìn)來(lái)一個(gè)客戶端需要去競(jìng)爭(zhēng)資源的時(shí)候,就跑到持久化節(jié)點(diǎn)下去按順序創(chuàng)建一個(gè)直接點(diǎn),然后看一下是不是最小的一個(gè)。
如果是最小的就獲取到鎖,可以繼續(xù)后面的資源操作了。如果不是則監(jiān)聽比自己序號(hào)小的節(jié)點(diǎn),比如client-3 訂閱的是 node-2。
如果node-2被刪除,自己被喚醒,再次判斷自己是不是序列中最小的,如果是,則獲取鎖。
3.3.2 zk實(shí)現(xiàn)分布式鎖的優(yōu)缺點(diǎn)
優(yōu)點(diǎn):有效的解決單點(diǎn)問(wèn)題,不可重入問(wèn)題,非阻塞問(wèn)題以及鎖無(wú)法釋放的問(wèn)題。實(shí)現(xiàn)起來(lái)較為簡(jiǎn)單。
缺點(diǎn):性能上不如使用緩存實(shí)現(xiàn)分布式鎖。 需要對(duì)ZK的原理有所了解。
3.4 三種方案的對(duì)比總結(jié)
上面幾種方式,并不是都能做到十全十美,就像CAP一樣,在復(fù)雜性、可靠性、性能 三方面無(wú)法同時(shí)滿足一樣。所以,更多的是根據(jù)不同的應(yīng)用場(chǎng)景選擇最合適的方案。
特性 | 實(shí)現(xiàn)復(fù)雜度角度 | 性能角度 | 可靠性角度 |
數(shù)據(jù)庫(kù) | 高 | 低 | 低 |
緩存 | 中 | 高 | 中 |
Zookeeper | 低 | 中 | 高 |
以上就是從架構(gòu)思維角度分析分布式鎖方案的詳細(xì)內(nèi)容,更多關(guān)于分布式鎖架構(gòu)思維方案的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
java GUI實(shí)現(xiàn)ATM機(jī)系統(tǒng)(3.0版)
這篇文章主要為大家詳細(xì)介紹了java GUI實(shí)現(xiàn)ATM機(jī)系統(tǒng)(3.0版),文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-03-03mybatis源碼解讀-Java中executor包的語(yǔ)句處理功能
這篇文章主要介紹了Java中executor包的語(yǔ)句處理功能,在mybatis映射文件中傳參數(shù),主要用到#{}或者${},下文圍繞相關(guān)資料展開詳細(xì)內(nèi)容,需要的小伙伴可以參考一下2022-02-02Spring Security中的Servlet過(guò)濾器體系代碼分析
這篇文章主要介紹了Spring Security中的Servlet過(guò)濾器體系,本文通過(guò)圖文并茂的形式給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-07-07Spring解決依賴版本不一致報(bào)錯(cuò)問(wèn)題
許多同學(xué)經(jīng)常會(huì)遇到依賴版本不一致導(dǎo)致代碼報(bào)錯(cuò),所以這篇文章就給大家詳細(xì)介紹一下Spring解決依賴版本不一致報(bào)錯(cuò)問(wèn)題,需要的朋友跟著小編一起來(lái)看看吧2023-07-07Java實(shí)現(xiàn)OJ多組測(cè)試數(shù)據(jù)的輸入方法
今天小編就為大家分享一篇Java實(shí)現(xiàn)OJ多組測(cè)試數(shù)據(jù)的輸入方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-07-07JAVAEE項(xiàng)目結(jié)構(gòu)以及并發(fā)隨想
每個(gè)代碼里面的工具都是工具,API是你最需要理解的,哪個(gè)好,哪個(gè)不好,沒(méi)有準(zhǔn)確答案。 一切皆對(duì)象,對(duì)于Java來(lái)講是純粹的,代理是對(duì)象,反射是對(duì)象,對(duì)象是對(duì)象,基本數(shù)據(jù)類型不是對(duì)象。2016-04-04Java8如何從一個(gè)Stream中過(guò)濾null值
這篇文章主要介紹了Java8如何從一個(gè)Stream中過(guò)濾null值,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-05-05SpringBoot JPA 表關(guān)聯(lián)查詢實(shí)例
本篇文章主要介紹了SpringBoot JPA 表關(guān)聯(lián)查詢實(shí)例,使用JPA原生的findBy語(yǔ)句實(shí)現(xiàn),具有一定的參考價(jià)值,有興趣的可以了解一下。2017-04-04