1. 程式人生 > 其它 >對不起,網上找的Redis分散式鎖都有漏洞!

對不起,網上找的Redis分散式鎖都有漏洞!

基於 Redis 的分散式鎖對大家來說並不陌生,可是你的分散式鎖有失敗的時候嗎?在失敗的時候可曾懷疑過你在用的分散式鎖真的靠譜嗎?以下是結合自己的踩坑經驗總結的一些經驗之談。

圖片來自 Pexels

你真的需要分散式鎖嗎?


用到分散式鎖說明遇到了多個程序共同訪問同一個資源的問題。
一般是在兩個場景下會防止對同一個資源的重複訪問:

  • 提高效率。比如多個節點計算同一批任務,如果某個任務已經有節點在計算了,那其他節點就不用重複計算了,以免浪費計算資源。不過重複計算也沒事,不會造成其他更大的損失。也就是允許偶爾的失敗。

  • 保證正確性。這種情況對鎖的要求就很高了,如果重複計算,會對正確性造成影響。這種不允許失敗。

引入分散式鎖勢必要引入一個第三方的基礎設施,比如 MySQL,Redis,Zookeeper 等。
這些實現分散式鎖的基礎設施出問題了,也會影響業務,所以在使用分散式鎖前可以考慮下是否可以不用加鎖的方式實現?
不過這個不在本文的討論範圍內,本文假設加鎖的需求是合理的,並且偏向於上面的第二種情況,為什麼是偏向?因為不存在 100% 靠譜的分散式鎖,看完下面的內容就明白了。

從一個簡單的分散式鎖實現說起


分散式鎖的 Redis 實現很常見,自己實現和使用第三方庫都很簡單,至少看上去是這樣的,這裡就介紹一個最簡單靠譜的 Redis 實現。

最簡單的實現


實現很經典了,這裡只提兩個要點:

  • 加鎖和解鎖的鎖必須是同一個,常見的解決方案是給每個鎖一個鑰匙(唯一 ID),加鎖時生成,解鎖時判斷。

  • 不能讓一個資源永久加鎖。常見的解決方案是給一個鎖的過期時間。當然了還有其他方案,後面再說。

一個可複製貼上的實現方式如下:
加鎖:

publicstaticbooleantryLock(Stringkey,StringuniqueId,intseconds){
return"OK".equals(jedis.set(key,uniqueId,"NX","EX",seconds));
}


這裡呼叫了 SET key value PX milliseoncds NX,不明白這個命令的可以參考 SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]:

https://redis.io/commands/set


解鎖:

publicstaticbooleanreleaseLock(Stringkey,StringuniqueId){
StringluaScript="ifredis.call('get',KEYS[1])==ARGV[1]then"+
"returnredis.call('del',KEYS[1])elsereturn0end";
returnjedis.eval(
luaScript,
Collections.singletonList(key),
Collections.singletonList(uniqueId)
).equals(1L);
}


這段實現的精髓在那個簡單的 Lua 指令碼上,先判斷唯一 ID 是否相等再操作。

靠譜嗎?


這樣的實現有什麼問題呢?

  • 單點問題。上面的實現只要一個 Master 節點就能搞定,這裡的單點指的是單 Master,就算是個叢集,如果加鎖成功後,鎖從 Master 複製到 Slave 的時候掛了,也是會出現同一資源被多個 Client 加鎖的。

  • 執行時間超過了鎖的過期時間。上面寫到為了不出現一直上鎖的情況,加了一個兜底的過期時間,時間到了鎖自動釋放,但是,如果在這期間任務並沒有做完怎麼辦?由於 GC 或者網路延遲導致的任務時間變長,很難保證任務一定能在鎖的過期時間內完成。

如何解決這兩個問題呢?試試看更復雜的實現吧。

Redlock演算法


對於第一個單點問題,順著 Redis 的思路,接下來想到的肯定是 Redlock 了。
Redlock 為了解決單機的問題,需要多個(大於 2)Redis 的 Master 節點,多個 Master 節點互相獨立,沒有資料同步。
Redlock 的實現如下:
①獲取當前時間。
②依次獲取 N 個節點的鎖。每個節點加鎖的實現方式同上。這裡有個細節,就是每次獲取鎖的時候的過期時間都不同,需要減去之前獲取鎖的操作的耗時,
比如傳入的鎖的過期時間為 500ms,獲取第一個節點的鎖花了 1ms,那麼第一個節點的鎖的過期時間就是 499ms;獲取第二個節點的鎖花了 2ms,那麼第二個節點的鎖的過期時間就是 497ms。
如果鎖的過期時間小於等於 0 了,說明整個獲取鎖的操作超時了,整個操作失敗。
③判斷是否獲取鎖成功。如果 Client 在上述步驟中獲取到了(N/2+1)個節點鎖,並且每個鎖的過期時間都是大於 0 的,則獲取鎖成功,否則失敗。失敗時釋放鎖。
④釋放鎖。對所有節點發送釋放鎖的指令,每個節點的實現邏輯和上面的簡單實現一樣。
為什麼要對所有節點操作?因為分散式場景下從一個節點獲取鎖失敗不代表在那個節點上加速失敗,可能實際上加鎖已經成功了,但是返回時因為網路抖動超時了。
以上就是大家常見的 Redlock 實現的描述了,一眼看上去就是簡單版本的多 Master 版本,如果真是這樣就太簡單了,接下來分析下這個演算法在各個場景下是怎樣被玩壞的。

分散式鎖的坑

高併發場景下的問題


以下問題不是說在併發不高的場景下不容易出現,只是在高併發場景下出現的概率更高些而已。
效能問題來自於以下兩方面:
①獲取鎖的時間上。如果 Redlock 運用在高併發的場景下,存在 N 個 Master 節點,一個一個去請求,耗時會比較長,從而影響效能。
這個好解決,通過上面描述不難發現,從多個節點獲取鎖的操作並不是一個同步操作,可以是非同步操作,這樣可以多個節點同時獲取。
即使是並行處理的,還是得預估好獲取鎖的時間,保證鎖的 TTL>獲取鎖的時間+任務處理時間。
②被加鎖的資源太大。加鎖的方案本身就是會為了正確性而犧牲併發的,犧牲和資源大小成正比,這個時候可以考慮對資源做拆分。
拆分的方式有如下兩種:
①從業務上將鎖住的資源拆分成多段,每段分開加鎖。比如,我要對一個商戶做若干個操作,操作前要鎖住這個商戶,這時我可以將若干個操作拆成多個獨立的步驟分開加鎖,提高併發。

②用分桶的思想,將一個資源拆分成多個桶,一個加鎖失敗立即嘗試下一個。比如批量任務處理的場景,要處理 200w 個商戶的任務,為了提高處理速度,用多個執行緒,每個執行緒取 100 個商戶處理,就得給這 100 個商戶加鎖。
如果不加處理,很難保證同一時刻兩個執行緒加鎖的商戶沒有重疊,這時可以按一個維度。
比如某個標籤,對商戶進行分桶,然後一個任務處理一個分桶,處理完這個分桶再處理下一個分桶,減少競爭。
重試的問題:無論是簡單實現還是 Redlock 實現,都會有重試的邏輯。
如果直接按上面的演算法實現,是會存在多個 Client 幾乎在同一時刻獲取同一個鎖,然後每個 Client 都鎖住了部分節點,但是沒有一個 Client 獲取大多數節點的情況。
解決的方案也很常見,在重試的時候讓多個節點錯開,錯開的方式就是在重試時間中加一個隨機時間。這樣並不能根治這個問題,但是可以有效緩解問題,親試有效。

節點宕機


對於單 Master 節點且沒有做持久化的場景,宕機就掛了,這個就必須在實現上支援重複操作,自己做好冪等。
對於多 Master 的場景,比如 Redlock,我們來看這樣一個場景:

  • 假設有 5 個 Redis 的節點:A、B、C、D、E,沒有做持久化。

  • Client1 從 A、B、C 這3 個節點獲取鎖成功,那麼 client1 獲取鎖成功。

  • 節點 C 掛了。

  • Client2 從 C、D、E 獲取鎖成功,client2 也獲取鎖成功,那麼在同一時刻 Client1 和 Client2 同時獲取鎖,Redlock 被玩壞了。

怎麼解決呢?最容易想到的方案是開啟持久化。持久化可以做到持久化每一條 Redis 命令,但這對效能影響會很大,一般不會採用,如果不採用這種方式,在節點掛的時候肯定會損失小部分的資料,可能我們的鎖就在其中。
另一個方案是延遲啟動。就是一個節點掛了修復後,不立即加入,而是等待一段時間再加入,等待時間要大於宕機那一刻所有鎖的最大 TTL。
但這個方案依然不能解決問題,如果在上述步驟 3 中 B 和 C 都掛了呢,那麼只剩 A、D、E 三個節點,從 D 和 E 獲取鎖成功就可以了,還是會出問題。
那麼只能增加 Master 節點的總量,緩解這個問題了。增加 Master 節點會提高穩定性,但是也增加了成本,需要在兩者之間權衡。

任務執行時間超過鎖的 TTL


之前產線上出現過因為網路延遲導致任務的執行時間遠超預期,鎖過期,被多個執行緒執行的情況。
這個問題是所有分散式鎖都要面臨的問題,包括基於 Zookeeper 和 DB 實現的分散式鎖,這是鎖過期了和 Client 不知道鎖過期了之間的矛盾。
在加鎖的時候,我們一般都會給一個鎖的 TTL,這是為了防止加鎖後 Client 宕機,鎖無法被釋放的問題。
但是所有這種姿勢的用法都會面臨同一個問題,就是沒發保證 Client 的執行時間一定小於鎖的 TTL。
雖然大多數程式設計師都會樂觀的認為這種情況不可能發生,我也曾經這麼認為,直到被現實一次又一次的打臉。Martin Kleppmann 也質疑過這一點,這裡直接用他的圖:

  • Client1 獲取到鎖。

  • Client1 開始任務,然後發生了 STW 的 GC,時間超過了鎖的過期時間。

  • Client2 獲取到鎖,開始了任務。

  • Client1 的 GC 結束,繼續任務,這個時候 Client1 和 Client2 都認為自己獲取了鎖,都會處理任務,從而發生錯誤。

Martin Kleppmann 舉的是 GC 的例子,我碰到的是網路延遲的情況。不管是哪種情況,不可否認的是這種情況無法避免,手遊購買平臺地圖一旦出現很容易懵逼。
如何解決呢?一種解決方案是不設定 TTL,而是在獲取鎖成功後,給鎖加一個 watchdog,watchdog 會起一個定時任務,在鎖沒有被釋放且快要過期的時候會續期。
這樣說有些抽象,下面結合 Redisson 原始碼說下:

publicclassRedissonLockextendsRedissonExpirableimplementsRLock{
...
@Override
publicvoidlock(){
try{
lockInterruptibly();
}catch(InterruptedExceptione){
Thread.currentThread().interrupt();
}
}

@Override
publicvoidlock(longleaseTime,TimeUnitunit){
try{
lockInterruptibly(leaseTime,unit);
}catch(InterruptedExceptione){
Thread.currentThread().interrupt();
}
}
...
}


Redisson 常用的加鎖 API 是上面兩個,一個是不傳入 TTL,這時是 Redisson 自己維護,會主動續期。
另外一種是自己傳入 TTL,這種 Redisson 就不會幫我們自動續期了,或者自己將 leaseTime 的值傳成 -1,但是不建議這種方式,既然已經有現成的 API 了,何必還要用這種奇怪的寫法呢。
接下來分析下不傳參的方法的加鎖邏輯:

publicclassRedissonLockextendsRedissonExpirableimplementsRLock{

...

publicstaticfinallongLOCK_EXPIRATION_INTERVAL_SECONDS=30;
protectedlonginternalLockLeaseTime=TimeUnit.SECONDS.toMillis(LOCK_EXPIRATION_INTERVAL_SECONDS);


@Override
publicvoidlock(){
try{
lockInterruptibly();
}catch(InterruptedExceptione){
Thread.currentThread().interrupt();
}
}

@Override
publicvoidlockInterruptibly()throwsInterruptedException{
lockInterruptibly(-1,null);
}

@Override
publicvoidlockInterruptibly(longleaseTime,TimeUnitunit)throwsInterruptedException{
longthreadId=Thread.currentThread().getId();
Longttl=tryAcquire(leaseTime,unit,threadId);
//lockacquired
if(ttl==null){
return;
}

RFuture<RedissonLockEntry>future=subscribe(threadId);
commandExecutor.syncSubscription(future);

try{
while(true){
ttl=tryAcquire(leaseTime,unit,threadId);
//lockacquired
if(ttl==null){
break;
}

//waitingformessage
if(ttl>=0){
getEntry(threadId).getLatch().tryAcquire(ttl,TimeUnit.MILLISECONDS);
}else{
getEntry(threadId).getLatch().acquire();
}
}
}finally{
unsubscribe(future,threadId);
}
//get(lockAsync(leaseTime,unit));
}

privateLongtryAcquire(longleaseTime,TimeUnitunit,longthreadId){
returnget(tryAcquireAsync(leaseTime,unit,threadId));
}

private<T>RFuture<Long>tryAcquireAsync(longleaseTime,TimeUnitunit,finallongthreadId){
if(leaseTime!=-1){
returntryLockInnerAsync(leaseTime,unit,threadId,RedisCommands.EVAL_LONG);
}
RFuture<Long>ttlRemainingFuture=tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS,TimeUnit.SECONDS,threadId,RedisCommands.EVAL_LONG);
ttlRemainingFuture.addListener(newFutureListener<Long>(){
@Override
publicvoidoperationComplete(Future<Long>future)throwsException{
if(!future.isSuccess()){
return;
}

LongttlRemaining=future.getNow();
//lockacquired
if(ttlRemaining==null){
scheduleExpirationRenewal(threadId);
}
}
});
returnttlRemainingFuture;
}

privatevoidscheduleExpirationRenewal(finallongthreadId){
if(expirationRenewalMap.containsKey(getEntryName())){
return;
}

Timeouttask=commandExecutor.getConnectionManager().newTimeout(newTimerTask(){
@Override
publicvoidrun(Timeouttimeout)throwsException{

RFuture<Boolean>future=commandExecutor.evalWriteAsync(getName(),LongCodec.INSTANCE,RedisCommands.EVAL_BOOLEAN,
"if(redis.call('hexists',KEYS[1],ARGV[2])==1)then"+
"redis.call('pexpire',KEYS[1],ARGV[1]);"+
"return1;"+
"end;"+
"return0;",
Collections.<Object>singletonList(getName()),internalLockLeaseTime,getLockName(threadId));

future.addListener(newFutureListener<Boolean>(){
@Override
publicvoidoperationComplete(Future<Boolean>future)throwsException{
expirationRenewalMap.remove(getEntryName());
if(!future.isSuccess()){
log.error("Can'tupdatelock"+getName()+"expiration",future.cause());
return;
}

if(future.getNow()){
//rescheduleitself
scheduleExpirationRenewal(threadId);
}
}
});
}
},internalLockLeaseTime/3,TimeUnit.MILLISECONDS);

if(expirationRenewalMap.putIfAbsent(getEntryName(),task)!=null){
task.cancel();
}
}


...
}


可以看到,最後加鎖的邏輯會進入到 org.redisson.RedissonLock#tryAcquireAsync 中,在獲取鎖成功後,會進入 scheduleExpirationRenewal。
這裡面初始化了一個定時器,dely 的時間是 internalLockLeaseTime/3。
在 Redisson 中,internalLockLeaseTime 是 30s,也就是每隔 10s 續期一次,每次 30s。

如果是基於 Zookeeper 實現的分散式鎖,可以利用 Zookeeper 檢查節點是否存活,從而實現續期,Zookeeper 分散式鎖沒用過,不詳細說。
不過這種做法也無法百分百做到同一時刻只有一個 Client 獲取到鎖,如果續期失敗,比如發生了 Martin Kleppmann 所說的 STW 的 GC,或者 Client 和 Redis 叢集失聯了,只要續期失敗,就會造成同一時刻有多個 Client 獲得鎖了。
在我的場景下,我將鎖的粒度拆小了,Redisson 的續期機制已經夠用了。如果要做得更嚴格,得加一個續期失敗終止任務的邏輯。
這種做法在以前 Python 的程式碼中實現過,Java 還沒有碰到這麼嚴格的情況。
這裡也提下 Martin Kleppmann 的解決方案,我自己覺得這個方案並不靠譜,原因後面會提到。
他的方案是讓加鎖的資源自己維護一套保證不會因加鎖失敗而導致多個 Client 在同一時刻訪問同一個資源的情況。在客戶端獲取鎖的同時,也獲取到一個資源的 Token,這個 Token 是單調遞增的,每次在寫資源時,都檢查當前的 Token 是否是較老的 Token,如果是就不讓寫。
對於上面的場景,Client1 獲取鎖的同時分配一個 33 的 Token,Client2 獲取鎖的時候分配一個 34 的 Token。
在 Client1 GC 期間,Client2 已經寫了資源,這時最大的 Token 就是 34 了,Client1 從 GC 中回來,再帶著 33 的 Token 寫資源時,會因為 Token 過期被拒絕。
這種做法需要資源那一邊提供一個 Token 生成器。對於這種 fencing 的方案,我有幾點問題:
①無法保證事務。示意圖中畫的只有 34 訪問了 Storage,但是在實際場景中,可能出現在一個任務內多次訪問 Storage 的情況,而且必須是原子的。
如果 Client1 帶著 33 的 Token 在 GC 前訪問過一次 Storage,然後發生了 GC。
Client2 獲取到鎖,帶著 34 的 Token 也訪問了 Storage,這時兩個 Client 寫入的資料是否還能保證資料正確?
如果不能,那麼這種方案就有缺陷,除非 Storage 自己有其他機制可以保證,比如事務機制;如果能,那麼這裡的 Token 就是多餘的,fencing 的方案就是多此一舉。
②高併發場景不實用。因為每次只有最大的 Token 能寫,這樣 Storage 的訪問就是線性的,在高併發場景下,這種方式會極大的限制吞吐量,而分散式鎖也大多是在這種場景下用的,很矛盾的設計。
③這是所有分散式鎖的問題。這個方案是一個通用的方案,可以和 Redlock 用,也可以和其他的 lock 用。所以我理解僅僅是一個和 Redlock 無關的解決方案。

系統時鐘漂移


這個問題只是考慮過,但在實際專案中並沒有碰到過,因為理論上是可能出現的,這裡也說下。
Redis 的過期時間是依賴系統時鐘的,如果時鐘漂移過大時會影響到過期時間的計算。
為什麼系統時鐘會存在漂移呢?先簡單說下系統時間,Linux 提供了兩個系統時間:clock realtime 和 clock monotonic。
clock realtime 也就是 xtime/wall time,這個時間時可以被使用者改變的,被 NTP 改變,gettimeofday 拿的就是這個時間,Redis 的過期計算用的也是這個時間。
clock monotonic ,直譯過來時單調時間,不會被使用者改變,但是會被 NTP 改變。
最理想的情況時,所有系統的時鐘都時時刻刻和NTP伺服器保持同步,但這顯然時不可能的。
導致系統時鐘漂移的原因有兩個:

  • 系統的時鐘和 NTP 伺服器不同步。這個目前沒有特別好的解決方案,只能相信運維同學了。

  • clock realtime 被人為修改。在實現分散式鎖時,不要使用 clock realtime。

    不過很可惜,Redis 使用的就是這個時間,我看了下 Redis 5.0 原始碼,使用的還是 clock realtime。

    Antirez 說過改成 clock monotonic 的,不過大佬還沒有改。也就是說,人為修改 Redis 伺服器的時間,就能讓 Redis 出問題了。

總結


本文從一個簡單的基於 Redis 的分散式鎖出發,到更復雜的 Redlock 的實現,介紹了在使用分散式鎖的過程中才踩過的一些坑以及解決方案。

- End -