使用Redis+Redisson實現分佈式鎖Demo

使用redis實現分佈式鎖,相對於使用數據庫鎖或者使用ZooKeeper,簡單方便,相對可靠,是最常用的方式,本文上一個實現demo。

在寫代碼之前,先拋出幾個常見問題,帶着問題去實現代碼,邏輯更清晰完整。

redis實現分佈式鎖,幾個常見經典問題:

問題一:鎖不被釋放

就是說一個服務在獲取到分佈式鎖後,在釋放鎖之前,由於某種原因比如服務掛掉了,導致鎖一直不會被釋放,那麼其他服務自然也就再也拿不到鎖了。針對這個問題。解決辦法一般都是加鎖時,同步設置鎖的過期時間。

問題二:服務A釋放了服務B的鎖,導致問題

比如,服務A在拿到鎖之後,設置過期時間1s,但是服務A由於自身某種原因,業務執行了2s才結束;那麼,在鎖過期後,1.5s的時候,服務B正好來拿鎖,並且拿到了,然後執行B的業務1s,那麼B業務還沒執行結束,A結束了,然後去釋放鎖,這個時候釋放的就是B拿到的鎖。

爲了避免這個問題,需要爲每個服務拿鎖的請求進行標記,避免分不清鎖是誰的。釋放鎖的時候,判斷此刻redis中的鎖是不是自己檔時獲取到的。

問題三:釋放鎖過程要保證原子性

針對問題二,說到釋放鎖的時候,要進行判斷是不是自己的鎖,這個判斷+釋放的過程,必須是原子性的,否則同樣會產生釋放別人鎖的問題。

比如,服務A解鎖時剛判斷鎖是自己的,於是下一步就是釋放鎖,結果釋放鎖之前,鎖正好過期,並且服務B剛好申請到了此鎖,那麼服務A接下來釋放的鎖,必然是服務B的。

問題四:多個服務同時獲取到了鎖

業務中,分佈式鎖的目的肯定是隻希望同時只有一個服務拿到鎖,不能多個服務同時拿到鎖,不然就失去了鎖的意義。

但是,有一種場景,比如A服務拿到了鎖,由於A業務執行時間過長,在解鎖之前鎖早已經被釋放,同時又被服務B獲取到,這樣實際上就是服務A和服務B都獲取到了鎖並且在執行業務邏輯,這是有問題的。

我們可能會想到,把鎖的過期時間設置的足夠長,比如1min,保證不少於服務A的業務執行時間,這樣的確可以,但是這樣又產生了別的問題,比如服務A掛掉了,那麼其他服務就需要等1min的時間才能拿到鎖,這個等待時間未免太久;

那麼,過期時間到底設置多久呢,這個不好設定,只能說設置爲服務A業務大多數執行的時長,比如服務A的業務大多數執行時間是200ms,那麼就設置爲1s,這個應該足夠了,但是萬一服務A某次業務由於特殊原因,執行了2s呢,還是會有上述問題。

那麼,我們會想,既然服務執行時間不是那麼穩定,這個鎖的過期時間是否能根據業務執行時間動態變化呢?答案是肯定的,本問Demo中,我們使用守護線程來動態延長鎖的過期時間。

問題五:redis服務宕機,如何保證鎖正常使用

此問題是針對單機版的redis做分佈式鎖,如果此單機redis服務掛掉,那麼redis鎖將會不可用。解決方式是使用redis集羣,但是,在集羣環境下,我們的分佈式鎖的加鎖策略是怎樣的呢?

原理是對redis集羣的每個節點都加鎖,然後判斷超過半數的節點返回true,表示加鎖成功。這裏推薦使用Redisson框架,它實現的RedLock就是解決這種場景的。

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();

Redissonh實現了可重入鎖,公平鎖等各種java中定義的鎖類型,可以解決上述的5個問題,相關資料可參考官方文檔:https://github.com/redisson/redisson/wiki/目錄

Demo:
以上前4個問題,在本文Demo中都有解決,並添加了註釋,下面看代碼。

先上主線程:

public class RedisLockDemo {
    //隨便弄個key的名字
    private static final String LOCK_KEY = "distributedLock:key";

	//主線程
    public static void main(String[] args) {
        //獲取redis客戶端
        RedisClient redisClient = RedisClient.getInstance();
        //開啓兩個工作線程,模擬分佈式服務中的兩個服務
        for (int i = 0; i < 1; i++) {
            startAWork(redisClient, String.valueOf(i), 10);
        }
    }

    /**
     * 開啓一個工作線程,模擬分佈式中的一個服務,搶分佈式鎖
     *
     * @param redisClient  redis客戶端
     * @param threadName   線程名稱
     * @param lengthOfWork 工作時長 秒
     */
    public static void startAWork(RedisClient redisClient, String threadName, int lengthOfWork) {
        new Thread(() -> {
            try {
                //生成並保存 獲取分佈式鎖的 請求id,解決問題二
                String requestId = UUID.randomUUID().toString();
                RedisLockThreadLocalContext.getThreadLocal().set(requestId);

                //獲取分佈式鎖,設置過期時間2s,解決問題一
                boolean result = RedisTool.tryGetDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId, 2000);

                if (result) {//如果成功獲取到鎖
                    //開一個守護線程延長鎖的過期時間
                    Thread thread = new Thread(() -> {
                        while (true) {
                            Jedis jedis = redisClient.getJedis();
                            try {
                                TimeUnit.SECONDS.sleep(1);
                                System.out.println("守護線程延長鎖的過期時間1s");
                                jedis.setex(LOCK_KEY, 1, requestId);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            } finally {
                                if (jedis != null) {
                                    jedis.close();
                                }
                            }
                        }
                    });
                    thread.setDaemon(true);
                    thread.start();

                    System.out.println("線程" + threadName + "拿到鎖,乾點事情");
                    //睡眠一定時間,模擬業務耗時
                    TimeUnit.SECONDS.sleep(lengthOfWork);
                } else {
                    System.out.println("線程" + threadName + "沒有拿到鎖");
                }
            } catch (Exception e) {
                //
            } finally {
                //釋放分佈式鎖
                String requestId = RedisLockThreadLocalContext.getThreadLocal().get();
                boolean result = RedisTool.releaseDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId);
                if (result) {
                    System.out.println("線程" + threadName + "釋放鎖");
                } else {
                    System.out.println("線程" + threadName + "釋放鎖失敗");
                }

            }
            System.out.println("線程" + threadName + "結束");
        }).start();
    }
}

主線程說明:

  • 主線程比較簡單,只開啓了兩個工作線程,模擬搶分佈式鎖的過程;
  • 具體的startAWork()方法中,新建了工作線程,使用睡眠時間來模擬執行業務邏輯的耗時;
  • 在 RedisTool#tryGetDistributedLock()方法中,傳入了過期時間參數,方法內容看下問代碼。這個參數解決了問題一;
  • 在 RedisTool#tryGetDistributedLock()方法中,傳入了requestId參數,這個是一個隨機UUID,用來標識每一次加鎖的線程,同時這個參數保存在了線程本地變量ThreadLocal中,解決了問題二。
  • 在開啓工作線程後,代碼中緊接着又開啓另外一個線程,並使用thread.setDaemon(true);標識爲守護線程;這個守護線程的任務就是死循環延長鎖的過期時間;當業務線程執行完畢後,這個守護線程會自動銷燬。注意循環的時間間隔要小於鎖的過期時間,一般設置爲過期時間的一半即可。

其他輔助類:

添加jedis依賴包:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

使用JedisPool初始化一個Jedis客戶端:

/**
 * Description:Redis客戶端
 */
public class RedisClient {
    private static final Logger LOGGER = LoggerFactory.getLogger(RedisClient.class);
    private static RedisClient instance = new RedisClient();
    private JedisPool pool;

    private RedisClient() {
        init();
    }

    public static RedisClient getInstance() {
        return instance;
    }

    public Jedis getJedis() {
        return pool.getResource();
    }

    /**
     * 初始化redis連接池
     */
    private void init() {
        int maxTotal = 10;
        String ip = "redis IP";
        String pwd = "redis 密碼";
        int port = 6379;

        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(maxTotal);
        jedisPoolConfig.setMaxIdle(20);
        jedisPoolConfig.setMaxWaitMillis(6000);
        pool = new JedisPool(jedisPoolConfig, ip, port, 5000, pwd);
        LOGGER.info("連接池初始化成功 ip={}, port={}, maxTotal={}", ip, port, maxTotal);
    }
}

上述代碼初始化了redis連接信息,屬於固定代碼,沒啥好解釋的,繼續往下看代碼。

/**
 * Description:redis分佈式鎖訪問工具類,提供具體的獲取鎖,釋放鎖方法
 */
public class RedisTool {

    private static final String LOCK_SUCCESS = "OK";
    private static final Long RELEASE_SUCCESS = 1L;

    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 嘗試獲取分佈式鎖
     *
     * @param jedis      Redis客戶端
     * @param lockKey    鎖的key
     * @param requestId  鎖的Value,值是個唯一標識,用來標記加鎖的線程請求;可以使用UUID.randomUUID().toString()方法生成
     * @param expireTime 過期時間 ms
     * @return 是否獲取成功,成功返回true,否則false
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        String result = null;
        try {
            result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return LOCK_SUCCESS.equals(result);
    }

    /**
     * 釋放分佈式鎖
     *
     * @param jedis     Redis客戶端
     * @param lockKey   鎖
     * @param requestId 請求標識,鎖的Value
     * @return 是否釋放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
        Object result = null;
        try {
            //使用lua腳本保證原子性
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return RELEASE_SUCCESS.equals(result);
    }
}
  • RedisTool工具類,提供了加鎖和解鎖的兩個方法;
  • tryGetDistributedLock()加鎖方法設置了過期時間,解決了問題一;
  • releaseDistributedLock()解鎖方法中使用了lua腳本,具備原子性,解鎖時先判斷key的value值,也就是當初加鎖保存的requestId是不是和自己線程保存的一致,一致才說明是自己當初加的鎖,方可進行解鎖;不一致說明自己加鎖已經自動過期,無需解鎖;這個解決了問題二和問題三。
/**
 * Description:保存redis分佈式鎖的請求id
 */
public class RedisLockThreadLocalContext {

    private static ThreadLocal<String> threadLocal = new NamedThreadLocal<>("REDIS-LOCK-LOCAL-CONTEXT");

    public static ThreadLocal<String> getThreadLocal() {
        return threadLocal;
    }
}

上述RedisLockThreadLocalContext中創建了一個threadLocal單例,用於保存加鎖時設置的requestId。當然在使用線程池時,get完數據要注意清除裏面的保存信息,這裏就不寫那麼詳細了。

以上就是本文全部內容,特別要注意本文開頭的那幾個問題。

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