再談分散式鎖之剖析Redis實現

SnoWalker發表於2019-07-24

之前筆者已經寫過關於分散式鎖的內容,但囿於彼時對於分散式鎖的研究還不算太深入,如今讀來發現還是存在一些問題,故而寫作本文,對Redis分散式鎖的實現做一個更加全面、進階的闡述和總結,幫助讀者對Redis分散式鎖有一個更加深入客觀的瞭解。關於更多分散式鎖的其他實現,在後續的文章中也會陸續展開。

我們還是通過經典的WWH(what why how)三段論方式進行行文。首先再次從巨集觀上了解什麼是分散式鎖以及分散式鎖的約束條件和常見實現方式。

分散式鎖

這部分主要對分散式鎖再次做一次較為完整的回顧與總結。

什麼是分散式鎖

引用度孃的詞條,對於分散式鎖的解釋如下:

再談分散式鎖之剖析Redis實現

這段話概括的還是不錯的,根據概述以及對單機鎖的瞭解,我們能夠提煉並類比得出分散式鎖的幾個主要約束條件:

分散式鎖的約束條件

特點 描述
互斥性 即:在任意時刻,只有一個客戶端能持有鎖
安全性 即:不會出現死鎖的情況,當一個客戶端在持有鎖期間內,由於意外崩潰而導致鎖未能主動解鎖,其持有的鎖也能夠被正確釋放,並保證後續其它客戶端也能加鎖;
可用性 即:分散式鎖需要有一定的故障恢復能力,通過高可用機制能夠保證故障發生的情況下能夠最大限度對外提供服務,無單點風險。如:通過Redis的叢集模式、哨兵模式;ETCD/zookeeper的叢集選主能力等保證HA
對稱性 對於任意一個鎖,其加鎖和解鎖必須是同一個客戶端,即客戶端 A 不能把客戶端 B 加的鎖給解了。這又稱為鎖的可重入性。

基於上述特點,這裡直接給出常見的實現方式,筆者之前的文章也有對這些常見實現方式的詳述,此處只是作為概括,不再展開,感興趣的同學可以自行查閱部落格的歷史記錄。

分散式鎖常見實現方式

類別 舉例
通過資料庫方式實現 如:採用樂觀鎖、悲觀鎖或者基於主鍵唯一約束實現
基於分散式快取實現的鎖服務 如: Redis 和基於 Redis 的 RedLock(Redisson提供了參考實現)
基於分散式一致性演算法實現的鎖服務 如:ZooKeeper、Chubby(google閉源實現) 和 Etcd

簡單對分散式鎖的概念做了一個總結整理後,我們進入本文的正題,對Redis實現分散式鎖的機理展開論述。

分散式鎖Redis原理

這部分對Redis實現分散式鎖的原理進行展開論述。

Redis分散式鎖核心指令:加鎖

既然是鎖,核心操作無外乎加鎖、解鎖,首先來看一下通過Redis的哪個指令進行加鎖操作。

SET lock_name my_random_value NX PX 30000

這個指令的含義是在鍵“lock_name”不存在時,設定鍵的值,到期時間為30秒。我們通過該命令就能實現加鎖功能。

這裡對該命令做一個較為詳細的講解。

命令格式:

SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]

  • EX seconds − 設定到期時間(秒為單位)。
  • PX milliseconds - 設定到期時間(毫秒為單位)。
  • NX - 僅在鍵不存在時設定鍵。
  • XX - 只有在鍵已存在時才設定。

我們的目的在於使鎖具有互斥性,因此採用NX引數, 僅在鎖不存在時才能設定鎖成功。

加鎖引數解析

我們回過頭接著看下加鎖的完整例項:

SET lock_name my_random_value NX PX 30000

  • lock_name,即分散式鎖的名稱,對於 Redis 而言,lock_name 就是 Key-Value 中的 Key且具有唯一性。
  • my_random_value,由客戶端生成的一個隨機字串,它要保證在足夠長的一段時間內,且在所有客戶端的所有獲取鎖的請求中都是唯一的,用於唯一標識鎖的持有者。
  • NX 表示只有當 lock_name(key) 不存在的時候才能 SET 成功,從而保證只有一個客戶端能獲得鎖,而其它客戶端在鎖被釋放之前都無法獲得鎖。
  • PX 30000 表示這個鎖節點有一個 30 秒的自動過期時間(目的是為了防止持有鎖的客戶端故障後,無法主動釋放鎖而導致死鎖,因此要求鎖的持有者必須在過期時間之內執行完相關操作並釋放鎖)。

Redis分散式鎖核心指令:解鎖

解鎖通過del命令即可觸發,完整指令如下:

del lock_name

對該指令做一個解釋:

  • 在加鎖時為鎖設定過期時間,當過期時間到達,Redis 會自動刪除對應的 Key-Value,從而避免死鎖。
  • 注意,這個過期時間需要結合具體業務綜合評估設定,以保證鎖的持有者能夠在過期時間之內執行完相關操作並釋放鎖。
  • 正常執行完畢,未到達鎖過期時間,通過del lock_name主動釋放鎖。

以上便是基於Redis實現分散式鎖能力的核心指令,我們接著看一個常見的錯誤實現案例。

Redis分散式鎖常見錯誤案例:setNx

首先看一段java程式碼:

    Jedis jedis = jedisPool.getResource();
    // 如果鎖不存在則進行加鎖
    Long lockResult = jedis.setnx(lockName, myRandomValue);
    if (lockResult == 1) {
        // 設定鎖過期時間,加鎖和設定過期時間是兩步完成的,非原子操作
        jedis.expire(lockName, expireTime);
    }
複製程式碼

setnx() 方法的作用就是 SET IF NOT EXIST,expire() 方法就是給鎖加一個過期時間。 乍看覺得這段程式碼沒什麼問題,但仔細推敲一下就能看出,其實這裡是有問題的:加鎖實際上使用了兩條 Redis 命令,這個組合操作是非原子性的。

如果執行setNx成功後,接著執行expire時發生異常導致鎖的過期時間未能設定,便會造成鎖無過期時間。後續如果執行的過程中出現業務執行異常或者出現FullGC等情況,將會導致鎖一致無法釋放,從而造成死鎖。

網上很多部落格中採用的就是這種較為初級的實現方式,不建議仿效。

究其原因,還是因為setNx本身雖然能夠保證設定值的原子性,但它與expire組合使用,整個操作(加鎖並設定過期時間)便不是原子的,隱藏了死鎖風險。

優雅解鎖方案

說完加鎖,我們接著說說如何進行優雅的可靠解鎖。

這裡共有兩種方案:

  • 通過Lua指令碼執行解鎖
  • 通過使用Redis的事務功能,通過 Redis 事務功能,利用 Watch 命令監控鎖對應的 Key實現可靠解鎖

1. 利用Lua指令碼實現解鎖

我們看下官網對指令碼原子性的解釋:

再談分散式鎖之剖析Redis實現

我們看一段Lua指令碼實現的解鎖程式碼;

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

可能有些讀者朋友對Lua指令碼瞭解不多,這裡簡單介紹下這段指令碼的含義:

我們通過 Redis 的 eval() 函式執行 Lua 指令碼,其中入參 lockName 賦值給引數 KEYS[1],鎖的具體值賦值給 ARGV[1],eval() 函式將 Lua 指令碼交給 Redis 服務端執行。 從上面Redis官網文件截圖能夠看出,通過 eval() 執行 Lua 程式碼時,Lua 程式碼將被當成一個命令去執行(可保證原子性),並且直到 eval 命令執行完成,Redis 才會執行其他命令。因此,通過 Lua 指令碼結合eval函式,可以科學得實現解鎖操作的原子性,避免誤解鎖。

利用Jedis實現的Java版本程式碼如下:

    Long unlock = 1L;
    Jedis jedis = null;
    // Lua指令碼,用於校驗並釋放鎖
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] 
                        then return redis.call('del', KEYS[1]) 
                    else 
                        return 0 end";
    try {
        jedis = jedisPool.getResource();
        // 通過 Redis 的 eval() 函式執行 Lua 指令碼,
        // 入參 lockName 賦值給引數 KEYS[1],myRandomValue 賦值給 ARGV[1],
        // eval() 函式將 Lua 指令碼交給 Redis 服務端執行。
        Object result = 
        jedis.eval(script, 
                    Collections.singletonList(lockName),
                    Collections.singletonList(myRandomValue));

        // 注意:如果指令碼順利執行將返回1,
        // 如果執行指令碼時,其它的客戶端對這個lockName對應的值進行了更改
        // 則返回0
        if (unlock.equals(result) {
            return true;
        }
    }
    catch (Exception e) {
        throw e;
    }
    finally {
        if (null != jedis) {
            jedis.close();
        }
    }
    return false;
複製程式碼

2. 利用Redis事務實現解鎖

首先看一下利用Redis事務實現解鎖的程式碼實現:

    Jedis jedis = null;     
    try {
        jedis = jedisPool.getResource();
        // 監控鎖對應的Key,如果其它的客戶端對這個Key進行了更改,那麼本次事務會被取消。
        jedis.watch(lockName);
        // 成功獲取鎖,則操作公共資源執行自定義流程
        // ...自定義流程程式碼省略...

        // 校驗是否持有鎖
        if (lockValue.equals(jedis.get(lockName))) {
            // 開啟事務功能,
            Transaction multi = jedis.multi();
            // 釋放鎖
            multi.del(lockName);
            // 執行事務(如果其它的客戶端對這個Key進行了更改,那麼本次事務會被取消,不會執行)
            // 如果正常執行,由於只有一個刪除操作,返回的list將只有一個物件。
            List<Object> result = multi.exec();
            if (RELEASE_SUCCESS.equals(result.size())) {
                return true;
            }
        }
    }
    catch (Exception e) {
        throw e;
    }
    finally {
        if (null != jedis) {
            jedis.unwatch();
            jedis.close();
        }
    }
複製程式碼

根據程式碼實現,我們總結下通過Redis的事務功能監控並釋放鎖的步驟:

  1. 首先通過 Watch 命令監控鎖對應的 key(lockName)。當事務開啟後,如果其它的客戶端對這個 Key 進行了更改,那麼本次事務會被取消而不會執行 jedis.watch(lockName)
  2. 開啟事務功能,程式碼:jedis.multi()
  3. 執行釋放鎖操作。當事務開啟後,釋放鎖的操作便是事務中的一個元素且隸屬於該事務,程式碼:multi.del(lockName);
  4. 執行事務,程式碼: multi.exec();
  5. 最後對資源進行釋放,程式碼 jedis.unwatch();jedis.close();

一種常見的錯誤解鎖方式

這裡再重點介紹一種常見的錯誤解鎖方式,以便進行警示。

首先看下程式碼實現:

    Jedis jedis = jedisPool.getResource();
    jedis.del(lockName);
複製程式碼

該方式直接使用了 jedis.del() 方法刪除鎖且沒有進行校驗。這種不校驗鎖的擁有者而直接執行解鎖的粗暴方式,會導致已經存在的鎖被錯誤的釋放,從而破壞互斥性(如:一個程式直接通過該方是unlock掉另一個程式的鎖)

那麼如何進行優化呢?一種方式就是在解鎖之前進行校驗,判斷加鎖與解鎖的是否為同一個客戶端。程式碼如下:

Jedis jedis = jedisPool.getResource();
if (lockValue.equals(jedis.get(lockName))) {
    jedis.del(lockName);
}
複製程式碼

這種解鎖方式相較於上文中粗暴的方式已經有了明顯進步,在解鎖之前進行了校驗。但是問題並沒有得到解決,整個解鎖過程仍然是獨立的兩條命令,並非原子操作。

更為關鍵之處在於,如果在執行解鎖操作的時候,因為異常(如:業務程式碼異常、FullGC導致的stop the world現象等)而出現了客戶端阻塞的現象,導致鎖過期自動釋放,則當前客戶端已經不再持有鎖。

當程式恢復執行後,未進行鎖持有校驗(即程式認為自己還持有鎖)而直接呼叫 del(lockName) 直接對當前存在的鎖進行解鎖操作,從而導致其他程式持有的鎖被跨程式解鎖的異常現象,這種情況是不被允許的,它違反了互斥性的原則。

階段總結

上文中我們瞭解了基於Redis實現分散式鎖的原理,也瞭解了實現一個Redis分散式鎖需要解決的問題。

我們可以感受到實現一個可靠的分散式鎖並不是一件容易的事情。

除了上文提到的現象,就算我們程式碼實現的很健壯,當採用主從架構的Redis叢集,仍會出現異常現象:

對於主從非同步複製的架構模式,當出現主節點down機時,從節點的資料尚未得到及時同步,此時程式訪問到從機,判定為能夠加鎖,於是獲取到鎖,從而導致多個程式拿到一把鎖的異常現象。

那麼有沒有一種更加可靠健壯且易用性更好的Redis鎖實現方式呢?答案是顯而易見的,它就是接下來重點講解的Redisson分散式鎖實現。

關於如何基於Redisson封裝一個開箱即用的分散式鎖元件可以移步我的另一篇文章:《自己寫分散式鎖-基於redission》,本文中我只對Redisson的分散式鎖實現進行深度解析,具體的使用及封裝過程還請讀者自行閱讀我的博文。

關於Redisson的分散式鎖,在github上有較為詳細的官方文件,分散式鎖和同步器,我們這裡挑重點進行講解。

下文中的部分程式碼引自官方文件,此處做統一宣告。

Redisson分散式鎖

這部分對Redisson分散式鎖進行較為全面的介紹。

Redisson分散式鎖--可重入鎖

基於Redis的Redisson分散式可重入鎖RLock Java物件實現了java.util.concurrent.locks.Lock介面。同時還提供了非同步(Async)、反射式(Reactive)和RxJava2標準的介面。

一種常見的使用方式如下:

RLock lock = redisson.getLock("anyLock");
// 最常見的使用方法
lock.lock();
複製程式碼

當儲存這個分散式鎖的Redisson節點當機以後,且這個鎖剛好是鎖住的狀態時,會出現鎖死的情況。為了避免這種死鎖情況的發生,Redisson內部提供了一個監控鎖的看門狗,它的作用是在Redisson例項被關閉前,提供鎖續約能力,不斷的延長鎖的有效期。

預設情況下,看門狗的檢查鎖的超時時間是30秒鐘,這個具體的值可以通過修改Config.lockWatchdogTimeout來另行指定。

Redisson還提供了顯式進行鎖過期時間制定的介面,超過該時間便會對鎖進行自動解鎖,程式碼如下:

// 顯式制定解鎖時間,無需呼叫unlock方法手動解鎖
lock.lock(10, TimeUnit.SECONDS);
// 嘗試加鎖,最多等待100秒,上鎖以後10秒自動解鎖
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
    ...
} finally {
    lock.unlock();
}
複製程式碼

Redisson還提供了非同步方式的分散式鎖執行方法,由於用的不多,此處不再贅述,感興趣的同學可以自行檢視官方文件。

這裡還要補充一下,Redisson的分散式鎖實現的優點之一,在於它的RLock物件完全符合Java的Lock規範,RLock實現了JUC的Lock介面,之所以稱之為可重入鎖在於只有擁有鎖的程式才能解鎖,當其他程式解鎖則會丟擲IllegalMonitorStateException錯誤。

這可以從RLock原始碼的宣告出看出端倪

public interface RLock extends Lock, RLockAsync {
    ......
複製程式碼

後文中我會帶領讀者對RLock的原始碼實現做一個較為詳細的解讀。我們先接著瞭解一下其餘的鎖實現。

Redisson分散式鎖--公平鎖(Fair Lock)

基於Redis的Redisson分散式可重入公平鎖也是實現了java.util.concurrent.locks.Lock介面的一種RLock物件。同時還提供了非同步(Async)、反射式(Reactive)和RxJava2標準的介面。它保證了當多個Redisson客戶端執行緒同時請求加鎖時,優先分配給先發出請求的執行緒。所有請求執行緒會在一個佇列中排隊,當某個執行緒出現當機時,Redisson會等待5秒後繼續下一個執行緒,也就是說如果前面有5個執行緒都處於等待狀態,那麼後面的執行緒會等待至少25秒。

一種常見的Redisson公平鎖使用方式如下:

RLock fairLock = redisson.getFairLock("anyLock");
// 最常見的使用方法
fairLock.lock();
複製程式碼

公平鎖實現同樣具有自動續約的能力,該能力也是通過看門狗實現,與上文提到的重入鎖RLock原理完全相同。下文中提到的鎖型別也具有該能力,因此不再贅述,讀者只要記住,這些型別的鎖都能通過看門狗實現鎖自動續約,且看門狗檢查鎖超時時間預設為30s,該引數可以通過修改Config.lockWatchdogTimeout自行配置。

公平鎖也可以顯式制定鎖的加鎖時長:

// 10秒鐘以後自動解鎖
// 無需呼叫unlock方法手動解鎖
fairLock.lock(10, TimeUnit.SECONDS);

// 嘗試加鎖,最多等待100秒,上鎖以後10秒自動解鎖
boolean res = fairLock.tryLock(100, 10, TimeUnit.SECONDS);
...
fairLock.unlock();
複製程式碼

Redisson分散式鎖--聯鎖(MultiLock)

基於Redis的Redisson分散式聯鎖RedissonMultiLock物件可以將多個RLock物件關聯為一個聯鎖,每個RLock物件例項可以來自於不同的Redisson例項。

這種鎖型別挺有意思的,它為我們提供了多重鎖機制,當所有的鎖均加鎖成功,才認為成功,呼叫的程式碼如下,(個人認為使用場景並不算多,因此作為了解即可)

RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");

RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);
// 同時加鎖:lock1 lock2 lock3
// 所有的鎖都上鎖成功才算成功。
lock.lock();
...
lock.unlock();
複製程式碼

Redisson分散式鎖--紅鎖(RedLock)

紅鎖是Redisson實現的一種高可用的分散式鎖實現,因此此處對紅鎖做一個較為詳細的展開。

基於Redis的Redisson紅鎖RedissonRedLock物件實現了Redlock介紹的加鎖演算法。該物件也可以用來將多個RLock物件關聯為一個紅鎖,每個RLock物件例項可以來自於不同的Redisson例項。

基於上文對紅鎖的概述,我們可以得知,紅鎖是一個複合鎖,且每一個鎖的例項是位於不同的Redisson例項上的。

看一段紅鎖的使用樣例:

RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");

RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同時加鎖:lock1 lock2 lock3
// 紅鎖在大部分節點上加鎖成功就算成功。
lock.lock();
...
lock.unlock();
複製程式碼

紅鎖同樣能夠顯示制定加鎖時間:

RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 給lock1,lock2,lock3加鎖,如果沒有手動解開的話,10秒鐘後將會自動解開
lock.lock(10, TimeUnit.SECONDS);

// 為加鎖等待100秒時間,並在加鎖成功10秒鐘後自動解開
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();
複製程式碼

這裡引用一下官網對紅鎖演算法實現的舉例截圖:

再談分散式鎖之剖析Redis實現

我們可以從中提取出紅鎖實現的關鍵點:半數以上節點獲取鎖成功,才認為加鎖成功,某個節點超時就去下一個繼續獲取。

這裡體現出分散式領域解決一致性的一種常用思路:多數派思想。這種思想在Raft演算法、Zab演算法、Paxos演算法中都有所體現。

Redisson分散式鎖--讀寫鎖(ReadWriteLock)

Redisson同樣實現了java.util.concurrent.locks.ReadWriteLock介面,使得其具有了讀寫鎖能力。其中,讀鎖和寫鎖都繼承了RLock介面。

同上述的鎖一樣,讀寫鎖同樣是分散式的。

分散式可重入讀寫鎖允許同時有多個讀鎖和一個寫鎖處於加鎖狀態。

一種常見的使用方式如下:

RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常見的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();
複製程式碼

按照慣例,我們接著看下顯式方式指定加鎖時長的讀寫鎖的呼叫方式:

// 10秒鐘以後自動解鎖
// 無需呼叫unlock方法手動解鎖
rwlock.readLock().lock(10, TimeUnit.SECONDS);
// 或
rwlock.writeLock().lock(10, TimeUnit.SECONDS);

// 嘗試加鎖,最多等待100秒,上鎖以後10秒自動解鎖
boolean res = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS);
// 或
boolean res = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();
複製程式碼

Redisson同時還實現了分散式AQS同步器元件,如:分散式訊號量(RSemaphore)、可過期行分散式訊號量(RPermitExpirableSemaphore)、分散式閉鎖(RCountDownLatch)等,由於本文主要講解鎖相關的內容,因此不再進行展開介紹,感興趣的同學可以自行檢視官方文件及原始碼。

Redisson分散式鎖原始碼解析

這一章節我將重點對Redisson中的重入鎖(RLock)實現機制進行原始碼級別的討論。

原始碼結構

我們從Redisson的github官方倉庫下載最新的Redisson程式碼,匯入IDEA中進行檢視,原始碼結構如下:

再談分散式鎖之剖析Redis實現
圖中紅框圈住的模組即為Redisson的核心模組,也是我們閱讀原始碼的重點。

分散式鎖部分的原始碼實現在如下路徑

redisson-master
    |-redisson
        |-src
            |-main
                |-java
                    |-org.redisson
複製程式碼

我們逐級展開即可檢視關鍵原始碼,那麼廢話不多說,直接看程式碼。

原始碼解析

筆者看原始碼的方式應當也是貼近的主流的方式,我一般會從一個demo開始,從程式碼的入口逐層深入進行閱讀,我們首先找一段重入鎖的demo。

    RLock lock = redisson.getLock(lockName);
    boolean getLock = false;
    try {
        getLock = rLock.tryLock(0, expireSeconds, TimeUnit.SECONDS);
        if (getLock) {
            LOGGER.info("獲取Redisson分散式鎖[成功],lockName={}", lockName);
        } else {
            LOGGER.info("獲取Redisson分散式鎖[失敗],lockName={}", lockName);
        }
    } catch (InterruptedException e) {
        LOGGER.error("獲取Redisson分散式鎖[異常],lockName=" + lockName, e);
        e.printStackTrace();
        return false;
    }
    return getLock;
複製程式碼

這段程式碼擷取自筆者封裝的分散式鎖元件,目前star數為92,原始碼地址 ,感興趣的可以幫我點個star,哈哈。

首先,通過 redisson.getLock(lockName); 獲取RLock鎖例項,lockName一般為具有業務標識的分散式鎖key。

獲取RLock例項

先看下如何獲取RLock例項:

進入Redisson.java類,找到如下程式碼:

@Override
public RLock getLock(String name) {
    return new RedissonLock(connectionManager.getCommandExecutor(), name, id);
}
複製程式碼

此處的id為UUID。

protected final UUID id = UUID.randomUUID();
複製程式碼

可以看到是呼叫了過載方法,點進去,跳入RedissonLock.java,通過類宣告可以看到該類實現了RLock介面,宣告及構造方法如下:

public class RedissonLock extends RedissonExpirable implements RLock {

    ...省略部分程式碼...

    protected static final LockPubSub PUBSUB = new LockPubSub();

    final CommandAsyncExecutor commandExecutor;

    public RedissonLock(CommandAsyncExecutor commandExecutor, String name, UUID id) {
        super(commandExecutor, name);
        this.commandExecutor = commandExecutor;
        this.id = id;
        // 看門狗鎖續約檢查時間週期,預設30s
        this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
    }
複製程式碼

通過該構造方法構造了RedissonLock例項,其中internalLockLeaseTime即為看門狗的檢查鎖的超時時間,預設為30s。該引數可通過修改Config.lockWatchdogTimeout來指定新值。

tryLock加鎖邏輯

當獲取獲取了鎖例項成功後,進行嘗試加鎖操作,程式碼如下:

    boolean getLock = rLock.tryLock(0, expireSeconds, TimeUnit.SECONDS);
複製程式碼

進入RedissonLock.java檢視實現。

@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    long time = unit.toMillis(waitTime);
    long current = System.currentTimeMillis();
    final long threadId = Thread.currentThread().getId();

    // 申請鎖,返回還剩餘的鎖過期時間
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    // lock acquired
    // 如果ttl為空,表示鎖申請成功
    if (ttl == null) {
        return true;
    }
    
    time -= (System.currentTimeMillis() - current);
    if (time <= 0) {
        acquireFailed(threadId);
        return false;
    }
    
    current = System.currentTimeMillis();

    // 訂閱監聽redis的訊息,並建立RedissonLockEntry
    // 其中,RedissonLockEntry中比較關鍵的是一個Semaphore
    // 屬性物件用來控制本地的鎖的請求的訊號量同步,返回Netty框架的Future
    final RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);

    // 阻塞等待subscribe的future的結果物件,如果subscribe方法呼叫超過了time,
    // 說明已經超過了客戶端設定的最大的wait time,直接返回false,取消訂閱,並且不會再繼續申請鎖
    if (!await(subscribeFuture, time, TimeUnit.MILLISECONDS)) {
        if (!subscribeFuture.cancel(false)) {
            subscribeFuture.addListener(new FutureListener<RedissonLockEntry>() {
                @Override
                public void operationComplete(Future<RedissonLockEntry> future) throws Exception {
                    if (subscribeFuture.isSuccess()) {
                        unsubscribe(subscribeFuture, threadId);
                    }
                }
            });
        }
        acquireFailed(threadId);
        return false;
    }

    try {
        time -= (System.currentTimeMillis() - current);
        if (time <= 0) {
            acquireFailed(threadId);
            return false;
        }
    
        while (true) {
            long currentTime = System.currentTimeMillis();
            // 再次嘗試申請一次鎖
            ttl = tryAcquire(leaseTime, unit, threadId);
            // lock acquired
            // 獲得鎖並返回
            if (ttl == null) {
                return true;
            }

            time -= (System.currentTimeMillis() - currentTime);
            if (time <= 0) {
                // 不等待申請鎖並返回
                acquireFailed(threadId);
                return false;
            }

            // waiting for message
            // 阻塞等待鎖
            currentTime = System.currentTimeMillis();

            // 通過訊號量(共享鎖)進行阻塞,等待解鎖訊息
            // 如果剩餘時間 TTL 小於wait time,就在ttl時間內
            // 從Entry的訊號量獲取一個許可(除非發生中斷或者一直不存在可用的許可)
            // 否則就在wait time時間範圍內等待可以通過的訊號量
            if (ttl >= 0 && ttl < time) {
                getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
            }

            // 更新等待時間,(最大等待時間-已經消耗的阻塞時間)
            time -= (System.currentTimeMillis() - currentTime);
            if (time <= 0) {
                // 等待時間小於等於0,不等待申請鎖並返回
                acquireFailed(threadId);
                return false;
            }
        }
    } finally {
        // 無論最終獲取鎖是否成功,都需要取消訂閱解鎖訊息,防止死鎖發生。
        unsubscribe(subscribeFuture, threadId);
    }
}
複製程式碼

上面這段核心程式碼邏輯中,我們重點關注下tryAcquire(long leaseTime, TimeUnit unit),呼叫加鎖邏輯主要就在這段程式碼邏輯中

private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    return get(tryAcquireAsync(leaseTime, unit, threadId));
}
複製程式碼

點進去看一下 get(tryAcquireAsync(leaseTime, unit, threadId))

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    ...省略部分邏輯...
}
複製程式碼

ryAcquire(long leaseTime, TimeUnit unit)只針對leaseTime的不同引數進行對應的轉發處理邏輯。

trylock的無參方法就是直接呼叫了 get(tryLockInnerAsync(Thread.currentThread().getId()));

我們接著看一下核心的tryLockInnerAsyn,它返回的是一個future物件,是為了通過非同步方式對IO進行處理從而提高系統吞吐量。

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);

    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
              // 檢查key是否已被佔用,如果沒有則設定超時時間及唯一標識,初始化value=1
              "if (redis.call('exists', KEYS[1]) == 0) then " +
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              // 鎖重入的情況,判斷鎖的key field,一致的話,value加1
              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              // 返回剩餘的過期時間
              "return redis.call('pttl', KEYS[1]);",
                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
複製程式碼

這裡解釋下這段加鎖的Lua指令碼具體的引數:

  • KEYS[1] :需要加鎖的key,這裡需要是字串型別。
  • ARGV[1] :鎖的超時時間,防止死鎖
  • ARGV[2] :鎖的唯一標識,也就是剛才介紹的 id(UUID.randomUUID()) + “:” + threadId

執行這段Lua指令碼當返回空,說明獲取到鎖;如果返回一個long數值(pttl 命令的返回值),則表明鎖已被佔用,通過返回剩餘時間,外部可以做一些等待時間的判斷及調整的邏輯。

tryLock(long waitTime, long leaseTime, TimeUnit unit) 有leaseTime引數的申請鎖方法會按照leaseTime時間來自動釋放鎖。

對於沒有leaseTime引數的情況,比如tryLock()或者tryLock(long waitTime, TimeUnit unit)以及lock()是會一直持有鎖的。

unlock解鎖邏輯

解鎖的核心邏輯也是通過Lua指令碼實現的,可以看出Redisson也是通過指令碼來保證加鎖、解鎖的原子性,這與我們在文章開頭時候的講解也是保持一致的。

我們接著看一下unlock()方法的核心邏輯。

@Override
public void unlock() {
    // 解鎖核心邏輯
    Boolean opStatus = get(unlockInnerAsync(Thread.currentThread().getId()));
    // 解鎖返回空,丟擲異常
    if (opStatus == null) {
        throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                + id + " thread-id: " + Thread.currentThread().getId());
    }
    if (opStatus) {
        // 解鎖成功之後取消更新鎖expire的時間的任務
        cancelExpirationRenewal();
    }
}
複製程式碼

當解鎖成功之後,呼叫cancelExpirationRenewal(),移除更新鎖expire時間的任務,也就是鎖都不存在了,也就沒必要再進行鎖過期時間續約了。簡單看下它的程式碼實現:

void cancelExpirationRenewal() {
    Timeout task = expirationRenewalMap.remove(getEntryName());
    if (task != null) {
        task.cancel();
    }
}
複製程式碼

進入unlockInnerAsync方法。

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // 如果鎖的key已經不存在,表明鎖已經被解鎖,直接釋出redis訊息
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; " +
            "end;" +

            // key和field不匹配,說明當前的客戶端執行緒並沒有持有鎖,不能進行主動解鎖操作。
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                "return nil;" +
            "end; " +

            // 將value減1
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +

            // 如果counter>0表明鎖進行了重入,不能刪除key,也就是不進行解鎖操作
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +

            // 否則刪除key併發布解鎖訊息進行解鎖
            "else " +
                "redis.call('del', KEYS[1]); " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; "+
            "end; " +
            "return nil;",
            Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));

}
複製程式碼

可以看到這裡是通過Lua指令碼執行的解鎖,那麼我們來分析下這段指令碼的具體含義。

  • KEYS[1] :需要加鎖的key,這裡需要是字串型別。
  • KEYS[2] :redis訊息的ChannelName,一個分散式鎖對應唯一的一個channelName:“redisson_lock__channel__{” + getName() + “}”
  • ARGV[1] :reids訊息體,這裡只需要一個位元組的標記就可以,主要標記redis的key已經解鎖,再結合redis的Subscribe,能喚醒其他訂閱解鎖訊息的客戶端執行緒申請鎖。
  • ARGV[2] :鎖的超時時間,防止死鎖
  • ARGV[3] :鎖的唯一標識,也就是剛才介紹的 id(UUID.randomUUID()) + “:” + threadId

從程式碼的註釋應當能夠較為清楚的把握解鎖的核心脈絡。

額外提一下,我們可以看到在lua解鎖指令碼中使用了publish命令,它的作用為:

通過在鎖的唯一通道釋出解鎖訊息,能夠減少其他分散式節點的等待或者空轉,整體上能提高加鎖效率。

我們在看下Redisson如何處理unlock訊息,此處的訊息的內容即:unlockMessage = 0L。它和unlock方法中publish的內容是對應的。

public class LockPubSub extends PublishSubscribe<RedissonLockEntry> {

    // 解鎖訊息
    public static final Long UNLOCK_MESSAGE = 0L;
    public static final Long READ_UNLOCK_MESSAGE = 1L;

    ...省略部分邏輯...

    @Override
    protected void onMessage(RedissonLockEntry value, Long message) {
        // 如果訂閱的訊息為解鎖訊息,UNLOCK_MESSAGE = 0L
        if (message.equals(UNLOCK_MESSAGE)) {
            Runnable runnableToExecute = value.getListeners().poll();
            if (runnableToExecute != null) {
                runnableToExecute.run();
            }
            // 釋放一個許可,並喚醒等待entry.
            value.getLatch().release();
        } 
        ......
    }

}
複製程式碼

lock方法

除了tryLock方式能夠獲取鎖外,Redisson還提供了lock方法直接獲取鎖,我們再看下它是如何進行鎖獲取操作的。

@Override
public void lock() {
    try {
        lockInterruptibly();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}
複製程式碼

看下lockInterruptibly的具體邏輯。

@Override
public void lockInterruptibly() throws InterruptedException {
    lockInterruptibly(-1, null);
}
複製程式碼

點選去看下lockInterruptibly(long leaseTime, TimeUnit unit)這個過載。

@Override
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    long threadId = Thread.currentThread().getId();

    // 嘗試獲取鎖
    Long ttl = tryAcquire(leaseTime, unit, threadId);

    // 鎖獲取成功
    if (ttl == null) {
        return;
    }

    // 通過非同步方式訂閱Redis的channel,阻塞方式獲取訂閱結果
    RFuture<RedissonLockEntry> future = subscribe(threadId);
    commandExecutor.syncSubscription(future);

    try {
        // 通過迴圈判斷,直到鎖獲取成功,經典寫法。
        while (true) {
            ttl = tryAcquire(leaseTime, unit, threadId);
            // 鎖獲取成功,跳出迴圈
            if (ttl == null) {
                break;
            }

            // 如果剩餘時間 TTL 大於0,從Entry的訊號量獲取一個許可(除非發生中斷或者一直不存在可用的許可)
            // 否則就在wait time時間範圍內等待可以通過的訊號量
            if (ttl >= 0) {
                getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                getEntry(threadId).getLatch().acquire();
            }
        }
    } finally {
        // 無論最終獲取鎖是否成功,都需要取消訂閱解鎖訊息,防止死鎖發生。
        unsubscribe(future, threadId);
    }
}
複製程式碼

這段邏輯是不是有種很熟悉的感覺,它和我們上文中講到的tryLock邏輯很像。具體的邏輯在註釋中已經寫得比較清晰了就不再贅述。

到此就是Redisson重入鎖加解鎖核心邏輯的原始碼解析,相信會為聰明的你一些幫助。

總結

本文,我們從分散式鎖的概述入手,對Redis實現分散式鎖的原理進行了較為全面的剖析。並且重點對Redisson的分散式鎖實現進行了詳細的講解,從筆者對Redisson的封裝類庫的呼叫例項入手,對Redisson的重入鎖進行了深入的原始碼解析。經過這一系列的學習,深入淺出了Redis/Redisson分散式鎖的實現機理,相信之後遇到的類似問題,我們一定可以胸有成竹。

更多分散式鎖的實現及原始碼解析,將會陸續釋出,請拭目以待。

參考連結

Redis官方文件對紅鎖RedLock的說明

Redis官方文件對Lua指令碼的說明

再談分散式鎖之剖析Redis實現

相關文章