前言
近兩年來微服務變得越來越熱門,越來越多的應用部署在分布式環境中,在分布式環境中,資料一緻性是一直以來需要關注并且去解決的問題,分布式鎖也就成為了一種廣泛使用的技術,常用的分布式實作方式為Redis,Zookeeper,其中基于Redis的分布式鎖的使用更加廣泛。
但是在工作和網絡上看到過各個版本的Redis分布式鎖實作,每種實作都有一些不嚴謹的地方,甚至有可能是錯誤的實作,包括在代碼中,如果不能正确的使用分布式鎖,可能造成嚴重的生産環境故障,本文主要對目前遇到的各種分布式鎖以及其缺陷做了一個整理,并對如何選擇合适的Redis分布式鎖給出建議。

各個版本的Redis分布式鎖
V1.0
tryLock(){
SETNX Key 1
EXPIRE Key Seconds
}
release(){
DELETE Key
}
這個版本應該是最簡單的版本,也是出現頻率很高的一個版本,首先給鎖加一個過期時間操作是為了避免應用在服務重新開機或者異常導緻鎖無法釋放後,不會出現鎖一直無法被釋放的情況。
這個方案的一個問題在于每次送出一個Redis請求,如果執行完第一條指令後應用異常或者重新開機,鎖将無法過期,一種改善方案就是使用Lua腳本(包含SETNX和EXPIRE兩條指令),但是如果Redis僅執行了一條指令後crash或者發生主從切換,依然會出現鎖沒有過期時間,最終導緻無法釋放。
另外一個問題在于,很多同學在釋放分布式鎖的過程中,無論鎖是否擷取成功,都在finally中釋放鎖,這樣是一個鎖的錯誤使用,這個問題将在後續的V3.0版本中解決。
針對鎖無法釋放問題的一個解決方案基于GETSET指令來實作
V1.1 基于GETSET
tryLock(){
NewExpireTime=CurrentTimestamp+ExpireSeconds
if(SETNX Key NewExpireTime Seconds){
oldExpireTime = GET(Key)
if( oldExpireTime < CurrentTimestamp){
NewExpireTime=CurrentTimestamp+ExpireSeconds
CurrentExpireTime=GETSET(Key,NewExpireTime)
if(CurrentExpireTime == oldExpireTime){
return 1;
}else{
return 0;
}
}
}
}
release(){
DELETE key
}
思路:
(1)SETNX(Key,ExpireTime)擷取鎖
(2)如果擷取鎖失敗,通過GET(Key)傳回的時間戳檢查鎖是否已經過期
(3)GETSET(Key,ExpireTime)修改Value為NewExpireTime
(4)檢查GETSET傳回的舊值,如果等于GET傳回的值,則認為擷取鎖成功
注意:這個版本去掉了EXPIRE指令,改為通過Value時間戳值來判斷過期
問題:
(1)在鎖競争較高的情況下,會出現Value不斷被覆寫,但是沒有一個Client擷取到鎖
(2)在擷取鎖的過程中不斷的修改原有鎖的資料,設想一種場景C1,C2競争鎖,C1擷取到了鎖,C2鎖執行了GETSET操作修改了C1鎖的過期時間,如果C1沒有正确釋放鎖,鎖的過期時間被延長,其它Client需要等待更久的時間
V2.0 基于SETNX
tryLock(){
SETNX Key 1 Seconds
}
release(){
DELETE Key
}
Redis 2.6.12版本後SETNX增加過期時間參數,這樣就解決了兩條指令無法保證原子性的問題。但是設想下面一個場景:
(1)C1成功擷取到了鎖,之後C1因為GC進入等待或者未知原因導緻任務執行過長,最後在鎖失效前C1沒有主動釋放鎖
(2)C2在C1的鎖逾時後擷取到鎖,并且開始執行,這個時候C1和C2都同時在執行,會因重複執行造成資料不一緻等未知情況
(3)C1如果先執行完畢,則會釋放C2的鎖,此時可能導緻另外一個C3程序擷取到了鎖
大緻的流程圖
存在問題:
(1)由于C1的停頓導緻C1 和C2同都獲得了鎖并且同時在執行,在業務實作間接要求必須保證幂等性
(2)C1釋放了不屬于C1的鎖
V3.0
tryLock(){
SETNX Key UnixTimestamp Seconds
}
release(){
EVAL(
//LuaScript
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
)
}
這個方案通過指定Value為時間戳,并在釋放鎖的時候檢查鎖的Value是否為擷取鎖的Value,避免了V2.0版本中提到的C1釋放了C2持有的鎖的問題;另外在釋放鎖的時候因為涉及到多個Redis操作,并且考慮到Check And Set 模型的并發問題,是以使用Lua腳本來避免并發問題。
如果在并發極高的場景下,比如搶紅包場景,可能存在UnixTimestamp重複問題,另外由于不能保證分布式環境下的實體時鐘一緻性,也可能存在UnixTimestamp重複問題,隻不過極少情況下會遇到。
V3.1
tryLock(){
SET Key UniqId Seconds
}
release(){
EVAL(
//LuaScript
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
)
}
Redis 2.6.12後SET同樣提供了一個NX參數,等同于SETNX指令,官方文檔上提醒後面的版本有可能去掉SETNX, SETEX, PSETEX,并用SET指令代替,另外一個優化是使用一個自增的唯一UniqId代替時間戳來規避V3.0提到的時鐘問題。
這個方案是目前最優的分布式鎖方案,但是如果在Redis叢集環境下依然存在問題:
由于Redis叢集資料同步為異步,假設在Master節點擷取到鎖後未完成資料同步情況下Master節點crash,此時在新的Master節點依然可以擷取鎖,是以多個Client同時擷取到了鎖
分布式Redis鎖:Redlock
V3.1的版本僅在單執行個體的場景下是安全的,針對如何實作分布式Redis的鎖,國外的分布式專家有過激烈的讨論, antirez提出了分布式鎖算法Redlock,在distlock話題下可以看到對Redlock的詳細說明,下面是Redlock算法的一個中文說明(引用)
假設有N個獨立的Redis節點
1、擷取目前時間(毫秒數)。
2、按順序依次向N個Redis節點執行擷取鎖的操作。這個擷取操作跟前面基于單Redis節點的擷取鎖的過程相同,包含随機字元串my_random_value,也包含過期時間(比如PX 30000,即鎖的有效時間)。
為了保證在某個Redis節點不可用的時候算法能夠繼續運作,這個擷取鎖的操作還有一個逾時時間(time out),它要遠小于鎖的有效時間(幾十毫秒量級)。用戶端在向某個Redis節點擷取鎖失敗以後,應該立即嘗試下一個Redis節點。
這裡的失敗,應該包含任何類型的失敗,比如該Redis節點不可用,或者該Redis節點上的鎖已經被其它用戶端持有(注:Redlock原文中這裡隻提到了Redis節點不可用的情況,但也應該包含其它的失敗情況)。
3、計算整個擷取鎖的過程總共消耗了多長時間,計算方法是用目前時間減去第1步記錄的時間。如果用戶端從大多數Redis節點(>= N/2+1)成功擷取到了鎖,并且擷取鎖總共消耗的時間沒有超過鎖的有效時間(lock validity time),那麼這時用戶端才認為最終擷取鎖成功;否則,認為最終擷取鎖失敗。
4、如果最終擷取鎖成功了,那麼這個鎖的有效時間應該重新計算,它等于最初的鎖的有效時間減去第3步計算出來的擷取鎖消耗的時間。
5、如果最終擷取鎖失敗了(可能由于擷取到鎖的Redis節點個數少于N/2+1,或者整個擷取鎖的過程消耗的時間超過了鎖的最初有效時間),那麼用戶端應該立即向所有Redis節點發起釋放鎖的操作(即前面介紹的Redis Lua腳本)。
6、釋放鎖:對所有的Redis節點發起釋放鎖操作
然而Martin Kleppmann針對這個算法提出了質疑,提出應該基于fencing token機制(每次對資源進行操作都需要進行token驗證)
(1)Redlock在系統模型上尤其是在分布式時鐘一緻性問題上提出了假設,實際場景下存在時鐘不一緻和時鐘跳躍問題,而Redlock恰恰是基于timing的分布式鎖
(2)另外Redlock由于是基于自動過期機制,依然沒有解決長時間的gc pause等問題帶來的鎖自動失效,進而帶來的安全性問題。
接着antirez又回複了Martin Kleppmann的質疑,給出了過期機制的合理性,以及實際場景中如果出現停頓問題導緻多個Client同時通路資源的情況下如何處理。
針對Redlock的問題,基于Redis的分布式鎖到底安全嗎給出了詳細的中文說明,并對Redlock算法存在的問題提出了分析。歡迎大家關注我的公種浩【程式員追風】,整理了1000道2019年多家公司java面試題400多頁pdf文檔,文章都會在裡面更新,整理的資料也會放在裡面。
總結
不論是基于SETNX版本的Redis單執行個體分布式鎖,還是Redlock分布式鎖,都是為了保證以下特性
(1)安全性:在同一時間不允許多個Client同時持有鎖
(2)活性
死鎖:鎖最終應該能夠被釋放,即使Client端crash或者出現網絡分區(通常基于逾時機制)
容錯性:隻要超過半數Redis節點可用,鎖都能被正确擷取和釋放
是以在開發或者使用分布式鎖的過程中要保證安全性和活性,避免出現不可預測的結果。
另外每個版本的分布式鎖都存在一些問題,在鎖的使用上要針對鎖的實用場景選擇合适的鎖,通常情況下鎖的使用場景包括:
(1)Efficiency(效率):隻需要一個Client來完成操作,不需要重複執行,這是一個對寬松的分布式鎖,隻需要保證鎖的活性即可;
(2)Correctness(正确性):多個Client保證嚴格的互斥性,不允許出現同時持有鎖或者對同時操作同一資源,這種場景下需要在鎖的選擇和使用上更加嚴格,同時在業務代碼上盡量做到幂等
在Redis分布式鎖的實作上還有很多問題等待解決,我們需要認識到這些問題并清楚如何正确實作一個Redis 分布式鎖,然後在工作中合理的選擇和正确的使用分布式鎖。
Redisson分布式鎖實作原理的總結了一張圖
最後
歡迎大家一起交流,喜歡文章記得關注我點贊轉發喲,感謝支援!