曾奇:談談我所認識的分散式鎖

滴滴技術發表於2019-06-03

出品 | 滴滴技術
作者 | 曾奇

圖片描述

前言:隨著計算機技術和工程架構的發展,微服務變得越來越熱。如今,絕大多數服務都處於分散式環境中,其中,資料一致性是我們一直關注的重點。分散式鎖到底是什麼?經過了哪些發展演進?工程上有哪些實現方案?各種方案的利弊權衡又有哪些?希望這篇文章能夠對你有一些幫助。

▍閱讀索引

0.名詞定義
1.問題引入
2.分散式環境的特點
3.鎖
4.分散式鎖
5.分散式鎖實現方案

  • 5.1樸素Redis實現方案、樸素Redis方案小結
  • 5.2 ZooKeeper實現方案、ZooKeeper方案小結
  • 5.3 Redisson實現方案、Redission方案小結

6.總結
7.結束語
8.Reference

▍0. 名詞定義

分散式鎖:顧名思義,是指在分散式環境下的鎖,重點在鎖。所以我們先從鎖開始講起。

▍1. 問題引入

舉個例子:

某服務記錄資料X,當前值為100。A請求需要將X增加200;同時,B請求需要將X減100。 在理想的情況下,A先讀取到X=100,然後X增加200,最後寫入X=300。B請求接著讀取到X=300,減少100,最後寫入X=200。 然而在真實情況下,如果不做任何處理,則可能會出現:A和B同時讀取到X=100;A寫入之前B讀取到X;B比A先寫入等等情況。

上面這個例子相信大家都非常熟悉。出現不符合預期的結果本質上是對臨界資源沒有做好互斥操作。互斥性問題通俗來講,就是對共享資源的搶佔問題。對於共享資源爭搶的正確性,鎖是最常用的方式,其他的如CAS(compare and swap)等,這裡不展開。

▍2. 分散式環境的特點

我們的絕大部分服務都處於分散式環境中。那麼,分散式系統有哪些特點呢?大致如下:

  • 可擴充套件性:可通過橫向水平擴充套件提高系統的效能和吞吐量。
  • 高可靠性:高容錯,即使系統中一臺或幾臺故障,系統仍可提供服務。
  • 高併發性:各機器並行獨立處理和計算。
  • 廉價高效:多臺小型機而非單臺高效能機。

▍3.鎖

我們先來看下非分散式情況下的鎖方案(多執行緒和多程式的情況),然後再演進到分散式鎖。

▍多執行緒下的鎖機制:

各種語言有不同的實現方式,比較成熟。比如,go語言中的sync.RWMutex(讀寫鎖)、sync.Mutex(互斥鎖);JAVA中的ReentrantLock、synchronized;在php中沒有找到原生的支援鎖的方式,只能通過外部來間接實現,比如檔案鎖,藉助外部儲存的鎖等。

▍多程式下的鎖機制:

對於臨界資源的訪問已經超出了單個程式的控制範圍。在多程式的情況下,主要是利用作業系統層面的程式間通訊原理來解決臨界資源的搶佔問題。比較常見的一種方法便是使用訊號量(Semaphores)。

▍對訊號量的操作,主要是P操作(wait)和V操作(signal):

  • P操作 ( wait ) :

先檢查訊號量的大小,若值大於零,則將訊號量減1,同時程式獲得共享資源的訪問許可權,繼續執行;若小於或者等於零,則該程式被阻塞後,進入等待佇列。

  • V操作 ( signal ) :

該操作將訊號量的值加1,如果有程式阻塞著等待該訊號量,那麼其中一個程式將被喚醒。

可看出,多程式鎖方案跟多執行緒的鎖方案實現思路大同小異。

我們將互斥的級別拉高,分散式環境下不同節點不同程式或執行緒之間的互斥,就是分散式鎖的挑戰之一。後面再細講。

另外,在傳統的基於資料庫的架構中,對於資料的搶佔問題也可以通過資料庫事務(ACID)來保證。在分散式環境中,出於對效能以及一致性敏感度的要求,使得分散式鎖成為了一種比較常見而高效的解決方案。

▍從上面對於多執行緒和多程式鎖的概括,可以總結出鎖的抽象條件:

1)“需要有儲存鎖的空間,並且鎖的空間是可以訪問到的”:

對於多執行緒就是記憶體(程式中不同的執行緒都可以讀寫),多程式中通過共享記憶體的方式,也是提供一塊地方,供不同程式讀寫。主要目的是保證不同的進執行緒改動對於其他進執行緒可見,進而滿足互斥性需求。

2)“鎖需要被唯一標識”:

不同的共享資源,必然需要用不同的鎖進行保護,因此相應的鎖必須有唯一的標識。在多執行緒環境中,鎖可以是一個物件,那麼對這個物件的引用便是這個唯一標識。多程式下,比如有名訊號量,便是用硬碟中的檔名作為唯一標識。

3)“鎖要有至少兩種狀態”:

有鎖,沒鎖。存在,不存在等等。很好理解。

滿足上述三個條件就可以實現基礎的分散式鎖了。但是隨著技術的演進,

▍相應地,對鎖也提出了更高階的條件:

1)可重入:

外層函式獲得鎖之後,內層函式還可以獲得鎖。原因是隨著軟體複雜性增加,方法巢狀獲取鎖已經很難避免。但是從程式碼層面很難分析出這個問題,因此我們要使用可重入鎖。導致鎖需要支援可重入的場景。對於可重入的思考,每種語言有自己的哲學和取捨,如go就捨棄了支援重入:Recursive locking in Go [ https://stackoverflow.com/que... ]以後go又會不會認為“可重入真香”呢?哈哈,我們拭目以待。

2)避免產生驚群效應(Herd Effect):

驚群效應指,在有多個請求等待獲取鎖的時候,一旦佔有鎖的執行緒釋放之後,所有等待方都同時被喚醒,嘗試搶佔鎖。但是絕大多數的搶佔都是不必要的。這種情況在多執行緒和多程式中開銷同樣很大。要儘量避免這種情況出現。

3)公平鎖和非公平鎖:

公平鎖:優先把鎖給等待時間最長的一方;非公平鎖:不保證等待執行緒拿鎖的順序。公平鎖的實現成本較高。

4)阻塞鎖和自旋鎖:

主要是效率的考慮。自旋鎖適用於臨界區操作耗時短的場景;阻塞鎖適用於臨界區操作耗時長的場景。

5)鎖超時:

防止釋放鎖失敗,出現死鎖的情況。

6)高效,高可用:

加鎖和解鎖需要高效,同時也需要保證高可用防止分散式鎖失效,可以增加降級。

還有很多其他更高要求的條件,不一一列舉了。有興趣的小夥伴可以看看程式設計史上鎖的演進過程。

▍4. 分散式鎖

▍使用分散式鎖的必要性:

1)服務要求:部署的服務本身就處於分散式環境中

2)效率:使用分散式鎖可以避免不同節點重複相同的工作,這些工作會浪費資源。比如使用者付了錢之後有可能不同節點會發出多封簡訊

3)正確性:跟2)類似。如果兩個節點在同一條資料上面操作,比如多個節點機器對同一個訂單操作不同的流程有可能會導致該筆訂單最後狀態出現錯誤,造成損失

包括但不限於這些必要性,在強烈地呼喚我們今天的主角---“分散式鎖”閃亮登場。

▍5. 分散式鎖實現方案

有了非分散式鎖的實現思路,和分散式環境的挑戰,我們來看看分散式鎖的實現策略。
分散式鎖本質上還是要實現一個簡單的目標---佔一個“坑”,當別的節點機器也要來佔時,發現已經有人佔了,就只好放棄或者稍後再試。

▍大體分為4種

1)使用資料庫實現
2)使用樸素Redis等快取系統實現
3)使用ZooKeeper等分散式協調系統實現
4)使用Redisson來實現(本質上基於Redis)

因為利用mysql實現分散式鎖的效能低以及改造大,我們這裡重點講一下下面3種實現分散式鎖的方案。

▍5.1 樸素Redis實現方案

我們循序漸進,對比幾種實現方式,找出優雅的方式:

方案1:setnx+delete

1 setnx lock_key lock_value
2 // do sth
3 delete lock_key

缺點:一旦服務掛掉,鎖無法被刪除釋放,會導致死鎖。硬傷,pass!2

方案2:setnx + setex

1 setnx lock_key lock_value
2 setex lock_key N lock_value  // N s超時
3 // do sth
4 delete lock_key

在方案1的基礎上設定了超時時間。但是還是會出現跟1一樣的問題。如果setnx之後、setex之前服務掛掉,一樣會陷入死鎖。本質原因是,setnx/setex分為了兩個步驟,非原子操作。硬傷,pass!

方案3:set ex nx

1 SET lock_key lock_value EX N NX //N s超時
2 // do sth
3 delete lock_key

將加鎖、設定超時兩個步驟合併為一個原子操作,從而解決方案1、2的問題。(Redis原生命令支援,Redis version需要>=2.6.12,滴滴生產環境Redis version一般為3.2,所以日常能夠使用)。

優點:此方案目前大多數sdk、Redis部署方案都支援,實現簡單
缺點:會存在鎖被錯誤的釋放,被錯誤的搶佔的情況。如下圖:

圖片描述

這塊有2個問題:

1)GC期間,client1超時時間已到,導致將client2錯誤地放進來

2)client1執行完邏輯後會顯式呼叫del,將所有的鎖都釋放了(正確的情況應該只釋放自己的鎖,錯誤地釋放了client2的鎖)

方案4:

在3的基礎上,對於問題1,將client的超時時間設定長一些,保證只能通過顯式del來釋放鎖,而超時時間只是作為一種最終兜底的方案。針對問題2,增加對 value 的檢查,只解除自己加的鎖,為保證原子性,只能需要通過lua指令碼實現。

lua指令碼:https://redis.io/commands/eval

1 if redis.call("get",KEYS[1]) == ARGV[1] then
2   return redis.call("del",KEYS[1])
3 else
4   return 0
5 end

如果超時時間設定長,只能通過顯式的del來釋放鎖,就不會出現問題2(錯誤釋放掉其他client的鎖)。跟滴滴KV store的王斌同學討論過,目前沒有找到方案4優於方案3(只要超時時間設定的長一些)的場景。所以,在我的認知中,方案4跟方案3的優勢一樣,但是方案3的實現成本明顯要低很多。

樸素Redis方案小結

方案3用的最多,實現成本小,對於大部分場景,將超時時間設定的長一些,極少出現問題。同時本方案對不同語言的友好度極高。

▍5.2 ZooKeeper實現方案

我們先簡要介紹一些ZooKeeper(以下簡稱ZK):

ZooKeeper是一種“分散式協調服務”。所謂分散式協調服務,可以在分散式系統中共享配置,協調鎖資源,提供命名服務等。為讀多寫少的場景所設計,ZK中的節點(以下簡稱ZNode)非常適合用於儲存少量的狀態和配置資訊。

對ZK常見的操作:

create:建立節點
delete:刪除節點
exists:判斷一個節點的資料
setdata:設定一個節點的資料
getchildren:獲取節點下的所有子節點

這其中,exists,getData,getChildren屬於讀操作。Zookeeper客戶端在請求讀操作的時候,可以選擇是否設定Watch(監聽機制)。

什麼是Watch?

Watch機制是zk中非常有用的功能。我們可以理解成是註冊在特定Znode上的觸發器。當這個Znode發生改變,也就是呼叫了create,delete,setData方法的時候,將會觸發Znode上註冊的對應事件,請求Watch的客戶端會接收到非同步通知。
我們在實現分散式鎖的時候,正是通過Watch機制,來通知正在等待的session相關鎖釋放的資訊。

什麼是ZNode?

ZNode就是ZK中的節點。ZooKeeper節點是有生命週期的,這取決於節點的型別。在 ZooKeeper 中,節點型別可以分為臨時節點(EPHEMERAL),時序節點(SEQUENTIAL ),持久節點(PERSISTENT )。

臨時節點(EPHEMERAL):

節點的生命週期跟session繫結,session建立的節點,一旦該session失效,該節點就會被刪除。

臨時順序節點(EPHEMERAL_SEQUENTIAL):

在臨時節點的基礎上增加了順序。每個父結點會為自己的第一級子節點維護一份時序。在建立子節點的時候,會自動加上數字字尾,越後建立的節點,順序越大,字尾越大。

持久節點(PERSISTENT ):

節點建立之後就一直存在,不會因為session失效而消失。

持久順序節點(PERSISTENT_SEQUENTIAL):

與臨時順序節點同理。

ZNode中的資料結構:

data(znode儲存的資料資訊),acl(記錄znode的訪問許可權,即哪些人或哪些ip可以訪問本節點),stat(包含znode的各種後設資料,比如事務id,版本號,時間戳,大小等等),child(當前節點的子節點引用)。

利用ZK實現分散式鎖,主要得益於ZK保證了資料的強一致性。

下面說說通過zk簡單實現一個保持獨佔的鎖(利用臨時節點的特性):

我們可以將ZK上的ZNode看成一把鎖(類似於Redis方案中的key)。多個session都去建立同一個distribute_lock節點,只會有一個建立成功的session。相當於只有該session獲取到鎖,其他session沒有獲取到鎖。在該成功獲鎖的session失效前,鎖將會一直阻塞住。session失效時,節點會自動被刪除,鎖被解除。(類似於Redis方案中的expire)。

上述實現方案跟Redis方案3的實現效果一樣。

但是,這樣的鎖有沒有改進的地方?當然!

1)我們可能會有可重入的需求,因此希望能有可重入的鎖機制。

2)有些場景下,在爭搶鎖的時候,我們既不想一次爭搶不到就pass,也不想一直阻塞住直到獲取到鎖。一個樸素的需求是,我們希望有超時時間來控制是否去上鎖。更進一步,我們不想主動的去查到底是否能夠加鎖,我們希望能夠有事件機制來通知是否能夠上鎖。(這裡,你是不是想到了ZK的Watch機制呢?)

要滿足這樣的需求就需要控制時序。利用順序臨時節點和Watch機制的特性,來實現:

我們事先建立/distribute_lock節點,多個session在它下面建立臨時有序節點。由於zk的特性,/distribute_lock該節點會維護一份sequence,來保證子節點建立的時序性。

具體實現如下:

1)客戶端呼叫create()方法在/distribute_lock節點下建立EPHEMERAL_SEQUENTIAL節點。

2)客戶端呼叫getChildren(“/distribute_lock”)方法來獲取所有已經建立的子節點。

3)客戶端獲取到所有子節點path之後,如果發現自己在步驟1中建立的節點序號最小,那麼就認為這個客戶端獲得了鎖。

4)如果在步驟3中發現自己並非所有子節點中最小的,說明自己還沒有獲取到鎖。此時客戶端需要找到比自己小的那個節點,然後對其呼叫exist()方法,同時註冊事件監聽。需要注意是,只在比自己小一號的節點上註冊Watch事件。如果在比自己都小的節點上註冊Watch事件,將會出現驚群效應,要避免。

5)之後當這個被關注的節點被移除了,客戶端會收到相應的通知。這個時候客戶端需要再次呼叫getChildren(“/distribute_lock”)方法來獲取所有已經建立的子節點,確保自己確實是最小的節點了,然後進入步驟3)。

Curator框架封裝了對ZK的api操作。以Java為例來進行演示:
引入依賴:

1 <dependency>
2   <groupId>org.apache.curator</groupId>
3   <artifactId>curator-recipes</artifactId>
4   <version>2.11.1</version>
5 </dependency>

使用的時候需要注意Curator框架和ZK的版本相容問題。
以排他鎖為例,看看怎麼使用:

 1 public class TestLock {
 2
 3    public static void main(String[] args) throws Exception {
 4        //建立zookeeper的客戶端
 5        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
 6        CuratorFramework client = CuratorFrameworkFactory.newClient(“ip:port", retryPolicy);
 7        client.start();
 8
 9        //建立分散式鎖, 鎖空間的根節點路徑為/sunnyzengqi/curator/lock
10        InterProcessMutex mutex = new InterProcessMutex(client, "/sunnyzengqi/curator/lock");
11        mutex.acquire();
12        //獲得了鎖, 進行業務流程
13        System.out.println("Enter mutex");
14        Thread.sleep(10000);
15        //完成業務流程, 釋放鎖
16        mutex.release();
17        //關閉客戶端
18        client.close();
19    }
20 }
21

△左滑瀏覽全貌

上面程式碼在業務執行的過程中,在ZK的/sunnyzengqi/curator/lock路徑下,會建立一個臨時節點來佔位。相同的程式碼,在兩個機器節點上執行,可以看到該路徑下建立了兩個臨時節點:

圖片描述

圖片描述

圖片描述

執行命令echo wchc | nc localhost 2181檢視watch資訊:

圖片描述

可以看到lock1節點的session在監聽節點lock0的變動。此時是lock0獲取到鎖。等到lock0執行完,session會失效,觸發Watch機制,通知lock1的session說鎖已經被釋放了。這時,lock1可以來搶佔鎖,進而執行自己的操作。

除了簡單的排它鎖的實現,還可以利用ZK的特性來實現更高階的鎖(比如訊號量,讀寫鎖,聯鎖)等,這裡面有很多的玩法。

ZooKeeper方案小結

能夠實現很多具有更高條件的鎖機制,並且由於ZK優越的session和watch機制,適用於複雜的場景。因為有久經檢驗的Curator框架,整合了很多基於ZK的分散式鎖的api,對於Java語言非常友好。對於其他語言,雖然也有一些開源專案封裝了它的api,但是穩定性和效率需要自己去實際檢驗。

▍5.3 Redisson實現方案

我們先簡要介紹一下Redisson:

Redisson是Java語言編寫的基於Redis的client端。功能也非常強大,功能包括:分散式物件,分散式集合,分散式鎖和同步器,分散式服務等。被大家熟知的場景還是在分散式鎖的場景。

為了解決加鎖執行緒在沒有解鎖之前崩潰進而出現死鎖的問題,不同於樸素Redis中通過設定超時時間來處理。Redisson採用了新的處理方式:Redisson內部提供了一個監控鎖的看門狗,它的作用是在Redisson例項被關閉前,不斷的延長鎖的有效期。
跟Zookeeper類似,Redisson也提供了這幾種分散式鎖:可重入鎖,公平鎖,聯鎖,紅鎖,讀寫鎖等。具體怎麼用這裡不展開,感興趣的朋友可以自己去實驗。

Redisson方案小結

跟ZK一樣,都能夠實現很多具有更高條件的鎖機制,適用於複雜的場景。但對語言非常挑剔,目前只能支援Java語言。

▍6. 總結

上一節,我們討論了三種實現的方案:樸素Redis實現方案,ZooKeeper實現方案,Redisson實現方案。由於第1種與第3種都是基於Redis,所以主要是ZK和基於Redis兩種。我們不禁想問,在實現分散式鎖上,基於ZK與基於Redis的方案,有什麼不同呢?

1)鎖的時長設定上:

得益於ZK的session機制,客戶端可以持有鎖任意長的時間,這可以確保它做完所有需要的資源訪問操作之後再釋放鎖。避免了基於Redis的鎖對於有效時間到底設定多長的兩難問題。實際上,基於ZooKeeper的鎖是依靠Session(心跳)來維持鎖的持有狀態的,而Redis不支援Sesion。

優勢:ZK>Redisson>樸素Redis。

2)監聽機制上:

得益於ZK的watch機制,在獲取鎖失敗之後可以等待鎖重新釋放的事件。這讓客戶端對鎖的使用更加靈活。避免了Redis方案主要去輪詢的方式。

優勢:ZK>Redisson=樸素Redis。

3)使用便利性上:

由於生產環境都有穩定的Redis和ZooKeeper叢集,有專業的同學維護,這兩者差別不大。在語言侷限性上,樸素Redis從不挑食。ZK和Redisson都偏向於Java語言。在開發難度上,Redis最簡單,幾乎不用寫什麼程式碼;ZK和Redisson次之,依賴於使用的語言是否有整合的api以及整合穩定性等。

優勢:樸素Redis>ZK>Redisson。

4)支援鎖形式的多樣性上:

上面有提及,ZK和Redisson都支援了各種花樣的分佈鎖。樸素Redis就比較捉急了,在實現更高要求的鎖方面,如果自己造輪子,往往費時費力,力不從心。

優勢:ZK=Redisson>Redis。

▍7. 結束語

分散式鎖在日常Coding中已經很常用。但是分散式鎖這方面的知識依然非常深奧。2016年,Martin Kleppmann與Antirez兩位分散式領域非常有造詣的前輩還針對“Redlock演算法”在分散式鎖上面的應用炒得沸沸揚揚。

最後藉助這場歷史鬧劇中Martin的話來結束我們今天的分享。與諸君共勉!將學習當成一生的主題!

對我來說最重要的一點在於:我並不在乎在這場辯論中誰對誰錯 —— 我只關心從其他人的工作中學到的東西,以便我們能夠避免重蹈覆轍,並讓未來更加美好。前人已經為我們創造出了許多偉大的成果:站在巨人的肩膀上,我們得以構建更棒的軟體。
……
對於任何想法,務必要詳加檢驗,通過論證以及檢查它們是否經得住別人的詳細審查。那是學習過程的一部分。但目標應該是為了獲得知識,而不應該是為了說服別人相信你自己是對的。有時候,那隻不過意味著停下來,好好地想一想。

由於時間倉促,自己水平有限,文中必定存在諸多疏漏與理解不當的地方。非常希望得到各位指正,暢談技術。

▍Reference

0.Apache ZooKeeper
1.Redisson
2.Redis
3.Redis分散式鎖進化史
4.分散式系統互斥性與冪等性問題的分析與解決
5.淺談可重入性及其他
6.Distributed locks with Redis
7.How to do distributed locking
8.Is Redlock safe?
9.Note on fencing and distributed locks

▍END
轉載請至 / 轉載合作入口

圖片描述

圖片描述

北京科技大學本碩,2018年應屆入職滴滴。熱愛技術,更熱愛用技術去解決實際問題。對分散式系統,大型網站架構有一定的瞭解。

圖片描述

相關文章