出品 | 滴滴技術
做者 | 曾奇php
前言:隨着計算機技術和工程架構的發展,微服務變得愈來愈熱。現在,絕大多數服務都處於分佈式環境中,其中,數據一致性是咱們一直關注的重點。分佈式鎖究竟是什麼?通過了哪些發展演進?工程上有哪些實現方案?各類方案的利弊權衡又有哪些?但願這篇文章可以對你有一些幫助。html
▍閱讀索引node
0.名詞定義
1.問題引入
2.分佈式環境的特色
3.鎖
4.分佈式鎖
5.分佈式鎖實現方案mysql
6.總結
7.結束語
8.Referencegit
▍0. 名詞定義github
分佈式鎖:顧名思義,是指在分佈式環境下的鎖,重點在鎖。因此咱們先從鎖開始講起。redis
▍1. 問題引入算法
舉個例子:sql
某服務記錄數據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):
先檢查信號量的大小,若值大於零,則將信號量減1,同時進程得到共享資源的訪問權限,繼續執行;若小於或者等於零,則該進程被阻塞後,進入等待隊列。
該操做將信號量的值加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
將加鎖、設置超時兩個步驟合併爲一個原子操做,從而解決方案一、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年應屆入職滴滴。熱愛技術,更熱愛用技術去解決實際問題。對分佈式系統,大型網站架構有必定的瞭解。