深度剖析: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 拿到鎖後,如果發生下面的場景,就會造成「死鎖」:
程式處理業務邏輯異常,沒及時釋放鎖 程式掛了,沒機會釋放鎖
這時,這個客戶端就會一直佔用這個鎖,而其它客戶端就「永遠」拿不到這把鎖了。
怎麼解決這個問題呢?
如何避免死鎖?
我們很容易想到的方案是,在申請鎖時,給這把鎖設定一個「租期」。
在 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 條命令,有沒有可能只執行了第一條,第二條卻「來不及」執行的情況發生呢?例如:
SETNX 執行成功,執行 EXPIRE 時由於網路問題,執行失敗 SETNX 執行成功,Redis 異常當機,EXPIRE 沒有機會執行 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 操作共享資源完成,釋放鎖(但釋放的是客戶端 2 的鎖)
看到了麼,這裡存在兩個嚴重的問題:
鎖過期:客戶端 1 操作共享資源耗時太久,導致鎖被自動釋放,之後被客戶端 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 執行 GET,判斷鎖是自己的 客戶端 2 執行了 SET 命令,強制獲取到鎖(雖然發生機率比較低,但我們需要嚴謹地考慮鎖的安全性模型) 客戶端 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 實現的分散式鎖,一個嚴謹的的流程如下:
加鎖:SET lock_key $unique_id EX $expire_time NX 操作共享資源 釋放鎖:Lua 指令碼,先 GET 判斷鎖是否歸屬自己,再 DEL 釋放鎖
好,有了這個完整的鎖模型,讓我們重新回到前面提到的第一個問題。
鎖過期時間不好評估怎麼辦?
鎖過期時間不好評估怎麼辦?
前面我們提到,鎖的過期時間如果評估不好,這個鎖就會有「提前」過期的風險。
當時給的妥協方案是,儘量「冗餘」過期時間,降低鎖提前過期的機率。
這個方案其實也不能完美解決問題,那怎麼辦呢?
是否可以設計這樣的方案:加鎖時,先設定一個過期時間,然後我們開啟一個「守護執行緒」,定時去檢測這個鎖的失效時間,如果鎖快要過期了,操作共享資源還未完成,那麼就自動對鎖進行「續期」,重新設定過期時間。
這確實一種比較好的方案。
如果你是 Java 技術棧,幸運的是,已經有一個庫把這些工作都封裝好了:Redisson。
Redisson 是一個 Java 語言實現的 Redis SDK 客戶端,在使用分散式鎖時,它就採用了「自動續期」的方案來避免鎖過期,這個守護執行緒我們一般也把它叫做「看門狗」執行緒。
除此之外,這個 SDK 還封裝了很多易用的功能:
可重入鎖 樂觀鎖 公平鎖 讀寫鎖 Redlock(紅鎖,下面會詳細講)
這個 SDK 提供的 API 非常友好,它可以像操作本地鎖的方式,操作分散式鎖。如果你是 Java 技術棧,可以直接把它用起來。
這裡不重點介紹 Redisson 的使用,大家可以看官方 Github 學習如何使用,比較簡單。
到這裡我們再小結一下,基於 Redis 的實現分散式鎖,前面遇到的問題,以及對應的解決方案:
死鎖:設定過期時間 過期時間評估不好,鎖提前過期:守護執行緒,自動續期 鎖被別人釋放:鎖寫入唯一標識,釋放鎖先檢查標識,再釋放
還有哪些問題場景,會危害 Redis 鎖的安全性呢?
之前分析的場景都是,鎖在「單個」Redis 例項中可能產生的問題,並沒有涉及到 Redis 的部署架構細節。
而我們在使用 Redis 時,一般會採用主從叢集 + 哨兵的模式部署,這樣做的好處在於,當主庫異常當機時,哨兵可以實現「故障自動切換」,把從庫提升為主庫,繼續提供服務,以此保證可用性。
那當「主從發生切換」時,這個分佈鎖會依舊安全嗎?
試想這樣的場景:
客戶端 1 在主庫上執行 SET 命令,加鎖成功 此時,主庫異常當機,SET 命令還未同步到從庫上(主從複製是非同步的) 從庫被哨兵提升為新主庫,這個鎖在新的主庫上,丟失了!
可見,當引入 Redis 副本後,分佈鎖還是可能會受到影響。
怎麼解決這個問題?
為此,Redis 的作者提出一種解決方案,就是我們經常聽到的 Redlock(紅鎖)。
它真的可以解決上面這個問題嗎?
Redlock 真的安全嗎?
好,終於到了這篇文章的重頭戲。啊?上面講的那麼多問題,難道只是基礎?
是的,那些只是開胃菜,真正的硬菜,從這裡剛剛開始。
如果上面講的內容,你還沒有理解,我建議你重新閱讀一遍,先理清整個加鎖、解鎖的基本流程。
如果你已經對 Redlock 有所瞭解,這裡可以跟著我再複習一遍,如果你不瞭解 Redlock,沒關係,我會帶你重新認識它。
值得提醒你的是,後面我不僅僅是講 Redlock 的原理,還會引出有關「分散式系統」中的很多問題,你最好跟緊我的思路,在腦中一起分析問題的答案。
現在我們來看,Redis 作者提出的 Redlock 方案,是如何解決主從切換後,鎖失效問題的。
Redlock 的方案基於 2 個前提:
不再需要部署從庫和哨兵例項,只部署主庫 但主庫要部署多個,官方推薦至少 5 個例項
也就是說,想用使用 Redlock,你至少要部署 5 個 Redis 例項,而且都是主庫,它們之間沒有任何關係,都是一個個孤立的例項。
注意:不是部署 Redis Cluster,就是部署 5 個簡單的 Redis 例項。
Redlock 具體如何使用呢?
整體的流程是這樣的,一共分為 5 步:
客戶端先獲取「當前時間戳T1」 客戶端依次向這 5 個 Redis 例項發起加鎖請求(用前面講到的 SET 命令),且每個請求會設定超時時間(毫秒級,要遠小於鎖的有效時間),如果某一個例項加鎖失敗(包括網路超時、鎖被其它人持有等各種異常情況),就立即向下一個 Redis 例項申請加鎖 如果客戶端從 >=3 個(大多數)以上 Redis 例項加鎖成功,則再次獲取「當前時間戳T2」,如果 T2 - T1 < 鎖的過期時間,此時,認為客戶端加鎖成功,否則認為加鎖失敗 加鎖成功,去操作共享資源(例如修改 MySQL 某一行,或發起一個 API 請求) 加鎖失敗,向「全部節點」發起釋放鎖請求(前面講到的 Lua 指令碼釋放鎖)
我簡單幫你總結一下,有 4 個重點:
客戶端在多個 Redis 例項上申請加鎖 必須保證大多數節點加鎖成功 大多數節點加鎖的總耗時,要小於鎖設定的過期時間 釋放鎖,要向全部節點發起釋放鎖請求
第一次看可能不太容易理解,建議你把上面的文字多看幾遍,加深記憶。
然後,記住這 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 請求鎖定節點 A、B、C、D、E 客戶端 1 的拿到鎖後,進入 GC(時間比較久) 所有 Redis 節點上的鎖都過期了 客戶端 2 獲取到了 A、B、C、D、E 上的鎖 客戶端 1 GC 結束,認為成功獲取鎖 客戶端 2 也認為獲取到了鎖,發生「衝突」
Martin 認為,GC 可能發生在程式的任意時刻,而且執行時間是不可控的。
注:當然,即使是使用沒有 GC 的程式語言,在發生網路延遲、時鐘漂移時,也都有可能導致 Redlock 出現問題,這裡 Martin 只是拿 GC 舉例。
3) 假設時鐘正確的是不合理的
又或者,當多個 Redis 節點「時鐘」發生問題時,也會導致 Redlock 鎖失效。
客戶端 1 獲取節點 A、B、C 上的鎖,但由於網路問題,無法訪問 D 和 E 節點 C 上的時鐘「向前跳躍」,導致鎖到期 客戶端 2 獲取節點 C、D、E 上的鎖,由於網路問題,無法訪問 A 和 B 客戶端 1 和 2 現在都相信它們持有了鎖(衝突)
Martin 覺得,Redlock 必須「強依賴」多個節點的時鐘是保持同步的,一旦有節點時鐘發生錯誤,那這個演算法模型就失效了。
即使 C 不是時鐘跳躍,而是「崩潰後立即重啟」,也會發生類似的問題。
Martin 繼續闡述,機器的時鐘發生錯誤,是很有可能發生的:
系統管理員「手動修改」了機器時鐘 機器時鐘在同步 NTP 時間時,發生了大的「跳躍」
總之,Martin 認為,Redlock 的演算法是建立在「同步模型」基礎上的,有大量資料研究表明,同步模型的假設,在分散式系統中是有問題的。
在混亂的分散式系統的中,你不能假設系統時鐘就是對的,所以,你必須非常小心你的假設。
4) 提出 fecing token 的方案,保證正確性
相對應的,Martin 提出一種被叫作 fecing token 的方案,保證分散式鎖的正確性。
這個模型流程如下:
客戶端在獲取鎖時,鎖服務可以提供一個「遞增」的 token 客戶端拿著這個 token 去操作共享資源 共享資源可以根據 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 作者反駁到:
手動修改時鐘:不要這麼做就好了,否則你直接修改 Raft 日誌,那 Raft 也會無法工作... 時鐘跳躍:透過「恰當的運維」,保證機器時鐘不會大幅度跳躍(每次透過微小的調整來完成),實際上這是可以做到的
為什麼 Redis 作者優先解釋時鐘問題?因為在後面的反駁過程中,需要依賴這個基礎做進一步解釋。
2) 解釋網路延遲、GC 問題
之後,Redis 作者對於對方提出的,網路延遲、程式 GC 可能導致 Redlock 失效的問題,也做了反駁:
我們重新回顧一下,Martin 提出的問題假設:
客戶端 1 請求鎖定節點 A、B、C、D、E 客戶端 1 的拿到鎖後,進入 GC 所有 Redis 節點上的鎖都過期了 客戶端 2 獲取節點 A、B、C、D、E 上的鎖 客戶端 1 GC 結束,認為成功獲取鎖 客戶端 2 也認為獲取到鎖,發生「衝突」
Redis 作者反駁到,這個假設其實是有問題的,Redlock 是可以保證鎖安全的。
這是怎麼回事呢?
還記得前面介紹 Redlock 流程的那 5 步嗎?這裡我再拿過來讓你複習一下。
客戶端先獲取「當前時間戳T1」 客戶端依次向這 5 個 Redis 例項發起加鎖請求(用前面講到的 SET 命令),且每個請求會設定超時時間(毫秒級,要遠小於鎖的有效時間),如果某一個例項加鎖失敗(包括網路超時、鎖被其它人持有等各種異常情況),就立即向下一個 Redis 例項申請加鎖 如果客戶端從 3 個(大多數)以上 Redis 例項加鎖成功,則再次獲取「當前時間戳T2」,如果 T2 - T1 < 鎖的過期時間,此時,認為客戶端加鎖成功,否則認為加鎖失敗 加鎖成功,去操作共享資源(例如修改 MySQL 某一行,或發起一個 API 請求) 加鎖失敗,向「全部節點」發起釋放鎖請求(前面講到的 Lua 指令碼釋放鎖)
注意,重點是 1-3,在步驟 3,加鎖成功後為什麼要重新獲取「當前時間戳T2」?還用 T2 - T1 的時間,與鎖的過期時間做比較?
Redis 作者強調:如果在 1-3 發生了網路延遲、程式 GC 等耗時長的異常情況,那在第 3 步 T2 - T1,是可以檢測出來的,如果超出了鎖設定的過期時間,那這時就認為加鎖會失敗,之後釋放所有節點的鎖就好了!
Redis 作者繼續論述,如果對方認為,發生網路延遲、程式 GC 是在步驟 3 之後,也就是客戶端確認拿到了鎖,去操作共享資源的途中發生了問題,導致鎖失效,那這不止是 Redlock 的問題,任何其它鎖服務例如 Zookeeper,都有類似的問題,這不在討論範疇內。
這裡我舉個例子解釋一下這個問題:
客戶端透過 Redlock 成功獲取到鎖(透過了大多數節點加鎖成功、加鎖耗時檢查邏輯) 客戶端開始操作共享資源,此時發生網路延遲、程式 GC 等耗時很長的情況 此時,鎖過期自動釋放 客戶端開始操作 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 類似的功能,但卻沒有展開相關細節,根據我查閱的資料,大概流程應該如下,如有錯誤,歡迎交流~
客戶端使用 Redlock 拿到鎖 客戶端在操作共享資源之前,先把這個鎖的 VALUE,在要操作的共享資源上做標記 客戶端處理業務邏輯,最後,在修改共享資源時,判斷這個標記是否與之前一樣,一樣才修改(類似 CAS 的思路)
還是以 MySQL 為例,舉個例子就是這樣的:
客戶端使用 Redlock 拿到鎖 客戶端要修改 MySQL 表中的某一行資料之前,先把鎖的 VALUE 更新到這一行的某個欄位中(這裡假設為 current_token 欄位) 客戶端處理業務邏輯 客戶端修改 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 和 2 都嘗試建立「臨時節點」,例如 /lock 假設客戶端 1 先到達,則加鎖成功,客戶端 2 加鎖失敗 客戶端 1 操作共享資源 客戶端 1 刪除 /lock 節點,釋放鎖
你應該也看到了,Zookeeper 不像 Redis 那樣,需要考慮鎖的過期時間問題,它是採用了「臨時節點」,保證客戶端 1 拿到鎖後,只要連線不斷,就可以一直持有鎖。
而且,如果客戶端 1 異常崩潰了,那麼這個臨時節點會自動刪除,保證了鎖一定會被釋放。
不錯,沒有鎖過期的煩惱,還能在異常時自動釋放鎖,是不是覺得很完美?
其實不然。
思考一下,客戶端 1 建立臨時節點後,Zookeeper 是如何保證讓這個客戶端一直持有鎖呢?
原因就在於,客戶端 1 此時會與 Zookeeper 伺服器維護一個 Session,這個 Session 會依賴客戶端「定時心跳」來維持連線。
如果 Zookeeper 長時間收不到客戶端的心跳,就認為這個 Session 過期了,也會把這個臨時節點刪除。
同樣地,基於此問題,我們也討論一下 GC 問題對 Zookeeper 的鎖有何影響:
客戶端 1 建立臨時節點 /lock 成功,拿到了鎖 客戶端 1 發生長時間 GC 客戶端 1 無法給 Zookeeper 傳送心跳,Zookeeper 把臨時節點「刪除」 客戶端 2 建立臨時節點 /lock 成功,拿到了鎖 客戶端 1 GC 結束,它仍然認為自己持有鎖(衝突)
可見,即使是使用 Zookeeper,也無法保證程式 GC、網路延遲異常場景下的安全性。
這就是前面 Redis 作者在反駁的文章中提到的:如果客戶端已經拿到了鎖,但客戶端與鎖伺服器發生「失聯」(例如 GC),那不止 Redlock 有問題,其它鎖服務都有類似的問題,Zookeeper 也是一樣!
所以,這裡我們就能得出結論了:一個分散式鎖,在極端情況下,不一定是安全的。
如果你的業務資料非常敏感,在使用分散式鎖時,一定要注意這個問題,不能假設分散式鎖 100% 安全。
好,現在我們來總結一下 Zookeeper 在使用分散式鎖時優劣:
Zookeeper 的優點:
不需要考慮鎖的過期時間 watch 機制,加鎖失敗,可以 watch 等待鎖釋放,實現樂觀鎖
但它的劣勢是:
效能不如 Redis 部署和運維成本高 客戶端與 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 爭論過後,寫下的感悟來結尾:
“前人已經為我們創造出了許多偉大的成果:站在巨人的肩膀上,我們可以才得以構建更好的軟體。無論如何,透過爭論和檢查它們是否經得起別人的詳細審查,這是學習過程的一部分。但目標應該是獲取知識,而不是為了說服別人,讓別人相信你是對的。有時候,那只是意味著停下來,好好地想一想。”
共勉。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2927677/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 基於 Redis 的分散式鎖到底安全嗎?Redis分散式
- [轉載]基於Redis的分散式鎖到底安全嗎?Redis分散式
- 【高併發】Redis如何助力高併發秒殺系統,看完這篇我徹底懂了!!Redis
- 共享WiFi專案到底怎麼做比較好?看完這篇文章就懂了!WiFi
- 【高併發】億級流量場景下如何實現分散式限流?看完我徹底懂了!!(文末有福利)分散式
- 手撕redis分散式鎖,隔壁張小帥都看懂了!Redis分散式
- 看完這篇文章,我奶奶都懂了https的原理HTTP
- 再談分散式鎖之剖析Redis實現分散式Redis
- Linux零拷貝技術,看完這篇文章就懂了Linux
- 搞懂“分散式鎖”,看這篇文章就對了分散式
- 雲原生技術是什麼?看完這篇文章你就懂了
- 十九、Redis分散式鎖、Zookeeper分散式鎖Redis分散式
- Redis分散式鎖這樣用,有坑?Redis分散式
- 分散式鎖-Redis分散式Redis
- Redis 分散式鎖Redis分散式
- Redis分散式鎖Redis分散式
- redisson分散式鎖原理剖析Redis分散式
- 敏捷與安全不可兼得嗎?看完這篇文章後,我想說:未必!敏捷
- 不就是分散式事務,這下徹底清楚了?分散式
- 再有人問你分散式鎖,這篇文章扔給他分散式
- 面試官:你真的瞭解Redis分散式鎖嗎?面試Redis分散式
- Redis 分散式鎖(一)Redis分散式
- Redis分散式鎖解析Redis分散式
- redis系列:分散式鎖Redis分散式
- redis分散式鎖-可重入鎖Redis分散式
- Redis分散式鎖加鎖案例Redis分散式
- 運維到底是幹什麼的?看完這篇你就懂了運維
- 深度剖析分散式事務效能分散式
- 細說Redis分散式鎖?Redis分散式
- Redis分散式鎖實戰Redis分散式
- 分散式鎖----Redis實現分散式Redis
- Redis 應用-分散式鎖Redis分散式
- Redis實現分散式鎖Redis分散式
- 基於 Redis 分散式鎖Redis分散式
- 詳解Redis分散式鎖Redis分散式
- 【Redis】利用 Redis 實現分散式鎖Redis分散式
- 深度剖析Saga分散式事務分散式
- 徹底理解SpringIOC、DI-這篇文章就夠了Spring