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阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342