手把手教你實現一個基於Redis的分散式鎖

java邵先生發表於2018-09-08

簡介

分散式鎖在分散式系統中非常常見,比如對公共資源進行操作,如賣車票,同一時刻只能有一個節點將某個特定座位的票賣出去;如避免快取失效帶來的大量請求訪問資料庫的問題

設計

這非常像一道面試題:如何實現一個分散式鎖?在簡介中,基本上已經對這個分散式工具提出了一些需求,你可以不著急看下面的答案,自己思考一下分散式鎖應該如何實現?

首先我們需要一個簡單的答題套路:需求分析、系統設計、實現方式、缺點不足

需求分析

能夠在高併發的分散式的系統中應用

需要實現鎖的基本特性:一旦某個鎖被分配出去,那麼其他的節點無法再進入這個鎖所管轄範圍內的資源;失效機制避免無限時長的鎖與死鎖

進一步實現鎖的高階特性和JUC併發工具類似功能更好:可重入、阻塞與非阻塞、公平與非公平、JUC的併發工具(Semaphore, CountDownLatch, CyclicBarrier)

系統設計

轉換成設計是如下幾個要求:

對加鎖、解鎖的過程需要是高效能、原子性的

需要在某個分散式節點都能訪問到的公共平臺上進行鎖狀態的操作

所以,我們分析出系統的構成應該要有鎖狀態儲存模組連線儲存模組的連線池模組鎖內部邏輯模組

鎖狀態儲存模組

分散式鎖的儲存有三種常見實現,因為能滿足實現鎖的這些條件:高效能加鎖解鎖、操作的原子性、是分散式系統中不同節點都可以訪問的公共平臺:

資料庫(利用主鍵唯一規則、MySQL行鎖)

基於Redis的NX、EX引數

Zookeeper臨時有序節點

由於鎖常常是在高併發的情況下才會使用到的分散式控制工具,所以使用資料庫實現會對資料庫造成一定的壓力,連線池爆滿問題,所以不推薦資料庫實現;我們還需要維護Zookeeper叢集,實現起來還是比較複雜的。如果不是原有系統就依賴Zookeeper,同時壓力不大的情況下。一般不使用Zookeeper實現分散式鎖。所以快取實現分散式鎖還是比較常見的,因為快取比較輕量、快取的響應快、吞吐高、還有自動失效的機制保證鎖一定能釋放

連線池模組

可使用JedisPool實現,如果後期效能不佳,可考慮參照HikariCP自己實現

鎖內部邏輯模組

基本功能:加鎖、解鎖、超時釋放

高階功能:可重入、阻塞與非阻塞、公平與非公平、JUC併發工具功能

實現方式

儲存模組使用Redis,連線池模組暫時使用JedisPool,鎖的內部邏輯將從基本功能開始,逐步實現高階功能,下面就是各種功能實現的具體思路與程式碼了。

加鎖、超時釋放

NX是Redis提供的一個原子操作,如果指定key存在,那麼NX失敗,如果不存在會進行set操作並返回成功。我們可以利用這個來實現一個分散式的鎖,主要思路就是,set成功表示獲取鎖,set失敗表示獲取失敗,失敗後需要重試。再加上EX引數可以讓該key在超時之後自動刪除。

下面是一個阻塞鎖的加鎖操作,將迴圈去掉並返回執行結果就能寫出非阻塞鎖(就不粘出來了):

public void lock(String key, String request, int timeout) throws InterruptedException {

    Jedis jedis = jedisPool.getResource();

    while (timeout >= 0) {

        String result = jedis.set(LOCK_PREFIX + key, request, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, DEFAULT_EXPIRE_TIME);

        if (LOCK_MSG.equals(result)) {

            jedis.close();

            return;

        }

        Thread.sleep(DEFAULT_SLEEP_TIME);

        timeout -= DEFAULT_SLEEP_TIME;

    }

}

但超時時間這個引數會引發一個問題,如果超過超時時間但是業務還沒執行完會導致併發問題,其他程式就會執行業務程式碼,至於如何改進,下文會講到。

解鎖

最常見的解鎖程式碼就是直接使用jedis.del()方法刪除鎖,這種不先判斷鎖的擁有者而直接解鎖的方式,會導致任何客戶端都可以隨時進行解鎖,即使這把鎖不是它的。

比如可能存在這樣的情況:客戶端A加鎖,一段時間之後客戶端A解鎖,在執行jedis.del()之前,鎖突然過期了,此時客戶端B嘗試加鎖成功,然後客戶端A再執行del()方法,則將客戶端B的鎖給解除了。

所以我們需要一個具有原子性的方法來解鎖,並且要同時判斷這把鎖是不是自己的。由於Lua指令碼在Redis中執行是原子性的,所以可以寫成下面這樣:

public boolean unlock(String key, String value) {

    Jedis jedis = jedisPool.getResource();

    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(LOCK_PREFIX + key), Collections.singletonList(value));

    jedis.close();

    return UNLOCK_MSG.equals(result);

}

來用測試梭一把

此時我們可以來寫個測試來試試有沒有達到我們想要的效果,上面的程式碼都寫在src/main/java下的RedisLock裡,下面的測試程式碼需要寫在src/test/java裡,因為單元測試只是測試程式碼的邏輯,無法測試真實連線Redis之後的表現,也沒辦法體驗到被鎖住帶來的緊張又刺激的快感,所以本專案中主要以整合測試為主,如果你想試試帶Mock的單元測試,可以看看這篇文章

那麼整合測試會需要依賴一個Redis例項,為了避免你在本地去裝個Redis來跑測試,我用到了一個嵌入式的Redis工具以及如下程式碼來幫我們New一個Redis例項,盡情去連線吧 ~ 程式碼可參看EmbeddedRedis類。另外,整合測試使用到了Spring,是不是倍感親切?相當於也提供了一個整合Spring的例子。

@Configuration

public class EmbeddedRedis implements ApplicationRunner {

    private static RedisServer redisServer;

    @PreDestroy

    public void stopRedis() {

        redisServer.stop();

    }

    @Override

    public void run(ApplicationArguments applicationArguments) {

        redisServer = RedisServer.builder().setting(“bind 127.0.0.1”).setting(“requirepass test”).build();

        redisServer.start();

    }

}

對於需要考慮併發的程式碼下的測試是比較難且比較難以達到檢測程式碼質量的目的的,因為測試用例會用到多執行緒的環境,不一定能百分百通過且難以重現,但本專案的分散式鎖是一個比較簡單的併發場景,所以我會盡可能保證測試是有意義的。

我第一個測試用例是想測試一下鎖的互斥能力,能否在A拿到鎖之後,B就無法立即拿到鎖:

@Test

public void shouldWaitWhenOneUsingLockAndTheOtherOneWantToUse() throws InterruptedException {

    Thread t = new Thread(() -> {

        try {

            redisLock.lock(lock1Key, UUID.randomUUID().toString());

        } catch (InterruptedException e) {

            e.printStackTrace();

        }

    });

    t.start();

    t.join();

    long startTime = System.currentTimeMillis();

    redisLock.lock(lock1Key, UUID.randomUUID().toString(), 3000);

    assertThat(System.currentTimeMillis() – startTime).isBetween(2500L, 3500L);

}

但這僅僅測試了加鎖操作時候的互斥性,但是沒有測試解鎖是否會成功以及解鎖之後原來等待鎖的程式會繼續進行,所以你可以參看一下testLockAndUnlock方法是如何測試的。不要覺得寫測試很簡單,想清楚測試的各種情況,設計測試情景並實現並不容易。然而以後寫的測試不會單獨拿出來講,畢竟本文想關注的還是分散式鎖的實現嘛。

超時釋放導致的併發問題

問題:如果A拿到鎖之後設定了超時時長,但是業務執行的時長超過了超時時長,導致A還在執行業務但是鎖已經被釋放,此時其他程式就會拿到鎖從而執行相同的業務,此時因為併發導致分散式鎖失去了意義。

如果你說我可以通過在key快要過期的時候判斷下任務有沒有執行完畢,如果還沒有那就自動延長過期時間,那麼確實可以解決併發的問題,但是超時時長也就失去了意義,我設定超時時長就是為了想在超時的時候自動釋放鎖,避免其他程式被阻塞。所以個人認為最好的解決方式是在鎖超時的時候通知伺服器去停掉超時任務,但是結合上Redis的訊息通知機制不免有些過重了;或者讓業務程式碼自己去檢查是否執行超時,但是工具不就是讓業務實現人員更加關注業務嗎?

所以這個問題上,分散式鎖的Redis實現並不靠譜

單點故障導致的併發問題

建立主從複製架構,但是還是會由於主節點掛掉導致某些資料還沒同步就已經丟失,所以推薦多主架構,有N個獨立的master伺服器,客戶端會向所有的伺服器傳送獲取鎖的操作。

可以繼續優化的地方

提供多主配置方式與加鎖解鎖實現

使用訂閱解鎖訊息與Semaphore代替Thread.sleep()避免時間浪費,可參考Redisson中RedissonLock的lockInterruptibly方法

Java高架構師、分散式架構、高可擴充套件、高效能、高併發、效能優化、Spring boot、Redis、ActiveMQ、Nginx、Mycat、Netty、Jvm大型分散式專案實戰學習架構師視訊免費學習加群:835638062 點選連結加入群聊【Java高階架構】:https://jq.qq.com/?_wv=1027&k=5S3kL3v


相關文章