1. 程式人生 > >基於Redis分散式鎖(獲取鎖及解鎖)

基於Redis分散式鎖(獲取鎖及解鎖)


  目前幾乎很多大型網站及應用都是分散式部署的,分散式場景中的資料一致性問題一直是一個比較重要的話題。分散式的CAP理論告訴我們“任何一個分散式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分割槽容錯性(Partition tolerance),最多隻能同時滿足兩項。”所以,很多系統在設計之初就要對這三者做出取捨。在網際網路領域的絕大多數的場景中,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證“最終一致性”,只要這個最終時間是在使用者可以接受的範圍內即可。

  在很多場景中,我們為了保證資料的最終一致性,需要很多的技術方案來支援,比如分散式事務、分散式鎖等。有的時候,我們需要保證一個方法在同一時間內只能被同一個執行緒執行。在單機環境中,Java中其實提供了很多併發處理相關的API,但是這些API在分散式場景中就無能為力了。也就是說單純的Java Api並不能提供分散式鎖的能力。所以針對分散式鎖的實現目前有多種方案。

  針對分散式鎖的實現,目前比較常用的有以下幾種方案:

  基於資料庫實現分散式鎖、基於快取(redis,memcached)、實現分散式鎖 基於Zookeeper實現分散式鎖。

  在分析這幾種實現方案之前我們先來想一下,我們需要的分散式鎖應該是怎麼樣的?(這裡以方法鎖為例,資源鎖同理)

  可以保證在分散式部署的應用叢集中,同一個方法在同一時間只能被一臺機器上的一個執行緒執行。

  這把鎖要是一把可重入鎖(避免死鎖)。

  這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)。

  有高可用的獲取鎖和釋放鎖功能。

  獲取鎖和釋放鎖的效能要好。

  基於Redis鎖實現

  加鎖

  private static final String LOCK_SUCCESS = OK;

  private static final String SET_IF_NOT_EXIST = NX;

  private static final String SET_WITH_EXPIRE_TIME = PX;

  /**

  * 嘗試獲取分散式鎖

  * @param jedis Redis客戶端

  * @param lockKey 鎖

  * @param requestId 請求標識

  * @param expireTime 超期時間

  * @return 是否獲取成功

  */

  public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

  String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

  if (LOCK_SUCCESS.equals(result)) {

  return true;

  }

  return false;

  }

  可以看到,我們加鎖就一行程式碼:jedis.set(String key, String value, String nxxx, String expx, int time),這個set()方法一共有五個形參:

  第一個為key,我們使用key來當鎖,因為key是唯一的。

  第二個為value,我們傳的是requestId,很多童鞋可能不明白,有key作為鎖不就夠了嗎,為什麼還要用到value?原因就是我們在上面講到可靠性時,分散式鎖要滿足第四個條件解鈴還須繫鈴人,通過給value賦值為requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據。requestId可以使用UUID.randomUUID().toString()方法生成。

  第三個為nxxx,這個引數我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;

  第四個為expx,這個引數我們傳的是PX,意思是我們要給這個key加一個過期的設定,具體時間由第五個引數決定。

  第五個為time,與第四個引數相呼應,代表key的過期時間。

  總的來說,執行上面的set()方法就只會導致兩種結果:1. 當前沒有鎖(key不存在),那麼就進行加鎖操作,並對鎖設定個有效期,同時value表示加鎖的客戶端。2. 已有鎖存在,不做任何操作。

  心細的童鞋就會發現了,我們的加鎖程式碼滿足我們可靠性裡描述的三個條件。首先,set()加入了NX引數,可以保證如果已有key存在,則函式不會呼叫成功,也就是隻有一個客戶端能持有鎖,滿足互斥性。其次,由於我們對鎖設定了過期時間,即使鎖的持有者後續發生崩潰而沒有解鎖,鎖也會因為到了過期時間而自動解鎖(即key被刪除),不會發生死鎖。最後,因為我們將value賦值為requestId,代表加鎖的客戶端請求標識,那麼在客戶端在解鎖的時候就可以進行校驗是否是同一個客戶端。由於我們只考慮Redis單機部署的場景,所以容錯性我們暫不考慮。

  解鎖

  private static final Long RELEASE_SUCCESS = 1L;

  /**

  * 釋放分散式鎖

  * @param jedis Redis客戶端

  * @param lockKey 鎖

  * @param requestId 請求標識

  * @return 是否釋放成功

  */

  public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

  String script = if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end;

  Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

  if (RELEASE_SUCCESS.equals(result)) {

  return true;

  }

  return false;

  }

  可以看到,我們解鎖只需要兩行程式碼就搞定了!第一行程式碼,我們寫了一個簡單的Lua指令碼程式碼,上一次見到這個程式語言還是在《黑客與畫家》裡,沒想到這次居然用上了。第二行程式碼,我們將Lua程式碼傳到jedis.eval()方法裡,並使引數KEYS[1]賦值為lockKey,ARGV[1]賦值為requestId。eval()方法是將Lua程式碼交給Redis服務端執行。

  無論加鎖還是解鎖,必須是原子操作。

  分散式鎖的異常問題

  如果一個獲取到鎖的client因為某種原因導致沒能及時釋放鎖,並且Redis因為超時釋放了鎖,另外一個client獲取到了鎖,此時情況如下圖所示:

  

img

 

  那麼如何解決這個問題呢?

  一種方案是引入鎖續約機制,也就是獲取鎖之後,釋放鎖之前,會定時進行鎖續約,比如以鎖超時時間的1/3為間隔週期進行鎖續約。

  關於開源的Redis的分散式鎖實現有很多,比較出名的有redisson、百度的dlock。

  對於高可用性,一般可以通過叢集或者master-slave來解決,Redis鎖優勢是效能出色,劣勢就是由於資料在記憶體中,一旦快取服務宕機,鎖資料就丟失了。

  像Redis自帶複製功能,可以對資料可靠性有一定的保證,但是由於複製也是非同步完成的,因此依然可能出現master節點寫入鎖資料而未同步到slave節點的時候宕機,鎖資料丟失問題。這個暫時沒有好的解決辦法。