隨着分佈式的快速發展,本地的加鎖每每不能知足咱們的須要,在咱們的分佈式環境中上面加鎖的方法就會失去做用。java
因而人們爲了在分佈式環境中也能實現本地鎖的效果,也是紛紛各出其招,今天讓咱們來聊一聊通常分佈式鎖實現的套路。node
爲什麼須要分佈式鎖mysql
Martin Kleppmann 是英國劍橋大學的分佈式系統的研究員,以前和 Redis 之父 Antirez 進行過關於 RedLock(紅鎖,後續有講到)是否安全的激烈討論。redis
Martin 認爲通常咱們使用分佈式鎖有兩個場景:算法
效率:使用分佈式鎖能夠避免不一樣節點重複相同的工做,這些工做會浪費資源。好比用戶付了錢以後有可能不一樣節點會發出多封短信。sql
正確性:加分佈式鎖一樣能夠避免破壞正確性的發生,若是兩個節點在同一條數據上面操做,好比多個節點機器對同一個訂單操做不一樣的流程有可能會致使該筆訂單最後狀態出現錯誤,形成損失。數據庫
分佈式鎖的一些特色編程
當咱們肯定了在不一樣節點上須要分佈式鎖,那麼咱們須要瞭解分佈式鎖到底應該有哪些特色?緩存
分佈式鎖的特色以下:安全
互斥性:和咱們本地鎖同樣互斥性是最基本,可是分佈式鎖須要保證在不一樣節點的不一樣線程的互斥。
可重入性:同一個節點上的同一個線程若是獲取了鎖以後那麼也能夠再次獲取這個鎖。
鎖超時:和本地鎖同樣支持鎖超時,防止死鎖。
高效,高可用:加鎖和解鎖須要高效,同時也須要保證高可用防止分佈式鎖失效,能夠增長降級。
支持阻塞和非阻塞:和 ReentrantLock 同樣支持 lock 和 trylock 以及 tryLock(long timeOut)。
支持公平鎖和非公平鎖(可選):公平鎖的意思是按照請求加鎖的順序得到鎖,非公平鎖就相反是無序的。這個通常來講實現的比較少。
常見的分佈式鎖
咱們瞭解了一些特色以後,咱們通常實現分佈式鎖有如下幾個方式:
MySQL
ZK
Redis
自研分佈式鎖:如谷歌的 Chubby。
下面分開介紹一下這些分佈式鎖的實現原理。
MySQL
首先來講一下 MySQL 分佈式鎖的實現原理,相對來講這個比較容易理解,畢竟數據庫和咱們開發人員在平時的開發中息息相關。
對於分佈式鎖咱們能夠建立一個鎖表:
前面咱們所說的 lock(),trylock(long timeout),trylock() 這幾個方法能夠用下面的僞代碼實現。
lock()
lock 通常是阻塞式的獲取鎖,意思就是不獲取到鎖誓不罷休,那麼咱們能夠寫一個死循環來執行其操做:
mysqlLock.lcok 內部是一個 sql,爲了達到可重入鎖的效果,咱們應該先進行查詢,若是有值,須要比較 node_info 是否一致。
這裏的 node_info 能夠用機器 IP 和線程名字來表示,若是一致就加可重入鎖 count 的值,若是不一致就返回 false。若是沒有值就直接插入一條數據。
僞代碼以下:
須要注意的是這一段代碼須要加事務,必需要保證這一系列操做的原子性。
tryLock() 和 tryLock(long timeout)
tryLock() 是非阻塞獲取鎖,若是獲取不到就會立刻返回,代碼以下:
tryLock(long timeout) 實現以下:
mysqlLock.lock 和上面同樣,可是要注意的是 select … for update 這個是阻塞的獲取行鎖,若是同一個資源併發量較大仍是有可能會退化成阻塞的獲取鎖。
unlock()
unlock 的話若是這裏的 count 爲 1 那麼能夠刪除,若是大於 1 那麼須要減去 1。
鎖超時
咱們有可能會遇到咱們的機器節點掛了,那麼這個鎖就不會獲得釋放,咱們能夠啓動一個定時任務,經過計算通常咱們處理任務的時間。
好比是 5ms,那麼咱們能夠稍微擴大一點,當這個鎖超過 20ms 沒有被釋放咱們就能夠認定是節點掛了而後將其直接釋放。
MySQL 小結:
適用場景:MySQL 分佈式鎖通常適用於資源不存在數據庫,若是數據庫存在好比訂單,能夠直接對這條數據加行鎖,不須要咱們上面多的繁瑣的步驟。
好比一個訂單,咱們能夠用 select * from order_table where id = 'xxx' for update 進行加行鎖,那麼其餘的事務就不能對其進行修改。
優勢:理解起來簡單,不須要維護額外的第三方中間件(好比 Redis,ZK)。
缺點:雖然容易理解可是實現起來較爲繁瑣,須要本身考慮鎖超時,加事務等等。性能侷限於數據庫,通常對比緩存來講性能較低。對於高併發的場景並非很適合。
樂觀鎖
前面咱們介紹的都是悲觀鎖,這裏想額外提一下樂觀鎖,在咱們實際項目中也是常常實現樂觀鎖,由於咱們加行鎖的性能消耗比較大,一般咱們對於一些競爭不是那麼激烈。
可是其又須要保證咱們併發的順序執行使用樂觀鎖進行處理,咱們能夠對咱們的表加一個版本號字段。
那麼咱們查詢出來一個版本號以後,update 或者 delete 的時候須要依賴咱們查詢出來的版本號,判斷當前數據庫和查詢出來的版本號是否相等,若是相等那麼就能夠執行,若是不等那麼就不能執行。
這樣的一個策略很像咱們的 CAS(Compare And Swap),比較並交換是一個原子操做。這樣咱們就能避免加 select * for update 行鎖的開銷。
ZooKeeper
ZooKeeper 也是咱們常見的實現分佈式鎖方法,相比於數據庫若是沒了解過 ZooKeeper 可能上手比較難一些。
ZooKeeper 是以 Paxos 算法爲基礎的分佈式應用程序協調服務。ZK 的數據節點和文件目錄相似,因此咱們能夠用此特性實現分佈式鎖。
咱們以某個資源爲目錄,而後這個目錄下面的節點就是咱們須要獲取鎖的客戶端,未獲取到鎖的客戶端註冊須要註冊 Watcher 到上一個客戶端,能夠用下圖表示:
/lock 是咱們用於加鎖的目錄,/resource_name 是咱們鎖定的資源,其下面的節點按照咱們加鎖的順序排列。
Curator
Curator 封裝了 ZooKeeper 底層的 API,使咱們更加容易方便的對 ZooKeeper 進行操做,而且它封裝了分佈式鎖的功能,這樣咱們就不須要在本身實現了。
Curator 實現了可重入鎖(InterProcessMutex),也實現了不可重入鎖(InterProcessSemaphoreMutex)。在可重入鎖中還實現了讀寫鎖。
InterProcessMutex
InterProcessMutex 是 Curator 實現的可重入鎖,咱們能夠經過下面的一段代碼實現咱們的可重入鎖:
咱們利用 acuire 進行加鎖,release 進行解鎖。
加鎖的流程具體以下:
首先進行可重入的斷定:這裏的可重入鎖記錄在 ConcurrentMap
若是 threadData.get(currentThread)是有值的那麼就證實是可重入鎖,而後記錄就會加 1。
咱們以前的 MySQL 其實也能夠經過這種方法去優化,能夠不須要 count 字段的值,將這個維護在本地能夠提升性能。
而後在咱們的資源目錄下建立一個節點:好比這裏建立一個 /0000000002 這個節點,這個節點須要設置爲 EPHEMERAL_SEQUENTIAL 也就是臨時節點而且有序。
獲取當前目錄下全部子節點,判斷本身的節點是否位於子節點第一個。
若是是第一個,則獲取到鎖,那麼能夠返回。
若是不是第一個,則證實前面已經有人獲取到鎖了,那麼須要獲取本身節點的前一個節點。
/0000000002 的前一個節點是 /0000000001,咱們獲取到這個節點以後,再上面註冊 Watcher(這裏的 Watcher 其實調用的是 object.notifyAll(),用來解除阻塞)。
object.wait(timeout) 或 object.wait():進行阻塞等待,這裏和咱們第 5 步的 Watcher 相對應。
解鎖的具體流程:
首先進行可重入鎖的斷定:若是有可重入鎖只須要次數減 1 便可,減 1 以後加鎖次數爲 0 的話繼續下面步驟,不爲 0 直接返回。
刪除當前節點。
刪除 threadDataMap 裏面的可重入鎖的數據。
讀寫鎖
Curator 提供了讀寫鎖,其實現類是 InterProce***eadWriteLock,這裏的每一個節點都會加上前綴:
private static final String READ_LOCK_NAME = "__READ__"; private static final String WRITE_LOCK_NAME = "__WRIT__";
根據不一樣的前綴區分是讀鎖仍是寫鎖,對於讀鎖,若是發現前面有寫鎖,那麼須要將 Watcher 註冊到和本身最近的寫鎖。寫鎖的邏輯和咱們以前 4.2 分析的依然保持不變。
鎖超時
ZooKeeper 不須要配置鎖超時,因爲咱們設置節點是臨時節點,咱們的每一個機器維護着一個 ZK 的 Session,經過這個 Session,ZK 能夠判斷機器是否宕機。
若是咱們的機器掛掉的話,那麼這個臨時節點對應的就會被刪除,因此咱們不須要關心鎖超時。
ZK 小結:
優勢:ZK 能夠不須要關心鎖超時時間,實現起來有現成的第三方包,比較方便,而且支持讀寫鎖,ZK 獲取鎖會按照加鎖的順序,因此其是公平鎖。對於高可用利用 ZK 集羣進行保證。
缺點:ZK 須要額外維護,增長維護成本,性能和 MySQL 相差不大,依然比較差。而且須要開發人員瞭解 ZK 是什麼。
Redis
你們在網上搜索分佈式鎖,恐怕最多的實現就是 Redis 了,Redis 由於其性能好,實現起來簡單因此讓不少人都對其十分青睞。
Redis 分佈式鎖簡單實現
熟悉 Redis 的同窗那麼確定對 setNx(set if not exist) 方法不陌生,若是不存在則更新,其能夠很好的用來實現咱們的分佈式鎖。
對於某個資源加鎖咱們只須要:
setNx resourceName value
這裏有個問題,加鎖了以後若是機器宕機那麼這個鎖就不會獲得釋放因此會加入過時時間,加入過時時間須要和 setNx 同一個原子操做。
在 Redis 2.8 以前咱們須要使用 Lua 腳本達到咱們的目的,可是 Redis 2.8 以後 Redis 支持 nx 和 ex 操做是同一原子操做。
set resourceName value ex 5 nx
Redission
Javaer 都知道 Jedis,Jedis 是 Redis 的 Java 實現的客戶端,其 API 提供了比較全面的 Redis 命令的支持。
Redission 也是 Redis 的客戶端,相比於 Jedis 功能簡單。Jedis 簡單使用阻塞的 I/O 和 Redis 交互,Redission 經過 Netty 支持非阻塞 I/O。
Jedis 最新版本 2.9.0 是 2016 年的快 3 年了沒有更新,而 Redission 最新版本是 2018 年 10 月更新。
Redission 封裝了鎖的實現,其繼承了 java.util.concurrent.locks.Lock 的接口,讓咱們像操做咱們的本地 Lock 同樣去操做 Redission 的 Lock。
下面介紹一下其如何實現分佈式鎖:
Redission 不只提供了 Java 自帶的一些方法(lock,tryLock),還提供了異步加鎖,對於異步編程更加方便。
因爲內部源碼較多,就不貼源碼了,這裏用文字敘述來分析它是如何加鎖的,這裏分析一下 tryLock 方法:
①嘗試加鎖:首先會嘗試進行加鎖,因爲須要兼容老版本的 Redis,因此不能直接使用 ex,nx 原子操做的 API,那麼就只能使用 Lua 腳本,相關的 Lua 腳本以下:
能夠看見它並無使用咱們的 sexNx 來進行操做,而是使用的 hash 結構,咱們的每個須要鎖定的資源均可以看作是一個 HashMap,鎖定資源的節點信息是 Key,鎖定次數是 Value。
經過這種方式能夠很好的實現可重入的效果,只須要對 Value 進行加 1 操做,就能進行可重入鎖。固然這裏也能夠用以前咱們說的本地計數進行優化。
②若是嘗試加鎖失敗,判斷是否超時,若是超時則返回 false。
③若是加鎖失敗以後,沒有超時,那麼須要在名字爲 redisson_lock__channel+lockName 的 channel 上進行訂閱,用於訂閱解鎖消息,而後一直阻塞直到超時,或者有解鎖消息。
④重試步驟 1,2,3,直到最後獲取到鎖,或者某一步獲取鎖超時。
對於咱們的 unlock 方法比較簡單也是經過 lua 腳本進行解鎖,若是是可重入鎖,只是減 1。若是是非加鎖線程解鎖,那麼解鎖失敗。
Redission 還有公平鎖的實現,對於公平鎖其利用了 list 結構和 hashset 結構分別用來保存咱們排隊的節點,和咱們節點的過時時間,用這兩個數據結構幫助咱們實現公平鎖,這裏就不展開介紹了,有興趣能夠參考源碼。
RedLock
咱們想象一個這樣的場景當機器 A 申請到一把鎖以後,若是 Redis 主宕機了,這個時候從機並無同步到這一把鎖,那麼機器 B 再次申請的時候就會再次申請到這把鎖。
爲了解決這個問題 Redis 做者提出了 RedLock 紅鎖的算法,在 Redission 中也對 RedLock 進行了實現。
經過上面的代碼,咱們須要實現多個 Redis 集羣,而後進行紅鎖的加鎖,解鎖。
具體的步驟以下:
①首先生成多個 Redis 集羣的 Rlock,並將其構形成 RedLock。
②依次循環對三個集羣進行加鎖,加鎖的過程和 5.2 裏面一致。
③若是循環加鎖的過程當中加鎖失敗,那麼須要判斷加鎖失敗的次數是否超出了最大值,這裏的最大值是根據集羣的個數,好比三個那麼只容許失敗一個,五個的話只容許失敗兩個,要保證多數成功。
④加鎖的過程當中須要判斷是否加鎖超時,有可能咱們設置加鎖只能用 3ms,第一個集羣加鎖已經消耗了 3ms 了。那麼也算加鎖失敗。
⑤3,4 步裏面加鎖失敗的話,那麼就會進行解鎖操做,解鎖會對全部的集羣在請求一次解鎖。
能夠看見 RedLock 基本原理是利用多個 Redis 集羣,用多數的集羣加鎖成功,減小 Redis 某個集羣出故障,形成分佈式鎖出現問題的機率。
Redis 小結:
優勢:對於 Redis 實現簡單,性能對比 ZK 和 MySQL 較好。若是不須要特別複雜的要求,本身就能夠利用 setNx 進行實現,若是本身須要複雜的需求的話,能夠利用或者借鑑 Redission。對於一些要求比較嚴格的場景可使用 RedLock。
缺點:須要維護 Redis 集羣,若是要實現 RedLock 須要維護更多的集羣。
分佈式鎖的安全問題
上面咱們介紹過紅鎖,可是 Martin Kleppmann 認爲其依然不安全。
有關於 Martin 反駁的幾點,我認爲其實不只僅侷限於 RedLock,前面說的算法基本都有這個問題,下面咱們來討論一下這些問題。
長時間的 GC pause
熟悉 Java 的同窗確定對 GC 不陌生,在 GC 的時候會發生 STW(stop-the-world)。
例如 CMS 垃圾回收器,它會有兩個階段進行 STW 防止引用繼續進行變化。那麼有可能會出現下面圖(引用至 Martin 反駁 Redlock 的文章)中這個狀況:
client1 獲取了鎖而且設置了鎖的超時時間,可是 client1 以後出現了 STW,這個 STW 時間比較長,致使分佈式鎖進行了釋放。
client2 獲取到了鎖,這個時候 client1 恢復了鎖,那麼就會出現 client1,2 同時獲取到鎖,這個時候分佈式鎖不安全問題就出現了。
這個不只僅侷限於 RedLock,對於咱們的 ZK,MySQL 同樣的有一樣的問題。
時鐘發生跳躍
對於 Redis 服務器若是其時間發生了跳躍,確定會影響咱們鎖的過時時間。
那麼咱們的鎖過時時間就不是咱們預期的了,也會出現 client1 和 client2 獲取到同一把鎖,也會出現不安全,這個對於 MySQL 也會出現。可是 ZK 因爲沒有設置過時時間,那麼發生跳躍也不會受影響。
長時間的網絡 I/O
這個問題和咱們的 GC 的 STW 很像,也就是咱們這個獲取了鎖以後咱們進行網絡調用,其調用時間由可能比咱們鎖的過時時間都還長,那麼也會出現不安全的問題,這個 MySQL 也會有,ZK 也不會出現這個問題。
對於這三個問題,在網上包括 Redis 做者在內發起了不少討論。
GC 的 STW
對於這個問題能夠看見基本全部的都會出現問題,Martin 給出了一個解法,對於 ZK 這種他會生成一個自增的序列,那麼咱們真正進行對資源操做的時候,須要判斷當前序列是不是最新,有點相似於樂觀鎖。
固然這個解法 Redis 做者進行了反駁,你既然都能生成一個自增的序列了那麼你徹底不須要加鎖了,也就是能夠按照相似於 MySQL 樂觀鎖的解法去作。
我本身認爲這種解法增長了複雜性,當咱們對資源操做的時候須要增長判斷序列號是不是最新,不管用什麼判斷方法都會增長複雜度,後面會介紹谷歌的 Chubby 提出了一個更好的方案。
時鐘發生跳躍
Martin 以爲 RedLock 不安全很大的緣由也是由於時鐘的跳躍,由於鎖過時強依賴於時間,可是 ZK 不須要依賴時間,依賴每一個節點的 Session。
Redis 做者也給出瞭解答,對於時間跳躍分爲人爲調整和 NTP 自動調整:
人爲調整:人爲調整影響的徹底能夠人爲不調整,這個是處於可控的。
NTP 自動調整:這個能夠經過必定的優化,把跳躍時間控制在可控範圍內,雖然會跳躍,可是是徹底能夠接受的。
長時間的網絡 I/O
這一塊不是他們討論的重點,我本身以爲,對於這個問題的優化能夠控制網絡調用的超時時間,把全部網絡調用的超時時間相加。
那麼咱們鎖過時時間其實應該大於這個時間,固然也能夠經過優化網絡調用好比串行改爲並行,異步化等。
Chubby 的一些優化
你們搜索 ZK 的時候,會發現他們都寫了 ZK 是 Chubby 的開源實現,Chubby 內部工做原理和 ZK 相似。可是 Chubby 的定位是分佈式鎖和 ZK 有點不一樣。
Chubby 也是使用上面自增序列的方案用來解決分佈式不安全的問題,可是它提供了多種校驗方法:
CheckSequencer():調用 Chubby 的 API 檢查此時這個序列號是否有效。
訪問資源服務器檢查,判斷當前資源服務器最新的序列號和咱們的序列號的大小。
lock-delay:爲了防止咱們校驗的邏輯***咱們的資源服務器,其提供了一種方法當客戶端失聯的時候,並不會當即釋放鎖,而是在必定的時間內(默認 1min)阻止其餘客戶端拿去這個鎖。
那麼也就是給予了必定的 buffer 等待 STW 恢復,而咱們的 GC 的 STW 時間若是比 1min 還長那麼你應該檢查你的程序,而不是懷疑你的分佈式鎖了。
小結
本文主要講了多種分佈式鎖的實現方法,以及它們的一些優缺點。最後也說了一下關於分佈式鎖的安全的問題。
對於不一樣的業務須要的安全程度徹底不一樣,咱們須要根據本身的業務場景,經過不一樣的維度分析,選取最適合本身的方案。