概述
爲了防止分佈式系統中的多個進程之間相互干擾,我們需要一種分佈式協調技術來對這些進程進行調度。而這個分佈式協調技術的核心就是來實現這個分佈式鎖。
爲什麼要使用分佈式鎖
- 成員變量 A 存在 JVM1、JVM2、JVM3 三個 JVM 內存中
- 成員變量 A 同時都會在 JVM 分配一塊內存,三個請求發過來同時對這個變量操作,顯然結果是不對的
- 不是同時發過來,三個請求分別操作三個不同 JVM 內存區域的數據,變量 A 之間不存在共享,也不具有可見性,處理的結果也是不對的
注:該成員變量 A 是一個有狀態的對象
如果我們業務中確實存在這個場景的話,我們就需要一種方法解決這個問題,這就是分佈式鎖要解決的問題
分佈式鎖應該具備哪些條件
- 在分佈式系統環境下,一個方法在同一時間只能被一個機器的一個線程執行
- 高可用的獲取鎖與釋放鎖
- 高性能的獲取鎖與釋放鎖
- 具備可重入特性(可理解爲重新進入,由多於一個任務併發使用,而不必擔心數據錯誤)
- 具備鎖失效機制,防止死鎖
- 具備非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗
分佈式鎖的實現有哪些
- Memcached:利用 Memcached 的
add
命令。此命令是原子性操作,只有在key
不存在的情況下,才能add
成功,也就意味着線程得到了鎖。 - Redis:和 Memcached 的方式類似,單線程,利用 Redis 的
setnx
命令。此命令同樣是原子性操作,只有在key
不存在的情況下,才能set
成功。 - Zookeeper:利用 Zookeeper 的順序臨時節點,來實現分佈式鎖和等待隊列。Zookeeper 設計的初衷,就是爲了實現分佈式鎖服務的。
- Chubby:Google 公司實現的粗粒度分佈式鎖服務,底層利用了 Paxos 一致性算法。
通過 Redis 分佈式鎖的實現理解基本概念
分佈式鎖實現的三個核心要素:
加鎖
最簡單的方法是使用 setnx
命令。key
是鎖的唯一標識,按業務來決定命名。比如想要給一種商品的秒殺活動加鎖,可以給 key
命名爲 “lock_sale_商品ID” 。而 value
設置成什麼呢?我們可以姑且設置成 1
。加鎖的僞代碼如下:
setnx(lock_sale_商品ID,1)
當一個線程執行 setnx
返回 1
,說明 key
原本不存在,該線程成功得到了鎖;當一個線程執行 setnx
返回 0
,說明 key
已經存在,該線程搶鎖失敗。
解鎖
有加鎖就得有解鎖。當得到鎖的線程執行完任務,需要釋放鎖,以便其他線程可以進入。釋放鎖的最簡單方式是執行 del
指令,僞代碼如下:
del(lock_sale_商品ID)
釋放鎖之後,其他線程就可以繼續執行 setnx
命令來獲得鎖。
鎖超時
鎖超時是什麼意思呢?如果一個得到鎖的線程在執行任務的過程中掛掉,來不及顯式地釋放鎖,這塊資源將會永遠被鎖住(死鎖),別的線程再也別想進來。所以,setnx
的 key
必須設置一個超時時間,以保證即使沒有被顯式釋放,這把鎖也要在一定時間後自動釋放。setnx
不支持超時參數,所以需要額外的指令,僞代碼如下:
expire(lock_sale_商品ID, 30)
綜合僞代碼如下:
if(setnx(lock_sale_商品ID,1) == 1){
expire(lock_sale_商品ID,30)
try {
do something ......
} finally {
del(lock_sale_商品ID)
}
}
存在什麼問題
以上僞代碼中存在三個致命問題
setnx
和 expire
的非原子性
設想一個極端場景,當某線程執行 setnx
,成功得到了鎖:
setnx
剛執行成功,還未來得及執行 expire
指令,節點 1 掛掉了。
這樣一來,這把鎖就沒有設置過期時間,變成死鎖,別的線程再也無法獲得鎖了。
怎麼解決呢?setnx
指令本身是不支持傳入超時時間的,set
指令增加了可選參數,僞代碼如下:
set(lock_sale_商品ID,1,30,NX)
這樣就可以取代 setnx
指令。
del
導致誤刪
又是一個極端場景,假如某線程成功得到了鎖,並且設置的超時時間是 30 秒。
如果某些原因導致線程 A 執行的很慢很慢,過了 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)
}
但是,這樣做又隱含了一個新的問題,判斷和釋放鎖是兩個獨立操作,不是原子性。
出現併發的可能性
還是剛纔第二點所描述的場景,雖然我們避免了線程 A 誤刪掉 key
的情況,但是同一時間有 A,B 兩個線程在訪問代碼塊,仍然是不完美的。怎麼辦呢?我們可以讓獲得鎖的線程開啓一個守護線程,用來給快要過期的鎖“續航”。
當過去了 29 秒,線程 A 還沒執行完,這時候守護線程會執行 expire
指令,爲這把鎖“續命”20 秒。守護線程從第 29 秒開始執行,每 20 秒執行一次。
當線程 A 執行完任務,會顯式關掉守護線程。
另一種情況,如果節點 1 忽然斷電,由於線程 A 和守護線程在同一個進程,守護線程也會停下。這把鎖到了超時的時候,沒人給它續命,也就自動釋放了。