深入理解Redis分布式锁

深入理解分布式锁

前言

为什么需要使用 分布式锁?

传统单体开发,以及集群开发都是 Jvm 进程内的锁如lock锁,synchronized锁,再比如cas原子类轻量级锁
一旦夸 Jvm 进程以及跨机器,这种锁就不适合业务场景,会存在问题。

对此需要一个分布式锁,唯一一把锁,所有服务都只有这一把锁。

分布式锁都有哪些实现方式,这里我们只讨论 Redis 实现的分布式锁的方式以及优缺点,是否是一个严格意义上的分布式锁。

分布式锁

加锁

redis 里提供了一个命令

set key value

将字符串值 value 关联到 key 。
如果 key 已经持有其他值, SET 就覆写旧值,无视类型。

下面代码模拟了下单减库存的场景,我们分析下在高并发场景下会存在什么问题

@RequestMapping("/deduct_stock0")
    public String deductStock0() {
        String lockKey = "lock:product_001";
        //锁和过期时间非原子性
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "test");
        //stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
        if (!result) {
            return "error_code";
        }

        try {
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock);
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
          stringRedisTemplate.delete(lockKey);

        }


        return "end";
    }
复制代码

如果单纯的实用这个命令来加锁,很有可能会造成死锁。
这个命令如果没执行完,后面所有的请求都会进不来。

如果这个命令里代码有死循环或者一直执行超时,锁就一直占着,还是会死锁。

对此,使用锁为了防止死锁,需要一个超时时间,去控制防止死锁。

加入过期时间

我们使用给key设置过期时间

stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
复制代码

但是这两个命令不是原子性,在多线程情况下,还是会存在问题。

原子性加锁

redis 里还提供了另一种锁

setnx key value

将 key 的值设为 value ,当且仅当 key 不存在。

若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。

对应到java代码

    @RequestMapping("/deduct_stock11")
    public String deductStock11() {
        String lockKey = "lock:product_001";

        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "clientId", 30, TimeUnit.SECONDS);
        if (!result) {
            return "error_code";
        }

        try {
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock);
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
                stringRedisTemplate.delete(lockKey);
        }
        return "end";
    }
复制代码

setIfAbsent这里将设置key和过期时间一起在执行,原子性问题解决了。

但是再多线程下还是存在不安全问题。

我们来分析一下:

在这里插入图片描述
这是一种情况

在这里插入图片描述

这种情况线程加的锁可能会被别的线程释放

还有其他情况暂时不分析了,总的来说这个锁还是不安全。

如何优化?

防止不同的线程删除非自己加的锁

    @RequestMapping("/deduct_stock1")
    public String deductStock1() {
        String lockKey = "lock:product_001";
        //防止不同的线程删除非自己加的锁
        String clientId = UUID.randomUUID().toString();
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
        if (!result) {
            return "error_code";
        }

        try {

            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock);
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
            if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {

              

                stringRedisTemplate.delete(lockKey);
            }
        }


        return "end";
    }
复制代码

这样看起来似乎完美了,但其实很是存在线程安全问题。

在这里插入图片描述
key存在缓存失效,多线程场景下,这种缓存失效的概率还是很存在的,还是会导致误删锁。

Redisson分布式锁

Redis 提供了一个java客户端 redisson,实现了一个分布式锁

Redisson详情

引入依赖

<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.18.0</version>
</dependency>  
复制代码

初始化

    @Bean
    public Redisson redisson() {
        // 此为单机模式
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379").setDatabase(0);
        return (Redisson) Redisson.create(config);
    }
复制代码

Redisson实现分布式锁

用法很简单


    @RequestMapping("/deduct_stock3")
    public String deductStock3() {
        String lockKey = "lock:product_001";
        //获取锁对象
        RLock redissonLock = redisson.getLock(lockKey);
        //加分布式锁
        redissonLock.lock();
        try {
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock);
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
            //解锁
            redissonLock.unlock();
        }


        return "end";
    }
复制代码

这个锁和我们 Lock 锁的实用方式是一样的,包括很多api也相似

其实Redisson对应juc下很多类都实现了一个分布式的一个api,用法都很相似。

这里截取一部分api目录图
在这里插入图片描述

Redisson lcok锁实现原理

可以看到其内部是实现了一个看门狗,在实例未执行结束之前不断的续锁。
在这里插入图片描述

我们来看一下源码

先看lock()加锁

在这里插入图片描述

tryAcquire 方法

在这里插入图片描述
tryAcquireAsync 方法

在这里插入图片描述

这里保证线程自己的锁,也就上上面uuid那块,这里内部自己定义了uuid+thread id 表示锁是否是自己的锁

在这里插入图片描述

真正加锁逻辑,这里是使用lua表达式 保证多个命令的原子性

在这里插入图片描述

继续看 tryAcquireAsync 加锁方法
在这里插入图片描述
tryLockInnerAsync 方法会异步执行,然后会回调 addListener 方法,进行重试续锁,

看门狗时间默认 30s
在这里插入图片描述

加锁成功 scheduleExpirationRenewal 方法,嵌套执行,表示定时延迟执行

同样是使用lua表达式判断锁是否存在存在则续锁,不存在则返回0

在这里插入图片描述

解锁

在这里插入图片描述

同样可以看到使用 Lua 表达式保证原子性 解锁
在这里插入图片描述

消息监听 释放信号量,唤醒其他阻塞线程

在这里插入图片描述

大概的一个加锁逻辑流程
在这里插入图片描述

RedLock

Redisson锁其实也存在一个问题,在主从或者集群模式下,matser加锁成功,此时还没同步到 slave,然后主节点挂了,从节点选举成功,此时从节点还是会加锁成功。这种场景会产生问题。

如何解决这种问题呢。

Redis 官网退出了 RedLock 红锁,多数节点写入成功就表示加锁成功。

在这里插入图片描述

相比于Redisson 解决了主切换时候从节点没锁的问题,红锁就一定安全吗?

其实在一定场景下红锁也是不安全的。

场景一:redis1 redis2 redis3 加锁成功,redis2挂了,此时redis2的从选举成功,还是继续可以加锁的。
场景 二:redis1 ,2,3 其中 2,3挂了,此时加锁会加不上。如果多增加节点呢?那每个节点都要加锁成功(大多数),节点越多,加锁时间越长,影响性能。

RedLock 也不一定安全,比Redisson肯能要稍微好一点,但是带来的问题也就,节点越多,加锁性能越低,严重影响redis性能,那为什么不直接用zk加锁呢?

RedLock 加锁代码实例

  String lockKey = "key";
        //需要自己实例化不同redis实例的redisson客户端
        RLock lock1 = redisson1.getLock(lockKey);
        RLock lock2 = redisson2.getLock(lockKey);
        RLock lock3 = redisson3.getLock(lockKey);

        /**
         * 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)
         */
        RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
        try {
            boolean res = redLock.tryLock(10, 30, TimeUnit.SECONDS);
            if (res) {
                //成功获得锁,在这里处理业务
            }
        } catch (Exception e) {
            throw new RuntimeException("lock fail");
        } finally {
            //最后都要解锁
            redLock.unlock();
        }
复制代码

一般来说推荐使用Redisson加锁,出现问题的概率小点,毕竟技术不够人工来凑。

最后总结

  • 异步方法回调内部嵌套看门狗
  • 内部方法嵌套方式进行重试续锁
  • 使用信号量进行阻塞线程
  • unlock 解锁进行闭环,内部 redis发布监听消息解锁

深入理解信号量Semaphore

猜你喜欢

转载自juejin.im/post/7231804369523015740