问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

Redisson分布式锁原理详解:可重入、可重试、主从一致性解决方案

创作时间:
作者:
@小白创作中心

Redisson分布式锁原理详解:可重入、可重试、主从一致性解决方案

引用
CSDN
1.
https://blog.csdn.net/Tingfeng__/article/details/142027409

Redis分布式锁在实际应用中存在诸多问题,如不可重入、不可重试、超时释放和主从一致性等。为了解决这些问题,Redisson应运而生。本文将深入探讨Redisson分布式锁的实现原理,包括可重入锁、锁重试机制、WatchDog机制以及如何解决主从一致性问题。

1.0 基于 Redis 实现的分布式锁存在的问题

在基于setnx实现的分布式锁中,存在以下问题:

  1. 不可重入:同一个线程无法多次获取同一把锁。
  2. 不可重试:获取锁只尝试一次就返回false,没有重试机制。
  3. 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。
  4. 主从一致性:如果Redis提供了主从集群,主从同步延迟,当主机宕机时,如果未来得及同步到其他机器上,则就会出现多线程获取锁成功情况,从而导致线程安全问题。

为了解决这些问题,Java提供了Redisson分布式服务类。

2.0 Redisson 功能概述

Redisson是一个在Redis基础上实现的Java驻内存数据网络。它不仅提供了一系列的分布式Java常用对象,还提供了许多分布式服务,其中包含了各种分布式锁的实现。Redisson解决了不可重入问题、不可重试问题、超时释放问题、主从一致性问题。比如说,分布式锁的可重入锁、公平锁、联锁、红锁等等。

3.0 Redisson 具体使用

1)引入依赖

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

2)配置 RedissonClient类

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient client(){
        //配置类
        Config config = new Config();
        //添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://8.152.162.159:6379").setPassword("****");
        //创建客户端
        return Redisson.create(config);
    }
}

3)使用 RedissonClient类

@Autowired
RedissonClient redissonClient;

@Test
void contextLoads() throws InterruptedException {
    //先获取锁对象,根据业务来锁定资源
    RLock lock = redissonClient.getLock("lock");
    //尝试获取锁
    //tryLock() 进行了重写,有无参、只有两个参数、有三个参数
    boolean b = lock.tryLock(1, TimeUnit.SECONDS);
    
    if (b){
        System.out.println("成功获取锁!");
    }else {
        System.out.println("获取锁失败!");
    }
    
}

先注入RedissonClient对象,根据getLock("锁")方法获取RLock lock锁对象,根据业务需要对资源进行锁定。调用lock对象中的tryLock()方法来尝试获取锁,该方法进行了重写:

  1. boolean tryLock():当获取锁失败时,默认不等待,就是不重试获取锁,默认锁的超时时间为30秒。
  2. boolean tryLock(long time, TimeUnit unit):在time时间内会进行重试尝试获取锁,unit为时间单位。默认锁的超时时间为30秒。
  3. boolean tryLock(long waitTime, long leaseTime, TimeUnit unit):在获取锁失败时,在waitTime时间内进行重试尝试获取锁,锁的超时时间为leaseTime秒,unit为时间单位。

最后,调用lock对象中的方法unlock()来释放锁。

具体代码:

@Autowired
RedissonClient redissonClient;

@Test
void contextLoads() throws InterruptedException {
    //先获取锁对象,根据业务来锁定资源
    RLock lock = redissonClient.getLock("lock");
    //尝试获取锁
    //tryLock() 进行了重写,有无参、只有两个参数、有三个参数
    boolean b = lock.tryLock(1, TimeUnit.SECONDS);
    if (!b){
        System.out.println("获取锁失败!");
    }
    try {
        System.out.println("获取锁成功!");
    } catch (Exception e) {
        throw new RuntimeException(e);
    } finally {
        //释放锁
        lock.unlock();
    }
}

4.0 Redisson 可重入锁原理

在之前的基于setnx实现的分布式锁是不支持可重入锁,举个例子:线程一来获取锁,使用setnx来设置,当设置成功,则获取锁成功了,线程一在获取锁成功之后,再想来获取相同的锁时,则再次执行setnx命令,那一定是不可能成功获取,因为setxn已经存在了,这就是基于setnx来实现分布式锁不可重入锁的核心原因。

而对于Redisson可以实现可重入锁,这是如何实现的呢?

其核心原因是基于Redis中的哈希结构实现的分布式锁,利用key来锁定资源,对于field来标识唯一成功获取锁的对象,而对于value来累计同一个线程成功获取相同的锁的次数。

具体实现思路:

  1. 尝试获取锁:
  • 先判断缓存中是否存在key字段,如果存在,则说明锁已经被成功获取,这时候需要继续判断成功获取锁的对象是否为当前线程,如果根据key field来判断是当前线程,则value += 1且还需要重置锁的超时时间;如果根据key field判断不是当前线程,则直接返回null。如果缓存中不存在key字段,则说明锁还没有被其他线程获取,则获取锁成功。
  1. 释放锁:
  • 当业务完成之后,在释放锁之前,先判断获取锁的对象是不是当前线程,如果不是当前线程,则说明可能由于超时,锁已经被自动释放了,这时候直接返回null;如果是当前线程,则进行value -= 1,最后再来判断value是否大于0,当大于0时,则不能直接释放锁,需要重置锁的超时时间;当value = 0时,则可以真正的释放锁。

又因为使用Java实现不能保证原子性,所以需要借助Lua脚本实现多条Redis命令来保证原则性。

尝试获取锁的Lua脚本:

释放锁的Lua脚本:

5.0 Redisson 锁重试原理

在之前基于setnx实现的分布式锁,获取锁只尝试一次就返回false,没有重试机制。

而Redisson是如何实现锁重试的呢?

实现锁重试

追踪源代码:

得到该类:

首先,将等待时间转换为毫秒,接着获取当前时间和获取当前线程ID,再接着第一个尝试去获取锁,将参数waitTime最大等待时间,leaseTime锁的超时时间,unit时间单位,threadId当前线程ID传进去tryAcquire方法中。

紧接着来查看tryAcquire方法:

再查看调用的tryAcquireAsync方法:

当指定了leaseTime锁的超时时间,则会调用tryLockInnerAsync方法;当没有指定leaseTime锁的超时时间,则会调用getLockWatchdogTimeout方法,默认超时时间为30秒。

接着查看tryLockInnerAsync方法:

可以看到,这就是尝试获取是的Lua脚本执行多条Redis命令。

细心可以发现,如果正常获取锁,则返回null;如果获取锁失败,则返回当前锁的TTL,锁的剩余时间。

因此最后将当前锁的TTL返回赋值给Long ttl变量。

再接着往下:

当ttl == null,则说明当前线程成功获取锁,因此就不需要接着往下再次尝试去获取锁了。相反,当ttl != null,则需要接着往下走,重新尝试去获取锁。

判断time等于当前时间减去在第一次获取锁之前的时间,time也就是最大的等待时间还剩多少。判断time是否小于0,若小于0则已经到了最大等待时间了,所以不需要再继续等下去了,直接返回false即可。

若time还是大于0,则接着往下走:

调用subscribe方法,该方法可以理解成订阅锁,一旦锁被释放之后,该方法就会收到通知,然后再去尝试获取锁。

回顾在释放锁的时候,使用Redis命令中的redis.call('publish', KEYS[2], ARGV[1])来发布消息,通知锁已经被释放,一旦锁被释放,那么就可以成功订阅。

因此,在订阅锁的过程中,并不是一直死等下去,而是在time剩余最大等待时间之内,如果可以订阅锁成功,才会去尝试获取锁。如果在time时间内,订阅锁失败,则会取消订阅,再返回false。

接着往下走,当在time时间内订阅锁成功,会更新time时间,也就是更新最大的等待时间,判断time小于0,则返回false,如果time还是大于0,则到了真正尝试第二次获取锁,调用tryAcquire(waitTime, leaseTime, unit, threadId)方法,将返回值再次赋值给变量ttl,判断ttl == null,则说明成功获取锁了,直接返回true;判断ttl != null,则第二次获取锁还是失败,由需要更新time了,因为在调用尝试获取锁的过程中,消耗时间还是挺大的,同理,判断更新完之后的time是否大于0,如果time小于0,则超过了剩余最大锁的超时时间,返回false;

如果判断time仍旧大于0:

那么先判断锁的过期时间ttl与剩余时间time,如果ttl < time,则类似订阅方法一样的思路,选择等待ttl锁的过期时间,当ttl过期之后,就会订阅该锁;如果time < ttl,则ttl还没有释放,就不需要等ttl了,等到time结束还没有订阅到锁,则time也就小于0了,如果在time时间内获取到锁,再次尝试去获取锁,同样的,当在ttl时间内,成功订阅了,而且time > 0,则会第三次去尝试获取锁。之后的步骤都是如此,这里使用了do whlie循环,判断循环成立为time > 0,当time < 0,则会退出循环。

总结,在解决可重试锁过程中,并不是循环不断的调用tryAcquire(waitTime, leaseTime, unit, threadId)方法来获取锁,这样容易造成CPU的浪费,而是通过等待锁释放,再去获取锁的方式来实现的可重试锁,利用信号量(Semaphore)和发布/订阅(PubSub)模式实现等待、唤醒、获取锁失败的重试机制。

6.0 Redisson WatchDog 机制

在之前基于setnx实现的分布式锁,锁超时释放虽然可以避免死锁,但是如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。

6.1 Redisson 是如何解决超时释放问题的呢?

解决超时释放的核心是:当leaseTime == -1时,为了保证当前业务执行完毕才能释放锁,而不是业务还没有执行完毕,锁就被自动释放了。

追踪源代码:

当leaseTime == -1时,默认锁的最大超时时间为30秒,会执行以下代码。

接着点进去:

WatchDog会在锁的过期时间到期之前,定期向Redis发送续约请求,更新锁的过期时间。这通常是通过设置一个较短的过期时间和一个续约间隔来实现的。

如果持有锁的线程正常释放锁,WatchDog会停止续约操作。如果持有锁的线程崩溃或失去响应,WatchDog会在锁的过期时间到达后自动释放锁。

简单概述一下WatchDog机制:在获取锁成功之后,就会调用scheduleExpirationRenewal(threadId)方法开启自动续约,具体是由在map中添加业务名称和任务定时器,这个定时器会在一定时间内执行,比如说10秒就会自动开启任务,而该定时器中的任务就是不断的重置锁的最大超时时间,使用递归,不断的调用重置锁的时间,这就保证了锁是永久被当前线程持有。

这样就可以保证执行业务之后,才会释放锁。释放锁之后,会取消定时任务。

7.0 Redisson MultiLock 原理

7.1 Redisson 分布式锁是如何解决主从一致性问题的呢?

先搞清楚什么是主从一致性问题,在集群的Redis中会区分出主力机和一般机器,在写Redis命令会放到主力机中运行,而主力机和一般机器需要保证数据都是一样的,也就是主从同步数据,在主力机中执行写命令时,突然发生宕机,未来得及将数据同步到其他一般机器中,而且当主力机宕机之后,会选出一台一般机器充当主力机,这时候的主力机没有同步之前的数据,那么其他线程再来写命名的时候就会出现问题了,这出现了主从不一致性。

那么Redisson是如何来解决该问题呢?

在多主架构中,每台主机都可以接收写请求,这样即使某一台主机宕机,其他主机仍然可以继续处理写请求。

当某一台主机宕机后,如果在它恢复之前有新的写操作发生,可能会导致数据不一致。通过比较不同主机的数据状态,可以很容易地发现这些不一致的问题。

当宕机的主机恢复后,可以通过与其他主机的数据进行比较,找出差异并进行数据同步,确保所有主机的数据一致。

简单来说,设置多台主力机,每一次写命令都是一式多份,当某一台主力机出现宕机了,主从未来得及同步时,再写命令,同样一式多份,这样充当主力机出现了跟其他主力机不同的结果时,就很容易的发现问题了。

通过设置多台主力机并进行写操作的多份复制,可以有效提高系统的可靠性,并在出现问题时快速发现和解决数据不一致的问题。

具体使用:

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号
Redisson分布式锁原理详解:可重入、可重试、主从一致性解决方案