redis--分布式锁详解

分布式锁

我们已经发现在集群模式下,Synchronized的锁失效了,Synchronized只能够保证单个JVM内部的多个线程的互斥,而没有办法让我们集群下的多个JVM内部的线程互斥。

如果想要解决这种情况,我们必须要使用分布式锁。

分布式锁工作原理

Synchronized锁利用JVM的锁监视器来控制线程,在JVM的内部只有一个锁监视器,所以只能有一个线程获取锁,可以实现线程间的互斥,当有多个JVM的时候,就会有多个锁监视器,就会有多个线程获取锁,这样就没有办法实现多JVM进程之间的互斥。

要想解决这个问题,就不能够使用JVM内部的锁监视器,必须让多个JVM去使用同一个锁监视器,因此,这个锁监视器一定独立于JVM之间,可以被多JVM进程识别到。此时无论是资源内部的还是多JVM的进程,都应该来找这个锁监视器来获取锁。这样只会有一个线程能够获取锁。就能够实现多JVM进程之间的互斥。

过程分析

假设有两个JVM,JVM1中的线程A,来获取锁,就需要去寻找外部的锁监视器,一旦获取成功,就回去记录当前获取锁的是这个线程A,在高并发情况下,此时如果有其他线程也来获取锁,比如JVM2线程中的线程C,但因为外部的锁监视器中已经记录锁的持有者了,因此线程C获取锁失败,则需要等待锁释放,当线程A释放锁成功后,线程C来获取锁,之后执行自己的业务。此时,无论是单个JVM内部的线程互斥,还是多个jvm之间的线程互斥,都会被同一个锁监视器监视,达到一个互斥效果。也就是一个线程能拿到线程锁。这样就避免了并发安全问题的发生。

总结:

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

特性:

  • 多线程可见:使用redis,能够让所有JVM都能看到

  • 互斥:必须确保不管谁来访问,只能够有一个线程拿到,其余都要失败

  • 高可用:大多数情况下获取锁的时候都应该是成功的,

  • 高性能:因为加锁本身就会影响业务的性能,因此获取锁的速度需要做到高并发

  • 安全性:需要考虑获取锁之后的异常情况处理

以上是分布式锁需要满足的基本特性,除此之外,还有一些功能性的特征,

  • 是否满足可重入性

  • 阻塞锁还是非阻塞锁

  • 公平锁还是非公平锁

分布式锁的实现

分布锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:

MySQLRedisZookeeper
互斥利用MySQL本身的互斥锁机制利用setnx的互斥命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开连接,自动释放锁利用锁超时机制,到期释放临时节点,断开连接自动释放

MySQL:

互斥锁机制:MySQL事务在执行写操作时,MySQL会自动分配互斥锁,在多个事务之间就是互斥的。

举例:我们可以在业务执行之前去MySQL中申请一个互斥锁,再去执行业务,当业务执行完之后,再去提交事务,当业务抛出异常时,会自动触发回滚,锁同时也释放。

可用性:MySQL支持主从模式,可用性良好

安全性:当MySQL断开连接后,锁也会自动释放

Redis:

互斥:setnx:值再往redis数据库set数据时,只有数据不存在时才能set成功,如果存在,则set失败,假如在多线程高并发场景下,所有线程是同一个key,则只能有一个线程可以成功,这就实现了互斥。

释放锁只需要将该key的值删除即可,这样其他线程就可以往redis数据库中存储了

可用性:不仅支持主从模式,还支持集群模式、哨兵模式。

安全性:利用redis的key过期机制,可以给key上设置过期时间,这样当服务宕机时,就算没有删除该key,一旦过期,就会自动释放

Zookeeper:

互斥:Zookeeper内部可以去创建数据节点,节点具备唯一性与有序性,还可以创建临时节点,唯一性即在创建节点时,节点不能重复。

有序性即每创建一个节点,节点的Id是递增的。

利用有序性实现互斥:在多线程高并发场景下,在Zookeeper中创建节点,每个节点的id就是单调递增的,如果约定ID最小的那个线程获取锁成功,这样一来就实现互斥。

利用唯一性实现互斥:在多线程高并发场景下,在Zookeeper中创建节点,节点名称设置一样,这样一来就只有一个线程可以成功。

一般情况下利用有序性实现互斥。

释放锁就只需要将节点删除即可。

可用性:支持集群模式

性能:Zookeeper的集群模式强调 强一致性,而这种强一致性会导致主从之间做数据同步需要消耗一定的时间,性能相对来讲较于redis差一些。

安全性:一般创建的都是临时节点,就算服务宕机,当指定时间,节点就会自动释放。

基于Redis的分布式锁

思路:

实现分布式锁时需要实现的两个基本方法:

  • 获取锁:

    • 互斥:确保只能有一个线程获取锁(利用setnx命令实现)

注意事项:必须确保获取锁的动作与和添加过期时间的动作保证原子性(要么都成功,要么都失败)

解决方案:在set命令后可以跟许多参数,其中就包括ex(设置过期时间),以及nx(互斥效果)。

我们可以在一个set命令中通过设置参数同时实现互斥效果,以及设置过期时间,这样就可以实现原子性的操作。

# 添加锁,NX是互斥,EX是设置超时时间
set key value NX EX 10

  • 释放锁:

    • 手动释放(del命令实现)(理想情况)

    • 设置过期值令其超时释放(利用expire命令设置过期时间)(紧急情况)

发现问题:在获取锁失败之后,线程应该做什么?

在JDK中提供的锁有两种机制:

  • 阻塞:获取锁失败,进行阻塞等待,等到锁被释放为止

  • 非阻塞:获取锁失败,结束返回一个结果。

而在redis获取锁失败后,会进行一个非阻塞的机制,因为阻塞线程使用的是操作系统底层原语mtuex,每次阻塞需要操作系统从用户态切换到内核态,比较消耗性能,其次,阻塞等待实现较为麻烦。

实现流程:

线程开启,尝试获取锁,执行命令,结果有两种,OK,NIL,NIL代表失败,证明锁已经被获取,OK代表获取互斥锁成功,则执行业务,业务执行完之后,释放锁。如果在执行业务期间出现服务宕机情况,一旦到达过期时间,锁自动释放,避免死锁的发生。

代码实现

案例:基于redis实现分布式锁初级版本

需求:定义一个类,实现下面接口,利用redis实现分布式锁功能

public interface ILock {
    /**
     * 尝试获取锁
     * @param timeoutSec 锁的过期时间,单位秒,过期后自动释放
     * @return true代表获取锁成功,false代表获取锁失败 采用非阻塞模式,获取锁只尝试一次,返回结果
     */
    boolean tryLock(long timeoutSec);
    /**
     * 释放锁
     */
    void unlock();
        
    
}

代码展示:

@RequiredArgsConstructor
public class SimpleRedisLock implements ILock{
    //业务名称
    private  final String name;
    //锁的key前缀
    private  final String KEY_PREFIX = "lock:";
    private  final StringRedisTemplate  stringRedisTemplate;
    //注意事项:设置锁的名称应该与业务相关,不能将锁名称写死,不能让所有业务获取同一把锁
    //  锁的业务应该与锁的名称相关,不同的业务获取不同的锁
    @Override
    public boolean tryLock(long timeoutSec) {
        //  获取锁的key值
        String key = KEY_PREFIX + name;
        //  获取当前线程的id,作为线程标识,可以识别哪个线程拿到的锁
        long value = Thread.currentThread().getId();
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, value+ "", timeoutSec, TimeUnit.SECONDS);
        //自动拆箱可能导致空指针,因此使用工具类判断
        return Boolean.TRUE.equals(flag);
    }

    @Override
    public void unlock() {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

进行测试:

在模拟集群下再次进行测试:

//创建锁对象,锁的范围缩小到每个用户,避免锁的粒度太大,提升并发效率
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁,设置超时时间与你的业务执行时间相关
boolean isLock = lock.tryLock(1200);
//判断是否获取锁成功
if (!isLock) {
    //获取锁失败,返回错误或者重试
    return Result.fail("不允许重复下单");
}
//利用实现类自调用,防止事务自调用问题
    //第二种方法:创建代理对象,利用代理对象调用方法,防止事务自调用问题
try {
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId, seckillVoucher);
} catch (IllegalStateException e) {
    //释放锁
    lock.unlock();
}

测试:

打开ApiFox发送两个请求,在idea中打上断点,查看运行状态:

image-20250608173206122

这样就证明现在只有一个服务请求成功了。因为虽然有两个进程,但是是从同一台redis机器上去获取锁,并且获取锁的名字一致。

这就是redis分布式锁的实现思路。

Redis分布式锁误删问题

问题发现:

在多线程高并发场景下,线程一来获取锁,因为只有一个线程,因此获取成功,开始执行业务,业务发生堵塞,堵塞时间超过过期时间,锁被提前释放,线程二获取锁成功,开始执行业务,在执行业务期间,线程一业务执行结束,释放锁,但是释放的是线程二的锁,因此线程三获取锁成功,开始执行业务,这样就又出现了并行执行的现象,并发安全问题。

image-20250608174617242

原因:线程一在释放锁的时候并没有检查是否是自己的锁,如果在释放锁的时候进行判断,判断锁的标识是否一致,锁是否为自身的锁。如果是可以删除,如果不是就不可以。

修改业务流程:

在业务完成后检查锁上的唯一标识是否相同,是则释放,如果不是,说明自己的锁已经释放。

image-20250608180146848

案例:改进Redis的分布式锁

需求:修改之前的分布式锁实现,满足:

  • 在获取锁时存入线程标识(可以使用UUID表示)

  • 在释放锁时现获取锁的线程标识,判断是否与当前线程标识相同

    • 如果一直则释放锁

    • 如果不一直则不释放锁

注意事项:存储线程标识时,最好使用UUID,之前使用的线程ID就是一个递增数字,在JVM内部每创建一个线程,数字就会递增。如果是在集群的情况下,多个JVM进程,那么线程ID一定有重复的。不具备唯一性,因此不能使用。因此我们可以在创建锁时,在线程ID后面加上UUID,两者结合,用UUID区分JVM进程,用线程ID区分线程。

代码实现:

 @RequiredArgsConstructor
 public class SimpleRedisLock implements ILock{
     //业务名称
     private  final String name;
     //锁的key前缀
     private  final String KEY_PREFIX = "lock:";
     //线程标识 toString("true") 去掉符号UUID的-
     private  final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
     private  final StringRedisTemplate  stringRedisTemplate;
     //注意事项:设置锁的名称应该与业务相关,不能将锁名称写死,不能让所有业务获取同一把锁
     //  锁的业务应该与锁的名称相关,不同的业务获取不同的锁
     @Override
     public boolean tryLock(long timeoutSec) {
         //  获取锁的key值
         String key = KEY_PREFIX + name;
         //  获取当前线程的id,作为线程标识,可以识别哪个线程拿到的锁
         String value = ID_PREFIX + Thread.currentThread().getId();
         Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS);
         //自动拆箱可能导致空指针,因此使用工具类判断
         return Boolean.TRUE.equals(flag);
     }
 ​
     @Override
     public void unlock() {
         //获取线程标识
         String value = ID_PREFIX + Thread.currentThread().getId();
         //获取锁中的标识
         String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
         //判断标识是否一致
         if (value.equals(id)) {
             //一致,释放锁
             stringRedisTemplate.delete(KEY_PREFIX + name);
         }
 ​
     }
 }

开始测试:

开启两个服务,利用ApiFox发送两个请求:

让其中一个服务拿到锁:

image-20250608183906109

并将锁删除,用来模拟业务操作时间大于超时时间

再让另一个服务拿到锁:

image-20250608183957796

在让第一个服务继续进行到释放锁阶段:拿到标识,进行判断:

image-20250608184048027

发现不一致,则直接结束,没有权限删除:

再让第二个服务进行到锁释放阶段:

image-20250608184139115

表示一致,进行释放锁阶段。

至此,锁误删问题解决。

分布式锁原子性问题

在前面解决误删问题时,我们在unlock中拿到redis锁中的值,并在进行判断之后进行了释放锁。

这两者都是redis数据库的操作,在程序拿到redis锁中的标识后,进行判断,判断成功后才会进行释放。

如果在判断完成之后,释放锁时服务产生了堵塞(堵塞可能原因:在JVM内部会有守护线程(gc线程),用来回收,在其进行full GC时,会阻塞所有的代码),堵塞时间一旦超出过期时间,锁就会被释放,其他线程就有乘虚而入了,这时的第一个线程已经进行了对标识的判断。

因此,在堵塞结束后,不会再进行判断,会直接释放锁,这时释放的锁就不是自己的锁,又一次发生了误删问题。而此时redis中已经没有锁,就意味着又有线程可以获得锁并执行业务,这样就又出现了并发安全问题。

如图所示:

image-20250608185617541

问题原因:判断锁标识和释放是两个动作,这两个动作之间产生阻塞

解决方案:确保判断锁识别的动作和释放锁的动作是一个原子性操作。

一想到原子性操作就会想到MySQL中事务的进行就是原子性的,要么一起成功,要么一起失败。

而redis中的事务首先是能够保证原子性,但无法保证一致性,而且事务里面的多个操作是一个批处理,整合到一块,最终一次性去处理。因此无法将判断锁识别的动作和释放锁的动作放在同一个事务中,因为做查询动作时无法拿到结果,他是最终整合一次性执行。

因此只能利用redis中的乐观锁去做判断,确保我释放时没有被修改。但这种做法比较复杂。因此这里可以使用redis的Lua脚本来完成。

Redis的Lua脚本

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,基础语法参考菜鸟教程:Lua 教程 

在使用JAVA语言调用redis时是靠spring框架中的redisTemplate,那么使用Lua脚本语言如何调用redis呢?

Lua官方给我们提供了一个内置的函数方便调用redis:

# 执行redis命令
redis.call('命令名称','key','其他函数',...)

类似于Java的面向对象,调用call方法。

举例说明:执行set name Jack 脚本:

redis.call('set','name','jack')

脚本的实质:在脚本中写大量的命令,用来实现一个复杂业务逻辑。

脚本举例:

#先执行redis命令 set name Jack
redis.call('set','name','jack')
#执行 get name
local name = redis.call('get','name')
#返回
return name

利用Lua语言写完脚本后,需要使用redis命令来调用脚本,调用脚本的常见命令如下:

image-20250608192559256

举例说明:

执行:redis.call('set','name','jack')脚本,语法如下:

image-20250608204646186

脚本内容需要用双引号修饰,因为脚本本质是一个字符串,用双引号修饰是声明脚本内容。

执行脚本后面的数字时参数的数量,指该脚本中的key类型参数数量,实例脚本中并没有任何key类型参数,因此是0.

脚本可以理解为函数,而参数就是函数中的变量,因此如果没有参数就意味着脚本内容全部写死,那脚本的拓展性就很差,

如果在实例脚本中的name与Jack传参,就意味着这个脚本可以给不同的key赋不同的值,这样脚本的拓展性就得到了很大的增强。

代码展示:无参脚本

image-20250608203756347

有参脚本:

Redis中的参数分为两类(redis是key-value结构):

  • key类型的参数

  • 其他类型的参数

众所周知:redis命令中有mset命令,就可以添加多个key与value,那么我们的脚本里也可能有这种命令,可能脚本中key的个数不定,这时候就需要区分有几个key,以及有几个value。因此我们需要得知哪些是key类型参数,哪些是其他类型参数

如果为1,则往后的一个参数是key类型参数,以此类推。

如果在脚本中传了许多key,许多其他参数,这些参数在脚本中如何索取参数?

这时候需要有一个占位符去获取参数。

在脚本中传参后,key类型参数会放在keys数组,其他参数会放在ARGV数组,在脚本中可以在keys和ARGV数组获取这些参数:

举例说明:

image-20250608210103872

image-20250608210250215

这就是redis的Lua脚本的基本用法。

回顾:

释放锁的业务流程:

  1. 获取锁中的线程标识

  2. 判断是否于指定的标识(当前线程标识)一致

  3. 如果一致则释放锁

  4. 如果不一致则什么都不做

将该业务流程用Lua脚本编译:

-- 锁的key
local key = KEYS[1]
-- 当前线程标识
local threadId = ARGV[1]
-- 获取锁的线程标识,即key中value
local id = redis.call('get',KEYS[1])
-- 比较线程标识与锁中的标识是否一致
if(id == ARGV[1]) then
    -- 释放锁
   return redis.call('del',KEYS[1])
end
return 0

简化得:

if(redis.call('get',KEYS[1]) == ARGV[1]) then
    -- 释放锁
   return redis.call('del',KEYS[1])
end
return 0
Java调用Lua脚本改造分布式锁

案例:再次改进Redis锁的分布式锁

需求:基于Lua脚本实现分布式锁的释放锁逻辑

提示:RedisTemplate调用Lua脚本的API如下:

image-20250608222926348

与redis脚本命令对应如下

image-20250608223331435

修改释放锁的业务代码:

public void unlock() {
    //调用Lua脚本
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name),
            ID_PREFIX + Thread.currentThread().getId()
    );
}

注意事项:我们将Lua脚本写到一个文件中,那么程序运行到unlock方法时就需要去做一次IO流,每执行一次就需要去读取文件一次,所以我们直接提前将脚本文件定义为常量。

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}

进行测试:与之前测试结果一致。

Lua脚本根本目的是为了解决判断线程标识与释放锁动作的原子性问题。因此避免了线程并发安全问题。

到此时实现的分布式锁已经是一个生产可用、相对完善的分布式锁了。

总结:

基于Redis分布式锁的实现思路:

  • 利用set nx ex 获取锁,并设置过期时间,保存线程标识

  • 释放锁先判断线程标识是否与自己一直,一致则删除锁。

特性:

  • 利用set nx 满足互斥性

  • 利用set ex 保证故障时锁依然能释放、避免死锁,提高安全性

  • 利用redis集群模式保证高可用性和高并发特性。

基于redis的分布式锁优化

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

  • 不可重入问题:同一个线程无法获取同一把锁

可重入就是指同一个线程可以多次获取同一把锁。

举例说明:

方法a调用方法b,在方法a重要先获取锁,然后执行业务,去调用方法b,然后方法b也需要获取同一把锁,在这种情况下,如果锁是不可重入的,方法a中获取锁之后,方法b就无法获取锁,只能等待方法a中锁的释放,但锁无法释放,因为方法a的业务没有完成,于是就会出现死锁的情况。

在这种场景下,我们要求的锁是可重入锁。

  • 不可重试问题:获取锁只尝试一次就返回false,没有重试机制。

在许多业务中,不能说获取锁失败后直接返回,不去重试,如果获取锁失败,等待一段时间后再去重试,如果成功了,在去执行业务。

在大多数业务中要求的锁是可重试的锁。

  • 超时释放问题:锁超时释放虽然是可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。

  • 主从一致性问题:如果redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并未同步主从的锁数据,则会出现并发安全问题

拓展知识:主从模式可以理解为读写模式,写操作在主节点上执行,读操作在从节点上执行,主从数据会去进行同步,保证主从之间的数据是一致的,提高整个服务的并发能力和高可用性,如果主节点宕机,我们还可以从节点中选出一个作为新的主节点,增强了集群的可用性。

但是主从之间的数据同步是有延迟的,所以在极端情况下,可能会发生:

在高并发多线程场景中,其中一个线程在主节点中获取了锁,获取锁是写操作,在主节点完成,倘若在主节点尚未同步给从节点时,主节点服务宕机,此时会去选择一个新的从节点作为主节点,但主节点并未数据同步,从节点上并没有锁的标识,即:这时其他线程就可以趁虚而入,拿到锁,执行业务。就会出现并发同步问题,因为不止一个线程才能拿到锁。

这种情况概率较低,因为主从之间的数据同步中的延迟较低,往往是在毫秒级别或者更低。

以上的四个问题,要么发生概率极低,要么就是不一定有这样的需求。

这些都是功能上的拓展,并不是说必须实现。

但如果业务中有要求,那么就必须要实现,但是这些问题解决方案十分复杂,不建议自己定义去实现,可以尝试寻找前辈留下的方法。

Redisson

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。他不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包括各种分布式锁的实现。

Redisson是一个在redis基础上实现的一个分布式工具的集合,在分布式系统下用到的绝大多数工具都在其中,包括分布式锁

image-20250609131011278

官方文档:Redisson

关于分布式锁文档:锁和同步器 - Redisson 参考指南

Redisson入门

1.引入依赖:

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

2.配置Redisson客户端

有两种方式:

  • 利用Java配置方式实现:

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

  • 利用配置文件和springboot去整合实现。springboot官方也提供了一个关于REdisson的起步依赖

但是这种方式不推荐使用,因为会去替代spring官方提供的对于redis的配置与实现。因此建议使用第一种方案。

3.使用Redisson的分布式锁

@Resource
private RedissonClient redissonCLient;
@Test
void test Redisson() throws InterruptedExpection{
		//获得锁(可重入),指定锁的名称
		RLock lock = redissonClient.getLock("anyLock");
		//尝试获取锁,参数分别为:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
		boolean isLock = lock.tryLock(1,10,TimUnit.SECONDS);
		//判断锁是否成功
		if(isLock){
		try{
		System.out.println("执行业务");
		}fianlly{
		//释放锁
		lock.unlock();
		}
	}
}

案例展示:

   private final RedissonClient redissonClient;
...
    
//创建锁对象,锁的范围缩小到每个用户,避免锁的粒度太大,提升并发效率
        RLock lock = redissonClient.getLock("lock:order:" + userId);

        //获取锁,参数waitTime默认值为-1,代表不等待获取锁失败则立即返回,不重试。 leaseTime参数为锁自动释放时间,默认值为30s,
        boolean isLock = lock.tryLock();
        //判断是否获取锁成功
        if (!isLock) {
            //获取锁失败,返回错误或者重试
            return Result.fail("不允许重复下单");
        }
        //利用实现类自调用,防止事务自调用问题
            //第二种方法:创建代理对象,利用代理对象调用方法,防止事务自调用问题
        try {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId, seckillVoucher);
        }finally {
            //释放锁
            lock.unlock();
        }

进行测试,添加订单成功。

现在利用Jmeter进行并发测试:

image-20250609155507740

显示只有一个线程成功。

检查数据库:

image-20250609155558806

测试成功,没有并发安全问题。

Redisson可重入锁原理

案例说明:

首先会去创建一个锁的对象,进行测试,在方法1中首先尝试获取锁,获取锁之后去执行业务,业务中的方法2也需要去获取锁,但是获取锁其实就是redis数据库中的setnx命令,因为是在同一线程,key相同,方法一获得锁,那么方法2就获取锁失败,这就是不可重入问题。

RLock lock = redissonClient.getLock("anyLock");
 @Test
 public void method1() {
     boolean isLock = lock.tryLock();
     if (!isLock) {
         log.error("获取锁失败,1");
         return;
     }
         try {
            log.info("获取锁成功,1");
            method2();
         } finally {
             //释放锁
             log.info("释放锁,1");
             lock.unlock();
         }
     
 }
 @Test
 public void method2() { 
     boolean isLock = lock.tryLock();
     if (!isLock) {
         log.error("获取锁失败,2");
         return;
     }
     try {
         log.info("获取锁成功,2");
     } finally {
         log.info("释放锁,2");
         //释放锁
         lock.unlock();
     }
 }

解决方案

可以参考jdk里提供的ReentrantLock(可重入锁)简单了解一下原理:可重入锁就是在获取锁的时候,当判断锁是否被获取的同时,检查获取锁的是哪个线程,如果是同一个线程的话,就让其通过获取锁,这样就需要在线程获取锁的时候添加一个计数器记录重入的次数,在有线程获取锁时就会累加,释放锁时减一,这就是可重入的基本原理。

这就需要我们在锁中不仅记录获取锁的线程,还需要记录该线程重入的次数。我们现在需要往一个key中存储两个value,那么现在的String结构就不够用了,因此我们需要换成hash类型。

具体实现原理

在高并发多线程场景中,线程一尝试获取锁,发现没有人获取锁,则获取成功,将线程的标识记录下来,此时如果线程一中的业务执行时还需要获取锁,发现有人获得锁,这时在进行一次判断,判断得到的线程标识与锁中存储的线程标识对比,如果相同,则允许其获取锁,并且在重入次数加一,如果业务执行完成,则开始释放锁,释放锁之前需要进行判断,先将重入次数减一,在判断重入次数是否为0,如果为0,执行释放锁方法,如果没有,说明还有业务在使用锁。

业务流程:

首先判断锁是否存在(用exists命令判断),返回两种结果,存在或者不存在,如果不存在,获取锁并添加线程标识,设置有效时间,执行业务,如果锁已经存在,判断锁标识是否是同一线程,如果是同一个线程,只需要将重入次数加一,再重置有效时间,执行业务,业务完成后,需要释放锁,释放锁需要判断,需要先判断锁的线程标识是否一致,如果不一致,说明锁已经释放,如果标识一致,先将重入次数减一,再去进行判断重入次数的值,如果不为0,则证明不是最外层的业务,需要去重置锁的有效期,给后续业务执行留下充足的执行时间,如果为0,说明已经到最外层,此时可以直接释放锁。至此,业务完成。

业务流程图:

image-20250609203301084

这样的逻辑使用Java代码实现无法保证其操作之间的原子性,因此要采用Lua脚本来编译。

获取锁的Lua脚本:

local key = KEYS[1]; -- 锁的key
local value = ARGV[1]; -- 线程唯一标识
local expireTime  = ARGV[2]; -- 锁的过期时间
-- 判断是否存在
if (redis.call('exists',key) == 0)
    then
        -- 不存在,设置锁
        redis.call('set',key,value,'1');
        -- 设置锁的过期时间
        redis.call('expire',key,expireTime);
        -- 返回true
        return 1;
end 
-- 存在,判断value是否一致
if (redis.call('hexists',key,value) == 1)
    then
    --一致 ,获取锁,重入次数+1
    redis.call('hincrby',key,'count',1);
    -- 设置锁的过期时间
    redis.call('expire',key,expireTime);
    -- 返回true
    return 1;
end 
return 0;

释放锁的Lua脚本:

local key = KEYS[1] -- 锁的key
local value = ARGV[1] -- 线程唯一标识
local expireTime = ARGV[2] -- 锁的过期时间
if(redis.call('hexists',key,value) == 0)
    then
    return nil -- 锁不存在,返回0
end
-- 是自己的锁,则重入次数-1
local count = redis.call('hincrby',key,value,-1)
-- 重入次数减为0,则删除锁
if (count  > 0) then
    -- 大于0,说明不能释放锁,重置有效期后返回
    redis.call('expire',key,expireTime)
    return nil
else -- 重入次数减为0,则删除锁
    redis.call('del',key)
    return nil
end

对Redisson锁进行测试,看是否满足可重入锁:

@Slf4j
 @SpringBootTest
 public class ReentrantLockTest {
     @Resource
     private   RedissonClient  redissonClient;
     private RLock lock;
     @BeforeEach
     void setUp(){
         lock = redissonClient.getLock("lock:");
     }
     @Test
     public void method1() {
         boolean isLock = lock.tryLock();
         if (!isLock) {
             log.error("获取锁失败,1");
             return;
         }
             try {
                log.info("获取锁成功,1");
                method2();
                log.info("执行业务逻辑,1");
             } finally {
                 //释放锁
                 log.warn("释放锁,1");
                 lock.unlock();
             }
 ​
     }
     @Test
     public void method2() {
         boolean isLock = lock.tryLock();
         if (!isLock) {
             log.error("获取锁失败,2");
             return;
         }
         try {
             log.info("获取锁成功,2");
             log.info("执行业务逻辑,2");
         } finally {
             log.info("释放锁,2");
             //释放锁
             lock.unlock();
         }
     }
 ​
 }

进行测试:测试成功

同一个线程的两个方法成功获得锁,可重入次数变为2,并在释放锁时,第一个业务释放锁时,可重入次数减一,第二个业务释放锁时,可重入数减一,变为零。因此锁被释放

image-20250609220910709

解析源码得:

根据测试案例中的tryLock来追踪redissonLock的源码:

我们选择的是较为基础的RedissonLock

image-20250609221814883

image-20250609221905632

继续向下追踪:

image-20250609221943575

依旧是RedissonLock类

image-20250609222112572

image-20250609222152685

至此:

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    return this.evalWriteSyncedNoRetryAsync(this.getRawName(), LongCodec.INSTANCE, command, 
           "if ((redis.call('exists', KEYS[1]) == 0) or (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]);", Collections.singletonList(this.getRawName()), new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)});
}

我们发现这段硬编码就是Lua脚本,并且与我们开始写的获取锁的逻辑别无二致。

再看释放锁:同上所示,直接找到最底层,中间就不展示了:

image-20250609233018113

至此:RedissonLock的可重入锁的原理解析完毕。

Redisson的锁重试和WatchDog机制

解决了不可重入的问题,但是依然还存在几个问题:

  • 不可重试问题:获取锁只尝试一次就返回false,没有重试机制

  • 超时释放问题:锁超时释放虽然是可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。

  • 主从一致性问题:如果redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并未同步主从的锁数据,则会出现并发安全问题

那么这三个问题在redisson内部是如何解决的呢?

我们还是需要去根据以上案例来跟踪源码:

我们在实例案例中的tryLock()开始使用的是空参,但是时可以传递参数的,共计三个参数,第一个参数waitTime,获取锁的最大等待时长,一旦传递出这个参数,那么在该线程第一次获取锁失败后就不会立即返回,而是在等待时间内,不断地去尝试,在指定时间过去之后,才会返回false,一旦传入第一个参数,就会变成一个可重试锁。

第二个参数:LeaseTime,锁自动失效释放的时间,最后一个参数是时间的单位。

开始跟踪源码:

image-20250609230102338

继续追踪到最底层:

image-20250609230941548

image-20250609231650740

image-20250609231952963

image-20250609232548869

image-20250609233302371

image-20250610222018832

image-20250610223230818

这种设计的巧妙之处就在于利用了消息订阅以及信号量的机制,并不是无休止的盲目等待机制,无休止的等待是对CPU的一种浪费,而现在所用的机制就是等待其他线程释放再去尝试,对CPU较为友好。

这就是redisson锁的重试机制。

锁超时机制(WatchDog机制)

如果获取锁成功后,执行事务时发生堵塞,导致ttl到期,这时其他线程发现锁过期,则重试获取锁,这样又导致了线程安全问题。

因此我们必须确保锁是因为业务执行完释放,而不能因为阻塞释放。

跟踪源码

image-20250610230136559

当锁的持有时间未明确指定(leaseTime <= 0)时,Redisson 会自动启用看门狗机制。

scheduleExpirationRenewal(threadId) 的作用是:为当前线程持有的锁启动一个定时任务,定期刷新锁的过期时间,防止锁因超时而被释放

image-20250610230457563

该方法用于调度锁的自动续期任务。具体功能如下: renewalScheduler.renewLock(...):调用续期调度器,对指定的锁进行自动续期;

getRawName():获取锁的原始名称;

threadId:当前持有锁的线程ID;

getLockName(threadId):生成该线程持有的锁的具体名称。

image-20250610231425636

该方法用于续订指定锁的持有时间。其逻辑如下: 如果当前没有任务(reference为null),则创建一个新的LockTask任务; 获取当前的任务对象; 调用add方法将锁信息添加到任务中,用于后续自动续租。

image-20250610231637047

创建一个新的LockEntry对象,并将其赋值给变量entry。 调用entry对象的addThreadId方法,将threadId和lockName添加到entry中。这可能意味着记录哪个线程持有哪个锁。 最后,调用另一个重载版本的add方法,传入rawName, lockName, threadId, 和新创建的entry对象。这个重载版本的方法可能负责将锁条目存储到某个数据结构中,比如一个映射表或集合。

image-20250610231807128

使用threadId2counter.compute方法来更新与threadId关联的计数器。 compute方法接受两个参数:键(threadId)和一个BiFunction,该函数定义了如何计算新的值。 在BiFunction中: 使用Optional.ofNullable(counter).orElse(0)来获取当前计数器的值。如果计数器不存在,则默认为0。 将计数器的值加1。(可重入锁) 将threadId添加到threadsQueue队列中。 返回更新后的计数器值。 使用threadId2lockName.putIfAbsent方法来确保每个threadId只与一个lockName关联。 如果threadId在threadId2lockName映射中已经存在,则不会进行任何操作。 如果threadId在threadId2lockName映射中不存在,则将threadId和lockName添加到映射中。 总结来说,这个方法的主要功能是: 更新与特定线程ID关联的计数器,每次调用时计数器加1。 将线程ID添加到一个队列中。 确保每个线程ID只与一个锁名称关联,如果该线程ID尚未关联任何锁名称,则进行关联。

我也没有怎么搞懂,其实就是一旦线程成功获取锁,Redisson 会启动一个后台线程(看门狗)来监控和续期该锁。 看门狗机制会在锁的过期时间到达之前自动续期锁的过期时间。

并且看门狗机制还可以判断重入次数。并将每一个业务的锁分配好。

释放锁的原理流程图

image-20250611210433450

释放锁的原理流程图

image-20250611210931569

总结:

redisson分布式锁原理:

  • 可重入:利用hash结构记录线程id和重入次数,当线程获取锁失败后去判断锁包含的线程id是否一致,如果一致,则让其获取锁,并且重入次数加一。在释放锁时,先去判断锁中的线程ID,再去判断重入次数是否为0,如果为0,则释放,不为0,则无视

  • 可重试:利用信号量和pubsub功能实现等待、唤醒、获取锁失败的重试机制。

在第一次获取锁失败以后,并不是直接返回false,尝试获取锁的返回结果就是ttl(剩余有效时间),如果返回值为null。则获取锁成功,若不为null,则获取锁失败,

获取锁失败后会开启一个订阅功能,就是去接收其他线程释放锁时发送的消息,再进行判断,在指定时间内没有获取到释放锁的消息时则取消订阅,并且返回false.

如果成功,在去判断等待消耗的时间,如果时间超时,则返回false,如果时间还剩余,则开启while(true)循环.

进行再次尝试获取锁,其中会一直判断时间是否超时,如果再次失败,则会等待一段时间,其中如果剩余时间大于ttl时间,则等待ttl时间后再次重试,这里采用了信号量的方案,去获取其他线程释放锁后释放的信号量,获得后就会再次尝试获取锁,直到时间超时,则返回false,没有则重复循环获取。

这种设计的巧妙之处就在于利用了消息订阅以及信号量的机制,并不是无休止的盲目等待机制,无休止的等待是对CPU的一种浪费,而现在所用的机制就是等待其他线程释放再去尝试,对CPU较为友好。

  • 超时续约:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间.

redisson的multiLock原理

此时还剩下一个主从一致性问题我们还没有解决。

问题分析:

redisson分布式锁主从一致性问题

在之前的学习中,采用的都是单节点的redis,如果redis出现故障,项目中依赖于redis的业务都会出现问题,包括分布式锁。

而在实际开发中肯定是不被允许的,为了提高Redis的高可用性,在实际开发中会去搭建redis的主从模式:

有多台Redis机器,不过角色不同,一台redis机器作为主节点(Master),剩下的作为从节点(Slave),主从职责也不一样,往往会做读写的分离,主节点负责写操作,从节点负责读操作,主从节点之间需要做到数据同步,但毕竟不在同一个机器上,主从数据同步或有一定的延迟,尽管延时很短,但是存在,即使主从数据同步不及时,也只会产生旧读,而不会产生脏读,但是在一些较为严谨的业务中,就会出现主从不一致问题。

主从不一致问题:如果有Java应用,现在要获取锁,执行set命令,属于写操作,需要在主节点上执行,并且主节点会去保存锁的标识,而后主节点就会向从节点进行同步,如果此时主节点发生故障,导致数据未同步,而此时redis中会有哨兵去监控集群状态,当其发现主节点故障后,会先断开连接,再从从节点中选出一个作为主节点,但是数据未同步,锁的标识就丢失了,即锁失效,此时其他线程就可以获取锁,就会出现并发安全问题。

解决方案思路:每一个redis机器之间没有主从关系,每一个都可以去做读写操作,在改变获取锁的方式,从前是先找到master节点,再里面获取锁,现在必须依次向多个redis节点获取锁(写入set命令)。都保存锁的标识。

该方案思路解决了主从一致性问题以及可用性问题。

如果还不放心,还可以在每个节点后面再加几个从节点,形成主从关系,这样也不会产生主从一致性问题,如果出现上述场景,主从数据不同步,这时其他线程想要获取锁,不会成功,因为需要在每一个节点都拿到锁才算成功。

方案优点:不仅保存了这种主从同步机制,确保了整个redis集群的高可用性,同时也避免了主从一致引发的锁失效问题。

方案名:multiLock(连锁)把多个独立的锁联合在一起,变成一个联合起来的锁。

使用multiLock方案较为灵活,可以随意建立独立节点,可以不建立主从关系。

测试:

利用docker开启另外两个redis节点:

image-20250626172730307

在配置类中去配置

@Bean
 public RedissonClient  redissonClient() {
     //配置类
     Config config = new Config();
     //添加REdis地址,这里添加了单点的地址,也可以使用config.userClusterServers()添加集群地址
     config.useSingleServer().setAddress("redis://127.0.0.1:6379");
     //创建客户端
     return Redisson.create(config);
 }
 @Bean
 public RedissonClient  redissonClient2() {
     //配置类
     Config config = new Config();
     //添加REdis地址,这里添加了单点的地址,也可以使用config.userClusterServers()添加集群地址
     config.useSingleServer().setAddress("redis://0.0.0.0:6380");
     //创建客户端
     return Redisson.create(config);
 }
 ​
 @Bean
 public RedissonClient  redissonClient3() {
     //配置类
     Config config = new Config();
     //添加REdis地址,这里添加了单点的地址,也可以使用config.userClusterServers()添加集群地址
     config.useSingleServer().setAddress("redis://0.0.0.0:6381");
     //创建客户端
     return Redisson.create(config);
 }

在测试类进行测试:

创建联锁:

 void setUp(){
     RLock lock1 = redissonClient.getLock("order:");
     RLock lock2 = redissonClient2.getLock("order:");
     RLock lock3 = redissonClient3.getLock("order:");
     //创建联锁 multiLock
      lock = redissonClient.getMultiLock(lock1, lock2, lock3);
 }

测试:

 public void method1() throws InterruptedException {
     boolean isLock = lock.tryLock(1L,  TimeUnit.SECONDS);
     if (!isLock) {
         log.error("获取锁失败,1");
         return;
     }
         try {
            log.info("获取锁成功,1");
            method2();
            log.info("执行业务逻辑,1");
         } finally {
             //释放锁
             log.warn("释放锁,1");
             lock.unlock();
         }
 ​
 }

测试成功。

image-20250626172957651

接下来进行源码分析:

image-20250626214929407

image-20250626215700949

如果开始获取锁失败

image-20250626220631009

image-20250626221012199

以上就是获取锁以及需要重试的流程

总结

  • 不可重入redis分布式锁:

    • 原理:利用setex的互斥性;利用ex避免死锁;释放锁时判断线程标示

    • 缺陷:不可重入,无法重试、锁超时失效

  • 可重入的Redis分布式锁

    • 原理:利用hash结构,记录线程标示和重入次数;利用watchDog机制延续锁时间;利用信号量控制锁重试等待

    • 缺陷:redis宕机引起锁失效问题

  • redisson的multiLock:

    • 原理:多个独立的redis节点,必须在所有节点都获取重入锁,才算获取锁成功

    • 缺陷:运维成本高,实现复杂

希望对大家有所帮助

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值