Redisson 分散式鎖實現之原始碼篇 → 為什麼推薦用 Redisson 客戶端

青石路發表於2021-07-05

開心一刻

  一男人站在樓頂準備跳樓,樓下有個勸解員拿個喇叭準備勸解

  勸解員:兄弟,別跳

  跳樓人:我不想活了

  勸解員:你想想你媳婦

  跳樓人:媳婦跟人跑了

  勸解員:你還有兄弟

  跳樓人:就是跟我兄弟跑的

  勸解員:你想想你家孩子

  跳樓人:孩子是他倆的

  勸解員:死吧,媽的你活著也沒啥價值了

Redisson 分散式鎖實現之原始碼篇 → 為什麼推薦用 Redisson 客戶端

前言

  關於鎖,相信大家都不陌生,一般我們用其在多執行緒環境中控制對共享資源的併發訪問

  單服務下,用 JDK 中的 synchronized 或 Lock 的實現類可實現對共享資源的併發訪問

  分散式服務下,JDK 中的鎖就顯得力不從心了,分散式鎖也就應運而生了

  分散式鎖的實現方式有很多,常見的有如下幾種

    基於 MySQL,利用行級悲觀鎖(select ... for update)

    基於 Redis,利用其 (setnx + expire) 或 set 

    基於 Zookeeper,利用其臨時目錄和事件回撥機制

  具體的實現細節就不展開了,網上資料很多

  看下文之前最好先看下:Redisson 分散式鎖實現之前置篇 → Redis 的釋出/訂閱 與 Lua,方便更好的理解下文

分散式鎖的特點

  可以類比 JDK 中的鎖

  互斥

    不僅要保證同個服務中不同執行緒的互斥,還需要保證不同服務間、不同執行緒的互斥

    如何處理互斥,是自旋、還是阻塞 ,還是其他 ?

  超時

    鎖超時設定,防止程式異常奔潰而導致鎖一直存在,後續同把鎖一直加不上

  續期

    程式具體執行的時長無法確定,所以過期時間只能是個估值,那麼就不能保證程式在過期時間內百分百能執行完

    所以需要進行鎖續期,保證業務能夠正常執行完

  可重入

    可重入鎖又名遞迴鎖,是指同一個執行緒在外層方法已經獲得鎖,再進入該執行緒的中層或內層方法會自動獲取鎖

    簡單點來說,就是同個執行緒可以反覆獲取同一把鎖

  專一釋放

    通俗點來講:誰加的鎖就只有它能釋放這把鎖

    為什麼會出現這種錯亂釋放的問題了,舉個例子就理解了

      執行緒 T1 對資源 lock_zhangsan 加了鎖,由於某些原因,加鎖業務還未執行完,鎖過期自動釋放了,此時執行緒 T2 對資源 lock_zhangsan 加鎖成功

      T2 執行業務的時候,T1 業務執行完後釋放資源 lock_zhangsan 的鎖,結果把 T2 加的鎖給釋放了

  公平與非公平

    公平鎖:多個執行緒按照申請鎖的順序去獲得鎖,所有執行緒都在佇列裡排隊,這樣就保證了佇列中的第一個先得到鎖

    非公平鎖:多個執行緒不按照申請鎖的順序去獲得鎖,而是同時直接去嘗試獲取鎖

    JDK 中的 ReentrantLock 就有公平和非公平兩種實現,有興趣的可以去看看它的原始碼

    多數情況下用的是非公平鎖,但有些特殊情況下需要用公平鎖

 

  很多小夥伴覺得:引入一個簡單的分散式鎖,有必要考慮這麼多嗎?

  雖然絕大部分情況下,我們的程式都是在跑正常流程,但不能保證異常情況 100% 跑不到,出於健壯性考慮,異常情況都需要考慮到

  下面我們就來看看 Redisson 是如何實現這些特點的

Redisson 實現分散式鎖

  關於 Redisson,更多詳細資訊可檢視官方文件

  Redisson 是 Redis 官方推薦的 Java 版的 Redis 客戶端,它提供了非常豐富的功能,其中就包括本文關注的分散式鎖

  環境準備

    簡單示例開始之前,我們先看下環境;版本不同,會有一些差別

    JDK:1.8

    Redis:3.2.8

    Redisson:3.13.6

  簡單示例

    先將 Redis 資訊配置給 Redisson,建立出 RedissonClient

    Redis 的部署方式不同,Redisson 配置模式也會不同,詳細資訊可檢視:Configuration

    我們就配置最簡單的 Single instance mode 

    RedissonClient 建立出來後,就可以通過它來獲取鎖

    完整示例程式碼:redisson-demo

  接下來我們從原始碼層面一起看看 Redisson 具體是如何實現分散式鎖的特點的

客戶端建立

  客服端的建立過程中,會生成一個 id 作為唯一標識,用以區分分散式下不同節點中的客戶端

Redisson 分散式鎖實現之原始碼篇 → 為什麼推薦用 Redisson 客戶端

  id 值就是一個 UUID,客戶端啟動時生成

  那麼這個 id 有什麼用,大家暫且在腦中留下這個疑問,我們接著往下看

鎖的獲取

  我們從 lock 開始跟原始碼

Redisson 分散式鎖實現之原始碼篇 → 為什麼推薦用 Redisson 客戶端

  最終會來到有三個引數的 lock 方法

Redisson 分散式鎖實現之原始碼篇 → 為什麼推薦用 Redisson 客戶端
    private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
        long threadId = Thread.currentThread().getId();
        
        // 嘗試獲取鎖;ttl為null表示鎖獲取成功; ttl不為null表示獲取鎖失敗,其值為其他執行緒佔用該鎖的剩餘時間
        Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
        // lock acquired
        if (ttl == null) {
            return;
        }

        // 鎖被其他執行緒佔用而獲取失敗,使用redis的釋出訂閱功能來等待鎖的釋放通知,而非自旋監測鎖的釋放
        RFuture<RedissonLockEntry> future = subscribe(threadId);
        
        // 當前執行緒會阻塞,直到鎖被釋放時當前執行緒被喚醒(有超時等待,預設 7.5s,而不會一直等待)
        // 持有鎖的執行緒釋放鎖之後,redis會發布訊息,所有等待該鎖的執行緒都會被喚醒,包括當前執行緒
        if (interruptibly) {
            commandExecutor.syncSubscriptionInterrupted(future);
        } else {
            commandExecutor.syncSubscription(future);
        }

        try {
            while (true) {
                // 嘗試獲取鎖;ttl為null表示鎖獲取成功; ttl不為null表示獲取鎖失敗,其值為其他執行緒佔用該鎖的剩餘時間
                ttl = tryAcquire(-1, leaseTime, unit, threadId);
                // lock acquired
                if (ttl == null) {
                    break;
                }

                // waiting for message
                if (ttl >= 0) {
                    try {
                        // future.getNow().getLatch() 返回的是 Semaphore 物件,其初始許可證為 0,以此來控制執行緒獲取鎖的順序
                        // 通過 Semaphore 控制當前服務節點競爭鎖的執行緒數量
                        future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                    } catch (InterruptedException e) {
                        if (interruptibly) {
                            throw e;
                        }
                        future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                    }
                } else {
                    if (interruptibly) {
                        future.getNow().getLatch().acquire();
                    } else {
                        future.getNow().getLatch().acquireUninterruptibly();
                    }
                }
            }
        } finally {
            // 退出鎖競爭(鎖獲取成功或者放棄獲取鎖),則取消鎖的釋放訂閱
            unsubscribe(future, threadId);
        }
//        get(lockAsync(leaseTime, unit));
    }
View Code

  主要是三個點:嘗試獲取鎖、訂閱、取消訂閱;我們一個一個來看

  嘗試獲取鎖

Redisson 分散式鎖實現之原始碼篇 → 為什麼推薦用 Redisson 客戶端

  嘗試獲取鎖主要做了兩件事:1、嘗試獲取鎖,2、鎖續期

  嘗試獲取鎖主要涉及到一段 lua 程式碼

  結合我的上篇文章來看,這個 lua 指令碼還是很好理解的

    1、用 exists 判斷 key 不存在,則用 hash 結構來存放鎖,key = 資源名,field = uuid + : + threadId,value 自增 1

      設定鎖的過期時間(預設是 lockWatchdogTimeout = 30 * 1000 毫秒),並返回 nil

    2、用 hexists 判斷 field = uuid + : + threadId 存在

      則該 field 的 value 自增 1,並重置過期時間,最後返回 nil

      這裡相當於實現了鎖的重入

    3、上面兩種情況都不滿足,則說明鎖被其他執行緒佔用了,直接返回鎖的過期時間

  這裡有個疑問:為什麼 field = uuid + : + threadId,而不是 field = threadId

    友情提示下:從多個服務(也就是多個 Redisson 客戶端)來考慮

    這個問題想清楚了,那麼前面提到的:在 Redisson 客戶端建立的過程中生成的 id(一個隨機的 uuid 值),它的作用也就清楚了

  在獲取鎖成功之後,會啟一個定時任務實現鎖續期,也涉及到一段 lua 指令碼

  這段指令碼很簡單,相信大家都能看懂

  預設情況下,鎖的過期時間是 30s,鎖獲取成功之後每隔 10s 進行一次鎖續期,重置過期時間成 30s

  若鎖已經被釋放了,則定時任務也會停止,不會再續期

  訂閱

Redisson 分散式鎖實現之原始碼篇 → 為什麼推薦用 Redisson 客戶端

  獲取鎖的過程中,嘗試獲取鎖失敗(鎖被其他執行緒鎖佔有),則會完成對該鎖頻道的訂閱,訂閱過程中執行緒會阻塞

  持有鎖的執行緒釋放鎖時會向鎖頻道釋出訊息,訂閱了該鎖頻道的執行緒會被喚醒,繼續去獲取鎖

  這裡有個疑問:假設持有鎖的執行緒意外停止了,未向鎖頻道釋出訊息,那訂閱了鎖頻道的執行緒該如何喚醒

    Redisson 其實已經考慮到了

    有超時機制,預設超時時長 = 3000 + 1500 * 3 = 7500 毫秒

  再提個問題:為什麼要用 Redis 的釋出訂閱

    假設我們不用 Redis 的釋出訂閱,我們該如何實現,自旋?

    自旋有什麼缺點? 自旋頻率難以掌控,太高會增大 CPU 的負擔,太低會不及時(鎖都釋放半天了才檢測到)

    可以類比 生產者與消費者 來考慮這個問題

  取消訂閱

  有訂閱,肯定就有取消訂閱;當阻塞的執行緒被喚醒並獲取到鎖時需要取消對鎖頻道的訂閱

  當然,取消獲取鎖的執行緒也需要取消對鎖頻道的訂閱

Redisson 分散式鎖實現之原始碼篇 → 為什麼推薦用 Redisson 客戶端

   比較好理解,就是取消當前執行緒對鎖頻道的訂閱

鎖的釋放

  我們從 unlock 開始

  程式碼比較簡單,我們繼續往下跟

Redisson 分散式鎖實現之原始碼篇 → 為什麼推薦用 Redisson 客戶端

  主要有兩點:1、鎖釋放,2、取消續期定時任務

  鎖釋放

    重點在於一個 lua 指令碼

    我們把引數具象化,指令碼就好理解了

      KEYS[1] = 鎖資源,KEYS[2] = 鎖頻道

      ARGV[1] = 鎖頻道訊息型別,ARGV[2] = 過期時間,ARGV[3] = uuid + : + threadId

    1、如果當前執行緒未持有鎖,直接返回 nil

    2、hash 結構的 field 的 value 自減 1,counter = 自減後的 value 值

      如果 counter > 0,表示執行緒重入了,重置鎖的過期時間,返回 0

      如果 counter <= 0,刪除鎖,並對鎖頻道釋出鎖釋放訊息(頻道訂閱者則可收到訊息,然後喚醒執行緒去獲取鎖),返回 1

    3、上面 1、2 都不滿足,則直接返回 nil

    兩個細節:1、重入鎖的釋放,2、鎖徹底釋放後的訊息釋出

  取消續期定時任務

  比較簡單,沒什麼好說的

總結

  我們從分散式鎖的特點出發,來總結下 Redisson 是如何實現這些特點的

  互斥

  Redisson 採用 hash 結構來存鎖資源,通過 lua 指令碼對鎖資源進行操作,保證執行緒之間的互斥

  互斥之後,未獲取到鎖的執行緒會訂閱鎖頻道,然後進入一定時長的阻塞

  超時

  有超時設定,給 hash 結構的 key 加上過期時間,預設是 30s

  續期

  執行緒獲取到鎖之後會開啟一個定時任務(watchdog),每隔一定時間(預設 10s)重置 key 的過期時間

  可重入

  通過 hash 結構解決,key 是鎖資源,field 是持有鎖的執行緒,value 表示重入次數

  專一釋放

  通過 hash 結構解決,field 中存放了執行緒資訊,釋放的時候就能夠知道是不是執行緒加上的鎖,是才能夠進行鎖釋放

  公平與非公平

  留給大家補充

參考

  再有人問你分散式鎖,這篇文章扔給他

  拜託,面試請不要再問我Redis分散式鎖的實現原理!【石杉的架構筆記】

相關文章