如何實現一個分佈鎖?
基本概念
為何需要分散式鎖?
-
傳統環境中的情況:
在程式開發過程中不得不考慮的就是併發問題。在java中對於同一個jvm而言,jdk已經提供了lock和同步等。但是在分散式情況下,往往存在多個程序對一些資源產生競爭關係,而這些程序往往在不同的機器上,這個時候jdk中提供的已經不能滿足。也就是說單純的Java Api並不能提供分散式鎖的能力。所以針對分散式鎖的實現目前有多種方案。 -
在很多的場景中,為了保證資料的最終一致性,需要很多方案來支援,比如分散式事務、分散式鎖等。我們需要保證一個方法在同一時間內只有一個執行緒被執行。(比如Java中的併發情況lock, synchronized)就是實現了單機環境下的同步,保證共享變數的一致性。
-
分散式鎖主要用於在分散式環境中保護跨程序、跨主機、跨網路的共享資源實現互斥訪問,以達到保證資料的一致性。
目前的解決方案
- 基於資料庫實現分散式鎖
- 基於快取(redis、memcached)等實現分佈鎖
- 基於zookeeper實現分佈鎖
要達到的目標
在分析上述幾種方案之前,理想的分佈鎖理應達到的效果
- 可以保證在分散式部署的應用叢集中,同一個方法在同一個時間內只能被一臺機器上面的一個執行緒執行。
- 這把鎖事可重入鎖避免死鎖即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。
- 有高可用的獲取鎖和釋放鎖的功能
- 釋放鎖和獲取鎖的成本要低,效能要好(包括正常情況下的釋放鎖和非正常情況下的釋放鎖)
- 互斥性。在任意時刻,只有一個客戶端能持有鎖
- 具有容錯性。只要大部分的Redis節點正常執行,客戶端就可以加鎖和解鎖。
# 基於資料庫實現分散式鎖
基於資料庫的鎖實現也有兩種方式,一是基於資料庫表,另一種是基於資料庫排他鎖。
基於資料庫表
要實現分散式鎖,最簡單的方式就是之間建立一張鎖表,然後通過操作表中的資料來實現。當我們要鎖住某個資源或方法時,我們就在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。
基於資料庫表增刪是最簡單的方式,首先建立一張鎖的表主要包含下列欄位:方法名,時間戳等欄位。
建立這樣一張資料庫表:
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '備註資訊',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMPCOMMENT '儲存資料時間,自動生成',
PRIMARY KEY (`id`), UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';
當我們想要鎖住某個方法,執行一下SQL
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)
當我們對method_name做了唯一性約束,這裡如果有多個請求同時提交到資料的話,資料庫會保證只有一個操作可以成功,那麼我們就可以認為操作成功的那個執行緒獲得了該方法的鎖,可以執行方法體內容。
當方法執行完畢之後,想要釋放鎖的話,需要執行以下Sql:
delete from methodLock where method_name ='method_name'
基於資料庫排他鎖
除了可以通過增刪操作資料表中的記錄以外,其實還可以藉助資料中自帶的鎖來實現分散式的鎖。
我們還可以通過資料庫的排他鎖來實現分散式鎖。基於MySql的InnoDB引擎,用剛剛建立的那張資料庫表,可以使用以下方法來實現加鎖操作:
####### 1. 獲得鎖
public boolean lock(){
connection.setAutoCommit(false)
while(true){
try{
result = select * from methodLock where method_name=xxx for update;
if(result!=null){
//代表獲取到鎖
return true;
}
}catch(Exception e)
{ e.printStack();
}
//為空或者拋異常的話都表示沒有獲取到鎖
sleep(1000);
} return false;
}
在查詢語句後面增加for update
,資料庫會在查詢過程中給資料庫表增加排他鎖(這裡再多提一句,InnoDB引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這裡我們希望使用行級鎖,就要給method_name新增索引,值得注意的是,這個索引一定要建立成唯一索引,否則會出現多個過載方法之間無法同時被訪問的問題。過載方法的話建議把引數型別也加上。)。當某條記錄被加上排他鎖之後,其他執行緒無法再在該行記錄上增加排他鎖。
2. 釋放鎖
我們可以認為獲得排它鎖的執行緒即可獲得分散式鎖,當獲取到鎖之後,可以執行方法的業務邏輯,執行完方法之後,再通過以下方法解鎖:
public void unlock(){
connection.commit();
}
通過==connection.commit()==
操作來釋放鎖。
存在的問題
- 這把鎖強依賴資料庫的可用性,資料庫是一個單點,一旦資料庫掛掉,會導致業務系統不可用。
- 這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在資料庫中,其他執行緒無法再獲得到鎖。(釋放鎖的效能不高)
- 這把鎖只能是非阻塞的,因為資料的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的執行緒並不會進入排隊佇列,要想再次獲得鎖就要再次觸發獲得鎖操作。
- 這把鎖是非重入的,同一個執行緒在沒有釋放鎖之前無法再次獲得該鎖。因為資料中資料已經存在了。
在查詢語句後面增加for update
,資料庫會在查詢過程中給資料庫表增加排他鎖(這裡再多提一句,InnoDB引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這裡我們希望使用行級鎖,就要給method_name新增索引,值得注意的是,這個索引一定要建立成唯一索引,否則會出現多個過載方法之間無法同時被訪問的問題。過載方法的話建議把引數型別也加上。)。當某條記錄被加上排他鎖之後,其他執行緒無法再在該行記錄上增加排他鎖。
當然也有方式解決上面的問題
- 資料庫是單點?搞兩個資料庫,資料之前雙向同步。一旦掛掉快速切換到備庫上。
- 沒有失效時間?只要做一個定時任務,每隔一定時間把資料庫中的超時資料清理一遍。
- 非阻塞的?搞一個while迴圈,直到insert成功再返回成功。
- 非重入的?在資料庫表中加個欄位,記錄當前獲得鎖的機器的主機資訊和執行緒資訊,那麼下次再獲取鎖的時候先查詢資料庫,如果當前機器的主機資訊和執行緒資訊在資料庫可以查到的話,直接把鎖分配給他就可以了。
總結
總結一下使用資料庫來實現分散式鎖的方式,這兩種方式都是依賴資料庫的一張表,一種是通過表中的記錄的存在情況確定當前是否有鎖存在,另外一種是通過資料庫的排他鎖來實現分散式鎖。
優點
直接藉助資料庫,容易理解
缺點
操作資料庫需要一定的開銷,效能問題需要考慮。
使用資料庫的行級鎖並不一定靠譜,尤其是當我們的鎖表並不大的時候。
使用zookeeper實現分散式鎖
基於zookeeper臨時有序節點可以實現的分散式鎖。
背景知識(節點知識)
節點代表一個客戶端在伺服器下面建立的一個標誌的東西
在描述演算法流程之前,先看下zookeeper中幾個關於節點的有趣的性質:
- 有序節點:假如當前有一個父節點為/lock,我們可以在這個父節點下面建立子節點;zookeeper提供了一個可選的有序特性,例如我們可以建立子節點“/lock/node-”並且指明有序,那麼zookeeper在生成子節點時會根據當前的子節點數量自動新增整數序號,也就是說如果是第一個建立的子節點,那麼生成的子節點為/lock/node-0000000000,下一個節點則為/lock/node-0000000001,依次類推。
- 臨時節點:客戶端可以建立一個臨時節點,在會話結束或者會話超時後,zookeeper會自動刪除該節點。
- 事件監聽:在讀取資料時,我們可以同時對節點設定事件監聽,當節點資料或結構變化時,zookeeper會通知客戶端。當前zookeeper有如下四種事件:1)節點建立;2)節點刪除;3)節點資料修改;4)子節點變更。
總體結構
在介紹使用Zookeeper實現分散式鎖之前,首先看當前的系統架構圖
演算法總體流程
下面描述使用zookeeper實現分散式鎖的演算法流程,假設鎖空間的根節點為/lock:
- 客戶端連線zookeeper,並在/locker下建立臨時的且有序的子節點,第一個客戶端對應的子節點為/lock/lock-0000000000,第二個為/lock/lock-0000000001,以此類推。
- 客戶端獲取/lock下的子節點列表,判斷自己建立的子節點是否為當前子節點列表中序號最小的子節點,如果是則認為獲得鎖,否則監聽/lock的子節點變更訊息,獲得子節點變更通知後重復此步驟直至獲得鎖;
- 執行業務程式碼;
- 完成業務流程後,刪除對應的子節點釋放鎖。(定義了釋放鎖的操作In ZooKeeper, the lock releasing in normal circumstances means to delete the temporary nodes)
步驟1中建立的臨時節點能夠保證在故障的情況下鎖也能被釋放,考慮這麼個場景:假如客戶端a當前建立的子節點為序號最小的節點,獲得鎖之後客戶端所在機器宕機了,客戶端沒有主動刪除子節點;如果建立的是永久的節點,那麼這個鎖永遠不會釋放,導致死鎖;由於建立的是臨時節點,客戶端宕機後,過了一定時間zookeeper沒有收到客戶端的心跳包判斷會話失效,將臨時節點刪除從而釋放鎖。
另外細心的朋友可能會想到,在步驟2中獲取子節點列表與設定監聽這兩步操作的原子性問題,考慮這麼個場景:客戶端a對應子節點為/lock/lock-0000000000,客戶端b對應子節點為/lock/lock-0000000001,客戶端b獲取子節點列表時發現自己不是序號最小的,但是在設定監聽器前客戶端a完成業務流程刪除了子節點/lock/lock-0000000000,客戶端b設定的監聽器豈不是丟失了這個事件從而導致永遠等待了?這個問題不存在的。因為zookeeper提供的API中設定監聽器的操作與讀操作是原子執行的,也就是說在讀子節點列表時同時設定監聽器,保證不會丟失事件。
最後,對於這個演算法有個極大的優化點:假如當前有1000個節點在等待鎖,如果獲得鎖的客戶端釋放鎖時,這1000個客戶端都會被喚醒,這種情況稱為“羊群效應”;在這種羊群效應中,zookeeper需要通知1000個客戶端,這會阻塞其他的操作,最好的情況應該只喚醒新的最小節點對應的客戶端。應該怎麼做呢?在設定事件監聽時,每個客戶端應該對剛好在它之前的子節點設定事件監聽,例如子節點列表為/lock/lock-0000000000、/lock/lock-0000000001、/lock/lock-0000000002,序號為1的客戶端監聽序號為0的子節點刪除訊息,序號為2的監聽序號為1的子節點刪除訊息。
所以調整後的分散式鎖演算法流程如下:
-
客戶端連線zookeeper,並在/lock下建立臨時的且有序的子節點,第一個客戶端對應的子節點為/lock/lock-0000000000,第二個為/lock/lock-0000000001,以此類推。(唯一的臨時有序節點)
-
客戶端獲取/lock下的子節點列表,判斷自己建立的子節點是否為當前子節點列表中序號最小的子節點,如果是則認為獲得鎖,否則監聽剛好在自己之前一位的子節點刪除訊息,獲得子節點變更通知後重復此步驟直至獲得鎖;(getChildren(/mylock)
-
執行業務程式碼;
-
完成業務流程後,刪除對應的子節點釋放鎖。
可以總結如下
每個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個唯一的瞬時有序節點。 判斷是否獲取鎖的方式很簡單,只需要判斷有序節點中序號最小的一個。 當釋放鎖的時候,只需將這個瞬時節點刪除即可。同時,其可以避免服務宕機導致的鎖無法釋放,而產生的死鎖問題。
演算法總體流程
虛擬碼
1. 獲取鎖
public void lock(){
path = Create temporary sequence nodes under the parent node
while(true){
children = Obtain all the nodes under the parent node
If (path is the smallest unit in children){
indicates the node is obtained
return;
}else{
Add a watcher to monitor whether the previous node exists
wait();
}
}
}
```
//先建立臨時節點,然後判斷是否是最小的節點,如果是獲得鎖,否則在前一個節點處watch監聽
Content in watcher{
notifyAll();
}
2.釋放鎖
public void release{
Delete the node create above
}
All unlock() does is explictly delete this process’s node which notifies all the other waiting processes and allows the next one in line to go. Because the nodes are EPHEMERAL, the process can exit without unlocking and ZooKeeper will eventually reap its node allowing the next process to execute. This is a good thing because it means if your process ends prematurely without you having a chance to call unlock() it will not block the remaining processes. Note that it is best to explicitly call unlock() if you can, because it is much faster than waiting for ZooKeeper to reap your node
zookeeper 如何解決資料庫方式存在的問題
-
鎖無法釋放?
使用zookeeper可以有效解決鎖無法釋放的問題。在獲取鎖之前,客戶端會在ZK服務端zookeeper/locker/node_序號(臨時), 一旦客戶端獲得鎖之後掛掉(Session連線斷開),那麼這個臨時節點就會自動刪除掉,釋放鎖。其他客戶端就可以再次獲得鎖
-
非阻塞鎖?
使用Zookeeper可以實現阻塞的鎖,客戶端可以通過在ZK中建立順序節點,如果獲取鎖不成功,就在前面一個節點註冊watch監聽器;一旦節點有變化,Zookeeper會通知客戶端,客戶端可以檢查自己建立的節點是不是當前所有節點中序號最小的,如果是,那麼自己就獲取到鎖,便可以執行業務邏輯了。
-
不可重入?
使用Zookeeper也可以有效的解決不可重入的問題,客戶端在建立節點的時候,把當前客戶端的主機資訊和執行緒資訊直接寫入到節點中,下次想要獲取鎖的時候和當前最小的節點中的資料比對一下就可以了。如果和自己的資訊一樣,那麼自己直接獲取到鎖,如果不一樣就再建立一個臨時的順序節點,參與排隊。
-
單點問題?
單點問題?使用Zookeeper可以有效的解決單點問題,ZK是叢集部署的,只要叢集中有半數以上的機器存活,就可以對外提供服務。(保證高可用)
zookeeper開源客戶端
Curator 和zkclient等客戶端情況
雖然zookeeper原生客戶端暴露的API已經非常簡潔了,但是實現一個分散式鎖還是比較麻煩的…
我們可以直接使用curator這個開源專案提供的zookeeper分散式鎖實現。
缺點
- zookeeper實現的分散式鎖其實存在一個缺點,那就是效能上可能並沒有快取服務那麼高。因為每次在建立鎖和釋放鎖的過程中,都要動態建立、銷燬瞬時節點來實現鎖功能。
- ZK中建立和刪除節點只能通過Leader伺服器來執行,然後將資料同不到所有的Follower機器上。併發問題,可能存在網路抖動,客戶端和ZK叢集的session連線斷了,zk叢集以為客戶端掛了,就會刪除臨時節點,這時候其他客戶端就可以獲取到分散式鎖了
redis實現分佈鎖
In Redis, you usually can use the SETNX command to implement distributed locks.
相比較於基於資料庫實現分散式鎖的方案來說,基於快取來實現在效能方面會表現的更好一點。而且很多快取是可以叢集部署的,可以解決單點問題。(同樣zookeeper 形式)
程式碼
1.獲得鎖
public void lock(){
for(){
ret = setnx lock_ley (current_time + lock_timeout)
if(ret){
//The lock is obtained
break;
}
//The lock is not obtained
sleep(100);
}
}
釋放鎖
-
釋放鎖
public void release(){ del lock_key }
優點
相對於基於資料庫實現分散式鎖的方案來說,基於快取來實現在效能方面會表現的更好一點,存取速度快很多。而且很多快取是可以叢集部署的,可以解決單點問題。基於快取的鎖有好幾。
缺點
實現複雜
Java 客戶端
Redisson 連結
redis實現分散式鎖和zookeeper實現分散式鎖的區別?
Redis為單程序單執行緒模式,採用佇列模式將併發訪問變成序列訪問,且多客戶端對Redis的連線並不存在競爭關係。其次Redis提供一些命令SETNX,GETSET,可以方便實現分散式鎖機制
Redis分散式鎖,必須使用者自己間隔時間輪詢去嘗試加鎖,當鎖被釋放後,存在多執行緒去爭搶鎖,並且可能每次間隔時間去嘗試鎖的時候,都不成功,對效能浪費很大。
Zookeeper分佈鎖,首先建立加鎖標誌檔案,如果需要等待其他鎖,則新增監聽後等待通知或者超時,當有鎖釋放,無須爭搶,按照節點順序,依次通知使用者。
從上面可以知道,基於zookeeper的鎖,client的都有節點序號(有序節點的特性),按照序號來, 都是註冊在locker 下面下的node_n, 不存在多個競爭的方式
Redis實現分散式鎖服務時,有可能存在master崩潰導致多個節點獲取鎖的問題
總結
Zookeeper實現簡單,但效率較低;Redis實現複雜,但效率較高。
相對於基於資料庫實現分散式鎖的方案來說,基於快取來實現在效能方面會表現的更好一點,存取速度快很多。而且很多快取是可以叢集部署的,可以解決單點問題。基於快取的鎖有好幾。
三種方案的比較
上面幾種方式,哪種方式都無法做到完美。就像CAP一樣,在複雜性、可靠性、效能等方面無法同時滿足,所以,根據不同的應用場景選擇最適合自己的才是王道。
從理解的難易程度角度(從低到高)
資料庫 > 快取 > Zookeeper
從實現的複雜性角度(從低到高)
Zookeeper >= 快取 > 資料庫
從效能角度(從高到低)
快取 > Zookeeper >= 資料庫
從可靠性角度(從高到低)
Zookeeper > 快取 > 資料庫