從分佈式理論到如何做一個生產級別的分佈式鎖

前言

微服務的流行,使得現在基本都是分佈式開發,也就是同一份代碼會在多臺機器上部署運行,此時若多臺機器需要同步訪問同一個資源(同一時間只能有一個節點機器在運行同一段代碼),就需要使用到分佈式鎖。然而做好一個分佈式鎖並不容易,要考慮的點非常多,建議架構能力一般的公司對於分佈式鎖還是使用現有的開源框架來做(例如Redis的Redisson、Zookeeper的Curator、etcd等等),如果需要基於Redis、ZK進行自研的話,建議閱讀接下來討論的幾個要點。

1. AP還是CP?

首先我覺得最重要的就是考慮分佈式協議的CAP特性,因爲這直接決定了分佈式鎖的強弱、性能的強弱,詳細的CAP理論到分佈式一致性協議的討論請點擊訪問,接下來看看AP、CP模型的分佈式鎖都將會有哪些表現

1.1 CP模型

這裏CP模型我選用了Zookeeper(Zab協議)做例子,其實Etcd的Raft算法也是一個道理,但是我沒用過Etcd…

首先,Zab和Raft是如何保證CP模型呢?沒有這個基礎的讀者建議火速瀏覽這篇文章,這裏只討論優點和可能存在的某些問題。
在這裏插入圖片描述
在這裏插入圖片描述

主要是由圖1的數據複製規則+圖2的選舉主節點規則結合起來,使得Set的數據在集羣半數節點以上存活的時候一定不會丟,保證了數據一致性,但是如果集羣半數以上節點宕機,集羣將不對外服務,查詢不到值,也是一種一致性的體現(不給總比給錯亂給好,至少在分佈式鎖場景下是這樣,不加鎖總比鎖失效強吧?)

CP模型存在的問題?

從圖3的數據複製規則來看,客戶端要求Set一個值的時候並沒有立即返回,而是需要確保這個值在半數以上的節點上保存下來了纔會返回給客戶端成功響應,這樣的話延時性就取決於最快的那半數節點的寫入性能,而且加多了網絡通信來回的開銷,在一定程度上延時性會弱一些,框架如果在高併發場景下可能會出現性能下滑(Zookeeper)的結果,這也就是爲什麼Zookeeper作爲註冊中心不被看好的原因,微服務鏈路調用都需要使用註冊中心獲取服務的IP地址,併發量可見一斑,但IP地址這種東西小概率存在不一致(服務剛上線,但註冊中心沒有這個服務的IP地址,使得不被訪問到)其實是可以接受的,在註冊中心的場景下延時性才最重要,這也就是爲什麼Nacos的註冊中心會選用我們接下來要討論的AP模型,他的延時性相對是要好的。

但是如果在不容許分佈式鎖失效且併發量、性能要求不是特別嚴格的場景下,這種CP模型是再合適不過了。

1.2 AP模型

這裏AP模型我選用了Redis的主備集羣做例子

首先,爲什麼Redis的主備是AP模型?
在這裏插入圖片描述

從圖3可以看出來,redis的主備複製採用了異步增量複製(在新的節點啓動時會全量+增量優化啓動複製的時間,這裏不討論全量+增量模式),主Master節點設置值之後立刻就會返回給客戶端OK信息,接下來增量複製值給Slave從節點
在這裏插入圖片描述
試想一下,若在第三步返回給客戶端OK信息後主Master節點宕機了,數據沒來得及複製給Slave從節點,此時Sentinel哨兵會選擇一個從節點成爲Master主節點,由圖4可以看出,不管我們此時選擇哪個節點做爲Master,剛剛設置的值確實是丟失了,這裏就造成了不一致,這種主從集羣架構會丟失或不一致主節點宕機的一段時間的數據

AP模型的好處?

但這種模型有什麼好處呢?又或者說Redis的主備設計成AP模型有什麼考量呢?我們結合上面的CP模型來看,可以發現在接受事務請求(增刪改數據)的時候,主Master節點只需要確保自己寫入即可立即返回給客戶端,複製的過程由於是異步的,客戶端延時性上來說影響並不大,相比於CP模型的確保半數提交成功,AP模型的延時性是比較低的,Redis本身的定位就是要快,所以這相當符合Redis的設計初衷。再來看看可用性,如果集羣有三個節點,他可以容許宕機兩個節點,可以看出來,可用性的容錯節點是N-1個,相比於CP模型他的可用性會更高,Redis的定位不就是緩存(快+高可用+數據丟失一部分可以接受)嗎?

RedLock

那麼有沒有辦法解決Redis主從這種不一致呢?這就是將要介紹的RedLock所做的事,其思想其實和CP模型一樣,基於至少3個獨立的Redis實例,獲取鎖的時候要分別訪問三個Redis獲取鎖,半數以上的Redis返回獲取鎖成功後才能算獲取到鎖(這不是和CP一樣嗎?),保證了數據一致性,但是卻帶來了額外的延時性(要訪問3個或以上的Redis服務,也存在網絡開銷),額外的後期運維複雜性(要多個獨立的Redis實例),筆者個人覺得,非要一致性強的場景,爲什麼不去用Zookeeper或是Etcd呢。。。?在延時要求高、鎖偶爾失效可以接受的場景下才會用Redis吧?RedLock犧牲了延時,帶來了額外的複雜度,在某種程度上得不償失,還不如使用專門做這個的強一致性分佈式協議做。

Tips

由於Redis作爲分佈式鎖的話有可能會造成數據的不一致,在分佈式鎖的場景下有可能會造成兩個節點同時獲取同一把鎖,有可能你需要互斥的資源會同一時間被執行兩次,如果你使用分佈式鎖的場景是爲了更好的利用系統資源(CPU、內存),讓多節點不做一些重複的工作,並行互斥執行不同的任務,那麼不妨將你的任務做成冪等的,這樣就算兩個節點做同一個任務,任務被執行了兩次但是它們是冪等的,其結果也不會被影響,而且大部分時間上來看Redis的這把分佈式鎖確實能夠更好的分配系統資源,讓一些節點互斥並行起來。

1.3 總結

  • 在延遲性要求高、客戶端響應不能太慢、性能要求高的場景下,允許犧牲小部分時間的鎖失效來換取好的性能,那麼建議使用AP模型(例如Redis)來實現分佈式鎖
    • 某些場景可以通過冪等彌補小部分鎖失效帶來的負面影響
  • 在延遲性要求不高、主要保證鎖不能失效、高一致性的場景下,允許犧牲一點性能來換取一致性,那麼建議使用CP模型(Zookeeper、Etcd)來實現分佈式鎖
    • 併發量極高的情況下可能有問題,選型時注意調研考慮考慮這點

2. 宕機鎖釋放問題

什麼是宕機鎖釋放問題?考慮一種情況:分佈式鎖拿到鎖的節點意外宕機,拿到鎖而不釋放鎖,從而死鎖,這就是我們要討論的一個宕機鎖釋放問題。

2.1 Redis宕機鎖釋放問題

在Redis中我們解決宕機鎖釋放問題通常會在設置鎖的同時給他設置一個超時時間,這就有一個問題了,這個超時時間要設置多長?如果這個超時時間太長,那節點宕機沒釋放鎖就只能等待鎖超時,死鎖時間會變長(服務至少有一段時間的不可用),這是不容許的,那如果超時時間太短,又會造成如果有什麼做了很久的業務操作,這邊還沒執行完,另一個節點卻也能獲取到鎖,造成的鎖失效,這確實是一個兩難的問題,業界有Redisson這個框架實現的還是比較好的,我們接下來討論Redis的分佈式鎖會以他作爲例子進行分析

Redisson是怎麼做的

Redisson加鎖入口在org.redisson.RedissonLock#lock(long, java.util.concurrent.TimeUnit, boolean)

在開始加鎖的時候會執行這樣一段Lua腳本

不要擔心看不懂lua腳本,有很詳細的註釋,僅閱讀文字也能知道大概流程

-- KEYS[1] = lockName
-- ARGV[1] = 鎖超時時間
-- ARGV[2] = threadId(爲了不誤釋放鎖,下面會提到)

-- 判斷鎖是否存在
if (redis.call('exists', KEYS[1]) == 0) then
-- 這裏就是鎖不存在,設置鎖
redis.call('hset', KEYS[1], ARGV[2], 1);
-- 給一個定義的超時時間,比如60s
redis.call('pexpire', KEYS[1], c);
-- 這裏可以看出,返回空的話就可以判斷獲取鎖成功了
return nil;
end;
-- 這裏判斷重入鎖的情況,是否同一個線程獲取同一個鎖,這裏就是threadId發揮作用的地方
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 重入鎖,將此threadId獲取鎖次數 +1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
-- 重新設置過期時間
redis.call('pexpire', KEYS[1], ARGV[1]);
-- 返回空即爲告訴客戶端獲取鎖成功了
return nil;
end;
-- 代碼執行到這裏,已經可以判斷獲取鎖失敗了,執行一個ttl指令返回鎖還有多久超時
return redis.call('pttl', KEYS[1]);

這裏我們可以知道,返回空就是獲取鎖成功,返回一個數字即爲獲取鎖失敗,接下來看看對於Redis返回的值Redisson是如何處理的:

// 這裏是lock獲取分佈式鎖方法中的一個核心獲取鎖方法
private RFuture<Boolean> tryAcquireOnceAsync(long leaseTime, TimeUnit unit, long threadId) {
  // ...
  
  // tryLockInnerAsync方法就是剛剛執行的那一段lua腳本
  RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
  // onComplete方法是在指定lua腳本執行成功之後要回調onComplete方法
  ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
    if (e != null) {
      return;
    }

    // lock acquired
    // 這裏ttlRemaining代表lua腳本返回值,從上面已經可以看出,如果等於空,代表獲取鎖成功
    if (ttlRemaining == null) {
      // 獲取鎖成功之後會進入這個方法
      scheduleExpirationRenewal(threadId);
    }
  });
  return ttlRemainingFuture;
}

解決超時時間的祕訣就在scheduleExpirationRenewal方法,其會異步交給後臺線程去將剛剛獲取到的鎖的超時時間定時地續租,那麼續租多久呢?繼續看

private void scheduleExpirationRenewal(long threadId) {
	  // ...屏蔽無關代碼
    // 這裏比較關鍵
    renewExpiration();
  }
}
private void renewExpiration() {
  
  //...
	
  // 設置一個定時任務並提交給後臺線程做
  Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
    
    // 定時任務邏輯
    @Override
    public void run(Timeout timeout) throws Exception {
      // ...

      // 執行續租的lua腳本
      RFuture<Boolean> future = renewExpirationAsync(threadId);
      future.onComplete((res, e) -> {
        if (e != null) {
          log.error("Can't update lock " + getName() + " expiration", e);
          return;
        }

        // 從下面的lua腳本分析中,可以看出res如果=true,表示鎖還在執行
        // 那麼繼續遞歸renewExpiration方法繼續續租
        if (res) {
          // reschedule itself
          renewExpiration();
        }
      });
    }
    // internalLockLeaseTime是一個自定義的超時時間
    // 可以看出,比如我們設置分佈式鎖的超時時間爲60s,那麼這裏定時會 60/3=20s 去續租一次超時時間
  }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

  ee.setTimeout(task);
}

可以看到,每過1/3時間就會續租一次,我們進入renewExpirationAsync方法的lua腳本看看續租的邏輯:

-- KEYS[1] = lockName
-- ARGV[1] = 鎖超時時間
-- ARGV[2] = threadId(爲了不誤釋放鎖,下面會提到)

-- 查看鎖是否存在,返回1即爲存在
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 續租一個超時時間
redis.call('pexpire', KEYS[1], ARGV[1]);
-- 返回1就是true,說明鎖還在
return 1;
end;
-- 代碼執行到這裏就說明鎖已經不在了,0就是false,在上面的代碼來看就相當於告訴客戶端不需要續租了
return 0;

看到這裏,可以發現Redisson其實解決了超時時間過短,鎖失效的問題,雖然有續租,但是不建議超時時間太長,如果超時時間太長還是會造成死鎖的時間(如果超時時間設置1小時。。那還是會有1小時鎖無法獲取的情況),也不建議太短,萬一JVM進行GC(Stop The World),整個代碼進行停頓,後臺線程因此有幾秒時間無法續租,鎖也會失效被其他節點獲取,所以這裏建議超時時間設置的不大偏小,3、5分鐘左右這樣子,自己斟酌吧。

2.2 Zookeeper的宕機鎖釋放問題

在Zookeeper中的宕機鎖釋放問題其實相對比較好解決,如果使用Zookeeper作爲分佈式鎖,客戶端會在ZK上創建一個臨時節點,獲取到鎖的客戶端會與ZK維持一個心跳連接,如果ZK收不到客戶端的心跳就說明客戶端宕機了,此時臨時節點會自動釋放,相當於自動釋放了鎖,也就解決了節點宕機鎖得不到釋放的問題。

3. 鎖等待問題

什麼是鎖等待問題?試想一個場景,A節點獲取到鎖執行鎖區塊的業務邏輯,B節點獲取不到鎖,那麼B節點怎麼才能知道自己需要阻塞等待多久?這就需要一個通知機制,在鎖釋放的時候中間件需要通知等待中的節點來獲取鎖。

3.1 Redis中的鎖等待問題

這裏依然以Redisson作爲例子來分析

獲取鎖入口:org.redisson.RedissonLock#lock()

先來看看Redisson中獲取鎖的邏輯是怎麼樣的

private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
  long threadId = Thread.currentThread().getId();
  // 這裏就是執行2.1節開頭展示的lua腳本獲取鎖了
  // 從上面的lua腳本結論我們可以看出來,如果返回空值,說明獲取鎖成功了
  // 如果獲取鎖失敗,lua腳本中最後會執行ttl指令,返回一個鎖的超時時間
  Long ttl = tryAcquire(leaseTime, unit, threadId);
  // lock acquired
  // 這裏 ttl=null 的話說明獲取鎖成功了
  if (ttl == null) {
    return;
  }

  // 代碼走到這裏,說明獲取鎖失敗了
  // redisson利用了redis的pubsub訂閱通知機制來獲取鎖釋放通知
  // 這裏以LockName作爲ChannelName來訂閱消息
  // 這裏不介紹pubsub機制,不瞭解的讀者可以暫時將其看作是消息隊列
  // subscribe指定channel表示對Channel做了訂閱,表示自己是消息消費者
  // 之後鎖釋放會在同一個channel發起通知,那麼訂閱的客戶端都會收到通知
  RFuture<RedissonLockEntry> future = subscribe(threadId);
  commandExecutor.syncSubscription(future);

  try {
    // 無限循環直到獲取到鎖
    while (true) {
      // 再嘗試獲取一次,很像ReentrantLock中獲取不到鎖會嘗試自旋獲取一波,算一個小優化
      // 類比JVM級別的輕量級鎖,其獲取不到鎖也會嘗試自旋獲取一波
      // 不過redis的鎖自旋開銷多了一個網絡的開銷,稍微重了一點,所以只自旋一次
      ttl = tryAcquire(leaseTime, unit, threadId);
      // lock acquired
      // 獲取到鎖了
      if (ttl == null) {
        break;
      }

      // ...
        
      // 這裏利用JUC的java.util.concurrent.Semaphore#tryAcquire方法去阻塞線程
      // 嘗試阻塞最長ttl時間
      getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
        
      // ...
      } 
    }
  } finally {
  	// 取消訂閱pubsub
    unsubscribe(future, threadId);
  }
}

這裏的代碼層級較深,爲了文章的簡潔性長話短說。

  1. 利用Redis的PubSub模式訂閱一個LockName關聯的channel(一把鎖對應一個channel)
  2. 設置一個監聽器,監聽PubSub中名稱爲剛剛的那個LockName的channel發出通知(有鎖釋放),動作爲調用Semaphore的release方法釋放信號量
  3. 當前獲取不到鎖的線程調用Semaphore的acquire方法嘗試獲取信號量,若沒有信號量則阻塞ttl個時間
  4. 等待超過ttl個時間或者有鎖釋放通知之後線程喚醒,繼續嘗試獲取鎖
  5. 若獲取不到鎖,繼續調用Semaphore#acquire方法阻塞然後獲取鎖,無限循環直到獲取到鎖

簡而言之,Redisson利用了PubSub模式完成了一個鎖釋放的通知機制。鎖釋放的通知機制是很必要的,我曾經看過有人分佈式鎖在獲取不到鎖之後指定 Thread#sleep 一個指定的時間,這種設計是萬萬不可取的,思考一下就知道吞吐量會被限制。

3.2 Zookeeper中的鎖等待問題

在Zookeeper中這種鎖等待問題倒是比較容易解決,難度比起Redis來還是比較簡單的,但如果使用Curator的話其實也和Redisson差不多,都給你封裝好了。

這個問題同樣使用通知機制(觀察者模式)會比較好解決,在Zookeeper中方案就是Watch機制,監聽一個節點是否產生變化,若變化會收到一個通知,當獲取不到鎖之後監聽鎖的那個臨時節點即可,不過需要注意一個驚羣效應,什麼是驚羣效應呢?假設A節點獲取到鎖,同時B、C、D、E也在獲取鎖,此時BCDE節點阻塞等待釋放鎖,當A節點釋放鎖之後,BCDE會同時起來爭奪鎖,但其實只有一個節點會獲得到鎖,就會浪費N-1個節點的系統資源去獲取鎖,驚動羣而做無用功,解決方案是按順序來,首先獲取鎖失敗之後註冊一個順序節點,按照自己的順序,向前一個節點註冊Watch,這樣一個個來即可解決驚羣效應。

4. 誤釋放鎖

爲什麼會有誤釋放鎖問題?如果使用Redis作爲分佈式鎖,想象兩個場景:

  1. A節點獲取到鎖之後,Redis掛了,重新選舉一個從節點的Redis後由於是AP模型,鎖信息不在這個從節點上,B節點此時來獲取鎖成功,B開始執行業務邏輯,A執行完業務邏輯之後來釋放鎖,就會把B的鎖釋放掉了…然後C又來獲取鎖,B執行完又把C的鎖釋放掉…以此類推
  2. A節點獲取到鎖之後,因爲某種原因(GC停頓或者…發揮想象力)沒有續租過期時間,鎖不小心釋放掉了但是業務邏輯還在跑,B節點此時來獲取鎖成功,B也在跑業務邏輯,A執行完邏輯之後釋放鎖,把B的鎖也給釋放掉了…然後C又來獲取鎖,B執行完又把C的鎖釋放掉…以此類推

Zookeeper有誤釋放鎖的情況嗎?其實也有,假設A節點獲取到鎖,此時GC停頓(Stop The World),後臺線程無法給Zookeeper發送心跳,ZK以爲A節點宕機,把臨時節點給刪了,這樣其他節點也會乘虛而入,然後就會出現上面說的循環釋放別人鎖的情況。

其實如果說GC停頓(Stop The World)貌似無解,但其實這裏想說的是避免循環一直釋放別人節點的鎖,造成分佈式鎖一直失效的問題

4.1 解決方案

我們可以借鑑Redisson的方案,在獲取鎖的時候將一個唯一標識設置爲value(貌似是一個UUID+threadId)

這裏設置一個threadId還有一個好處,就是可以做可重入鎖,當同一個線程再次獲取鎖的時候就可以以當前threadId作爲依據判斷是否是重入情況。

這樣,在釋放鎖的時候A節點發現要釋放的鎖不是自己這個UUID+threadId,就不會釋放別人的鎖了。

5. 其他小Tips

看到這裏,你一定會發現原來一個分佈式鎖這麼複雜,邏輯操作不是一個簡單的set key value可以做到的,如果是使用Redis,多步操作一定要用lua腳本,保證這一系列邏輯操作的原子性,不被打斷。

想到這裏就寫到這裏了,還有什麼需要注意的點可以等待大家來補充…

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