天天看點

架構與思維:分布式鎖方案分析

1 介紹

前面的文章我們介紹了分布式系統和它的CAP原理:一緻性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance)。參考這篇《分布式事務》

我們知道,一個分布式系統無法同時滿足三個特性,是以在設計系統之初,就有一個特性要被妥協和犧牲,因為分區容錯性的不可或缺性,一般我們的選擇是AP或者CP,這就要求我們要麼舍棄強一緻性,要麼舍棄高可用。

為了達到資料的一緻性,或者說至少達到資料的最終一緻性,我們需要一些額外的方法來保證,比如分布式事務,分布式鎖等等。

2 關于分布式鎖

在單體系統中,我們經常會遇到很多高并發的場景,比如熱點資料、熱點緩存,短時間會有大量的請求進行通路,當多個線程同時通路共享資源的時候,就可能産生資料不一緻的情況。

為了保證操作的順序性、原子性,是以我們需要輔助,比如線上程間中加鎖,當某個線程得到資源的時候,就對目前的資源進行加鎖,等完成操作之後,進行釋放,其他線程就可以繼續使用了。

Java在多線程實作中,專門提供了一些鎖機制來保障線程的互斥同步(synchronized/ReentrantLock)等。

1 synchronized(object:this){
 2    // todo 業務邏輯
 3 }
 4 ====================================
 5 Lock lock = new ReentrantLock(); 
 6 Condition condition = lock.newCondition(); 
 7 lock.lock(); 
 8 try { 
 9  while(這邊是條件表達式) { 
10    condition.wait(); 
11    // todo 業務邏輯 
12   } 
13  } finally { 
14     lock.unlock(); 
15 }
      

這種方式對于同一個module裡面的操作是沒什麼問題,但是在分布式系統中,就沒什麼用了,比如很典型的支付場景、跨行轉賬場景,均屬于多系統之間的資源操作。

是以,為了解決這個問題,我們就必須引入分布式鎖,來保障多個不同系統對共享資源進行互斥通路。

分布式鎖需要解決的問題一般包含如下:

1、排他性:分布式部署的應用叢集中,同一個方法在同一時間隻能被一台機器上的一個線程執行。

2、避免死鎖:鎖在執行一段有限的時間之後,會被釋放(正常釋放或異常導緻自動釋放),并且可以被重入,即目前線程可重複擷取。

3、高可用/高性能:擷取鎖和釋放鎖具備高可用;擷取和釋放鎖的性能優良。

3 分布式鎖的實作方案

分布式鎖的實作,比較常見的方案有3種:

1、基于資料庫實作分布式鎖

2、基于緩存(Redis或其他類型緩存)實作分布式鎖

3、基于Zookeeper實作分布式鎖

這三種方案,從實作的複雜度上來看,從1到3難度依次遞增。而且并不是每種解決方案都是完美的,它們都有各自的特性,還是需要根據實際的場景進行抉擇的。

3.1  基于資料庫實作

3.1.1 樂觀鎖的實作方式

樂觀鎖機制其實就是在資料庫表中引入一個版本号(version)字段來實作。如下,再表上添加了一個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 (8) DEFAULT 0 not NULL COMMENT '支付次數',
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 = '使用者支付資訊表';       

在每次進行資料庫表之前先查詢一下目前記錄資訊,然後執行更新語句并且讓指定字段進行自增,即 version = version+1 (因為MySQL同一張表隻支援一個自增鍵,這邊已經被id用了)。

修改完将新的資料與新的version更新到資料表中,更新的同時檢查目前資料庫裡version值是不是之前的那個version,如果是,則正常更新。

如果不是,則更新失敗,說明在這個執行間隙有其它的程序去更新過資料了,這時候如果強行更新進去,支付次數和總額度就不對了。操作如下:

1 -- 先查詢資料資訊
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};      

根據傳回修改記錄條數來判斷目前更新是否生效,如果改動的是0條資料,說明version發生了變更,導緻改動無效,這時候可以根據自己業務邏輯來判斷是否復原事務。

下面圖例分析一下:

架構與思維:分布式鎖方案分析

舉例如圖,你跟你老婆用同一個賬戶在支付,你支付瓦斯費,你老婆夠買手表,如果沒有鎖機制,在并發的情況下,可能會出現同時被扣25和8000,導緻最終餘額的不正确。

但是如果使用樂觀鎖機制,當兩個請求同時到達的時候,需要擷取到賬号資訊包括版本号資訊,不管是A操作(支付瓦斯費)還是B操作(購買手表),都會将版本号加1,即version=2,

那麼另外一個操作執行的時候,發現目前版本号變成了2,不再是之前讀取的 1,則更新失敗。

通過上面這個例子可以看出來,使用「樂觀鎖」機制,必須得滿足:

a)鎖服務要有遞增的版本号 version

b)每次更新資料的時候都必須先判斷版本号對不對,然後再寫入新的版本号

3.1.2 悲觀鎖的實作方式

悲觀鎖也叫作排它鎖,在MySQL中是基于 for update  文法來實作加鎖的,下面用僞代碼來示範,例如:

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         // 沒有擷取到鎖,繼續擷取
14         sleep(1000);
15     }
16     return false;
17 }
18 
19 // 釋放鎖
20 connection.commit();       

上面的示例中,user表中,id是主鍵,通過 for update 操作,資料庫在查詢的時候就會給這條記錄加上排它鎖。(需要注意的是,在InnoDB中隻有檢索字段加了索引的,才會是行級鎖,否者是表級鎖,是以這個id字段要加索引),

當這條記錄加上排它鎖之後,其它線程是無法操作這條記錄的。

那麼,這樣的話,我們就可以認為獲得了排它鎖的這個線程是擁有了分布式鎖,然後就可以執行我們想要做的業務邏輯,當邏輯完成之後,再調用上述釋放鎖的語句即可。 

3.1.3 資料庫鎖的優缺點

直接使用資料庫,容易了解、操作簡單。

但是會有各種各樣的問題,在解決問題的過程中會使整個方案變得越來越複雜。操作資料庫需要一定的開銷,性能問題需要考慮,特别是高并發場景下。

使用資料庫的行級鎖并不一定靠譜,尤其是當我們的鎖表并不大的時候。 

3.2  基于Redis實作

3.2.1 基于緩存實作分布式鎖

相比較于基于資料庫實作分布式鎖的方案來說,基于緩存來實作在性能方面會表現的更好一點。類似Redis可以多叢集部署的,解決單點問題。

基于Redis實作的鎖機制,主要是依賴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毫秒,當超過這個時間後,設定的鍵會自動失效

如果需要把上面的支付業務實作,則需要改寫如下:

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。

當某個程序設定成功之後,就可以去執行業務邏輯了,等業務邏輯執行完畢之後,再去進行解鎖。而解鎖之前或者自動過期之前,其他程序是進不來的。

實作鎖機制的原理是:這個指令是隻有在某個key不存在的時候,才會執行成功。那麼當多個程序同時并發的去設定同一個key的時候,就永遠隻會有一個程序成功。

解鎖很簡單,隻需要删除這個key就可以了。

另外,針對redis叢集模式的分布式鎖,可以采用redis的Redlock機制。

需要注意的是,如何設定恰當的逾時時間,如果設定的失效時間太短,方法沒等執行完,鎖就自動釋放了,那麼就會産生并發問題。如果設定的時間太長,其他擷取鎖的線程就要多等一段時間。這個問題使用資料庫實作分布式鎖同樣存在。

總結:可以使用緩存來代替資料庫來實作分布式鎖,會提供更好的性能,同時,很多緩存服務都是叢集部署的,可以避免單點問題。

并且很多緩存服務都提供了可以用來實作分布式鎖的方法,比如redis的setnx方法。并且,緩存服務也都提供了對資料的過期自動删除的支援,可以直接設定逾時時間來控制鎖的釋放。

3.2.2 緩存實作分布式鎖的優缺點 

優點是性能好,實作起來較為友善。缺點是通過逾時時間來控制鎖的失效時間并不是十分的靠譜。

3.3 基于Zookeeper實作

3.3.1 實作過程

基于zookeeper臨時有序節點可以實作分布式鎖。

其原理如下:

1、每個請求的用戶端,都去Zookeeper上的某個指定節點的目錄下(比如是對某個對象的操作),去生成一個唯一的臨時有序節點。

2、然後判斷自己是否是這些有序節點中序号最小的一個,如果是,則算是擷取了鎖。

3、如果不是最小序号,則說明沒有擷取到鎖,那麼就需要在序列中找到比自己小的那個節點,對其注冊事件監聽(調用exits()方法确認節點在不在)。比如下面圖中,client-3 生成 node-3,并監聽node-2。

4、當監聽到這個節點被删除了,那就再去判斷一次自己當初建立的節點是否變成了序列中最小的。如果是,則擷取鎖,如果不是,則重複上述步驟。

1 //建立子節點
 2 private String createSaNode() throws KeeperException, InterruptedException {
 3 // 如果根節點不存在,則建立根節點
 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 // 建立EPHEMERAL_SEQUENTIAL類型節點
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 }      

完整的實作方案可以參考:https://blog.csdn.net/liyiming2017/article/details/85063868

根據上訴的步驟,Zookeeper實際解決了如下問題:

  • 鎖無法釋放?使用Zookeeper可以有效的解決鎖無法釋放的問題,因為在建立鎖的時候,用戶端會在ZK中建立一個臨時節點,一旦用戶端擷取到鎖之後突然挂掉(Session連接配接斷開),那麼這個臨時節點就會自動删除掉。其他用戶端就可以再次獲得鎖。
  • 非阻塞鎖?使用Zookeeper可以實作阻塞的鎖,用戶端可以通過在ZK中建立順序節點,并且在節點上綁定監聽器,一旦節點有變化,Zookeeper會通知用戶端,用戶端可以檢查自己建立的節點是不是目前所有節點中序号最小的,如果是,那麼自己就擷取到鎖,便可以執行業務邏輯了。
  • 不可重入?使用Zookeeper也可以有效的解決不可重入的問題,用戶端在建立節點的時候,把目前用戶端的主機資訊和線程資訊直接寫入到節點中,下次想要擷取鎖的時候和目前最小的節點中的資料比對一下就可以了。如果和自己的資訊一樣,那麼自己直接擷取到鎖,如果不一樣就再建立一個臨時的順序節點,參與排隊。
  • 單點問題?使用Zookeeper可以有效的解決單點問題,ZK是叢集部署的,隻要叢集中有半數以上的機器存活,就可以對外提供服務。

下面圖例說明:

架構與思維:分布式鎖方案分析

Locker Object 是對需要競争的資源進行持久的節點,下面的node-1到node-n 就是上面說的有序子節點,由不同程序的client去建立。

當進來一個用戶端需要去競争資源的時候,就跑到持久化節點下去按順序建立一個直接點,然後看一下是不是最小的一個。

如果是最小的就擷取到鎖,可以繼續後面的資源操作了。如果不是則監聽比自己序号小的節點,比如client-3 訂閱的是 node-2。

如果node-2被删除,自己被喚醒,再次判斷自己是不是序列中最小的,如果是,則擷取鎖。

3.3.2 zk實作分布式鎖的優缺點 

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

缺點:性能上不如使用緩存實作分布式鎖。 需要對ZK的原理有所了解。

3.4 三種方案的對比總結

上面幾種方式,并不是都能做到十全十美,就像CAP一樣,在複雜性、可靠性、性能 三方面無法同時滿足一樣。是以,更多的是根據不同的應用場景選擇最合适的方案。

特性 實作複雜度角度 性能角度 可靠性角度
資料庫
緩存
Zookeeper
架構與思維:分布式鎖方案分析

架構與思維·公衆号:撰稿者為bat、位元組的幾位高階研發/架構。不做廣告、不賣課、不要打賞,隻分享優質技術

碼字不易,歡迎關注,歡迎轉載

作者:翁智華

出處:https://www.cnblogs.com/wzh2010/

本文采用「CC BY 4.0」知識共享協定進行許可,轉載請注明作者及出處。

繼續閱讀