Redisson 实现分布式锁

Redisson 实现分布式锁

分布式锁的应用场景有哪些?实现的方式有哪几种?Redisson 又是怎么实现的?

回到顶部

1、应用场景、特点及实现方式

1.1、分布式锁的应用场景

主要有以下两类:

提升处理效率:避免重复任务的执行,减少系统资源的浪费(例如幂等场景)。

保障数据一致性:在多个微服务并发访问时,避免出现访问数据不一致的情况,造成数据丢失更新等情况。

以下是不同客户端并发访问时的场景:

1

1.2、分布式锁的特点

分布式锁主要有以下几个特点:

独占性:同一时刻只有一个线程能够持有锁。

可重入:同一个线程能够重复获取已获得的锁。

超时:在获得锁之后限制锁的有效时间,避免资源无法释放而造成死锁。

高可用:有良好的获取锁与释放锁的功能,避免分布式锁失效。

1.3、分布式锁的实现方式

目前主流的实现方式有以下几种:

基于数据库(例如基于 CAS 的乐观锁)。

基于 Redis。

基于 zookeeper(不只具有服务注册与发现的功能)。

基于 etcd。

本篇讲解是基于 Redis 的方式去实现分布式锁,具体实现用到的是 Redisson。

回到顶部

2、Redisson 入门

概念:Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格。通俗来将,就是在 Redis 基础上实现的分布式工具集合。点击访问项目地址。

这里以 SpringBoot 项目怎么使用 Redisson 实现分布式锁为例。

首先要做的是引入相关依赖。

2.1、引入依赖

java 复制代码
<!--redisson-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

依赖引入后下一步就是老生常谈的配置环境了。

2.2、添加配置

redisson 支持单点、主从、哨兵、集群等部署方式:

java 复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
 * redisson 配置
 */
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        //单点
        Config config = new Config();
        //地址及密码
        config.useSingleServer()
            .setAddress("redis://127.0.0.1:6379").setPassword("123456");
        return Redisson.create(config);

        //主从
//        Config config = new Config();
//        config.useMasterSlaveServers()
//            .setMasterAddress("redis://127.0.0.1:6379").setPassword("123456")
//            .addSlaveAddress("redis://127.0.0.1:6389")
//            .addSlaveAddress("redis://127.0.0.1:6399");
//        return Redisson.create(config);

        //哨兵
//        Config config = new Config();
//        config.useSentinelServers()
//            .setMasterName("myMaster")
//            .addSentinelAddress("redis://127.0.0.1:6379", "redis://127.0.0.1:6389")
//            .addSentinelAddress("redis://127.0.0.1:6399");
//        return Redisson.create(config);

        //集群
//        Config config = new Config();
//        config.useClusterServers()
//                //cluster state scan interval in milliseconds
//            .setScanInterval(2000)
//            .addNodeAddress("redis://127.0.0.1:6379", "redis://127.0.0.1:6389")
//            .addNodeAddress("redis://127.0.0.1:6399");
//        return Redisson.create(config);
    }
}

配置完成之后,下一步就是编写类进行测试。

2.3、编写接口

java 复制代码
@Autowired
private RedissonClient redissonClient;

@RequestMapping("/test")
public  void test() throws InterruptedException {
    //获取锁
    RLock lock = redissonClient.getLock("lock");
    //加锁,参数:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
    //注意:如果指定锁自动释放时间,不管业务有没有执行完,锁都不会自动延期,即没有 watch dog 机制。
    boolean isLock = lock.tryLock(1, 2, TimeUnit.SECONDS);
    try {
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        if (isLock) {
            System.out.println(format.format(System.currentTimeMillis()) + "获取分布式锁成功");
            Thread.sleep(1000);
            System.out.println(format.format(System.currentTimeMillis()) + "业务完成");
        } else {
            System.out.println(format.format(System.currentTimeMillis()) + "获取分布式锁失败");
        }
    } catch (Exception e) {
        throw new RuntimeException("业务异常");
    } finally {
        //当前线程未解锁
        if (lock.isHeldByCurrentThread() && lock.isLocked()) {
            //释放锁
            System.out.println("解锁");
            lock.unlock();
        }
    }
}

分布式锁的使用分成以下 3 步:

获取锁:根据唯一的 key 去 redis 获取锁。

加锁:拿到锁后在指定的等待时间内不断尝试对其加锁,超过等待时间则加锁失败。

解锁:分成两种情形:

第一如果在加锁的时候指定了自动释放时间,那么在此时间范围内业务提前完成的话就在 finally 手动释放锁,而如果业务没有完成也会自动释放锁,所以指定自动释放时间需要做非常仔细的考量;

第二就是没有指定自动释放时间,由于 redisson 有 watch dog (看门狗)机制,watch dog 默认的 releaseTime 是 30s,给锁加上 30s 的自动释放时间,并且每隔 releaseTime / 3 即 10 s 去检查业务是否完成,如果没有完成重置 releaseTime 为 30 s, 即锁的续约,所以一个业务严重阻塞的话会造成系统资源的极大浪费。到这里你应该能够明白分布式锁是没有完美的解决方案的。

纸上得来终觉浅,下面我们开始测试接口。

2.4、测试

要模拟多个线程同时获取分布式锁,这里我用到了 jmeter。

3 个线程同时访问,控制台打印结果如下:

java 复制代码
//第一个线程加锁成功
2023-09-17 15:33:19获取分布式锁成功   
2023-09-17 15:33:20业务完成
//第一个线程释放锁     
解锁
//第二个线程加锁成功    
2023-09-17 15:33:20获取分布式锁成功
//第三个线程加锁失败,第二个线程已占有锁且已过等待时间 20 - 19 = 1    
2023-09-17 15:33:20获取分布式锁失败 
2023-09-17 15:33:21业务完成
//第二个线程释放锁    
解锁

对打印结果有疑问?

首先第 1 个线程在 19 - 20 秒的时间范围内加锁,2、3 线程处于阻塞状态,

在 20 秒 1 线程释放锁后 2 线程刚好在等待时间的临界点加锁成功,3 线程就没那么好运了,在临界点抢不过 2 线程,加锁失败。

21 秒 2 线程完成业务释放锁。

根据以上业务分析 Redisson 的分布式锁有哪些特点:

独占性:1 线程加锁成功后是 2、3 线程处于阻塞状态无法加锁。

超时:指定 2 秒的自动释放时间,由于 key 存放在 redis,即使服务宕机,redis 也会自动删除 key 。

高可用:1 线程和 2 线程加锁成功后能够良好的解锁(这里配置了单点,真正的高可用一般需要哨兵或集群)。

那么可重入呢?难道 Redisson 没有该特性?

不急,继续往下看。

3、Redisson 可重入

现在我们不了解Redisson 是否能够可重入,即同一个线程能否多次获得同一个锁?

既然不了解,那么直接上测试。

3.1、编写接口

java 复制代码
/**
     * 重入方法1
     *
     * @throws InterruptedException
     */
@RequestMapping("/reentrant")
public void reentrant1() throws InterruptedException {
    //获取锁
    RLock lock = redissonClient.getLock("reentrant");
    //加锁,参数:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
    boolean isLock = lock.tryLock(10, 25, TimeUnit.SECONDS);
    try {
        if (isLock) {
            SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            System.out.println(format.format(System.currentTimeMillis()) + "获取分布式锁1成功");
            Thread.sleep(15000);
            //调用方法2
            reentrant2();
            System.out.println(format.format(System.currentTimeMillis()) + "业务1完成");
        }
    } catch (Exception e) {
        throw new RuntimeException("业务异常");
    } finally {
        //当前线程未解锁
        if (lock.isHeldByCurrentThread() && lock.isLocked()) {
            //释放锁
            System.out.println("分布式锁1解锁");
            lock.unlock();
        }
    }
}

/**
     * 重入方法2
     *
     * @throws InterruptedException
     */
public void reentrant2() throws InterruptedException {
    //获取锁
    RLock lock = redissonClient.getLock("reentrant");
    //加锁,参数:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
    boolean isLock = lock.tryLock(5, 25, TimeUnit.SECONDS);
    try {
        if (isLock) {
            SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            System.out.println(format.format(System.currentTimeMillis()) + "获取分布式锁2成功");
            Thread.sleep(10000);
            System.out.println(format.format(System.currentTimeMillis()) + "业务2完成");
        }
    } catch (Exception e) {
        throw new RuntimeException("业务异常");
    } finally {
        //当前线程未解锁
        if (lock.isHeldByCurrentThread() && lock.isLocked()) {
            //释放锁
            System.out.println("分布式锁2解锁");
            lock.unlock();
        }
    }
}

这里在方法 1 中调用方法 2,并且都尝试获取同一把锁。

3.2、验证

使用 postman 测试接口,控制台打印结果如下:

java 复制代码
//方法1加锁
2023-09-17 17:16:01获取分布式锁1成功
//方法2获取同一把锁并加锁    
2023-09-17 17:16:16获取分布式锁2成功
2023-09-17 17:16:26业务2完成
//方法2释放锁    
分布式锁2解锁 
2023-09-17 17:16:26业务1完成
//方法1释放锁       
分布式锁1解锁

根据上面的打印结果,能够推测出 Redisson 是拥有可重入的特性的!!!

原因很简单,在方法 1 持有锁的同时,方法 2 能够再次加锁,而如果不可重入,则方法 2 肯定无法对其加锁。

方法 1 加锁时, value 为 1

方法 2 再次加锁,value 为 2

这进一步验证了上面的猜测,当方法 1 加锁时 value 为 1,方法 2 再次加锁实现了 value + 1。

释放锁的过程则相反,方法 2 释放锁时 value - 1, 方法 1 再次释放锁 value = 0,直接删除锁。

你说了那么多,我还是有点懵,你能不能画个流程出来? 我。。。。竟无语凝噎。

3.3、具体流程

Redisson 实现可重入采用 hash 的结构,在 key 的位置记录锁的名称,field 的位置记录线程标识, value 的位置则记录锁的重入次数。

加锁时,如果线程标识是自己,则锁的重入次数加 1,并重置锁的有效期。

释放锁时,重入次数减 1,并判断是否为 0,如果为 0 直接删除,否则重置锁的有效期。

3.4、源码

这里我以 tryLock()方法为例。

直接点到底层运用的tryLockInnerAsync()方法, 能够看到用的是lua脚本进行加锁实现计数 + 1。

加锁源码(这里是最新的源码,不是上面依赖的 3.13.6)如下:

java 复制代码
 <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        return commandExecutor.syncedEval(getRawName(), LongCodec.INSTANCE, command,
                        //判断锁是否存在
                "if ((redis.call('exists', KEYS[1]) == 0) " +
                            //或者锁已经存在,判断threadId是否是自己
                            "or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " +
                        //锁次数加 1
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        //设置有效期
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        //返回结果
                        "return nil; " +
                    "end; " +
                    //没获取到锁,返回锁的剩余等待时间
                    "return redis.call('pttl', KEYS[1]);",
                Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
    }

lua脚本能够保证操作的原子性,这里判断锁是否存在或者是当前线程,锁的次数加 1 并重置有效期。

反之无法加锁则返回锁的剩余等待时间。

说完了加锁,接下来说解锁,以unlock()方法为例。

解锁源码如下:

java 复制代码
  protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                    //判断锁是否自己持有
              "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                        //不持有,直接返回
                        "return nil;" +
                    "end; " +
                    //是自己的锁,重入次数 - 1
                    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                    //可重入次数为否为 0
                    "if (counter > 0) then " +
                        //大于0,不能释放锁,重置有效期
                        "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                        "return 0; " +
                    "else " +
                        //等于0,删除锁
                        "redis.call('del', KEYS[1]); " +
                        "redis.call(ARGV[4], KEYS[2], ARGV[1]); " +
                        "return 1; " +
                    "end; " +
                    "return nil;",
                Arrays.asList(getRawName(), getChannelName()),
                LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId), getSubscribeService().getPublishCommand());
    }

同样使用到了 lua 脚本,如果是自己的线程,重入次数 - 1,当可重入次数为 0 删除锁,否则重置有效期。

这下子总算是明白了。那这个锁尝试加锁是实现重试的?

4、Redisson 重试

tryLock()方法第一个参数waitTime是尝试加锁的最大等待时间,在这个时间内会不断地进行重试。

上面说到tryLockInnerAsync()方法用于执行加锁并计数,当加锁失败返回锁的剩余等待时间。

往回查看,最终返回的是RFuture的对象。

参考资料:

https://juejin.cn/post/7135307906031091749

https://github.com/redisson/redisson/wiki/Table-of-Content

https://cloud.tencent.com/developer/article/1839606

https://developer.aliyun.com/article/1041019

自我控制是最强者的本能-萧伯纳

相关推荐
老朋友此林1 小时前
Redisson 实现分布式锁源码浅析
java·redis·分布式
月落星还在5 小时前
ZooKeeper的五大核心作用及其在分布式系统中的关键价值
分布式·zookeeper·云原生
幼儿园扛把子\15 小时前
RabbitMQ入门:从安装到高级消息模式
分布式·rabbitmq·java-rabbitmq
天才测试猿18 小时前
Pytest自动化测试框架pytest-xdist分布式测试插件
自动化测试·软件测试·分布式·python·测试工具·测试用例·pytest
元气满满的热码式21 小时前
使用Fluent-bit将容器标准输入和输出的日志发送到Kafka
分布式·云原生·kafka·kubernetes
宋发元21 小时前
从网络通信探究分布式通信的原理
分布式
*_潇_*21 小时前
0011__Apache Spark
大数据·分布式·spark
闯闯桑21 小时前
Spark 解析_spark.sparkContext.getConf().getAll()
大数据·分布式·spark
Clank的游戏栈1 天前
游戏服务器分区的分布式部署
服务器·分布式·游戏
安替-AnTi1 天前
Free QWQ - 世界首个免费无限制分布式 QwQ API
分布式·免费·qwen·开源大模型