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

閱讀本文大約需要 20 分鐘。

大家好,我是 Kaito。

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

Redis 分佈式鎖的話題,很多文章已經寫爛了,我爲什麼還要寫這篇文章呢?

因爲我發現網上 99% 的文章,並沒有把這個問題真正講清楚。導致很多讀者看了很多文章,依舊雲裏霧裏。例如下面這些問題,你能清晰地回答上來嗎?

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

這篇文章,我就來把這些問題徹底講清楚。

讀完這篇文章,你不僅可以徹底瞭解分佈式鎖,還會對「分佈式系統」有更加深刻的理解。

文章有點長,但乾貨很多,希望你可以耐心讀完。

爲什麼需要分佈式鎖?

在開始講分佈式鎖之前,有必要簡單介紹一下,爲什麼需要分佈式鎖?

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

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

例如,現在的業務應用通常都是微服務架構,這也意味着一個應用會部署多個進程,那這多個進程如果需要修改 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 的結論:

1、Redlock 不倫不類:它對於效率來講,Redlock 比較重,沒必要這麼做,而對於正確性來說,Redlock 是不夠安全的。

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

3、無法保證正確性: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 作者對於對方提出的,網絡延遲、進程 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 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 作者的結論:

1、作者同意對方關於「時鐘跳躍」對 Redlock 的影響,但認爲時鐘跳躍是可以避免的,取決於基礎設施和運維。

2、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,部署成本也高,我還是會優先考慮使用主從+ 哨兵的模式 實現分佈式鎖。

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

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

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

所以,我們可以把這兩者結合起來用:

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

2、但對於要求數據絕對正確的業務,在資源層一定要做好「兜底」,設計思路可以借鑑 fecing token 的方案來做。

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

總結

好了,總結一下。

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

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

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

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

後記

這篇文章的信息量其實是非常大的,我覺得應該把分佈鎖的問題,徹底講清楚了。

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

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

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

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

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

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

共勉。

本文分享自微信公衆號 - Java識堂(erlieStar)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章