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 作爲唯一標識,用以區分分佈式下不同節點中的客戶端

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

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

鎖的獲取

  我們從 lock 開始跟源碼

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

    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

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

  嘗試獲取鎖

  嘗試獲取鎖主要做了兩件事: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 其實已經考慮到了

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

  再提個問題:爲什麼要用 Redis 的發佈訂閱

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

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

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

  取消訂閱

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

  當然,取消獲取鎖的線程也需要取消對鎖頻道的訂閱

   比較好理解,就是取消當前線程對鎖頻道的訂閱

鎖的釋放

  我們從 unlock 開始

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

  主要有兩點: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分佈式鎖的實現原理!【石杉的架構筆記】

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