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

翁智華 發表於 2022-01-14

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