一文徹底弄清楚分佈式鎖

作者:馬小莫 QAQ
鏈接:https://www.jianshu.com/p/edca1528dcc3

關於實現強一致性的手段,可以使用多種方式來進行實現,有分佈式事務,有一致性算法,還有分佈式鎖等等,那麼這篇文章我們就圍繞分佈式鎖這個話題來進行展開,首先,我們會先探究它的原理,然後結合實際應用,對目前較爲常見的分佈式鎖實現方式及注意事項進行詳細的分析。

首先、大家可以先思考幾個問題?

  1. 什麼時候需要加鎖

  2. 分佈式鎖有哪些特徵

  3. 如何用數據庫實現分佈式鎖

  4. 如何用 Redis 實現分佈式鎖(單機版 + 集羣版)

什麼時候需要加鎖

我們先給出答案:

  1. 有併發,多線程(這裏指的是資源的使用者多,也就是在多任務環境下才可能需要鎖的存在,多個任務想同時使用一個資源纔有競爭的可能)

  2. 有寫操作(這裏指的是資源的使用目的,如果是多個任務都是讀請求的話,那反正這個資源就在那裏,沒有人改它,不同任務來讀取的結果都是一樣的,也就沒有必要去控制誰先讀誰後讀)

  3. 有競爭關係(這裏指的是對資源的訪問方式是互斥的,我們這個資源雖然是共享的,同但一時刻只能有一個任務佔用它,不能同時共用,只能有一個任務佔有它,這個時候我們需要給它上鎖)

這麼說可能有些同學覺得抽象,我舉個栗子,可能不是很優雅但是比較形象:

就比如說你家裏有一個廁所坑位,我們叫它爲共享資源,你可以上,你家裏人也可以上。好,如果現在家裏只有你一個人,是不是你想什麼時候上就什麼時候上?想蹲多久都可以,想在裏面睡覺也可以。這個時候就不存在競爭了,你不鎖門也沒人闖進來。然後,如果現在家裏不止你一個人了,你的家裏人也在了,但是呢,大家都不需要蹲坑,可能只是都想來看看有沒有人在裏面而已,看完就走了,那這個時候你還用不用進去然後鎖門,然後再出來。最後,家裏有多個人然後都憋不住了,是不是就得搶坑位了,先搶到的人,爲了能夠安靜地順利地。。。它就需要把門鎖上了,免得其他人進來干擾。

這麼說可以理解了吧,一多二寫三互斥,如果還不理解就把上面的場景再腦補一下。

那如何上鎖呢?

在單機環境下,也就是單個 JVM 環境下多線程對共享資源的併發更新處理,我們可以簡單地使用 JDK 提供的 ReentrantLock 對共享資源進行加鎖處理。

ReentrantLock lock = new ReentrantLock();
try {
    lock.lock();
    //處理共享資源
} finally {
    lock.unlock();
}

那如果是在微服務架構多實例的環境下,每一個服務都有多個節點,我們如果還是按照之前的方式來做,就會出現這樣的情況:

這個時候再用 ReentrantLock 就沒辦法控制了,因爲這時候這些任務是跨 JVM 的,不再是簡單的單體應用了,需要協同多個節點信息,共同獲取鎖的競爭情況。

這時候就需要另一種形式的鎖——分佈式鎖:

通常是把鎖和應用分開部署,把這個鎖做成一個公用的組件,然後多個不同應用的不同節點,都去共同訪問這個組件(這個組件有多種實現方式,有些可能並不是嚴格意義上的分佈式鎖,這裏爲了方便演示,我們暫不做嚴格區分,統稱爲分佈式鎖)。

分佈式鎖實現方式

上面瞭解鎖概念和原理之後,接下來我們就來看一看,分佈式鎖比較常見的實現方式有哪些,看一看它們之間具體有什麼差異,理解它們各自的優缺點,知道哪種實現方式更容易、哪種性能更高、哪種運行更穩定,我們才能夠在實際應用中選擇合適的實現方式。還是像我們前面說的那樣,並非一定要使用哪一種方式,合適最重要。

基於數據庫實現的分佈式鎖

第一種方式,我們可以利用數據庫來實現,比如說我們創建一張表,每條記錄代表一個共享資源的鎖,其中有一個 status 字段代表鎖的狀態,L 代表 Locked ,U 代表 Unlocked。

那比如有一個線程要來更新商品庫存,它先根據商品 ID 找到代表該共享資源的鎖,然後執行下面這個語句

update  T t 
   set  t.status = 'L' 
 where  t.resource_id = '123456'
   and  t.owner = 'new owner' 
   and  t.status = 'U';

如果這條語句執行成功了並且返回的影響記錄數是 1,那麼說明了獲取鎖成功了,就可以繼續執行更新商品庫存的操作,然後釋放鎖時,則將 status 從 L 改爲 U 即可.

我們上面只說了上鎖和解鎖操作,那如果這個鎖已經被其他任務佔用了,也就是 status = ‘L’,這個時候這個語句就更新不到數據,也就意味着獲取不到鎖,程序是不是隻能等着,那要怎麼等?這是我們面臨的一個問題,因爲數據庫和我們的應用程序之間,除了發出執行語句和返回結果,基本就沒有其他交互了,它很難給應用程序發出通知,這樣就很難做到通過事件監聽機制來獲取到釋放鎖的事件,所以程序只能輪詢地去嘗試獲取鎖.

這會導致一個致命的問題,就是這種類似自旋鎖的阻塞方式,對數據庫資源消耗極大,原本數據庫的性能相對較差,即便加上連接池,性能也遠無法跟一些緩存中間件相比,而現在程序爲了搶鎖拼命發出 update 語句,對數據庫性能來說更是雪上加霜,而在分佈式環境中,尤其需要使用分佈式鎖的場景,基本上都是要求支持高併發的,這就出現一個悖論了,這一點基本上也宣告了數據庫在大部分需要分佈式鎖的場景中都用不上。

基於單機版 Redis 實現的分佈式鎖

既然數據庫性能不夠好,我們看一下用緩存中間件,也就是我們最經常使用的 Redis,如果用來實現鎖要怎麼樣做。Redis 的特點就是性能非常好,拿它跟數據庫比的話,你會發現它的性能好到爆炸。有些同學平時可能也有用過 Redis 來實現鎖,但是你採用的實現方式很有可能並不是真正的分佈式鎖,通常我們稱它爲單機版的 Redis 鎖更合適,我們先來了解這個單機版的鎖,因爲這種實現方式在實際的應用中也用的很多。後面再對比一下它與 Redis 作者提出的 Redlock 的具體區別。

對於單機版 Redis 鎖的實現主要有以下幾個步驟

第一步會先向 Redis 獲取鎖,然後返回是否獲取成功,如果獲取成功了那就可以開始操作共享資源,這段時間這個鎖就被佔用了。操作完成之後就可以釋放鎖,最後判斷一下鎖是否釋放成功。大體就分爲獲取鎖、使用鎖、還有釋放鎖這三大步驟。

那麼這三個步驟使用 Redis 是如何實現呢?

首先獲取鎖,獲取鎖只需要下面這一條命令即可

SET key value NX PX|EX time

那如何釋放鎖呢,通常我們會使用引入 Lua 腳本,我們看一下下面這個語句塊

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

那麼這個 lua 腳本的語義是執行這個腳本時,當輸入的 KEYS[1] 在 Redis 裏面的值等於輸入的 AEGV[1] 時,則刪除這個原有的 KEY,即代表釋放鎖操作(這裏查不到返回 0,也可能是因爲鎖已經過期了,前面我們獲取鎖的時候設置了過期時間)

我們可以看到上面代碼裏面有一個判斷,要保證獲取鎖和釋放鎖是同一個使用者。比如說有這種情況:

有一個客戶端 A 獲取到鎖之後去執行業務操作,然後由於某些原因,這個操作的時間,耗時比較長,超過了鎖的有效期,這個時候鎖就自動釋放了,那麼這個時候另一個客戶端 B 可能馬上就獲取到鎖,然後也去執行業務邏輯,在它還沒執行完的時候,客戶端 A 的流程處理完了,然後就執行到釋放鎖的步驟,這個時候如果沒有上面說的那個判斷,那麼就有可能發生這樣的情況:客戶端 A,把客戶端 B 持有的鎖,給釋放掉了!

那麼除了正常的獲取鎖和釋放鎖之外,單機版的 Redis 鎖有沒有哪些地方需要注意的呢?我們先來思考一下這個問題:

爲什麼需要設置緩存的過期時間?這裏是作爲鎖的有效期

定義了這個鎖,它對應的操作在正常情況下所需要的操作時間,如果超過了這個時間,鎖就會被自動釋放掉

我們想象一下這種場景,當一個使用者獲取鎖成功之後,假如它崩潰了(導致它崩潰有很多原因比如發生網絡分區,應用發生 GC 把業務執行流程給阻塞住了,或者時鐘發生變化導致它無法和 Redis 節點進行通信,發生這些情況我們就簡單說它崩潰了)這時會發生什麼情況呢,這個時候這個對應的鎖就一直不會過期了,因爲有互斥的機制所以其他使用者嘗試獲取鎖都 set 不成功,也無辦法釋放,因爲釋放時會判斷使用者是否是鎖的持有者。因此我們可以看到,獲取鎖一定要給它設置過期時間,也就是這個鎖是有租期的,使用者必須在這個規定的租期內完成對共享資源的操作,租期一到,如果使用者沒有主動釋放,那麼鎖也會自動過期。

那第二個問題,爲什麼釋放鎖的時候,要引入 Lua 腳本?

這裏我們先說一下結論,再來解釋一下爲什麼。其實這裏是爲了保證操作原子性。包括獲取鎖的 set 命令,也需要原子性的保障。

假如不考慮原子性,我們上面的獲取鎖和釋放鎖,按照功能邏輯的話,是不是換成以下的寫法也可以:

SET key value NX PX|EX time
=set key value;  
expire key time;

code
=>
get key == value
del key

這樣會有什麼問題呢,我們先看看獲取鎖的命令,使用者執行第一條成功了纔會執行第二條,那如果執行第一條成功之後使用者崩潰了,當它再連上的時候是不是就變成了我們上面說的那種情況,沒有設置鎖的過期時間。

那釋放鎖的過程,拆成兩條命令之後,又會導致什麼問題呢,我們來看一下這種場景

假如使用者 A 完成任務之後準備釋放自己持有的鎖,它先通過 get key 得到一個值,用來判斷出這個鎖確實是自己持有的鎖,並且還沒有釋放,這時候 A 由於某種原因,它還是崩潰了,造成崩潰的原因我們上面說了有多種情況,就阻塞了一段時間,在這段時間鎖恰好因爲超時自動釋放掉了,然後,使用者 B 剛好來獲取鎖,也就是執行了上面的 set 命令,然後呢使用者 A 恢復了,比如 GC 完成,然後 就開始執行它的第二步操作,也就是 del key 操作,那是不是剛好,就把使用者 B 的鎖給刪除掉了。相當於鎖的持有者和釋放者就不一致了,從而導致了鎖狀態出現錯亂。

前面我們從鎖的獲取和釋放流程,結合 Redis 命令的特性,分析了單機版 Redis 爲什麼要這麼實現,分析了這種實現方式的必要性以及可能出現的異常場景。那麼我們再從更宏觀的維度來看,這種單機版 Redis 鎖最大的風險是什麼呢?

如果這個 Redis 實例掛了,那就意味着整個鎖機制失效了,這時使用者無法獲取和釋放鎖,進一步導致使用者無法正常使用共享資源,從而出現阻塞、訪問失敗或者訪問衝突等異常;還有可能因爲共享資源失去了鎖的保護 ,引起數據不一致,導致業務上一系列連鎖反應。

那如何規避這種單點的問題呢?

有的同學可能會首先想到使用持久化機制。

那麼這種方式其實是通過利用 Redis 本身的 AOF 持久化機制,來保存每一條請求,如果 Redis 掛了,這個時候直接重新拉起,再通過 AOF 文件進行數據恢復。

但這種方式還是有一些缺點的:

假如說我們把 AOF 的同步機制設置爲每秒鐘同步一次,那這種情況下 Redis 的 AOF 持久化機制並不能保證完全不丟數據,也就是可能恢復之後少了某個鎖的數據,這樣其他使用者就可以獲取到這個鎖,導致狀態錯亂

假如說我們把它設置爲 Always,就是每個操作都要同步,這樣的話會嚴重降低 Redis 的性能,發揮不出它的優勢。

還有一點就是 AOF 文件的恢復一般比較耗時,這個時間不可控,取決於文件的大小,也就是文件越大,所需要的恢復時間越長,那恢復期間鎖就是不可用的狀態。

第二種是使用主從高可用,將單點變成多點模式來解決單點故障的風險,也就是:

使用主從(或者一主多從)進行高可用部署,當主節點掛了,從節點接手相關任務並保持鎖機不變。

那這種方式也是存在一些問題的:

首先主從複製它是異步的,所以這種方式也會存在數據丟失的風險。然後主從高可用機制它發現主節點不可用,到完成主從切換也是需要一定時間的,這個時間跟鎖的過期時間需要平衡好,否則當從節點接受之後,這個鎖的狀態及正確性是不可控的。

從上面的分析我們可以看到,單機版 Redis 在高可用方面還是存在不少問題的。如果我們的應用場景需要支持高併發,並且對它在這些特殊情況下的問題可以容忍的話,那用這種方式也沒有問題,比較它的實現方式相對簡單,並且性能也比較好,所以主要還是要結合業務場景來進行選擇。

那麼有沒有更具高可用的分佈式鎖實現方式呢?接下來我們繼續介紹 Redlock 的運行原理和機制,它在高可用性方面有更好的保障,當然相對也有一些實現代價,相比之下它會複雜一些。

基於 Redis 的高可用分佈式鎖——RedLock

RedLock 基本情況

  1. Redis 作者提出來的高可用分佈式鎖

  2. 由多個完全獨立的 Redis 節點組成,注意是完全獨立,而不是主從關係或者集羣關係,並且一般是要求分開機器部署的

  3. 利用分佈式高可以系統中大多數存活即可用的原則來保證鎖的高可用

  4. 針對每個單獨的節點,獲取鎖和釋放鎖的操作,完全採用我們上面描述的單機版的方式

RedLock 工作流程

獲取鎖
  1. 獲取當前時間 T1,作爲後續的計時依據;

  2. 按順序地,依次向 5 個獨立的節點來嘗試獲取鎖

  3. (SET resource_name my_random_value NX PX 30000)

  4. 計算獲取鎖總共花了多少時間,判斷獲取鎖成功與否

  5. 時間:T2-T1

  6. 多數節點的鎖(N/2+1)

  7. 當獲取鎖成功後的有效時間,要從初始的時間減去第三步算出來的消耗時間

  8. 如果沒能獲取鎖成功,儘快釋放掉鎖。

這裏需要注意兩點:

爲什麼要順序地向節點發起命令,那麼我們反過來想,假如不順序地發起命令會產生什麼問題?那麼我們想一下假如有 3 個客戶端同時來搶鎖,客戶端 A 先獲取到 1 號和 2 號節點,客戶端 B 先獲取到 3 號 4 號節點,客戶端 C 先獲取到 5 號節點,那麼這時候就滿足不了多數原則,5 個節點的情況下,最少需要 3 個節點都獲取到鎖,纔可以滿足 客戶端在向每個節點嘗試獲取鎖的時候,有一個超時時間限制,而且這個時間遠小於鎖的有效期,比如說幾毫秒到幾十毫秒之間,這樣的機制是爲了防止在向某一個節點獲取鎖的時候,等待的時間過長,從而導致獲取鎖的整體時間過長。比如說在獲取鎖的時候,有的節點會出現問題導致連接不上,那麼這個時候就應該儘快地轉移到下一個節點繼續嘗試,因爲最終的結果我們只需要滿足多數可用原則即可

釋放鎖

向所有節點發起釋放鎖的操作,不管這些節點有沒有成功設置過

正常情況下 RedLock 的運行狀態

client1 和 client2,對 Redis 節點 A-E 進行搶鎖操作,如圖,client1 先搶到節點 ABC,超過半數,因此持有分佈式鎖,在持有鎖期間,client2 搶鎖都是失敗的,當時序 = 6 時,client1 才處理完業務流程釋放分佈式鎖,這時候 client2 纔有可能搶鎖成功。

那麼 RedLock 的主要流程就是這樣,獲取鎖和釋放鎖,那麼這個號稱是真正的分佈式鎖,它相比前面單機版的鎖,很明顯的一個點就是它不再是單點的是吧,所以在高可用性上面,它是比單機版的鎖有提升的。

但是,RedLock 是否就是一個很完美的解決方案呢?在一些特殊場景下會不會存在什麼不足的地方?你也可以思考思考,歡迎留言交流。

此外,除了 redis 以外 ,其實我們可以用 ZooKeeper 來實現分佈式鎖 。實際上這 Redis 實現分佈式鎖的方式雖然性能比較高,但是在一些特殊場景下,它還是不夠健壯,相比之下,ZooKeeper 它的設計定位就是用來做分佈式協調的工作,更加註重一致性,非常適合用來做分佈式鎖,總的來說使用 ZooKeeper 去實現分佈式鎖相比 Redis 的話會更加健壯一些。具體的方案和實現方式需要對 ZooKeeper 有一些瞭解,這個後面相關篇章再作介紹。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/CI-s7-HvcZY0ba4WKUTn3g