分佈式鎖簡述
在單機時代,雖然不存在分佈式鎖,但也會面臨資源互斥的狀況,只不過在單機的狀況下,若是有多個線程要同時訪問某個共享資源的時候,咱們能夠採用線程間加鎖的機制,即當某個線程獲取到這個資源後,就須要對這個資源進行加鎖,當使用完資源以後,再解鎖,其它線程就能夠接着使用了。例如,在JAVA中,甚至專門提供了一些處理鎖機制的一些API(synchronize/Lock等)。node
可是到了分佈式系統的時代,這種線程之間的鎖機制,就沒做用了,系統可能會有多份而且部署在不一樣的機器上,這些資源已經不是在線程之間共享了,而是屬於進程之間共享的資源。所以,爲了解決這個問題,「分佈式鎖」就強勢登場了。程序員
分佈式鎖是控制分佈式系統之間同步訪問共享資源的一種方式。在分佈式系統中,經常須要協調他們的動做。若是不一樣的系統或是同一個系統的不一樣主機之間共享了一個或一組資源,那麼訪問這些資源的時候,每每須要互斥來防止彼此干擾來保證一致性,在這種狀況下,便須要使用到分佈式鎖。redis
在分佈式系統中,經常須要協調他們的動做。若是不一樣的系統或是同一個系統的不一樣主機之間共享了一個或一組資源,那麼訪問這些資源的時候,每每須要互斥來防止彼此干擾來保證一致性,這個時候,便須要使用到分佈式鎖。數據庫
目前相對主流的有三種,從實現的複雜度上來看,從上往下難度依次增長:併發
數據庫分佈式
redislua
zookeeperspa
基於數據庫來作分佈式鎖的話,一般有兩種作法:線程
- 基於數據庫的樂觀鎖
- 基於數據庫的悲觀鎖
樂觀鎖
樂觀鎖的特色先進行業務操做,不到萬不得已不去拿鎖。即「樂觀」的認爲拿鎖多半是會成功的,所以在進行完業務操做須要實際更新數據的最後一步再去拿一下鎖就好。code
樂觀鎖機制其實就是在數據庫表中引入一個版本號(version)字段來實現的。當咱們要從數據庫中讀取數據的時候,同時把這個version字段也讀出來,若是要對讀出來的數據進行更新後寫回數據庫,則須要將version加1,同時將新的數據與新的version更新到數據表中,且必須在更新的時候同時檢查目前數據庫裏version值是否是以前的那個version,若是是,則正常更新。若是不是,則更新失敗,說明在這個過程當中有其它的進程去更新過數據了。
看圖敘事。模擬實戰場景。
如上圖,故事男主人公(如下簡稱男主)打算去ATM機取3000元,故事女主人公(如下簡稱女主)則要在某寶買買買,買個包須要3000元,帳戶的餘額是5000元。若是沒有采用鎖的話,在兩人同時取款和買買買,可能會出現合計消費了6000,致使帳戶餘額異常。因此須要用到鎖的機制,當男主女主甚至更多小主同時消費時,除了讀取到6000的帳戶餘額外,還須要讀取到當前的版本號version=1,等先行消費成功的主人公(不管誰先消費)去出發修改帳戶餘額的同時,會觸發version=version+1,即version=2。那麼其餘人使用未更新的version(1)去更新帳戶餘額時就會發現版本號不對,就會致使本次更新失敗,就得從新去讀取最新帳戶餘額以及版本號。
樂觀鎖遵循的兩點法則:
- 鎖服務要有遞增的版本號version
- 每次更新數據的時候都必須先判斷版本號對不對,而後再寫入新的版本號
悲觀鎖
悲觀鎖的特色是先獲取鎖,再進行業務操做,即「悲觀」的認爲獲取鎖是很是有可能失敗的,所以要先確保獲取鎖成功再進行業務操做。
一般所說的「一鎖二查三更新」即指的是使用悲觀鎖。一般來說在數據庫上的悲觀鎖須要數據庫自己提供支持,即經過經常使用的select ... for update
操做來實現悲觀鎖。當數據庫執行select for update
時會獲取被select
中的數據行的行鎖,所以其餘併發執行的select for update
若是試圖選中同一行則會發生排斥(須要等待行鎖被釋放),所以達到鎖的效果。select for update
獲取的行鎖會在當前事務結束時自動釋放,所以必須在事務中使用。
基於數據庫來作分佈式鎖的話,一般有兩種作法:
- 基於數據庫的樂觀鎖
- 基於數據庫的悲觀鎖
樂觀鎖
樂觀鎖的特色先進行業務操做,不到萬不得已不去拿鎖。即「樂觀」的認爲拿鎖多半是會成功的,所以在進行完業務操做須要實際更新數據的最後一步再去拿一下鎖就好。
樂觀鎖機制其實就是在數據庫表中引入一個版本號(version)字段來實現的。當咱們要從數據庫中讀取數據的時候,同時把這個version字段也讀出來,若是要對讀出來的數據進行更新後寫回數據庫,則須要將version加1,同時將新的數據與新的version更新到數據表中,且必須在更新的時候同時檢查目前數據庫裏version值是否是以前的那個version,若是是,則正常更新。若是不是,則更新失敗,說明在這個過程當中有其它的進程去更新過數據了。
看圖敘事。模擬實戰場景。
如上圖,故事男主人公(如下簡稱男主)打算去ATM機取3000元,故事女主人公(如下簡稱女主)則要在某寶買買買,買個包須要3000元,帳戶的餘額是5000元。若是沒有采用鎖的話,在兩人同時取款和買買買,可能會出現合計消費了6000,致使帳戶餘額異常。因此須要用到鎖的機制,當男主女主甚至更多小主同時消費時,除了讀取到6000的帳戶餘額外,還須要讀取到當前的版本號version=1,等先行消費成功的主人公(不管誰先消費)去出發修改帳戶餘額的同時,會觸發version=version+1,即version=2。那麼其餘人使用未更新的version(1)去更新帳戶餘額時就會發現版本號不對,就會致使本次更新失敗,就得從新去讀取最新帳戶餘額以及版本號。
樂觀鎖遵循的兩點法則:
- 鎖服務要有遞增的版本號version
- 每次更新數據的時候都必須先判斷版本號對不對,而後再寫入新的版本號
悲觀鎖
悲觀鎖的特色是先獲取鎖,再進行業務操做,即「悲觀」的認爲獲取鎖是很是有可能失敗的,所以要先確保獲取鎖成功再進行業務操做。
一般所說的「一鎖二查三更新」即指的是使用悲觀鎖。一般來說在數據庫上的悲觀鎖須要數據庫自己提供支持,即經過經常使用的select ... for update
操做來實現悲觀鎖。當數據庫執行select for update
時會獲取被select
中的數據行的行鎖,所以其餘併發執行的select for update
若是試圖選中同一行則會發生排斥(須要等待行鎖被釋放),所以達到鎖的效果。select for update
獲取的行鎖會在當前事務結束時自動釋放,所以必須在事務中使用。
如何用Redis實現分佈式鎖?
Redis分佈式鎖的基本流程並不難理解,但要想寫得盡善盡美,也並非那麼容易。在這裏,咱們須要先了解分佈式鎖實現的三個核心要素:
1.加鎖
最簡單的方法是使用setnx命令。key是鎖的惟一標識,按業務來決定命名。好比想要給一種商品的秒殺活動加鎖,能夠給key命名爲 「lock_sale_商品ID」 。而value設置成什麼呢?咱們能夠姑且設置成1。加鎖的僞代碼以下:
setnx(key,1)
當一個線程執行setnx返回1,說明key本來不存在,該線程成功獲得了鎖;當一個線程執行setnx返回0,說明key已經存在,該線程搶鎖失敗。
2.解鎖
有加鎖就得有解鎖。當獲得鎖的線程執行完任務,須要釋放鎖,以便其餘線程能夠進入。釋放鎖的最簡單方式是執行del指令,僞代碼以下:
del(key)
釋放鎖以後,其餘線程就能夠繼續執行setnx命令來得到鎖。
3.鎖超時
鎖超時是什麼意思呢?若是一個獲得鎖的線程在執行任務的過程當中掛掉,來不及顯式地釋放鎖,這塊資源將會永遠被鎖住,別的線程再也別想進來。
因此,setnx的key必須設置一個超時時間,以保證即便沒有被顯式釋放,這把鎖也要在必定時間後自動釋放。setnx不支持超時參數,因此須要額外的指令,僞代碼以下:
expire(key, 30)
綜合起來,咱們分佈式鎖實現的初版僞代碼以下:
if(setnx(key,1) == 1){
expire(key,30)
try {
do something ......
} finally {
del(key)
}
}
好端端的代碼,怎麼就回家等通知了呢?
由於上面的僞代碼中,存在着三個致命問題:
1. setnx和expire的非原子性
設想一個極端場景,當某線程執行setnx,成功獲得了鎖:
setnx剛執行成功,還將來得及執行expire指令,節點1 Duang的一聲掛掉了。
這樣一來,這把鎖就沒有設置過時時間,變得「長生不老」,別的線程再也沒法得到鎖了。
怎麼解決呢?setnx指令自己是不支持傳入超時時間的,幸虧Redis 2.6.12以上版本爲set指令增長了可選參數,僞代碼以下:
set(key,1,30,NX)
這樣就能夠取代setnx指令。
2. del 致使誤刪
又是一個極端場景,假如某線程成功獲得了鎖,而且設置的超時時間是30秒。
若是某些緣由致使線程B執行的很慢很慢,過了30秒都沒執行完,這時候鎖過時自動釋放,線程B獲得了鎖。
隨後,線程A執行完了任務,線程A接着執行del指令來釋放鎖。但這時候線程B還沒執行完,線程A實際上刪除的是線程B加的鎖。
怎麼避免這種狀況呢?能夠在del釋放鎖以前作一個判斷,驗證當前的鎖是否是本身加的鎖。
至於具體的實現,能夠在加鎖的時候把當前的線程ID當作value,並在刪除以前驗證key對應的value是否是本身線程的ID。
加鎖:
String threadId = Thread.currentThread().getId()
set(key,threadId ,30,NX)
解鎖:
if(threadId .equals(redisClient.get(key))){
del(key)
}
可是,這樣作又隱含了一個新的問題,判斷和釋放鎖是兩個獨立操做,不是原子性。
咱們都是追求極致的程序員,因此這一塊要用Lua腳原本實現:
String luaScript = 'if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end';
redisClient.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId));
這樣一來,驗證和刪除過程就是原子操做了。
3. 出現併發的可能性
仍是剛纔第二點所描述的場景,雖然咱們避免了線程A誤刪掉key的狀況,可是同一時間有A,B兩個線程在訪問代碼塊,仍然是不完美的。
怎麼辦呢?咱們可讓得到鎖的線程開啓一個守護線程,用來給快要過時的鎖「續航」。
當過去了29秒,線程A還沒執行完,這時候守護線程會執行expire指令,爲這把鎖「續命」20秒。守護線程從第29秒開始執行,每20秒執行一次。
當線程A執行完任務,會顯式關掉守護線程。
另外一種狀況,若是節點1 突然斷電,因爲線程A和守護線程在同一個進程,守護線程也會停下。這把鎖到了超時的時候,沒人給它續命,也就自動釋放了。
其實基於ZooKeeper,就是使用它的臨時有序節點來實現的分佈式鎖。
原理
當某客戶端要進行邏輯的加鎖時,就在zookeeper上的某個指定節點的目錄下,去生成一個惟一的臨時有序節點, 而後判斷本身是不是這些有序節點中序號最小的一個,若是是,則算是獲取了鎖。若是不是,則說明沒有獲取到鎖,那麼就須要在序列中找到比本身小的那個節點,並對其調用exist()
方法,對其註冊事件監聽,當監聽到這個節點被刪除了,那就再去判斷一次本身當初建立的節點是否變成了序列中最小的。若是是,則獲取鎖,若是不是,則重複上述步驟。
當釋放鎖的時候,只需將這個臨時節點刪除便可。
如上圖,locker是一個持久節點,node_1/node_2/.../node_n
就是上面說的臨時節點,由客戶端client去建立的。
client_1/client_2/.../clien_n
都是想去獲取鎖的客戶端。以client_1爲例,它想去獲取分佈式鎖,則須要跑到locker下面去建立臨時節點(假如是node_1)建立完畢後,看一下本身的節點序號是不是locker下面最小的,若是是,則獲取了鎖。若是不是,則去找到比本身小的那個節點(假如是node_2),找到後,就監聽node_2,直到node_2被刪除,那麼就開始再次判斷本身的node_1是否是序列中最小的,若是是,則獲取鎖,若是還不是,則繼續找一下一個節點。