深度剖析:Redis分佈式鎖到底安全嗎?看完這篇文章完全懂了!

微信搜索關注「水滴與銀彈」公衆號,第一時間獲取優質技術乾貨。7年資深後端研發,給你呈現不同的技術視角。html

你們好,我是 Kaito。python

這篇文章我想和你聊一聊,關於 Redis 分佈式鎖的「安全性」問題。程序員

Redis 分佈式鎖的話題,不少文章已經寫爛了,我爲何還要寫這篇文章呢?redis

由於我發現網上 99% 的文章,並無把這個問題真正講清楚。致使不少讀者看了不少文章,依舊雲裏霧裏。例以下面這些問題,你能清晰地回答上來嗎?算法

  • 基於 Redis 如何實現一個分佈式鎖?
  • Redis 分佈式鎖真的安全嗎?
  • Redis 的 Redlock 有什麼問題?必定安全嗎?
  • 業界爭論 Redlock,到底在爭論什麼?哪一種觀點是對的?
  • 分佈式鎖到底用 Redis 仍是 Zookeeper?
  • 實現一個有「容錯性」的分佈式鎖,都須要考慮哪些問題?

這篇文章,我就來把這些問題完全講清楚。sql

讀完這篇文章,你不只能夠完全瞭解分佈式鎖,還會對「分佈式系統」有更加深入的理解。shell

文章有點長,但乾貨不少,但願你能夠耐心讀完。編程

爲何須要分佈式鎖?

在開始講分佈式鎖以前,有必要簡單介紹一下,爲何須要分佈式鎖?後端

與分佈式鎖相對應的是「單機鎖」,咱們在寫多線程程序時,避免同時操做一個共享變量產生數據問題,一般會使用一把鎖來「互斥」,以保證共享變量的正確性,其使用範圍是在「同一個進程」中。安全

若是換作是多個進程,須要同時操做一個共享資源,如何互斥呢?

例如,如今的業務應用一般都是微服務架構,這也意味着一個應用會部署多個進程,那這多個進程若是須要修改 MySQL 中的同一行記錄時,爲了不操做亂序致使數據錯誤,此時,咱們就須要引入「分佈式鎖」來解決這個問題了。

想要實現分佈式鎖,必須藉助一個外部系統,全部進程都去這個系統上申請「加鎖」。

而這個外部系統,必需要實現「互斥」的能力,即兩個請求同時進來,只會給一個進程返回成功,另外一個返回失敗(或等待)。

這個外部系統,能夠是 MySQL,也能夠是 Redis 或 Zookeeper。但爲了追求更好的性能,咱們一般會選擇使用 Redis 或 Zookeeper 來作。

下面我就以 Redis 爲主線,由淺入深,帶你深度剖析一下,分佈式鎖的各類「安全性」問題,幫你完全理解分佈式鎖。

分佈式鎖怎麼實現?

咱們從最簡單的開始講起。

想要實現分佈式鎖,必需要求 Redis 有「互斥」的能力,咱們可使用 SETNX 命令,這個命令表示SET if Not eXists,即若是 key 不存在,纔會設置它的值,不然什麼也不作。

兩個客戶端進程能夠執行這個命令,達到互斥,就能夠實現一個分佈式鎖。

客戶端 1 申請加鎖,加鎖成功:

127.0.0.1:6379> SETNX lock 1
(integer) 1     // 客戶端1,加鎖成功
複製代碼

客戶端 2 申請加鎖,由於後到達,加鎖失敗:

127.0.0.1:6379> SETNX lock 1
(integer) 0     // 客戶端2,加鎖失敗
複製代碼

此時,加鎖成功的客戶端,就能夠去操做「共享資源」,例如,修改 MySQL 的某一行數據,或者調用一個 API 請求。

操做完成後,還要及時釋放鎖,給後來者讓出操做共享資源的機會。如何釋放鎖呢?

也很簡單,直接使用 DEL 命令刪除這個 key 便可:

127.0.0.1:6379> DEL lock // 釋放鎖
(integer) 1
複製代碼

這個邏輯很是簡單,總體的路程就是這樣:

可是,它存在一個很大的問題,當客戶端 1 拿到鎖後,若是發生下面的場景,就會形成「死鎖」:

  1. 程序處理業務邏輯異常,沒及時釋放鎖
  2. 進程掛了,沒機會釋放鎖

這時,這個客戶端就會一直佔用這個鎖,而其它客戶端就「永遠」拿不到這把鎖了。

怎麼解決這個問題呢?

如何避免死鎖?

咱們很容易想到的方案是,在申請鎖時,給這把鎖設置一個「租期」。

在 Redis 中實現時,就是給這個 key 設置一個「過時時間」。這裏咱們假設,操做共享資源的時間不會超過 10s,那麼在加鎖時,給這個 key 設置 10s 過時便可:

127.0.0.1:6379> SETNX lock 1    // 加鎖
(integer) 1
127.0.0.1:6379> EXPIRE lock 10  // 10s後自動過時
(integer) 1
複製代碼

這樣一來,不管客戶端是否異常,這個鎖均可以在 10s 後被「自動釋放」,其它客戶端依舊能夠拿到鎖。

但這樣真的沒問題嗎?

仍是有問題。

如今的操做,加鎖、設置過時是 2 條命令,有沒有可能只執行了第一條,第二條卻「來不及」執行的狀況發生呢?例如:

  1. SETNX 執行成功,執行 EXPIRE 時因爲網絡問題,執行失敗
  2. SETNX 執行成功,Redis 異常宕機,EXPIRE 沒有機會執行
  3. SETNX 執行成功,客戶端異常崩潰,EXPIRE 也沒有機會執行

總之,這兩條命令不能保證是原子操做(一塊兒成功),就有潛在的風險致使過時時間設置失敗,依舊發生「死鎖」問題。

怎麼辦?

在 Redis 2.6.12 版本以前,咱們須要想盡辦法,保證 SETNX 和 EXPIRE 原子性執行,還要考慮各類異常狀況如何處理。

但在 Redis 2.6.12 以後,Redis 擴展了 SET 命令的參數,用這一條命令就能夠了:

// 一條命令保證原子性執行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
複製代碼

這樣就解決了死鎖問題,也比較簡單。

咱們再來看分析下,它還有什麼問題?

試想這樣一種場景:

  1. 客戶端 1 加鎖成功,開始操做共享資源
  2. 客戶端 1 操做共享資源的時間,「超過」了鎖的過時時間,鎖被「自動釋放」
  3. 客戶端 2 加鎖成功,開始操做共享資源
  4. 客戶端 1 操做共享資源完成,釋放鎖(但釋放的是客戶端 2 的鎖)

看到了麼,這裏存在兩個嚴重的問題:

  1. 鎖過時:客戶端 1 操做共享資源耗時過久,致使鎖被自動釋放,以後被客戶端 2 持有
  2. 釋放別人的鎖:客戶端 1 操做共享資源完成後,卻又釋放了客戶端 2 的鎖

致使這兩個問題的緣由是什麼?咱們一個個來看。

第一個問題,多是咱們評估操做共享資源的時間不許確致使的。

例如,操做共享資源的時間「最慢」可能須要 15s,而咱們卻只設置了 10s 過時,那這就存在鎖提早過時的風險。

過時時間過短,那增大冗餘時間,例如設置過時時間爲 20s,這樣總能夠了吧?

這樣確實能夠「緩解」這個問題,下降出問題的機率,但依舊沒法「完全解決」問題。

爲何?

緣由在於,客戶端在拿到鎖以後,在操做共享資源時,遇到的場景有多是很複雜的,例如,程序內部發生異常、網絡請求超時等等。

既然是「預估」時間,也只能是大體計算,除非你能預料並覆蓋到全部致使耗時變長的場景,但這其實很難。

有什麼更好的解決方案嗎?

別急,關於這個問題,我會在後面詳細來說對應的解決方案。

咱們繼續來看第二個問題。

第二個問題在於,一個客戶端釋放了其它客戶端持有的鎖。

想一下,致使這個問題的關鍵點在哪?

重點在於,每一個客戶端在釋放鎖時,都是「無腦」操做,並無檢查這把鎖是否還「歸本身持有」,因此就會發生釋放別人鎖的風險,這樣的解鎖流程,很不「嚴謹」!

如何解決這個問題呢?

鎖被別人釋放怎麼辦?

解決辦法是:客戶端在加鎖時,設置一個只有本身知道的「惟一標識」進去。

例如,能夠是本身的線程 ID,也能夠是一個 UUID(隨機且惟一),這裏咱們以 UUID 舉例:

// 鎖的VALUE設置爲UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK
複製代碼

這裏假設 20s 操做共享時間徹底足夠,先不考慮鎖自動過時的問題。

以後,在釋放鎖時,要先判斷這把鎖是否還歸本身持有,僞代碼能夠這麼寫:

// 鎖是本身的,才釋放
if redis.get("lock") == $uuid:
    redis.del("lock")
複製代碼

這裏釋放鎖使用的是 GET + DEL 兩條命令,這時,又會遇到咱們前面講的原子性問題了。

  1. 客戶端 1 執行 GET,判斷鎖是本身的
  2. 客戶端 2 執行了 SET 命令,強制獲取到鎖(雖然發生機率比較低,但咱們須要嚴謹地考慮鎖的安全性模型)
  3. 客戶端 1 執行 DEL,卻釋放了客戶端 2 的鎖

因而可知,這兩個命令仍是必需要原子執行才行。

怎樣原子執行呢?Lua 腳本。

咱們能夠把這個邏輯,寫成 Lua 腳本,讓 Redis 來執行。

由於 Redis 處理每個請求是「單線程」執行的,在執行一個 Lua 腳本時,其它請求必須等待,直到這個 Lua 腳本處理完成,這樣一來,GET + DEL 之間就不會插入其它命令了。

安全釋放鎖的 Lua 腳本以下:

// 判斷鎖是本身的,才釋放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
    return redis.call("DEL",KEYS[1])
else
    return 0
end
複製代碼

好了,這樣一路優化,整個的加鎖、解鎖的流程就更「嚴謹」了。

這裏咱們先小結一下,基於 Redis 實現的分佈式鎖,一個嚴謹的的流程以下:

  1. 加鎖:SET $lock_key $unique_id EX $expire_time NX
  2. 操做共享資源
  3. 釋放鎖:Lua 腳本,先 GET 判斷鎖是否歸屬本身,再 DEL 釋放鎖

好,有了這個完整的鎖模型,讓咱們從新回到前面提到的第一個問題。

鎖過時時間很差評估怎麼辦?

鎖過時時間很差評估怎麼辦?

前面咱們提到,鎖的過時時間若是評估很差,這個鎖就會有「提早」過時的風險。

當時給的妥協方案是,儘可能「冗餘」過時時間,下降鎖提早過時的機率。

這個方案其實也不能完美解決問題,那怎麼辦呢?

是否能夠設計這樣的方案:加鎖時,先設置一個過時時間,而後咱們開啓一個「守護線程」,定時去檢測這個鎖的失效時間,若是鎖快要過時了,操做共享資源還未完成,那麼就自動對鎖進行「續期」,從新設置過時時間。

這確實一種比較好的方案。

若是你是 Java 技術棧,幸運的是,已經有一個庫把這些工做都封裝好了:Redisson

Redisson 是一個 Java 語言實現的 Redis SDK 客戶端,在使用分佈式鎖時,它就採用了「自動續期」的方案來避免鎖過時,這個守護線程咱們通常也把它叫作「看門狗」線程。

除此以外,這個 SDK 還封裝了不少易用的功能:

  • 可重入鎖
  • 樂觀鎖
  • 公平鎖
  • 讀寫鎖
  • Redlock(紅鎖,下面會詳細講)

這個 SDK 提供的 API 很是友好,它能夠像操做本地鎖的方式,操做分佈式鎖。若是你是 Java 技術棧,能夠直接把它用起來。

這裏不重點介紹 Redisson 的使用,你們能夠看官方 Github 學習如何使用,比較簡單。

到這裏咱們再小結一下,基於 Redis 的實現分佈式鎖,前面遇到的問題,以及對應的解決方案:

  • 死鎖:設置過時時間
  • 過時時間評估很差,鎖提早過時:守護線程,自動續期
  • 鎖被別人釋放:鎖寫入惟一標識,釋放鎖先檢查標識,再釋放

還有哪些問題場景,會危害 Redis 鎖的安全性呢?

以前分析的場景都是,鎖在「單個」Redis 實例中可能產生的問題,並無涉及到 Redis 的部署架構細節。

而咱們在使用 Redis 時,通常會採用主從集羣 + 哨兵的模式部署,這樣作的好處在於,當主庫異常宕機時,哨兵能夠實現「故障自動切換」,把從庫提高爲主庫,繼續提供服務,以此保證可用性。

那當「主從發生切換」時,這個分佈鎖會依舊安全嗎?

試想這樣的場景:

  1. 客戶端 1 在主庫上執行 SET 命令,加鎖成功
  2. 此時,主庫異常宕機,SET 命令還未同步到從庫上(主從複製是異步的)
  3. 從庫被哨兵提高爲新主庫,這個鎖在新的主庫上,丟失了!

可見,當引入 Redis 副本後,分佈鎖仍是可能會受到影響。

怎麼解決這個問題?

爲此,Redis 的做者提出一種解決方案,就是咱們常常聽到的 Redlock(紅鎖)

它真的能夠解決上面這個問題嗎?

Redlock 真的安全嗎?

好,終於到了這篇文章的重頭戲。啊?上面講的那麼多問題,難道只是基礎?

是的,那些只是開胃菜,真正的硬菜,從這裏剛剛開始。

若是上面講的內容,你尚未理解,我建議你從新閱讀一遍,先理清整個加鎖、解鎖的基本流程。

若是你已經對 Redlock 有所瞭解,這裏能夠跟着我再複習一遍,若是你不瞭解 Redlock,不要緊,我會帶你從新認識它。

值得提醒你的是,後面我不只僅是講 Redlock 的原理,還會引出有關「分佈式系統」中的不少問題,你最好跟緊個人思路,在腦中一塊兒分析問題的答案。

如今咱們來看,Redis 做者提出的 Redlock 方案,是如何解決主從切換後,鎖失效問題的。

Redlock 的方案基於 2 個前提:

  1. 再也不須要部署從庫哨兵實例,只部署主庫
  2. 但主庫要部署多個,官方推薦至少 5 個實例

也就是說,想用使用 Redlock,你至少要部署 5 個 Redis 實例,並且都是主庫,它們之間沒有任何關係,都是一個個孤立的實例。

注意:不是部署 Redis Cluster,就是部署 5 個簡單的 Redis 實例。

Redlock 具體如何使用呢?

總體的流程是這樣的,一共分爲 5 步:

  1. 客戶端先獲取「當前時間戳T1」
  2. 客戶端依次向這 5 個 Redis 實例發起加鎖請求(用前面講到的 SET 命令),且每一個請求會設置超時時間(毫秒級,要遠小於鎖的有效時間),若是某一個實例加鎖失敗(包括網絡超時、鎖被其它人持有等各類異常狀況),就當即向下一個 Redis 實例申請加鎖
  3. 若是客戶端從 >=3 個(大多數)以上 Redis 實例加鎖成功,則再次獲取「當前時間戳T2」,若是 T2 - T1 < 鎖的過時時間,此時,認爲客戶端加鎖成功,不然認爲加鎖失敗
  4. 加鎖成功,去操做共享資源(例如修改 MySQL 某一行,或發起一個 API 請求)
  5. 加鎖失敗,向「所有節點」發起釋放鎖請求(前面講到的 Lua 腳本釋放鎖)

我簡單幫你總結一下,有 4 個重點:

  1. 客戶端在多個 Redis 實例上申請加鎖
  2. 必須保證大多數節點加鎖成功
  3. 大多數節點加鎖的總耗時,要小於鎖設置的過時時間
  4. 釋放鎖,要向所有節點發起釋放鎖請求

第一次看可能不太容易理解,建議你把上面的文字多看幾遍,加深記憶。

而後,記住這 5 步,很是重要,下面會根據這個流程,剖析各類可能致使鎖失效的問題假設。

好,明白了 Redlock 的流程,咱們來看 Redlock 爲何要這麼作。

1) 爲何要在多個實例上加鎖?

本質上是爲了「容錯」,部分實例異常宕機,剩餘的實例加鎖成功,整個鎖服務依舊可用。

2) 爲何大多數加鎖成功,纔算成功?

多個 Redis 實例一塊兒來用,其實就組成了一個「分佈式系統」。

在分佈式系統中,總會出現「異常節點」,因此,在談論分佈式系統問題時,須要考慮異常節點達到多少個,也依舊不會影響整個系統的「正確性」。

這是一個分佈式系統「容錯」問題,這個問題的結論是:若是隻存在「故障」節點,只要大多數節點正常,那麼整個系統依舊是能夠提供正確服務的。

這個問題的模型,就是咱們常常聽到的「拜占庭將軍」問題,感興趣能夠去看算法的推演過程。

3) 爲何步驟 3 加鎖成功後,還要計算加鎖的累計耗時?

由於操做的是多個節點,因此耗時確定會比操做單個實例耗時更久,並且,由於是網絡請求,網絡狀況是複雜的,有可能存在延遲、丟包、超時等狀況發生,網絡請求越多,異常發生的機率就越大。

因此,即便大多數節點加鎖成功,但若是加鎖的累計耗時已經「超過」了鎖的過時時間,那此時有些實例上的鎖可能已經失效了,這個鎖就沒有意義了。

4) 爲何釋放鎖,要操做全部節點?

在某一個 Redis 節點加鎖時,可能由於「網絡緣由」致使加鎖失敗。

例如,客戶端在一個 Redis 實例上加鎖成功,但在讀取響應結果時,網絡問題致使讀取失敗,那這把鎖其實已經在 Redis 上加鎖成功了。

因此,釋放鎖時,無論以前有沒有加鎖成功,須要釋放「全部節點」的鎖,以保證清理節點上「殘留」的鎖。

好了,明白了 Redlock 的流程和相關問題,看似 Redlock 確實解決了 Redis 節點異常宕機鎖失效的問題,保證了鎖的「安全性」。

但事實真的如此嗎?

Redlock 的爭論誰對誰錯?

Redis 做者把這個方案一經提出,就立刻受到業界著名的分佈式系統專家的質疑

這個專家叫 Martin,是英國劍橋大學的一名分佈式系統研究員。在此以前他曾是軟件工程師和企業家,從事大規模數據基礎設施相關的工做。它還常常在大會作演講,寫博客,寫書,也是開源貢獻者。

他立刻寫了篇文章,質疑這個 Redlock 的算法模型是有問題的,並對分佈式鎖的設計,提出了本身的見解。

以後,Redis 做者 Antirez 面對質疑,不甘示弱,也寫了一篇文章,反駁了對方的觀點,並詳細剖析了 Redlock 算法模型的更多設計細節。

並且,關於這個問題的爭論,在當時互聯網上也引發了很是激烈的討論。

二人思路清晰,論據充分,這是一場高手過招,也是分佈式系統領域很是好的一次思想的碰撞!雙方都是分佈式系統領域的專家,卻對同一個問題提出不少相反的論斷,到底是怎麼回事?

下面我會從他們的爭論文章中,提取重要的觀點,整理呈現給你。

提醒:後面的信息量極大,可能不宜理解,最好放慢速度閱讀。

分佈式專家 Martin 對於 Relock 的質疑

在他的文章中,主要闡述了 4 個論點:

1) 分佈式鎖的目的是什麼?

Martin 表示,你必須先清楚你在使用分佈式鎖的目的是什麼?

他認爲有兩個目的。

第一,效率。

使用分佈式鎖的互斥能力,是避免沒必要要地作一樣的兩次工做(例如一些昂貴的計算任務)。若是鎖失效,並不會帶來「惡性」的後果,例如發了 2 次郵件等,無傷大雅。

第二,正確性。

使用鎖用來防止併發進程互相干擾。若是鎖失效,會形成多個進程同時操做同一條數據,產生的後果是數據嚴重錯誤、永久性不一致、數據丟失等惡性問題,就像給患者服用重複劑量的藥物同樣,後果嚴重。

他認爲,若是你是爲了前者——效率,那麼使用單機版 Redis 就能夠了,即便偶爾發生鎖失效(宕機、主從切換),都不會產生嚴重的後果。而使用 Redlock 過重了,不必。

而若是是爲了正確性,Martin 認爲 Redlock 根本達不到安全性的要求,也依舊存在鎖失效的問題!

2) 鎖在分佈式系統中會遇到的問題

Martin 表示,一個分佈式系統,更像一個複雜的「野獸」,存在着你想不到的各類異常狀況。

這些異常場景主要包括三大塊,這也是分佈式系統會遇到的三座大山:NPC

  • N:Network Delay,網絡延遲
  • P:Process Pause,進程暫停(GC)
  • C:Clock Drift,時鐘漂移

Martin 用一個進程暫停(GC)的例子,指出了 Redlock 安全性問題:

  1. 客戶端 1 請求鎖定節點 A、B、C、D、E
  2. 客戶端 1 的拿到鎖後,進入 GC(時間比較久)
  3. 全部 Redis 節點上的鎖都過時了
  4. 客戶端 2 獲取到了 A、B、C、D、E 上的鎖
  5. 客戶端 1 GC 結束,認爲成功獲取鎖
  6. 客戶端 2 也認爲獲取到了鎖,發生「衝突」

Martin 認爲,GC 可能發生在程序的任意時刻,並且執行時間是不可控的。

注:固然,即便是使用沒有 GC 的編程語言,在發生網絡延遲、時鐘漂移時,也都有可能致使 Redlock 出現問題,這裏 Martin 只是拿 GC 舉例。

3) 假設時鐘正確的是不合理的

又或者,當多個 Redis 節點「時鐘」發生問題時,也會致使 Redlock 鎖失效

  1. 客戶端 1 獲取節點 A、B、C 上的鎖,但因爲網絡問題,沒法訪問 D 和 E
  2. 節點 C 上的時鐘「向前跳躍」,致使鎖到期
  3. 客戶端 2 獲取節點 C、D、E 上的鎖,因爲網絡問題,沒法訪問 A 和 B
  4. 客戶端 1 和 2 如今都相信它們持有了鎖(衝突)

Martin 以爲,Redlock 必須「強依賴」多個節點的時鐘是保持同步的,一旦有節點時鐘發生錯誤,那這個算法模型就失效了。

即便 C 不是時鐘跳躍,而是「崩潰後當即重啓」,也會發生相似的問題。

Martin 繼續闡述,機器的時鐘發生錯誤,是頗有可能發生的:

  • 系統管理員「手動修改」了機器時鐘
  • 機器時鐘在同步 NTP 時間時,發生了大的「跳躍」

總之,Martin 認爲,Redlock 的算法是創建在「同步模型」基礎上的,有大量資料研究代表,同步模型的假設,在分佈式系統中是有問題的。

在混亂的分佈式系統的中,你不能假設系統時鐘就是對的,因此,你必須很是當心你的假設。

4) 提出 fecing token 的方案,保證正確性

相對應的,Martin 提出一種被叫做 fecing token 的方案,保證分佈式鎖的正確性。

這個模型流程以下:

  1. 客戶端在獲取鎖時,鎖服務能夠提供一個「遞增」的 token
  2. 客戶端拿着這個 token 去操做共享資源
  3. 共享資源能夠根據 token 拒絕「後來者」的請求

這樣一來,不管 NPC 哪一種異常狀況發生,均可以保證分佈式鎖的安全性,由於它是創建在「異步模型」上的。

而 Redlock 沒法提供相似 fecing token 的方案,因此它沒法保證安全性。

他還表示,一個好的分佈式鎖,不管 NPC 怎麼發生,能夠不在規定時間內給出結果,但並不會給出一個錯誤的結果。也就是隻會影響到鎖的「性能」(或稱之爲活性),而不會影響它的「正確性」。

Martin 的結論:

一、Redlock 不三不四:它對於效率來說,Redlock 比較重,不必這麼作,而對於正確性來講,Redlock 是不夠安全的。

二、時鐘假設不合理:該算法對系統時鐘作出了危險的假設(假設多個節點機器時鐘都是一致的),若是不知足這些假設,鎖就會失效。

三、沒法保證正確性:Redlock 不能提供相似 fencing token 的方案,因此解決不了正確性的問題。爲了正確性,請使用有「共識系統」的軟件,例如 Zookeeper。

好了,以上就是 Martin 反對使用 Redlock 的觀點,看起來有理有據。

下面咱們來看 Redis 做者 Antirez 是如何反駁的。

Redis 做者 Antirez 的反駁

在 Redis 做者的文章中,重點有 3 個:

1) 解釋時鐘問題

首先,Redis 做者一眼就看穿了對方提出的最爲核心的問題:時鐘問題

Redis 做者表示,Redlock 並不須要徹底一致的時鐘,只須要大致一致就能夠了,容許有「偏差」。

例如要計時 5s,但實際可能記了 4.5s,以後又記了 5.5s,有必定偏差,但只要不超過「偏差範圍」鎖失效時間便可,這種對於時鐘的精度的要求並非很高,並且這也符合現實環境。

對於對方提到的「時鐘修改」問題,Redis 做者反駁到:

  1. 手動修改時鐘:不要這麼作就行了,不然你直接修改 Raft 日誌,那 Raft 也會沒法工做...
  2. 時鐘跳躍:經過「恰當的運維」,保證機器時鐘不會大幅度跳躍(每次經過微小的調整來完成),實際上這是能夠作到的

爲何 Redis 做者優先解釋時鐘問題?由於在後面的反駁過程當中,須要依賴這個基礎作進一步解釋。

2) 解釋網絡延遲、GC 問題

以後,Redis 做者對於對方提出的,網絡延遲wan、進程 GC 可能致使 Redlock 失效的問題,也作了反駁:

咱們從新回顧一下,Martin 提出的問題假設:

  1. 客戶端 1 請求鎖定節點 A、B、C、D、E
  2. 客戶端 1 的拿到鎖後,進入 GC
  3. 全部 Redis 節點上的鎖都過時了
  4. 客戶端 2 獲取節點 A、B、C、D、E 上的鎖
  5. 客戶端 1 GC 結束,認爲成功獲取鎖
  6. 客戶端 2 也認爲獲取到鎖,發生「衝突」

Redis 做者反駁到,這個假設實際上是有問題的,Redlock 是能夠保證鎖安全的。

這是怎麼回事呢?

還記得前面介紹 Redlock 流程的那 5 步嗎?這裏我再拿過來讓你複習一下。

  1. 客戶端先獲取「當前時間戳T1」
  2. 客戶端依次向這 5 個 Redis 實例發起加鎖請求(用前面講到的 SET 命令),且每一個請求會設置超時時間(毫秒級,要遠小於鎖的有效時間),若是某一個實例加鎖失敗(包括網絡超時、鎖被其它人持有等各類異常狀況),就當即向下一個 Redis 實例申請加鎖
  3. 若是客戶端從 3 個(大多數)以上 Redis 實例加鎖成功,則再次獲取「當前時間戳T2」,若是 T2 - T1 < 鎖的過時時間,此時,認爲客戶端加鎖成功,不然認爲加鎖失敗
  4. 加鎖成功,去操做共享資源(例如修改 MySQL 某一行,或發起一個 API 請求)
  5. 加鎖失敗,向「所有節點」發起釋放鎖請求(前面講到的 Lua 腳本釋放鎖)

注意,重點是 1-3,在步驟 3,加鎖成功後爲何要從新獲取「當前時間戳T2」?還用 T2 - T1 的時間,與鎖的過時時間作比較?

Redis 做者強調:若是在 1-3 發生了網絡延遲、進程 GC 等耗時長的異常狀況,那在第 3 步 T2 - T1,是能夠檢測出來的,若是超出了鎖設置的過時時間,那這時就認爲加鎖會失敗,以後釋放全部節點的鎖就行了!

Redis 做者繼續論述,若是對方認爲,發生網絡延遲、進程 GC 是在步驟 3 以後,也就是客戶端確認拿到了鎖,去操做共享資源的途中發生了問題,致使鎖失效,那這不止是 Redlock 的問題,任何其它鎖服務例如 Zookeeper,都有相似的問題,這不在討論範疇內

這裏我舉個例子解釋一下這個問題:

  1. 客戶端經過 Redlock 成功獲取到鎖(經過了大多數節點加鎖成功、加鎖耗時檢查邏輯)
  2. 客戶端開始操做共享資源,此時發生網絡延遲、進程 GC 等耗時很長的狀況
  3. 此時,鎖過時自動釋放
  4. 客戶端開始操做 MySQL(此時的鎖可能會被別人拿到,鎖失效)

Redis 做者這裏的結論就是:

  • 客戶端在拿到鎖以前,不管經歷什麼耗時長問題,Redlock 都可以在第 3 步檢測出來
  • 客戶端在拿到鎖以後,發生 NPC,那 Redlock、Zookeeper 都無能爲力

因此,Redis 做者認爲 Redlock 在保證時鐘正確的基礎上,是能夠保證正確性的。

3) 質疑 fencing token 機制

Redis 做者對於對方提出的 fecing token 機制,也提出了質疑,主要分爲 2 個問題,這裏最不宜理解,請跟緊個人思路。

第一,這個方案必需要求要操做的「共享資源服務器」有拒絕「舊 token」的能力。

例如,要操做 MySQL,從鎖服務拿到一個遞增數字的 token,而後客戶端要帶着這個 token 去改 MySQL 的某一行,這就須要利用 MySQL 的「事物隔離性」來作。

// 兩個客戶端必須利用事物和隔離性達到目的
// 注意 token 的判斷條件
UPDATE table T SET val = $new_val, current_token = $token WHERE id = $id AND current_token < $token
複製代碼

但若是操做的不是 MySQL 呢?例如向磁盤上寫一個文件,或發起一個 HTTP 請求,那這個方案就無能爲力了,這對要操做的資源服務器,提出了更高的要求。

也就是說,大部分要操做的資源服務器,都是沒有這種互斥能力的。

再者,既然資源服務器都有了「互斥」能力,那還要分佈式鎖幹什麼?

因此,Redis 做者認爲這個方案是站不住腳的。

第二,退一步講,即便 Redlock 沒有提供 fecing token 的能力,但 Redlock 已經提供了隨機值(就是前面講的 UUID),利用這個隨機值,也能夠達到與 fecing token 一樣的效果。

如何作呢?

Redis 做者只是提到了能夠完成 fecing token 相似的功能,但卻沒有展開相關細節,根據我查閱的資料,大概流程應該以下,若有錯誤,歡迎交流~​

  1. 客戶端使用 Redlock 拿到鎖
  2. 客戶端在操做共享資源以前,先把這個鎖的 VALUE,在要操做的共享資源上作標記
  3. 客戶端處理業務邏輯,最後,在修改共享資源時,判斷這個標記是否與以前同樣,同樣才修改(相似 CAS 的思路)

仍是以 MySQL 爲例,舉個例子就是這樣的:

  1. 客戶端使用 Redlock 拿到鎖
  2. 客戶端要修改 MySQL 表中的某一行數據以前,先把鎖的 VALUE 更新到這一行的某個字段中(這裏假設爲 current_token 字段)
  3. 客戶端處理業務邏輯
  4. 客戶端修改 MySQL 的這一行數據,把 VALUE 當作 WHERE 條件,再修改
UPDATE table T SET val = $new_val WHERE id = $id AND current_token = $redlock_value
複製代碼

可見,這種方案依賴 MySQL 的事物機制,也達到對方提到的 fecing token 同樣的效果。

但這裏還有個小問題,是網友參與問題討論時提出的:兩個客戶端經過這種方案,先「標記」再「檢查+修改」共享資源,那這兩個客戶端的操做順序沒法保證啊?

而用 Martin 提到的 fecing token,由於這個 token 是單調遞增的數字,資源服務器能夠拒絕小的 token 請求,保證了操做的「順序性」!

Redis 做者對於這個問題作了不一樣的解釋,我以爲頗有道理,他解釋道:分佈式鎖的本質,是爲了「互斥」,只要能保證兩個客戶端在併發時,一個成功,一個失敗就行了,不須要關心「順序性」。

前面 Martin 的質疑中,一直很關心這個順序性問題,但 Redis 的做者的見解卻不一樣。

綜上,Redis 做者的結論:

一、做者贊成對方關於「時鐘跳躍」對 Redlock 的影響,但認爲時鐘跳躍是能夠避免的,取決於基礎設施和運維。

二、Redlock 在設計時,充分考慮了 NPC 問題,在 Redlock 步驟 3 以前出現 NPC,能夠保證鎖的正確性,但在步驟 3 以後發生 NPC,不止是 Redlock 有問題,其它分佈式鎖服務一樣也有問題,因此不在討論範疇內。

是否是以爲頗有意思?

在分佈式系統中,一個小小的鎖,竟然可能會遇到這麼多問題場景,影響它的安全性!

不知道你看完雙方的觀點,更贊同哪一方的說法呢?

別急,後面我還會綜合以上論點,談談本身的理解。

好,講完了雙方對於 Redis 分佈鎖的爭論,你可能也注意到了,Martin 在他的文章中,推薦使用 Zookeeper 實現分佈式鎖,認爲它更安全,確實如此嗎?

基於 Zookeeper 的鎖安全嗎?

若是你有了解過 Zookeeper,基於它實現的分佈式鎖是這樣的:

  1. 客戶端 1 和 2 都嘗試建立「臨時節點」,例如 /lock
  2. 假設客戶端 1 先到達,則加鎖成功,客戶端 2 加鎖失敗
  3. 客戶端 1 操做共享資源
  4. 客戶端 1 刪除 /lock 節點,釋放鎖

你應該也看到了,Zookeeper 不像 Redis 那樣,須要考慮鎖的過時時間問題,它是採用了「臨時節點」,保證客戶端 1 拿到鎖後,只要鏈接不斷,就能夠一直持有鎖。

並且,若是客戶端 1 異常崩潰了,那麼這個臨時節點會自動刪除,保證了鎖必定會被釋放。

不錯,沒有鎖過時的煩惱,還能在異常時自動釋放鎖,是否是以爲很完美?

其實否則。

思考一下,客戶端 1 建立臨時節點後,Zookeeper 是如何保證讓這個客戶端一直持有鎖呢?

緣由就在於,客戶端 1 此時會與 Zookeeper 服務器維護一個 Session,這個 Session 會依賴客戶端「定時心跳」來維持鏈接。

若是 Zookeeper 長時間收不到客戶端的心跳,就認爲這個 Session 過時了,也會把這個臨時節點刪除。

一樣地,基於此問題,咱們也討論一下 GC 問題對 Zookeeper 的鎖有何影響:

  1. 客戶端 1 建立臨時節點 /lock 成功,拿到了鎖
  2. 客戶端 1 發生長時間 GC
  3. 客戶端 1 沒法給 Zookeeper 發送心跳,Zookeeper 把臨時節點「刪除」
  4. 客戶端 2 建立臨時節點 /lock 成功,拿到了鎖
  5. 客戶端 1 GC 結束,它仍然認爲本身持有鎖(衝突)

可見,即便是使用 Zookeeper,也沒法保證進程 GC、網絡延遲異常場景下的安全性。

這就是前面 Redis 做者在反駁的文章中提到的:若是客戶端已經拿到了鎖,但客戶端與鎖服務器發生「失聯」(例如 GC),那不止 Redlock 有問題,其它鎖服務都有相似的問題,Zookeeper 也是同樣!

因此,這裏咱們就能得出結論了:一個分佈式鎖,在極端狀況下,不必定是安全的。

若是你的業務數據很是敏感,在使用分佈式鎖時,必定要注意這個問題,不能假設分佈式鎖 100% 安全。

好,如今咱們來總結一下 Zookeeper 在使用分佈式鎖時優劣:

Zookeeper 的優勢:

  1. 不須要考慮鎖的過時時間
  2. watch 機制,加鎖失敗,能夠 watch 等待鎖釋放,實現樂觀鎖

但它的劣勢是:

  1. 性能不如 Redis
  2. 部署和運維成本高
  3. 客戶端與 Zookeeper 的長時間失聯,鎖被釋放問題

我對分佈式鎖的理解

好了,前面詳細介紹了基於 Redis 的 Redlock 和 Zookeeper 實現的分佈鎖,在各類異常狀況下的安全性問題,下面我想和你聊一聊個人見解,僅供參考,不喜勿噴。

1) 到底要不要用 Redlock?

前面也分析了,Redlock 只有創建在「時鐘正確」的前提下,才能正常工做,若是你能夠保證這個前提,那麼能夠拿來使用。

但保證時鐘正確,我認爲並非你想的那麼簡單就能作到的。

第一,從硬件角度來講,時鐘發生偏移是時有發生,沒法避免的。

例如,CPU 溫度、機器負載、芯片材料都是有可能致使時鐘發生偏移。

第二,從個人工做經從來說,曾經就遇到過期鍾錯誤、運維暴力修改時鐘的狀況發生,進而影響了系統的正確性,因此,人爲錯誤也是很難徹底避免的。

因此,我對 Redlock 的我的見解是,儘可能不用它,並且它的性能不如單機版 Redis,部署成本也高,我仍是會優先考慮使用 Redis「主從+哨兵」的模式,實現分佈式鎖。

那正確性如何保證呢?第二點給你答案。

2) 如何正確使用分佈式鎖?

在分析 Martin 觀點時,它提到了 fecing token 的方案,給我了很大的啓發,雖然這種方案有很大的侷限性,但對於保證「正確性」的場景,是一個很是好的思路。

因此,咱們能夠把這二者結合起來用:

一、使用分佈式鎖,在上層完成「互斥」目的,雖然極端狀況下鎖會失效,但它能夠最大程度把併發請求阻擋在最上層,減輕操做資源層的壓力。

二、但對於要求數據絕對正確的業務,在資源層必定要作好「兜底」,設計思路能夠借鑑 fecing token 的方案來作。

兩種思路結合,我認爲對於大多數業務場景,已經能夠知足要求了。

總結

好了,總結一下。

這篇文章,咱們主要探討了基於 Redis 實現的分佈式鎖,到底是否安全這個問題。

從最簡單分佈式鎖的實現,處處理各類異常場景,再到引出 Redlock,以及兩個分佈式專家的辯論,得出了 Redlock 的適用場景。

最後,咱們還對比了 Zookeeper 在作分佈式鎖時,可能會遇到的問題,以及與 Redis 的差別。

這裏我把這些內容總結成了思惟導圖,方便你理解。

後記

這篇文章的信息量實際上是很是大的,我以爲應該把分佈鎖的問題,完全講清楚了。

若是你沒有理解,我建議你多讀幾遍,並在腦海中構建各類假定的場景,反覆思辨。

在寫這篇文章時,我又從新研讀了兩位大神關於 Redlock 爭辯的這兩篇文章,可謂是是收穫滿滿,在這裏也分享一些心得給你。

一、在分佈式系統環境下,看似完美的設計方案,可能並非那麼「嚴絲合縫」,若是稍加推敲,就會發現各類問題。因此,在思考分佈式系統問題時,必定要謹慎再謹慎

二、從 Redlock 的爭辯中,咱們不要過多關注對錯,而是要多學習大神的思考方式,以及對一個問題嚴格審查的嚴謹精神。

最後,用 Martin 在對於 Redlock 爭論事後,寫下的感悟來結尾:

前人已經爲咱們創造出了許多偉大的成果:站在巨人的肩膀上,咱們能夠才得以構建更好的軟件。不管如何,經過爭論和檢查它們是否經得起別人的詳細審查,這是學習過程的一部分。但目標應該是獲取知識,而不是爲了說服別人,讓別人相信你是對的。有時候,那只是意味着停下來,好好地想想。

共勉。


qr_block.jpg

想看更多硬核技術文章?歡迎關注個人公衆號「水滴與銀彈」。

我是 Kaito,是一個對於技術有思考的資深後端程序員,在個人文章中,我不只會告訴你一個技術點是什麼,還會告訴你爲何這麼作?我還會嘗試把這些思考過程,提煉成通用的方法論,讓你能夠應用在其它領域中,作到觸類旁通。


參考文獻:

相關文章
相關標籤/搜索