四、Redis分布式锁

Java中的锁我们通常以synchronized 、Lock来使用它,但是只能保证在同一个JVM进程内中执行。如果在分布式集群环境下呢?分布式锁的实现有很多,比如基于数据库乐观锁、Redis、zookeeper、memcached、系统文件等。

    1、命令行加锁:SET lock_key random_value NX PX 5000    执行成功,则证明客户端获取到了锁。

        random_value:是客户端生成的唯一的字符串。

        NX:代表只在键不存在时,才对键进行设置操作。

        PX:5000 设置键的过期时间为5000毫秒。

        EX:可以让该 key 在超时之后自动删除。 

    2、Jedis加锁代码实现

        (1)、Long setnx(final String key, final String value):该方法只会对不存在的key设值,返回1代表获取锁成功;对存在的key设值,会返回0代表获取锁失败。value是客户端锁的唯一标识,不能重复,例:UUID或System.currentTimeMillis() (获取锁的时间)+锁持有的时间。

            命令格式:SETNX key value 

            注:执行setnx加了锁,需要再次执行语句设置过期时间,如果在setnx之后宕机,所就不会释放,就会产生死锁。无法保证两步操作的原子性,不可取 

        (2)、String getSet(final String key, final String value):返回redis中key对应的oldValue,然后再把key原来的值oldValue更新为新传入的值value。

            命令格式:GETSET key value

            注:当某个客户端锁过期时间,多个客户端开始争抢锁。虽然最后只有一个客户端能成功锁,但是获取锁失败的客户端调用getSet能覆盖获取锁成功客户端的value。当客户端的value被覆盖,会造成锁不具有标识性,会造成客户端没有释放锁。该方式同样不可取。

        (3)、String set(final String key, final String value, final String nxxx, final String expx, final long time):该方法合并普通的set()和expire()操作,保证NX和EX的原子性。不能把两个命令(NX EX)分开执行,如果在 NX 之后程序出现问题就有可能产生死锁。

private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX”;
private final String LOCK_PREFIX = "test:lock:key-";

public  boolean lock(String key, String value, long expireSeconds) {
    String key = LOCK_PREFIX + MD5.convertToMD5(key);
    String result = this.jedis.set(LOCK_PREFIX +key, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireSeconds);
    if (LOCK_MSG.equals(result)){
        return true ;
    }else {
        return false ;
    }
}

Jedis(单机)、JedisCluster(集群)

    3、RedisTemplate加锁代码实现

        (1)、加锁:

private final String LOCK_PREFIX = "test:lock:key-";
@Resource
private RedisTemplate<String, String> redisTemplate;

public boolean lock(String keyLock, String value, long expireSeconds){
    redisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + keyLock, value, expireSeconds, TimeUnit.SECONDS);
}

        (2)、解锁:

//单个删除
redisTemplate.delete(keyLock);
//批量删除
List<String> keys;
redisTemplate.delete(keys);

        (3)、指定key失效时间:redisTemplate.getExpire(key,TimeUnit.SECONDS);

        (4)、根据key获取过期时间:redisTemplate.expire(key, time, TimeUnit.SECONDS);

        (5)、判断key是否存在:redisTemplate.hasKey(key);

        (6)、Jedis和RedisTemplate区别

            Jedis是Redis官方推荐的面向Java的操作Redis的客户端,而RedisTemplate是spring-data-redis.jar中对JedisApi的高度封装。spring-data-redis.jar相对于Jedis来说可以方便地更换Redis的Java客户端,比Jedis多了自动管理连接池的特性,方便与其他Spring框架进行搭配使用如SpringCache。原生Jedis效率优于RedisTemplate。

    4、解锁:将key键删除,加锁时需要传递一个参数,将该参数作为这个 key 的 value,这样每次解锁时判断 value 是否相等从而知道这个锁是不是该进程自己的。

//此方式无法保证get和del的原子性
if(value.equals(redisTemplate.opsForValue().get(keyLock))){
    redisTemplate.delete(keyLock);
}

        解决方案:

        (1)、使用lua脚本

            使用lua脚本合并get()和del()操作,先判断value是否相等,相等再执行del命令。lua脚本能保证这里两个操作的原子性。

try {
    ...
} catch (Exception e) {
    ...
} finally {
    //获取Jedis资源
    Jedis jedis = RedisUtils.getJedis();
    //定义lua脚本
    String script = "if redis.call('get',KEYS[1]) == ARGV[1]" +
            "then" +
            "    return redis.call('del',KEYS[1])" +
            "else" +
            "    return 0" +
            "end";
    try {
        //对指定的key、value执行脚本
        Object o = jedis.eval(script, Collections.singletonList(keyLock), Collections.singletonList(value));
        if ("1".equals(o.toString())) {
            System.out.println("del redis lock ok");
        }else {
            System.out.println("del redis lock error");
        }
    }finally {
        //关闭Jedis资源
        if (null != jedis) {
            jedis.close();
        }
    }
}

        (2)、使用Redis事务

while (true) {
    //监视key
    redisTemplate.watch(keyLock);
    if(value.equals(redisTemplate.opsForValue().get(keyLock))){
        //开启事务支持
        redisTemplate.setEnableTransactionSupport(true);
        //开始事务
        redisTemplate.multi();
        redisTemplate.delete(keyLock);
        //执行命令,如果返回null,则事务执行失败,key对应的value被别人修改过,需要重新执行。
        List<Object> list = redisTemplate.exec();
        if(list == null){
            continue;
        }
    }
    //解除监视
    redisTemplate.unwatch();
    break;
}

    5、Redisson分布式锁实现原理:

         Redisson这个开源框架对Redis分布式锁的实现原理,Jedis和Redisson都是Java中对Redis操作的封装。Jedis只是简单的封装了Redis的API库,可以看作是Redis客户端,它的方法和Redis的命令很类似。Redisson不仅封装了redis,还封装了对更多数据结构的支持,以及锁等功能,相比于Jedis更强大。但Jedis相比于Redisson更原生一些,更灵活。

//获取Redisson对象
RLock lock = redisson.getLock(keyLock);
//加锁
redissonLock.lock();
try {
 ...
} catch (Exception e) {
  ...
} finally {
    //判断当前锁仍在锁定状态 && 是当前线程所持有。
    //如果当前判断不加可能会出现异常:attempt to unlock lock, not locked by current thread by node id: 0dsad...
    if (lock.isLocked() && lock.isHeldByCurrentThread()) {
        //解锁
        lock.unlock();
    }
}

         某个客户端要加锁,如果该客户端面对的是一个redis cluster集群,首先会根据hash节点只选择一台机器,然后发送一段LOCK的lua脚本到redis上,lua脚本用来封装复杂的业务逻辑,并保证这段复杂业务逻辑执行的原子性。

String LOCK = "if(redis.call('exists', KEYS[1]) == 0) then " +
        "redis.call('hset', KEYS[1], ARGV[2], 1); " +
        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
        "return nil; " +
        "end;" +
        "if(redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
        "return nil; " +
        "end;" +
        "return redis.call('pttl', KEYS[1]); ";

           锁的数据结构:

lockKey:{
          "8743c9c0-0795-4907-87fd-6c719a6b4586:1": 1
     }

        (1)、加锁机制:客户端1加锁,因为没有,所以执行第一个if

           参数释义:

           KEYS[1]:要加锁的key

           ARGV[1]:锁key的默认有效时间

           ARGV[2]:加锁的客户端的ID,例:8743c9c0-0795-4907-87fd-6c719a6b4586:1

           语句解析:

           exists KEYS[1]:判断要加的锁是否存在

           hset KEYS[1] ARGV[2] 1:执行加锁操作

           pexpire KEYS[1] ARGV[1]:设置有效时间

        (2)、锁互斥机制:客户端2加锁,因为存在客户端1之前加了锁,所以这个key的锁已存在,则执行第二个if,锁key的hash数据结构中,是否包含客户端2的ID,不包含返回锁key的剩余生存时间,此时客户端2会进入一个while循环,不停的尝试加锁。

        (3)、可重入加锁机制:重复执行lock.lock(),同一客户端多次加锁

           第一个if判断肯定不成立,“exists myLock”显示锁key已经存在。第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”。此时就会执行可重入加锁的逻辑,会用:incrby lockKey

通过这个命令,对客户端1的加锁次数,累加1。此时myLock数据结构变为下面这样:

lockKey:{
          "8743c9c0-0795-4907-87fd-6c719a6b4586:1": 2
     }

        (4)、释放锁机制:如果执行lock.unlock(),就可以释放分布式锁,其实就是每次都对lockKey数据结构中的那个加锁次数减1。如果加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:"del lockKey"命令,从redis里删除这个key。然后,另外的客户端2就可以尝试完成加锁了。

    6、Redis分布式锁的缺点:

           如果你对某个redis master实例,写入了lockKey这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。这时系统在业务语义上一定会出现问题, 导致各种脏数据的产生 。所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。

    7、死锁的四个必须要条件及解决方案

        (1)、必要条件

            ①、互斥条件:某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。

            ②、占有且等待条件:一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其他进程释放该资源。

            ③、不可剥夺条件:一个进程已经占有了某项资源,在末使用完之前,其他进程不能强行剥夺该资源。

            ④、循环等待条件:存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。