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

簡介

分佈式鎖在分佈式系統中很是常見,好比對公共資源進行操做,如賣車票,同一時刻只能有一個節點將某個特定座位的票賣出去;如避免緩存失效帶來的大量請求訪問數據庫的問題java

設計

這很是像一道面試題:如何實現一個分佈式鎖?在簡介中,基本上已經對這個分佈式工具提出了一些需求,你能夠不着急看下面的答案,本身思考一下分佈式鎖應該如何實現?面試

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

需求分析

1.可以在高併發的分佈式的系統中應用
2.須要實現鎖的基本特性:一旦某個鎖被分配出去,那麼其餘的節點沒法再進入這個鎖所管轄範圍內的資源;失效機制避免無限時長的鎖與死鎖
3.進一步實現鎖的高級特性和JUC併發工具相似功能更好:可重入、阻塞與非阻塞、公平與非公平、JUC的併發工具(Semaphore,CountDownLatch, CyclicBarrier)數據庫

系統設計

轉換成設計是以下幾個要求:緩存

1.對加鎖、解鎖的過程須要是高性能、原子性的
2.須要在某個分佈式節點都能訪問到的公共平臺上進行鎖狀態的操做
因此,咱們分析出系統的構成應該要有鎖狀態存儲模塊、鏈接存儲模塊的鏈接池模塊、鎖內部邏輯模塊服務器

鎖狀態存儲模塊

分佈式鎖的存儲有三種常見實現,由於能知足實現鎖的這些條件:高性能加鎖解鎖、操做的原子性、是分佈式系統中不一樣節點均可以訪問的公共平臺:多線程

1.數據庫(利用主鍵惟一規則、MySQL行鎖)
2.基於Redis的NX、EX參數
3.Zookeeper臨時有序節點

因爲鎖經常是在高併發的狀況下才會使用到的分佈式控制工具,因此使用數據庫實現會對數據庫形成必定的壓力,鏈接池爆滿問題,因此不推薦數據庫實現;咱們還須要維護Zookeeper集羣,實現起來仍是比較複雜的。若是不是原有系統就依賴Zookeeper,同時壓力不大的狀況下。通常不使用Zookeeper實現分佈式鎖。因此緩存實現分佈式鎖仍是比較常見的,由於緩存比較輕量、緩存的響應快、吞吐高、還有自動失效的機制保證鎖必定能釋放。架構

鏈接池模塊

可以使用JedisPool實現,若是後期性能不佳,可考慮參照HikariCP本身實現併發

鎖內部邏輯模塊

基本功能:加鎖、解鎖、超時釋放
高級功能:可重入、阻塞與非阻塞、公平與非公平、JUC併發工具功能app

實現方式

存儲模塊使用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實現並不靠譜。本人在Redisson中也沒有找到解決方式。或者使用Zookepper將超時消息發送給客戶端去執行超時狀況下的業務邏輯。

單點故障致使的併發問題

創建主從複製架構,可是仍是會因爲主節點掛掉致使某些數據還沒同步就已經丟失,因此推薦多主架構,有N個獨立的master服務器,客戶端會向全部的服務器發送獲取鎖的操做。

能夠繼續優化的地方

實現相似JUC中的Semaphore、CountDownLatch、公平鎖非公平鎖、讀寫鎖功能,可參考Redisson的實現參考RedLock方案,提供多主配置方式與加鎖解鎖實現使用訂閱解鎖消息與Semaphore代替Thread.sleep()避免時間浪費,可參考Redisson中RedissonLock的lockInterruptibly方法

相關文章
相關標籤/搜索