分散式鎖實現方案(REDIS,ZOOKEEPER,TAIR)

OkidoGreen發表於2017-03-30

Zookeeper

1、原生ZK方案

Zookeeper中有一種節點叫做順序節點,假如我們在/lock/目錄下建立節3個點,ZooKeeper叢集會按照提起建立的順序來建立節點,節點分別為/lock/0000000001、/lock/0000000002、/lock/0000000003。

ZooKeeper中還有一種名為臨時節點的節點,臨時節點由某個客戶端建立,當客戶端與ZooKeeper叢集斷開連線,則開節點自動被刪除。

EPHEMERAL_SEQUENTIAL為臨時順序節點

實現分散式鎖的基本邏輯:

  • 客戶端呼叫create()方法建立名為“locknode/guid-lock-”的節點,需要注意的是,這裡節點的建立型別需要設定為EPHEMERAL_SEQUENTIAL。
  • 客戶端呼叫getChildren(“locknode”)方法來獲取所有已經建立的子節點。
  • 客戶端獲取到所有子節點path之後,如果發現自己在步驟1中建立的節點是所有節點中序號最小的,那麼就認為這個客戶端獲得了鎖。
  • 如果建立的節點不是所有節點中需要最小的,那麼則監視比自己建立節點的序列號小的最大的節點,進入等待。直到下次監視的子節點變更的時候,再進行子節點的獲取,判斷是否獲取鎖。

釋放鎖的過程相對比較簡單,就是刪除自己建立的那個子節點即可。

以下是流程圖:



讀寫鎖:讀寫鎖的實現與互斥鎖類似,不同的地方在於建立自節點時讀鎖和寫鎖要區分型別。例如讀鎖的字首可以設定為read,寫鎖的字首可以設定為write。建立讀鎖的時候,檢查是否有編號小於自己的寫鎖存在,若存在則對編號剛好小於自己的寫鎖節點進行監聽。建立寫鎖時,檢查建立的節點編號是否為最小,如不是最小,則需要對編號剛好小於自己的節點進行監聽(此時不區分讀鎖和寫鎖)

2、Curator方案

封裝了zk的客戶端,其分散式實現方式和上面的基本相同。同時還提供了不同的鎖型別:

可重入鎖:實現類為InterProcessMutex,將執行緒物件,節點,鎖物件相關聯。InterProcessMutex內部維護了一個使用執行緒為key,{thread,path}為值的map,所以對不同的執行緒和請求加鎖的節點進行一一對應。提供方法acquire 和 release。

不可重入鎖:實現類為InterProcessSemaphoreMutex,類似InterProcessMutex,只是沒有維護執行緒的map。

可重入讀寫鎖:類似JDK的ReentrantReadWriteLock.一個讀寫鎖管理一對相關的鎖。 主要由兩個類實現:

  • InterProcessReadWriteLock
  • InterProcessLock

使用時首先建立一個InterProcessReadWriteLock例項,然後再根據你的需求得到讀鎖或者寫鎖, 讀寫鎖的型別是InterProcessLock

讀寫鎖的實現與互斥鎖類似,不同的地方在於建立自節點時讀鎖和寫鎖要區分型別。例如讀鎖的字首可以設定為read,寫鎖的字首可以設定為write。建立讀鎖的時候,檢查是否有編號小於自己的寫鎖存在,若存在則對編號剛好小於自己的寫鎖節點進行監聽。建立寫鎖時,檢查建立的節點編號是否為最小,如不是最小,則需要對編號剛好小於自己的節點進行監聽(此時不區分讀鎖和寫鎖)

還有訊號量和多鎖物件。

3、menagerie方案

menagerie基於Zookeeper實現了java.util.concurrent包的一個分散式版本。這個封裝是更大粒度上對各種分散式一致性使用場景的抽象。其中最基礎和常用的是一個分散式鎖的實現:
org.menagerie.locks.ReentrantZkLock,通過ZooKeeper的全域性有序的特性和EPHEMERAL_SEQUENTIAL型別znode的支援,實現了分散式鎖。

Redis

最常見互斥鎖方案:

Redis的SETNX(即SET if Not eXists)GETSET先寫新值,返回舊值,原子性操作,可以用於分辨是不是首次操作)可以用於分散式鎖:
  1. C3傳送SETNX lock.{orderid} 想要獲得鎖,由於C0還持有鎖,所以Redis返回給C3一個0,
  2. C3傳送GET lock.{orderid} 以檢查鎖是否超時了,如果沒超時,則等待或重試。
  3. 反之,如果已超時,C3通過下面的操作來嘗試獲得鎖:
    GETSET lock.{orderid} <current Unix time + lock timeout + 1>
  4. 通過GETSET,C3拿到的時間戳如果仍然是超時的,那就說明,C3如願以償拿到鎖了。
  5. 如果在C3之前,有個叫C4的客戶端比C3快一步執行了上面的操作,那麼C3拿到的時間戳是個未超時的值,這時,C3沒有如期獲得鎖,需要再次等待或重試。留意一下,儘管C3沒拿到鎖,但它改寫了C4設定的鎖的超時值,不過這一點非常微小的誤差帶來的影響可以忽略不計。
jeffkit的偽碼參考:
  1. # get lock
  2. lock = 0
  3. while lock !1:
  4.     timestamp = current Unix time + lock timeout + 1
  5.     lock = SETNX lock.orderid timestamp
  6.     if lock == 1 or (now() > (GET lock.orderid) and now() > (GETSET lock.orderid timestamp)):
  7.         break
  8.     else:
  9.         sleep(10ms)
  10.  
  11. do_your_job()
  12.  
  13. # release lock
  14. if now() < GET lock.orderid:
  15.     DEL lock.orderid

Tair

設計思路和Medis類似,但實現略有不同。

美團維護的Tair中增加了expireLock和expireUnlock介面,通過鎖狀態和過期時間戳來共同判斷鎖是否存在:只有鎖已經存在且沒有過期的狀態才判定為有鎖狀態。在有鎖狀態下,不能加鎖,能通過大於過期時間的時間戳進行解鎖;在無鎖狀態下,可以加鎖,加鎖成功會返回過期時間戳,用於解鎖使用。重要的是,expireLock的原子性可以保證加鎖和解鎖時不會因為執行緒搶佔引起錯誤。

不可重入鎖:在加鎖時呼叫expireLock,解鎖時呼叫expireUnlock介面。傳入的引數為過期時間或者過期時間戳。可以防止當執行緒拿到鎖之後阻塞或者當機,鎖可以在過期之後釋放出來。同時可以滿足解鎖動作安全,當自己的鎖過期時不會誤刪別人的鎖。

可重入鎖:類似不可重入鎖,維護類似zk的一個執行緒數和鎖名的map。

可重入讀寫鎖:

讀執行緒:先用當前時間進行一次解鎖expireUnlock,如果能解開則說明沒有執行緒在寫,可以進行讀操作,同時incr,將計數器加1;完成讀之後進行decr。

寫執行緒:getCount讀取計數器,如果為0,則說明沒有執行緒在讀,否則則需要等待;再expireLock,如果成功說明獲取到了寫鎖,否則則說明已經有執行緒在寫了;完成寫之後進行解鎖expireUnlock

缺陷:均有兩步操作,但無法保證原子性。




相關文章