從架構思維角度分析分布式鎖方案
1 介紹
前面的文章我們介紹了分布式系統(tǒng)和它的CAP原理:一致性(Consistency)、可用性(Availability)和分區(qū)容錯性(Partition tolerance)。
參考這篇《分布式事務CAP兩階段提交及三階段提交詳解》
我們知道,一個分布式系統(tǒng)無法同時滿足三個特性,所以在設計系統(tǒng)之初,就有一個特性要被妥協(xié)和犧牲,因為分區(qū)容錯性的不可或缺性,一般我們的選擇是AP或者CP,這就要求我們要么舍棄強一致性,要么舍棄高可用。
為了達到數(shù)據(jù)的一致性,或者說至少達到數(shù)據(jù)的最終一致性,我們需要一些額外的方法來保證,比如分布式事務,分布式鎖等等。
2 關于分布式鎖
在單體系統(tǒng)中,我們經(jīng)常會遇到很多高并發(fā)的場景,比如熱點數(shù)據(jù)、熱點緩存,短時間會有大量的請求進行訪問,當多個線程同時訪問共享資源的時候,就可能產(chǎn)生數(shù)據(jù)不一致的情況。
為了保證操作的順序性、原子性,所以我們需要輔助,比如在線程間中加鎖,當某個線程得到資源的時候,就對當前的資源進行加鎖,等完成操作之后,進行釋放,其他線程就可以繼續(xù)使用了。
Java在多線程實現(xiàn)中,專門提供了一些鎖機制來保障線程的互斥同步(synchronized/ReentrantLock)等。
1 synchronized(object:this){
2 // todo 業(yè)務邏輯
3 }
4 ====================================
5 Lock lock = new ReentrantLock();
6 Condition condition = lock.newCondition();
7 lock.lock();
8 try {
9 while(這邊是條件表達式) {
10 condition.wait();
11 // todo 業(yè)務邏輯
12 }
13 } finally {
14 lock.unlock();
15 }這種方式對于同一個module里面的操作是沒什么問題,但是在分布式系統(tǒng)中,就沒什么用了,比如很典型的支付場景、跨行轉賬場景,均屬于多系統(tǒng)之間的資源操作。
所以,為了解決這個問題,我們就必須引入分布式鎖,來保障多個不同系統(tǒng)對共享資源進行互斥訪問。
分布式鎖需要解決的問題一般包含如下:
1、排他性:分布式部署的應用集群中,同一個方法在同一時間只能被一臺機器上的一個線程執(zhí)行。
2、避免死鎖:鎖在執(zhí)行一段有限的時間之后,會被釋放(正常釋放或異常導致自動釋放),并且可以被重入,即當前線程可重復獲取。
3、高可用/高性能:獲取鎖和釋放鎖具備高可用;獲取和釋放鎖的性能優(yōu)良。
3 分布式鎖的實現(xiàn)方案
分布式鎖的實現(xiàn),比較常見的方案有3種:
1、基于數(shù)據(jù)庫實現(xiàn)分布式鎖
2、基于緩存(Redis或其他類型緩存)實現(xiàn)分布式鎖
3、基于Zookeeper實現(xiàn)分布式鎖
這三種方案,從實現(xiàn)的復雜度上來看,從1到3難度依次遞增。而且并不是每種解決方案都是完美的,它們都有各自的特性,還是需要根據(jù)實際的場景進行抉擇的。
3.1 基于數(shù)據(jù)庫實現(xiàn)
3.1.1 樂觀鎖的實現(xiàn)方式
樂觀鎖機制其實就是在數(shù)據(jù)庫表中引入一個版本號(version)字段來實現(xiàn)。如下,再表上添加了一個version字段,并且設置為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 '版本號', 7 PRIMARY KEY ( `id` ) 8 ) ENGINE = INNODB AUTO_INCREMENT = 137587 DEFAULT CHARSET = utf8 COMMENT = '用戶支付信息表';
在每次進行數(shù)據(jù)庫表之前先查詢一下當前記錄信息,然后執(zhí)行更新語句并且讓指定字段進行自增,即 version = version+1 (因為MySQL同一張表只支持一個自增鍵,這邊已經(jīng)被id用了)。
修改完將新的數(shù)據(jù)與新的version更新到數(shù)據(jù)表中,更新的同時檢查目前數(shù)據(jù)庫里version值是不是之前的那個version,如果是,則正常更新。
如果不是,則更新失敗,說明在這個執(zhí)行間隙有其它的進程去更新過數(shù)據(jù)了,這時候如果強行更新進去,支付次數(shù)和總額度就不對了。操作如下:
1 -- 先查詢數(shù)據(jù)信息
2 select pay_id,pay_count,balance,version from t_pay where id= #{id}
3 -- 判斷當前表中的version 是否與剛才查出的version一致,是的話正常更新
4 update t_pay set pay_count=paycount + 1, balance = balance + '具體消費額度' ,version = version+1 where id=#{id} and version= #{version};根據(jù)返回修改記錄條數(shù)來判斷當前更新是否生效,如果改動的是0條數(shù)據(jù),說明version發(fā)生了變更,導致改動無效,這時候可以根據(jù)自己業(yè)務邏輯來判斷是否回滾事務。
下面圖例分析一下:

舉例如圖,你跟你老婆用同一個賬戶在支付,你支付燃氣費,你老婆夠買手表,如果沒有鎖機制,在并發(fā)的情況下,可能會出現(xiàn)同時被扣25和8000,導致最終余額的不正確。
但是如果使用樂觀鎖機制,當兩個請求同時到達的時候,需要獲取到賬號信息包括版本號信息,不管是A操作(支付燃氣費)還是B操作(購買手表),都會將版本號加1,即version=2,
那么另外一個操作執(zhí)行的時候,發(fā)現(xiàn)當前版本號變成了2,不再是之前讀取的 1,則更新失敗。
通過上面這個例子可以看出來,使用「樂觀鎖」機制,必須得滿足:
a)鎖服務要有遞增的版本號 version
b)每次更新數(shù)據(jù)的時候都必須先判斷版本號對不對,然后再寫入新的版本號
3.1.2 悲觀鎖的實現(xiàn)方式
悲觀鎖也叫作排它鎖,在MySQL中是基于 for update 語法來實現(xiàn)加鎖的,下面用偽代碼來演示,例如:
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 // 結果不為空,
10 // 則說明獲取到了鎖
11 return true;
12 }
13 // 沒有獲取到鎖,繼續(xù)獲取
14 sleep(1000);
15 }
16 return false;
17 }
18
19 // 釋放鎖
20 connection.commit();上面的示例中,user表中,id是主鍵,通過 for update 操作,數(shù)據(jù)庫在查詢的時候就會給這條記錄加上排它鎖。(需要注意的是,在InnoDB中只有檢索字段加了索引的,才會是行級鎖,否者是表級鎖,所以這個id字段要加索引),
當這條記錄加上排它鎖之后,其它線程是無法操作這條記錄的。
那么,這樣的話,我們就可以認為獲得了排它鎖的這個線程是擁有了分布式鎖,然后就可以執(zhí)行我們想要做的業(yè)務邏輯,當邏輯完成之后,再調(diào)用上述釋放鎖的語句即可。
3.1.3 數(shù)據(jù)庫鎖的優(yōu)缺點
直接使用數(shù)據(jù)庫,容易理解、操作簡單。
但是會有各種各樣的問題,在解決問題的過程中會使整個方案變得越來越復雜。操作數(shù)據(jù)庫需要一定的開銷,性能問題需要考慮,特別是高并發(fā)場景下。
使用數(shù)據(jù)庫的行級鎖并不一定靠譜,尤其是當我們的鎖表并不大的時候。
3.2基于Redis實現(xiàn)
3.2.1 基于緩存實現(xiàn)分布式鎖
相比較于基于數(shù)據(jù)庫實現(xiàn)分布式鎖的方案來說,基于緩存來實現(xiàn)在性能方面會表現(xiàn)的更好一點。類似Redis可以多集群部署的,解決單點問題。
基于Redis實現(xiàn)的鎖機制,主要是依賴redis自身的原子操作,例如:
1 # 判斷是否存在,不存在設值,并提供自動過期時間 2 SET key value NX PX millisecond 3 4 # 刪除某個key 5 DEL key [key …]
NX:只在在鍵不存在時,才對鍵進行設置操作,SET key value NX 效果等同于 SETNX key value
PX millisecond:設置鍵的過期時間為millisecond毫秒,當超過這個時間后,設置的鍵會自動失效
如果需要把上面的支付業(yè)務實現(xiàn),則需要改寫如下:
1 # 設置賬戶Id為17124的賬號的值為1,如果不存在的情況下,并設置過期時間為500ms 2 SET pay_id_17124 1 NX PX 500 3 4 # 進行刪除 5 DEL pay_id_17124
上述代碼示例是指,
當redis中不存在pay_key這個鍵的時候,才會去設置一個pay_key鍵,鍵的值為 1,且這個鍵的存活時間為500ms。
當某個進程設置成功之后,就可以去執(zhí)行業(yè)務邏輯了,等業(yè)務邏輯執(zhí)行完畢之后,再去進行解鎖。而解鎖之前或者自動過期之前,其他進程是進不來的。
實現(xiàn)鎖機制的原理是:這個命令是只有在某個key不存在的時候,才會執(zhí)行成功。那么當多個進程同時并發(fā)的去設置同一個key的時候,就永遠只會有一個進程成功。
解鎖很簡單,只需要刪除這個key就可以了。
另外,針對redis集群模式的分布式鎖,可以采用redis的Redlock機制。
需要注意的是,如何設置恰當?shù)某瑫r時間,如果設置的失效時間太短,方法沒等執(zhí)行完,鎖就自動釋放了,那么就會產(chǎn)生并發(fā)問題。如果設置的時間太長,其他獲取鎖的線程就要多等一段時間。這個問題使用數(shù)據(jù)庫實現(xiàn)分布式鎖同樣存在。
總結:可以使用緩存來代替數(shù)據(jù)庫來實現(xiàn)分布式鎖,會提供更好的性能,同時,很多緩存服務都是集群部署的,可以避免單點問題。
并且很多緩存服務都提供了可以用來實現(xiàn)分布式鎖的方法,比如redis的setnx方法。并且,緩存服務也都提供了對數(shù)據(jù)的過期自動刪除的支持,可以直接設置超時時間來控制鎖的釋放。
3.2.2緩存實現(xiàn)分布式鎖的優(yōu)缺點
優(yōu)點是性能好,實現(xiàn)起來較為方便。缺點是通過超時時間來控制鎖的失效時間并不是十分的靠譜。
3.3 基于Zookeeper實現(xiàn)
3.3.1 實現(xiàn)過程
基于zookeeper臨時有序節(jié)點可以實現(xiàn)分布式鎖。
其原理如下:
1、每個請求的客戶端,都去Zookeeper上的某個指定節(jié)點的目錄下(比如是對某個對象的操作),去生成一個唯一的臨時有序節(jié)點。
2、然后判斷自己是否是這些有序節(jié)點中序號最小的一個,如果是,則算是獲取了鎖。
3、如果不是最小序號,則說明沒有獲取到鎖,那么就需要在序列中找到比自己小的那個節(jié)點,對其注冊事件監(jiān)聽(調(diào)用exits()方法確認節(jié)點在不在)。比如下面圖中,client-3 生成 node-3,并監(jiān)聽node-2。
4、當監(jiān)聽到這個節(jié)點被刪除了,那就再去判斷一次自己當初創(chuàng)建的節(jié)點是否變成了序列中最小的。如果是,則獲取鎖,如果不是,則重復上述步驟。
1 //創(chuàng)建子節(jié)點
2 private String createSaNode() throws KeeperException, InterruptedException {
3 // 如果根節(jié)點不存在,則創(chuàng)建根節(jié)點
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é)點
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 }完整的實現(xiàn)方案可以參考:ZooKeeper開發(fā)實際應用案例實戰(zhàn)
根據(jù)上訴的步驟,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é)點是不是當前所有節(jié)點中序號最小的,如果是,那么自己就獲取到鎖,便可以執(zhí)行業(yè)務邏輯了。
不可重入?使用Zookeeper也可以有效的解決不可重入的問題,客戶端在創(chuàng)建節(jié)點的時候,把當前客戶端的主機信息和線程信息直接寫入到節(jié)點中,下次想要獲取鎖的時候和當前最小的節(jié)點中的數(shù)據(jù)比對一下就可以了。如果和自己的信息一樣,那么自己直接獲取到鎖,如果不一樣就再創(chuàng)建一個臨時的順序節(jié)點,參與排隊。
單點問題?使用Zookeeper可以有效的解決單點問題,ZK是集群部署的,只要集群中有半數(shù)以上的機器存活,就可以對外提供服務。
下面圖例說明:

Locker Object 是對需要競爭的資源進行持久的節(jié)點,下面的node-1到node-n 就是上面說的有序子節(jié)點,由不同進程的client去創(chuàng)建。
當進來一個客戶端需要去競爭資源的時候,就跑到持久化節(jié)點下去按順序創(chuàng)建一個直接點,然后看一下是不是最小的一個。
如果是最小的就獲取到鎖,可以繼續(xù)后面的資源操作了。如果不是則監(jiān)聽比自己序號小的節(jié)點,比如client-3 訂閱的是 node-2。
如果node-2被刪除,自己被喚醒,再次判斷自己是不是序列中最小的,如果是,則獲取鎖。
3.3.2 zk實現(xiàn)分布式鎖的優(yōu)缺點
優(yōu)點:有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實現(xiàn)起來較為簡單。
缺點:性能上不如使用緩存實現(xiàn)分布式鎖。 需要對ZK的原理有所了解。
3.4 三種方案的對比總結
上面幾種方式,并不是都能做到十全十美,就像CAP一樣,在復雜性、可靠性、性能 三方面無法同時滿足一樣。所以,更多的是根據(jù)不同的應用場景選擇最合適的方案。
特性 | 實現(xiàn)復雜度角度 | 性能角度 | 可靠性角度 |
數(shù)據(jù)庫 | 高 | 低 | 低 |
緩存 | 中 | 高 | 中 |
Zookeeper | 低 | 中 | 高 |
以上就是從架構思維角度分析分布式鎖方案的詳細內(nèi)容,更多關于分布式鎖架構思維方案的資料請關注腳本之家其它相關文章!
相關文章
java GUI實現(xiàn)ATM機系統(tǒng)(3.0版)
這篇文章主要為大家詳細介紹了java GUI實現(xiàn)ATM機系統(tǒng)(3.0版),文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-03-03
mybatis源碼解讀-Java中executor包的語句處理功能
這篇文章主要介紹了Java中executor包的語句處理功能,在mybatis映射文件中傳參數(shù),主要用到#{}或者${},下文圍繞相關資料展開詳細內(nèi)容,需要的小伙伴可以參考一下2022-02-02
Spring Security中的Servlet過濾器體系代碼分析
這篇文章主要介紹了Spring Security中的Servlet過濾器體系,本文通過圖文并茂的形式給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-07-07
Java實現(xiàn)OJ多組測試數(shù)據(jù)的輸入方法
今天小編就為大家分享一篇Java實現(xiàn)OJ多組測試數(shù)據(jù)的輸入方法,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-07-07

