幾年前介紹過一種基於zk的分佈式鎖1的實現,那種是沒有通過實踐證實的,聽過一場分享,而後以爲原來分佈式鎖能夠這麼搞,而後在實驗環境寫了一些代碼,簡單驗證一下,就認爲成了。其實那裏面有幾個比較嚴重的問題,第一個是鎖操做若是在併發的狀況下不是block的,而是經過循環+sleep的方式來反覆判斷,性能上是比較差的,不夠實時。第二個是設計太複雜,其實能夠不須要ip地址的介入的。java
基於zookeeper,其實能夠設計出優雅的分佈式鎖。這個鎖能夠有這幾個API:lock()
, unlock()
, isLock()
,lock用於加鎖,unlock用於解鎖,isLock用於判斷是否已經鎖住了。zookeeper
提供了這麼一套機制,你能夠監控watch
節點的變化(內容更新,子節點添加,刪除),而後節點變化的時候經過回調咱們的監控器(watcher
)來通知咱們節點的實時變化。在這種機制下,咱們能夠很簡單的作一個鎖。node
在單機模式,沒有引入zookeeper
時,咱們能夠經過建立一個臨時文件來加鎖,而後在事務處理完畢的時候,把這個臨時文件刪除就能表明解鎖了。這種簡單的加鎖和解鎖模式能夠移植到zookeeper上,經過建立一個路徑,來證實該鎖已經存在,而後刪除路徑來釋放該鎖。而同時zookeeper又能支持對節點的監控,這樣一來,咱們在多機的狀況下就能同時且實時知道鎖是存在仍是已經解鎖了。網絡
如圖所示,咱們在/lock下建立了/app1 /app2 … /appN n個子目錄,用於適用不一樣的應用, 每一個/app* 下面均可以根據業務需求建立鎖 /lock. , 而每一個機器在獲取鎖的時候,會在/lock. 下面建立 _0000000* 的自增加臨時節點,這個節點上的數字用於表示獲取鎖的前後順序。前面說的仍是有點抽象,下面舉個例子:session
一個後臺應用(app爲back)總共有3臺機器在處理事務,其中有一個業務(lock爲 biz)同一時間只能有一臺機器能處理,其餘若是也同時收處處理消息的時候,須要對這個事務加個鎖,以保證全局的事務性。三臺機器分別表示爲 server1, server2和 server3。併發
對應的,鎖的路徑就是 /lock/back/biz 。 首先 server1先收到消息,要處理事務biz,那麼獲取鎖,並在/lock/back/biz下建立一個臨時節點/lock/back/biz/_00000001 ,這時候判斷/lock/back/biz 下的子節點,看最小的那個是否是和本身建立的相等,若是是,說明本身已經獲取了鎖,能夠繼續下一步的操做,直到事務完成。app
與此同時,server2和server3也收到的消息(稍微慢於server1收到),這時候server2和server3也分別會在/lock/back/biz下面建立臨時節點/lock/back/biz/_00000002 和 /lock/back/biz/_00000003,他們這時候也會判斷/lock/back/biz下的子節點,發現最小的節點是_00000001,不是本身建立的那個,因而乎就會進入一個wait的操做(經過mutex很容易實現)。框架
以後,等server1的事務處理完畢,釋放server1獲取的鎖,也就是把 /_00000001刪掉。因爲zookeeper能夠監控節點的變化,刪掉/_00000001的時候,zookeeper能夠經過節點刪除的事件,通知到server1 server2 server3,這時候server2和server3對上面經過mutex block住的區塊發送信號量(notify),server2和server3繼續進入判斷/lock/back/biz下面的子節點以及最小的節點和本身作對比,這時候server2因爲建立了節點_00000002,因此輪到他來獲取鎖:dom
以後server2開始進入事務處理,而後釋放鎖,server3開始獲得鎖,處理事務,釋放鎖。以此類推。分佈式
這樣一來,整個事務(biz)的處理就能保證同時只有一臺機能處理到了。性能
總體僞代碼以下:
public class LockImpl{ //獲取zk實例,最好是單例的或者是共享鏈接池,否則併發高的時候,容易掛 private Zookeeper zk = getZookeeper(); //用於本地作wait和notify private byte[] mutex = new byte[0]; //節點監控器 private Watcher watcher; //這個鎖生成的序列號 private String serial; public LockImpl(){ watcher = new DefaultWatcher(mutex); createIfNotExist(); } private createIfNotExist(){ String path = buildPath(); //建立路徑,若是不存在的話 createIfNotExsitPath(path); //註冊監控器,才能監控到。 registWatcher(watcher); } public void lock(){ //獲取序列號,其實就是建立一個當前path下的臨時節點,zk會返回該節點的值 String serial = genSerial(); while(true){ //獲取子節點 List<string> children = getChildren(); //按從小到大排序 sort(children); //若是當前節點是第一個,說明被當前進程的線程獲取。 if(children.index(serial) == 0){ //you get the lock break; }else{ //不然等待別人刪除節點後通知,而後進入下一次循環。 synchronized(mutex){ mutex.wait(); } } } return; } public void unlock(){ //刪除子節點就能解鎖 deletePath(serial); } public void isLock(){ //判斷路徑下面是否有子節點便可 return ifHasChildren(); } } //監控器類 public class DefaultWatcher implements Watcher{ private byte[] mutex; public DefaultWatcher(byte[] mutex){ this.mutex = mutex; } public void process(WatchedEvent event){ synchronized (mutex) { mutex.notifyAll(); } } }
至此,一個看起來優雅一點的分佈式鎖就實現了。這是一個理想狀態下的實現,咋看起來沒問題,其實裏面隱藏了比較嚴重的問題。就是這裏把zookeeper理想化了,認爲他是完美的,不會出現問題。這裏說的問題倒不是必定是zk的bug,好比網絡問題,在分佈式系統中,網絡問題是一個很常見的問題,很容易就會有異常的狀況。若是出現網絡問題,會出現什麼狀況呢?答案是「死鎖」。
下面按多種狀況來分析這個問題:
當咱們向zk發起請求,要求建立一個臨時節點的時候,發生了異常(包括網絡異常,zk的session失效等)
假設zk服務端收到請求了,但服務端發生異常,沒有建立成功,那麼咱們客戶端再次重試一下就能夠了。
假設zk服務端收到了請求,子節點建立成功了,可是返回給客戶端的時候網絡發生了異常。這時候咱們要是再作重試,建立的話,就會進入「死鎖」。這裏的「死鎖」跟咱們平時理解的死鎖不是一個概念,這裏的意思不是迴路循環等待,而是至關於這個鎖就是死掉了,由於前面的異常請求中其實建立了一個節點,而後這個節點沒有客戶端與他關聯,咱們能夠稱爲幽靈節點
。這個幽靈節點因爲前後順序,他是優先級最高的,然而沒有客戶端跟他關聯,也就是沒有客戶端能夠釋放這個幽靈節點,那麼全部的客戶端都會進入無限的等待,形成了「死鎖」的現象。
假設zk服務端收到請求了,子節點建立成功了,返回給客戶端成功了,可是客戶端在處理的時發生了異常(這種通常是zk的bug纔會出現),這時候咱們再作一次重試,也會進入上面的「死鎖」現象。
爲何會出現3,4的現象呢?由於咱們只是作了簡單的重試,並無對服務端和客戶端作驗證。就是客戶端建立了一個幽靈節點,可是建立者自己甚至都不知道是本身建立的幽靈節點,仍是別人建立的。要如何規避這個問題呢?廢話,引入驗證的流程。就是驗證+無限重試。怎麼作驗證,不要把驗證想的太複雜,驗證就是你建立的節點裏面有你建立的私有的信息,你客戶端自己也擁有這個信息,而後二者一對比,就能知道哪一個節點是哪一個客戶端建立的。固然,這個信息必須保證我有你無的,也就是惟一性。簡單了,引入UUID就能夠搞定這個問題。
好比客戶端1,生成了UUID: fd456c28-cc85-4e2f-8d52-bcf7538a2acf, 而後建立了一個臨時節點: /lock/back/biz/_fd456c28-cc85-4e2f-8d52-bcf7538a2acf_00000001
這時候服務端返回異常,拿客戶端第一件事是先把children撈出來,而後判斷這些children裏面有沒有本身建立的 uuid,若是有的話,說明本身實際上是建立成功了,而後就看是否是輪到本身了,解決3的問題。若是返回正常,可是客戶端有bug拋異常了,這時客戶端仍要進行重試,重試以前也會走前面的流程,能夠解決4的問題。
對children排序不能簡單的把node的路徑進行排序,由於randomUUID是徹底隨機的,按這個排序可能會致使某些鎖請求一直沒有被響應,也會有問題。這裏由於UUID的長度是固定的,並且也有規律可循,因此很容易從node中分解出 uuid和序列號,而後對序列號進行排序,找出最小的值,再賦予鎖,就能夠了。
分佈式系統中,異常出現很正常,若是你的業務須要覓等操做
(N^m = N)的話,就須要引入驗證和重試的機制。分佈式鎖就是須要一個覓等的操做,因此一個靠譜的分佈式鎖的實現,驗證和重試的機制是少不了的,這就是我想說的。具體要參考實現的話,能夠看netflix開源的zk客戶端框架curator2, 比直接用zk簡單得多也健壯得多。框架裏面也實現了一套分佈式鎖(還有其餘各類有用的東西),生產線其實能夠直接拿來使用的,很是方便。