Redis(八):Redis分布式鎖實現

其實Redis分布式鎖的介紹,前面幾篇文章中都要介紹到,只是沒有獨立成篇,今天把其單獨摘出來,便于學習和使用。

1、概述

當多個進程不在同一個系統中時,用分布式鎖控制多個進程對資源的操作或者訪問。

分布式鎖的實現要保證幾個基本點:

  • 1、互斥性:任意時刻,只有一個資源能夠獲取到鎖
  • 2、容災性:能夠在未成功釋放鎖的情況下,一定時限內能夠恢復鎖的正常功能
  • 3、統一性:加鎖和解鎖保證同一資源來進行操作

分布式鎖的實現方式有很多種:

2、Redis單機實現

2.1 原理

https://mp.weixin.qq.com/s?__biz=MzU0OTk3ODQ3Ng==&mid=2247483893&idx=1&sn=32e7051116ab60e41f72e6c6e29876d9&chksm=fba6e9f6ccd160e0c9fa2ce4ea1051891482a95b1483a63d89d71b15b33afcdc1f2bec17c03c&mpshare=1&scene=1&srcid=0416Kx8ryElbpy4xfrPkSSdB&key=1eff032c36dd9b3716bab5844171cca99a4ea696da85eed0e4b2b7ea5c39a665110b82b4c975d2fd65c396e91f4c7b3e8590c2573c6b8925de0df7daa886be53d793e7f06b2c146270f7c0a5963dd26a&ascene=1&uin=MTg2ODMyMTYxNQ%3D%3D&devicetype=Windows+10&version=62060739&lang=zh_CN&pass_ticket=y1D2AijXbuJ8HCPhyIi0qPdkT0TXqKFYo%2FmW07fgvW%2FXxWFJiJjhjTsnInShv0ap

Redisson底層原理簡單描述:
先判斷一個key存在不存在,如果不存在,則set key,同時設置過期時間和value(1),
這個過程使用lua腳本來實現,可以保證多個命令的原子性,當業務完成以后,刪除key;
如果存在說明已經有別的線程獲取鎖了,那么就循環等待一段時間后再去獲取鎖

如果是可重入鎖呢:
先判斷一個key存在不存在,如果不存在,則set key,同時設置過期時間和value(線程id:1),
如果存在,則判斷value中的線程id是否是當前線程的id,如果是,說明是可重入鎖,則value+1,變成(線程id:2),如果不是,說明是別的線程來獲取鎖,則獲取失敗;這個過程同樣使用lua腳本一次性提交,保證原子性。

如何防止業務還沒執行完,但是鎖key過期呢,可以在線程加鎖成功后,啟動一個后臺進程看門狗,去定時檢查,如果線程還持有鎖,就延長key的生存時間——Redisson就是這樣實現的。

其實Jedis也有現成的實現方式,單機、集群、分片都有實現,底層原理是利用連用setnx、setex指令
(Redis從2.6之后支持setnx、setex連用),核心是設置value和設置過期時間包裝成一個原子操作

jedis.set(key, value, "NX", "PX", expire)
image.png

注:setnx和setex都是原子性的
SETNX key value
將 key 的值設為 value ,當且僅當 key 不存在;若給定的 key 已經存在,則 SETNX 不做任何動作。
相當于是 EXISTS 、SET 兩個命令連用
SETEX key seconds value
將value關聯到key, 并將key的生存時間設為seconds(以秒為單位);如果key 已經存在,SETEX將重寫舊值;
相當于是SET、EXPIRE兩個命令連用

2.1 實現

public class RedisTool {

    private static final String LOCK_SUCCESS = "OK";
    //NX|XX, NX -- Only set the key if it does not already exist;
   //        XX -- Only set the key if it already exist.
    private static final String SET_IF_NOT_EXIST = "NX";
    //EX|PX, expire time units: EX = seconds; PX = milliseconds
    private static final String SET_WITH_EXPIRE_TIME = "PX";

   private static volatile JedisPool jedisPool = null;

    public static JedisPool getRedisPoolUtil() {
        if(null == jedisPool ){
            synchronized (RedisTool.class){
                if(null == jedisPool){
                    GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
                    poolConfig.setMaxTotal(100);
                    poolConfig.setMaxIdle(10);
                    poolConfig.setMaxWaitMillis(100*1000);
                    poolConfig.setTestOnBorrow(true);
                    jedisPool = new JedisPool(poolConfig,"192.168.10.151",6379);
                }
            }
        }
        return jedisPool;
    }



    /**
     * 嘗試獲取分布式鎖
     * @param lockKey 鎖
     * @param requestId 請求標識
     * @param expireTime 超期時間
     * @return 是否獲取成功
     */
    public static boolean tryGetDistributedLock(String lockKey, String requestId, int expireTime) {
        Jedis  jedis = jedisPool.getResource();

        try {
            String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

            if (LOCK_SUCCESS.equals(result)) {
                return true;
            }
            return false;
        }catch (Exception e){
            return false;
        }finally {
            jedisPool.returnResource(jedis);
        }

    }

    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 釋放分布式鎖
     * @param lockKey 鎖
     * @param requestId 請求標識
     * @return 是否釋放成功
     */
    public static boolean releaseDistributedLock(String lockKey, String requestId) {

        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        //如果使用的是切片shardedJedis,那么需要先獲取到jedis,
        //Jedis jedis = shardedJedis.getShard(key);
        Jedis  jedis = jedisPool.getResource();

        try {
            Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
            if (RELEASE_SUCCESS.equals(result)) {
                return true;
            }
            return false;
        }catch (Exception e){
            return false;
        }finally {
            jedisPool.returnResource(jedis);
        }
    }
}

從jedis源碼中可以發現上面的加鎖/釋放鎖指令在單機jedis/ShardedJedis/JedisCluster下都能實現(jedis版本為3.0以上),但是ShardedJedis可以直接加鎖,但是不能直接釋放鎖(沒有提供eval工具方法),但是可以先
Jedis jedis = shardedJedis.getShard(key) 獲得jedis,然后使用jedis.evel()來釋放鎖。

注:關于redisTool工具類的更優化實現見Java 函數式接口編程實例

3 、Cluster集群實現

上面介紹的分布式鎖的實現在Redis Cluster集群模式下,是存在問題的,Redis Cluster集群模式介紹見Redis(四):集群模式

整個過程如下:

  1. 客戶端1在Redis的節點A上拿到了鎖;
  2. 節點A宕機后,客戶端2發起獲取鎖key的請求,這時請求就會落在節點B上;
  3. 節點B由于之前并沒有存儲鎖key,所以客戶端2也可以成功獲取鎖,即客戶端1和客戶端2同時持有了同一個資源的鎖。

針對這個問題。Redis作者antirez提出了RedLock算法來解決這個問題

3.1 RedLock算法

RedLock算法思路如下:

  1. 獲取當前時間的毫秒數startTime;

  2. 按順序依次向N個Redis節點執行獲取鎖的操作,這個獲取鎖的操作和前面單Redis節點獲取鎖的過程相同,同時鎖超時時間應該遠小于鎖的過期時間

  3. 如果客戶端向某個Redis節點獲取鎖失敗/超時后,應立即嘗試下一個Redis節點;
    失敗包括Redis節點不可用或者該Redis節點上的鎖已經被其他客戶端持有

  4. 如果客戶端成功獲取到超過半數的鎖時,記錄當前時間endTime,同時計算整個獲取鎖過程的總耗時costTime = endTime - startTime,如果獲取鎖總共消耗的時間遠小于鎖的過期時間(即costTime < expireTime),則認為客戶端獲取鎖成功,否則,認為獲取鎖失敗

  5. 如果獲取鎖成功,需要重新計算鎖的過期時間。它等于最初鎖的有效時間減去第三步計算出來獲取鎖消耗的時間,即expireTime - costTime

  6. 如果最終獲取鎖失敗,那么客戶端立即向所有Redis發起釋放鎖的操作。(和單機釋放鎖的邏輯一樣)

3.2 缺陷

RedLock算法雖然可以解決單點Redis分布式鎖的安全性問題,但如果集群中有節點發生崩潰重啟,還是會對鎖的安全性有影響的。

假設一共有5個Redis節點:A, B, C, D, E。設想發生了如下的事件序列:

  1. 客戶端1成功鎖住了A, B, C,獲取鎖成功(但D和E沒有鎖住);
  2. 節點C崩潰重啟了,但客戶端1在C上加的鎖沒有持久化下來,丟失了;
  3. 節點C重啟后,客戶端2鎖住了C, D, E,獲取鎖成功;

這樣,客戶端1和客戶端2同時獲得了鎖(針對同一資源)。針對這樣場景,解決方式也很簡單,也就是讓Redis崩潰后延遲重啟,并且這個延遲時間大于鎖的過期時間就好。這樣等節點重啟后,所有節點上的鎖都已經失效了。也不存在以上出現2個客戶端獲取同一個資源的情況了

還有一種情況,如果客戶端1獲取鎖后,訪問共享資源操作執行任務時間過長(要么邏輯問題,要么發生了GC),導致鎖過期了,而后續客戶端2獲取鎖成功了,這樣就會導致客戶端1和客戶端2同時操作共享資源,相當于同一個時刻出現了2個客戶端獲得了鎖的情況。這也就是上面鎖過期時間要遠遠大于加鎖消耗的時間的原因。
服務器臺數越多,出現不可預期的情況也越多,所以針對分布式鎖的應用的時候需要多測試。
如果系統對共享資源有非常嚴格要求得情況下,還是建議需要做數據庫鎖的方案來補充,如飛機票或火車票座位得情況。
對于一些搶購獲取,針對偶爾出現超賣,后續可以通過人工介入來處理,畢竟redis節點不是天天奔潰,同時數據庫鎖的方案
性能又低。

3.3 實現

redisson包已經有對redlock算法封裝

public interface DistributedLock {
    /**
     * 獲取鎖
     * @author zhi.li
     * @return 鎖標識
     */
    String acquire();

    /**
     * 釋放鎖
     * @author zhi.li
     * @param indentifier
     * @return
     */
    boolean release(String indentifier);
}

public class RedisDistributedRedLock implements DistributedLock {

    /**
     * redis 客戶端
     */
    private RedissonClient redissonClient;

    /**
     * 分布式鎖的鍵值
     */
    private String lockKey;

    private RLock redLock;

    /**
     * 鎖的有效時間 10s
     */
    int expireTime = 10 * 1000;

    /**
     * 獲取鎖的超時時間
     */
    int acquireTimeout  = 500;

    public RedisDistributedRedLock(RedissonClient redissonClient, String lockKey) {
        this.redissonClient = redissonClient;
        this.lockKey = lockKey;
    }

    @Override
    public String acquire() {
        redLock = redissonClient.getLock(lockKey);
        boolean isLock;
        try{
            isLock = redLock.tryLock(acquireTimeout, expireTime, TimeUnit.MILLISECONDS);
            if(isLock){
                System.out.println(Thread.currentThread().getName() + " " + lockKey + "獲得了鎖");
                return null;
            }
        }catch (Exception e){
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public boolean release(String indentifier) {
        if(null != redLock){
            redLock.unlock();
            return true;
        }

        return false;
    }
}

4、項目中調用

RedisTool 中加鎖/釋放鎖實現后,在項目中怎么調用呢,如果直接在業務代碼中調用,那一方面太麻煩了,另一方面耦合太多,如果有一天需要改動其中的邏輯,那在項目中需要改動很多地方。

這里我們使用AOP+注解來實現調用,即在需要加鎖的方法上添加注解,然后再AOP中,統一加鎖,釋放鎖。

4.1 自定義注解

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLockAnnotation {
    int expire() default 5;

    String field() default "";

}

4.2 自定義切面

@Aspect
@Service
public class RedisLockAspect {

      //方法切點
     @Pointcut("@annotation(redisLock.RedisLockAnnotation)")
     public  void methodAspect() {

     }

    @Around("methodAspect()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        Signature signature = joinPoint.getSignature();
        Method method = ((MethodSignature) signature).getMethod();
        Method realMethod = joinPoint.getTarget().getClass().getDeclaredMethod(signature.getName(),method.getParameterTypes());
        RedisLockAnnotation redisLockAnnotation = realMethod.getAnnotation(RedisLockAnnotation.class);
        int expireTime = redisLockAnnotation.expire();
        String field = redisLockAnnotation.field();

        Map<String, Object> params = getNameAndValue(joinPoint, field);
        if (params==null){
            throw new RuntimeException("params is not allowed null");
        }
        String url = method.getDeclaringClass().getSimpleName() + "." + method.getName();
        String reqParam = JSONObject.toJSONString(params);

        //redis加鎖
        String localKey = url + ":" + reqParam;
        String requestFlag = UUID.randomUUID().toString();
        boolean lock = RedisTool.tryGetDistributedLock(localKey, requestFlag, expireTime);
        if(!lock){
            return "鎖已存在";
        }

        //加鎖成功
        Object result = null;
        try {
            //執行方法
            result =joinPoint.proceed();
        } finally {
            //方法執行完之后進行解鎖
            RedisTool.releaseDistributedLock(localKey, requestFlag);
        }

        return result;
    }


    /**
     * 獲取參數Map集合
     */
    private Map<String, Object> getNameAndValue(ProceedingJoinPoint joinPoint, String filedList) {
        Map<String, Object> param = new HashMap<String, Object>();
        Object[] paramValues = joinPoint.getArgs();
        String[] paramNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
        for (int i = 0; i < paramValues.length; i++) {
            List<String> targetFields = Arrays.asList(filedList.split(","));
            JSONObject valueDetialsJson = (JSONObject) JSONObject.toJSON(paramValues[i]);
            //得到屬性
            for (int j = 0; j < targetFields.size(); j++) {
                if (valueDetialsJson.get(targetFields.get(i))!=null){
                    param.put(targetFields.get(i), valueDetialsJson.get(targetFields.get(i)));
                }
            }

        }
        if (param != null && param.size() > 0) {
            return param;
        }
        for (int i = 0; i < paramNames.length; i++) {
            param.put(paramNames[i], paramValues[i]);
        }

        return param;
    }

}

4.3 使用

public class RedidLockTest1 {

    @RedisLockAnnotation(field = "userId")
    public Object test1(String userId){
        return userId+"==";
    }

}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,546評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,570評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,505評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,017評論 1 313
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,786評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,219評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,287評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,438評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,971評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,796評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,995評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,540評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,230評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,918評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,697評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374