天天看點

Distributed - 分布式鎖

背景

多線程環境下,我們有ReentrantLock 和 synchronized 等鎖。

那麼,分布式環境下也有鎖,就是分布式鎖。

1. 分布式鎖條件

再回顧下多線程和多程序環境下的鎖,可以發現鎖的實作有很多共通之處,它們都需要滿足一些最基本的條件: 1. 需要有存儲鎖的空間,并且鎖的空間是可以通路到的。 2. 鎖需要被唯一辨別。 3. 鎖要有至少兩種狀态。

仔細分析這三個條件:

存儲空間

鎖是一個抽象的概念,鎖的實作,需要依存于一個可以存儲鎖的空間。在多線程中是記憶體,在多程序中是記憶體或者磁盤。更重要的是,這個空間是可以被通路到的。多線程中,不同的線程都可以通路到堆中的成員變量;在多程序中,不同的程序可以通路到共享記憶體中的資料或者存儲在磁盤中的檔案。但是在分布式環境中,不同的主機很難通路對方的記憶體或磁盤。這就需要一個都能通路到的外部空間來作為存儲空間。

最普遍的外部存儲空間就是資料庫了,事實上也确實有基于資料庫做分布式鎖(行鎖、version樂觀鎖),如quartz叢集架構中就有所使用。除此以外,還有各式緩存如Redis、Tair、Memcached、Mongodb,當然還有專門的分布式協調服務Zookeeper,甚至是另一台主機。隻要可以存儲資料、鎖在其中可以被多主機通路到,那就可以作為分布式鎖的存儲空間。

唯一辨別

不同的共享資源,必然需要用不同的鎖進行保護,是以相應的鎖必須有唯一的辨別。在多線程環境中,鎖可以是一個對象,那麼對這個對象的引用便是這個唯一辨別。多程序環境中,信号量在共享記憶體中也是由引用來作為唯一的辨別。但是如果不在記憶體中,失去了對鎖的引用,如何唯一辨別它呢?上文提到的有名信号量,便是用硬碟中的檔案名作為唯一辨別。是以,在分布式環境中,隻要給這個鎖設定一個名稱,并且保證這個名稱是全局唯一的,那麼就可以作為唯一辨別。

至少兩種狀态

為了給臨界區加鎖和解鎖,需要存儲兩種不同的狀态。如ReentrantLock中的status,0表示沒有線程競争,大于0表示有線程競争;信号量大于0表示可以進入臨界區,小于等于0則表示需要被阻塞。是以隻要在分布式環境中,鎖的狀态有兩種或以上:如有鎖、沒鎖;存在、不存在等等,均可以實作。

有了這三個條件,基本就可以實作一個簡單的分布式鎖了。下面以資料庫為例,實作一個簡單的分布式鎖: 資料庫表,字段為鎖的ID(唯一辨別),鎖的狀态(0表示沒有被鎖,1表示被鎖)。 僞代碼為:

以上的方式即可以實作一個粗糙的分布式鎖,但是這樣的實作,有沒有什麼問題呢?

問題1:鎖狀态判斷原子性無法保證 從讀取鎖的狀态,到判斷該狀态是否為被鎖,需要經曆兩步操作。如果不能保證這兩步的原子性,就可能導緻不止一個請求擷取到了鎖,這顯然是不行的。是以,我們需要保證鎖狀态判斷的原子性。

問題2:網絡斷開或主機當機,鎖狀态無法清除 假設在主機已經擷取到鎖的情況下,突然出現了網絡斷開或者主機當機,如果不做任何處理該鎖将仍然處于被鎖定的狀态。那麼之後所有的請求都無法再成功搶占到這個鎖。是以,我們需要在持有鎖的主機當機或者網絡斷開的時候,及時的釋放掉這把鎖。

問題3:無法保證釋放的是自己上鎖的那把鎖 在解決了問題2的情況下再設想一下,假設持有鎖的主機A在臨界區遇到網絡抖動導緻網絡斷開,分布式鎖及時的釋放掉了這把鎖。之後,另一個主機B占有了這把鎖,但是此時主機A網絡恢複,退出臨界區時解鎖。由于都是同一把鎖,是以A就會将B的鎖解開。此時如果有第三個主機嘗試搶占這把鎖,也将會成功獲得。是以,我們需要在解鎖時,确定自己解的這個鎖正是自己鎖上的。

如果分布式鎖的實作,還能再解決上面的三個問題,那麼就可以算是一個相對完整的分布式鎖了。然而,在實際的系統環境中,還會對分布式鎖有更進階的要求。

可重入:線程中的可重入,指的是外層函數獲得鎖之後,内層也可以獲得鎖,ReentrantLock和synchronized都是可重入鎖;衍生到分布式環境中,一般仍然指的是線程的可重入,在絕大多數分布式環境中,都要求分布式鎖是可重入的。

驚群效應(Herd Effect):在分布式鎖中,驚群效應指的是,在有多個請求等待擷取鎖的時候,一旦占有鎖的線程釋放之後,如果所有等待的方都同時被喚醒,嘗試搶占鎖。但是這樣的情況會造成比較大的開銷,那麼在實作分布式鎖的時候,應該盡量避免驚群效應的産生。

公平鎖和非公平鎖:不同的需求,可能需要不同的分布式鎖。非公平鎖普遍比公平鎖開銷小。但是業務需求如果必須要鎖的競争者按順序獲得鎖,那麼就需要實作公平鎖。

阻塞鎖和自旋鎖:針對不同的使用場景,阻塞鎖和自旋鎖的效率也會有所不同。阻塞鎖會有上下文切換,如果并發量比較高且臨界區的操作耗時比較短,那麼造成的性能開銷就比較大了。但是如果臨界區操作耗時比較長,一直保持自旋,也會對CPU造成更大的負荷。

保留以上所有問題和條件,我們接下來看一些比較典型的實作方案。

2. 典型實作

ZooKeeper(以下簡稱“ZK”)中有一種節點叫做順序節點,假如我們在/lock/目錄下建立3個節點,ZK叢集會按照發起建立的順序來建立節點,節點分别為/lock/0000000001、/lock/0000000002、/lock/0000000003。

ZK中還有一種名為臨時節點的節點,臨時節點由某個用戶端建立,當用戶端與ZK叢集斷開連接配接,則該節點自動被删除。EPHEMERAL_SEQUENTIAL為臨時順序節點。

根據ZK中節點是否存在,可以作為分布式鎖的鎖狀态,以此來實作一個分布式鎖,下面是分布式鎖的基本邏輯: 1. 用戶端調用create()方法建立名為“/dlm-locks/lockname/lock-”的臨時順序節點。 2. 用戶端調用getChildren(“lockname”)方法來擷取所有已經建立的子節點。 3. 用戶端擷取到所有子節點path之後,如果發現自己在步驟1中建立的節點是所有節點中序号最小的,那麼就認為這個用戶端獲得了鎖。 4. 如果建立的節點不是所有節點中需要最小的,那麼則監視比自己建立節點的序列号小的最大的節點,進入等待。直到下次監視的子節點變更的時候,再進行子節點的擷取,判斷是否擷取鎖。

釋放鎖的過程相對比較簡單,就是删除自己建立的那個子節點即可,不過也仍需要考慮删除節點失敗等異常情況。

開源的基于ZK的Menagerie的源碼就是一個典型的例子:​​https://github.com/sfines/menagerie​​ 。

Menagerie中的lock首先實作了可重入鎖,利用ThreadLocal存儲進入的次數,每次加鎖次數加1,每次解鎖次數減1。如果判斷出是目前線程持有鎖,就不用走擷取鎖的流程。

通過tryAcquireDistributed方法嘗試擷取鎖,循環判斷前序節點是否存在,如果存在則監視該節點并且傳回擷取失敗。如果前序節點不存在,則再判斷更前一個節點。如果判斷出自己是第一個節點,則傳回擷取成功。

為了在别的線程占有鎖的時候阻塞,代碼中使用JUC的condition來完成。如果擷取嘗試鎖失敗,則進入等待且放棄localLock,等待前序節點喚醒。而localLock是一個本地的公平鎖,使得condition可以公平的進行喚醒,配合循環判斷前序節點,實作了一個公平鎖。

這種實作方式非常類似于ReentrantLock的CHL隊列,而且zk的臨時節點可以直接避免網絡斷開或主機當機,鎖狀态無法清除的問題,順序節點可以避免驚群效應。這些特性都使得利用ZK實作分布式鎖成為了最普遍的方案之一。

Redis的分布式緩存特性使其成為了分布式鎖的一種基礎實作。通過Redis中是否存在某個鎖ID,則可以判斷是否上鎖。為了保證判斷鎖是否存在的原子性,保證隻有一個線程擷取同一把鎖,Redis有SETNX(即SET if Not eXists)和GETSET(先寫新值,傳回舊值,原子性操作,可以用于分辨是不是首次操作)操作。

為了防止主機當機或網絡斷開之後的死鎖,Redis沒有ZK那種天然的實作方式,隻能依賴設定逾時時間來規避。

以下是一種比較普遍但不太完善的Redis分布式鎖的實作步驟(與下圖一一對應): 1. 線程A發送SETNX lock.orderid 嘗試獲得鎖,如果鎖不存在,則set并獲得鎖。 2. 如果鎖存在,則再判斷鎖的值(時間戳)是否大于目前時間,如果沒有逾時,則等待一下再重試。 3. 如果已經逾時了,在用GETSET lock.{orderid} 來嘗試擷取鎖,如果這時候拿到的時間戳仍舊逾時,則說明已經獲得鎖了。 4. 如果在此之前,另一個線程C快一步執行了上面的操作,那麼A拿到的時間戳是個未逾時的值,這時A沒有如期獲得鎖,需要再次等待或重試。

Distributed - 分布式鎖

該實作還有一個需要考慮的問題是全局時鐘問題,由于生産環境主機時鐘不能保證完全同步,對時間戳的判斷也可能會産生誤差。

以上是Redis的一種常見的實作方式,除此以外還可以用SETNX+EXPIRE來實作。Redisson是一個官方推薦的Redis用戶端并且實作了很多分布式的功能。它的分布式鎖就提供了一種更完善的解決方案,源碼:​​https://github.com/mrniko/redisson​​ 。

上一篇: C++調試問題
下一篇: 分布式緩存

繼續閱讀