redission分布式锁的介绍及使用

一、开篇:分布式场景下的 Redis 进阶之路

1.什么是redission

Redisson是一个基于Redis的工具包,功能非常强大。将JDK中很多常见的队列、锁、对象都基于Redis实现了对应的分布式版本。

2.为什么使用redission

2.1集群下的锁失效问题

我们在单节点的时候常用熟知的Synchronized锁来解决并发安全问题。

Synchronized中的重量级锁,底层就是基于**锁监视器(Monitor)**来实现的。简单来说就是锁对象头会指向一个锁监视器,而在监视器中则会记录一些信息,比如:

  • _owner:持有锁的线程

  • _recursions:锁重入次数

因此每一个锁对象,都会指向一个锁监视器,而每一个锁监视器,同一时刻只能被一个线程持有 ,这样就实现了互斥效果。但前提是,多个线程使用的是同一把锁

比如有三个线程来争抢锁资源,线程1获取锁成功,如图所示:

此时其它线程想要获取锁,会发现监视器中的_owner已经有值了,就会获取锁失败。

但问题来了,我们的服务肯定会多实例不是,形成集群。每一个实例都会有一个自己的JVM运行环境,因此即便是同一个用户,如果并发的发起了多个请求,由于请求进入了多个JVM,就会出现多个锁对象,自然就有多个锁监视器。此时就会出现每个JVM内部都有一个线程获取锁成功的情况,没有达到互斥的效果,并发安全问题就可能再次发生了:

可见,在集群环境下,JVM提供的传统锁机制就不再安全了。

那么该如何解决这个问题呢?

显然,我们不能让每个实例去使用各自的JVM内部锁监视器,而是应该在多个实例外部寻找一个锁监视器,多个实例争抢同一把锁

像这样的锁,就称为分布式锁。

分布式锁必须要满足的特征:

  • 多JVM实例都可以访问

  • 互斥

能满足上述特征的组件有很多,因此实现分布式锁的方式也非常多,例如:

  • 基于MySQL

  • 基于Redis

  • 基于Zookeeper

但目前使用最广泛的还应该是基于Redis的分布式锁。

2.2.简单分布式锁

Redis本身可以被任意JVM实例访问,同时Redis中的setnx命令具备互斥性,因此符合分布式锁的需求。不过实现分布式锁的时候还有一些细节需要考虑,绝不仅仅是setnx这么简单。

2.2.1.基本原理

Redis的setnx命令是对string类型数据的操作,语法如下:

复制代码
# 给key赋值为value
SETNX key value

当前仅当key不存在的时候,setnx才能执行成功,并且返回1,其它情况都会执行失败,并且返回0.我们就可以认为返回值是1就是获取锁成功,返回值是0就是获取锁失败,实现互斥效果。

而当业务执行完成时,我们只需要删除这个key即可释放锁。这个时候其它线程又可以再次获取锁了。

复制代码
# 删除指定key,用来释放锁
DEL key

例如,我们用lock作为某个业务的锁的key,获取锁就执行下面命令:

复制代码
# 获取锁,并记录持有锁的线程
SETNX lock thread1

假设说一开始lock不存在,有很多线程同时对lock执行setnx命令。由于Redis命令本身是串行执行的,也就是各个线程是串行依次执行。因此当第一个线程执行setnx时,会成功添加这个lock。但其余的线程会发现lock已经存在,自然就执行失败。自然就实现了互斥效果。

当业务执行完毕,直接删除lock,自然就释放锁了:

复制代码
# 释放锁
DEL lock

不过我们要考虑一种极端情况,比如我们获取锁成功,还未释放锁呢当前实例突然宕机了!那么释放锁的逻辑自然就永远不会被执行,这样lock就永远存在,再也不会有其它线程获取锁成功了!出现了死锁问题。

怎么办?

我们可以利用Redis的KEY过期时间机制,在获取锁时给锁添加一个超时时间:

复制代码
# NX 等同于SETNX lock thread1效果;
# EX 20 等同于 EXPIRE lock 20效果
SET lock thread1 NX EX 20

这里我们设置超时时间为20秒,远超任务执行时间。当业务正常执行时,这个过期时间不起作用,我们通过DEL命令来释放锁。

但是如果当前服务实例宕机,DEL无法执行。但由于我们设置了20秒的过期时间,当超过这个时间时,锁会因为过期被删除,因此就等于释放锁了,从而避免了死锁问题。这种策略就是超时释放锁策略。

综上,利用Redis实现的简单分布式锁流程如下:

2.3.redis分布式锁的问题

基于setnx的分布式锁实现起来并不复杂,但确确实实存在一些问题。

2.3.1.锁误删问题

第一个问题就是锁误删问题,目前释放锁的操作是基于DEL,但是在极端情况下会出现问题。

例如,有线程1获取锁成功,并且执行完任务,正准备释放锁:

但是因为某种原因导致释放锁的操作被阻塞了,直到锁被超时释放:

就在此时,有一个新的线+锁成功的:

而就在此时,线程1醒来,继续执行释放锁的操作,也就是DEL.结果就把线程2的锁给删除了:

然而此时线程2还在执行任务,如果有其它线程再来获取锁,就会认为无人持有锁从而获取锁成功,于是多个线程再次并行执行,并发安全问题就可能再次发生了:

解决思路:

我们会将持有锁的线程存入lock中。因此,我们应该在删除锁之前判断当前锁的中保存的是否是当前线程标示,如果不是则证明不是自己的锁,则不删除;如果锁标示是当前线程,则可以删除:

综上,分布式锁的实现逻辑就变化了:

2.3.2.超时释放问题

加上了锁标示判断逻辑,可以避免大多数情况下的锁误删问题,但是还有一种极端情况依然会存在误删可能。

例如,线程1获取锁成功,并且执行业务完成,并且也判断了锁标示,确实与自己一致:

接下来,线程1应该去释放自己的锁了,可就在此时发生了阻塞!直到锁超时释放:

此时,如果有线程2来获取锁,肯定可以获取锁成功:

就在线程2获取锁成功后,线程1从阻塞中醒来,继续释放锁。由于在阻塞之前已经完成了锁标示判断,现在就无需判断而是直接删除锁,结果就把线程2的锁删除了:

总结一下,误删的原因归根结底是因为什么?

  • 超时释放

  • 判断锁标识、删除锁两个动作不是原子操作

超时释放不能不做,因为要避免服务宕机导致的死锁,必须加超时时间。但是加了超时时间又出现了误删问题。怎么办?

操作锁的多行命令又该如何确保原子性?

2.3.3.其它问题

除了上述问题以外,分布式锁还会碰到一些其它问题:

  • 锁的重入问题:同一个线程多次获取锁的场景,目前不支持,可能会导致死锁

  • 锁失败的重试问题:获取锁失败后要不要重试?

  • Redis主从的一致性问题:由于主从同步存在延迟,当线程在主节点获取锁后,从节点可能未同步锁信息。如果此时主宕机,会出现锁失效情况。此时会有其它线程也获取锁成功。从而出现并发安全问题。

  • ...

当然,上述问题并非无法解决,只不过会比较麻烦。例如:

  • 原子性问题:可以利用Redis的LUA脚本来编写锁操作,确保原子性

  • 超时问题:利用WatchDog(看门狗)机制,获取锁成功时开启一个定时任务,在锁到期前自动续期,避免超时释放。而当服务宕机后,WatchDog跟着停止运行,不会导致死锁。

  • 锁重入 问题:可以模拟Synchronized原理,放弃setnx,而是利用Redis的Hash结构来记录锁的持有者 以及重入次数,获取锁时重入次数+1,释放锁是重入次数-1,次数为0则锁删除

  • 主从一致性问题:可以利用Redis官网推荐的RedLock机制来解决

这些解决方案实现起来比较复杂,因此我们通常会使用一些开源框架来实现分布式锁,而不是自己来编码实现。目前对这些解决方案实现的比较完善的一个第三方组件:Redisson

因此,我们只要会使用Redisson,即可解决上述问题,无需自己动手编码了。

二、基础集成:redission的基础使用

1.快速入门

首先引入依赖:

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

然后是配置:

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

最后是基本用法:

java 复制代码
@Autowired
 private RedissonClient redissonClient;

 @Test
 void testRedisson() throws InterruptedException {
    // 1.获取锁对象,指定锁名称
    RLock lock = redissonClient.getLock("anyLock");
    try {
        // 2.尝试获取锁,参数:waitTime、leaseTime、时间单位
        boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
        if (!isLock) {
            // 获取锁失败处理 ..
        } else {
            // 获取锁成功处理
        }
    } finally {
        // 4.释放锁
        lock.unlock();
    }
 }

利用Redisson获取锁时可以传3个参数:

  • waitTime:获取锁的等待时间。当获取锁失败后可以多次重试,直到waitTime时间耗尽。waitTime默认-1,即失败后立刻返回,不重试。

  • leaseTime:锁超时释放时间。默认是30,同时会利用WatchDog来不断更新超时时间。需要注意的是,如果手动设置leaseTime值,会导致WatchDog失效。

  • TimeUnit:时间单位

介绍完redission的基础继承,那么接下来大家了解一下单机redis的缺点,及如何解决

由于篇幅较长:迁移到其他文章

三、项目实践:场景落地与优化

1.1.通用分布式锁组件

Redisson的分布式锁使用并不复杂,基本步骤包括:

  • 创建锁对象

  • 尝试获取锁

  • 处理业务

  • 释放锁

但是,除了第3步以外,其它都是非业务代码,对业务的侵入较多:

可以发现,非业务代码格式固定,每次获取锁总是在重复编码。我们可以对这部分代码进行抽取和简化

1.1.1.实现思路分析

要优化这部分代码,需要通过整个流程来分析:

可以发现,只有红框部分是业务功能,业务前、后都是固定的锁操作。既然如此,我们完全可以基于AOP的思想,将业务部分作为切入点,将业务前后的锁操作作为环绕增强

但是,我们该如何标记这些切入点呢?

不是每一个service方法都需要加锁,因此我们不能直接基于类来确定切入点;另外,需要加锁的方法可能也较多,我们不能基于方法名作为切入点,这样太麻烦。因此,最好的办法是把加锁的方法给标记出来,利用标记来确定切入点。如何标记呢?

最常见的办法就是基于注解来标记了。同时,加锁时还有一些参数,比如:锁的key名称、锁的waitTime、releaseTime等等,都可以基于注解来传参。

因此,注解的核心作用是两个:

  • 标记切入点

  • 传递锁参数

综上,我们计划利用注解来标记切入点,传递锁参数。同时利用AOP环绕增强来实现加锁、释放锁等操作。

1.1.2.定义注解

注解本身起到标记作用,同时还要带上锁参数:

  • 锁名称

  • 锁等待时间

  • 锁超时时间

  • 时间单位

java 复制代码
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyLock {
    String name();

    long waitTime() default 1;

    long leaseTime() default -1;

    TimeUnit unit() default TimeUnit.SECONDS;
}

1.1.3.定义切面

我们还需要定义一个环绕增强的切面,实现加锁、释放锁:

java 复制代码
@Component
@Aspect
@RequiredArgsConstructor
public class MyLockAspect implements Ordered{

    private final RedissonClient redissonClient;

    @Around("@annotation(myLock)")
    public Object tryLock(ProceedingJoinPoint pjp, MyLock myLock) throws Throwable {
        // 1.创建锁对象
        RLock lock = redissonClient.getLock(myLock.name());
        // 2.尝试获取锁
        boolean isLock = lock.tryLock(myLock.waitTime(), myLock.leaseTime(), myLock.unit());
        // 3.判断是否成功
        if(!isLock) {
            // 3.1.失败,快速结束
            throw new BizIllegalException("请求太频繁");
        }
        try {
            // 3.2.成功,执行业务
            return pjp.proceed();
        } finally {
            // 4.释放锁
            lock.unlock();
        }
    }
    
    @Override
    public int getOrder() {
        return 0;
    }
}

注意,Spring中的AOP切面有很多,会按照Order排序,按照Order值从小到大依次执行。Spring事务AOP的order值是Integer.MAX_VALUE,优先级最低。

我们的分布式锁一定要先于事务执行,因此,我们的切面一定要实现Ordered接口,指定order值小于Integer.MAX_VALUE即可。

1.1.4.使用锁

定义好了锁注解和切面,接下来就可以实现业务了:

可以看到,业务中无需手动编写加锁、释放锁的逻辑了,没有任何业务侵入,使用起来也非常优雅。

不过呢,现在还存在几个问题:

  • Redisson中锁的种类有很多,目前的代码中把锁的类型写死了

  • Redisson中获取锁的逻辑有多种,比如获取锁失败的重试策略,目前都没有设置

  • 锁的名称目前是写死的,并不能根据方法参数动态变化

所以呢,我们接下来还要对锁的实现进行优化,注意解决上述问题。

1.1.5.工厂模式切换锁类型

Redisson中锁的类型有多种,例如:可重入锁,公平锁,联锁,跟读写锁。

因此,我们不能在切面中把锁的类型写死,而是交给使用者自己选择锁类型。

锁的类型虽然有多种,但类型是有限的几种,完全可以通过枚举 定义出来。然后把这个枚举作为MyLock注解的参数,交给使用者去选择自己要用的类型。

而在切面中,我们则需要根据用户选择的锁类型,创建对应的锁对象即可。但是这个逻辑不能通过if-else来实现,因为不符合开闭原则。

这里我们的需求是根据用户选择的锁类型,创建不同的锁对象。有一种设计模式刚好可以解决这个问题:简单工厂模式。

1.1.5.1.锁类型枚举

我们首先定义一个锁类型枚举:

然后在自定义注解中添加锁类型这个参数:

1.1.5.2.锁对象工厂

然后定义一个锁工厂,用于根据锁类型创建锁对象:

java 复制代码
@Component
public class MyLockFactory {

    private final Map<MyLockType, Function<String, RLock>> lockHandlers;

    public MyLockFactory(RedissonClient redissonClient) {
        this.lockHandlers = new EnumMap<>(MyLockType.class);
        this.lockHandlers.put(RE_ENTRANT_LOCK, redissonClient::getLock);
        this.lockHandlers.put(FAIR_LOCK, redissonClient::getFairLock);
        this.lockHandlers.put(READ_LOCK, name -> redissonClient.getReadWriteLock(name).readLock());
        this.lockHandlers.put(WRITE_LOCK, name -> redissonClient.getReadWriteLock(name).writeLock());
    }

    public RLock getLock(MyLockType lockType, String name) {
        return lockHandlers.get(lockType).apply(name);

    }
}

说明:

  • MyLockFactory内部持有了一个Map,key是锁类型枚举,值是创建锁对象的Function。注意这里不是存锁对象,因为锁对象必须是多例的,不同业务用不同锁对象;同一个业务用相同锁对象。

  • MyLockFactory内部的Map采用了EnumMap。只有当Key是枚举类型时可以使用EnumMap,其底层不是hash表,而是简单的数组。由于枚举项数量固定,因此这个数组长度就等于枚举项个数,然后按照枚举项序号作为角标依次存入数组。这样就能根据枚举项序号作为角标快速定位到数组中的数据。

1.1.5.3.改造切面代码

我们将锁对象工厂注入MyLockAspect,然后就可以利用工厂来获取锁对象了:

此时,在业务中,就能通过注解来指定自己要用的锁类型了:

1.1.6.锁失败策略

多线程争抢锁,大部分线程会获取锁失败,而失败后的处理方案和策略是多种多样的。目前,我们获取锁失败后就是直接抛出异常,没有其它策略,这与实际需求不一定相符。

1.1.6.1.策略分析

接下来,我们就分析一下锁失败的处理策略有哪些。

大的方面来说,获取锁失败要从两方面来考虑:

  • 获取锁失败是否要重试?有三种策略:

    • 不重试 ,对应API:lock.tryLock(0, 10, SECONDS),也就是waitTime小于等于0

    • 有限次数重试 :对应API:lock.tryLock(5, 10, SECONDS),也就是waitTime大于0,重试一定waitTime时间后结束

    • 无限重试 :对应API lock.lock(10, SECONDS) , lock就是无限重试

  • 重试失败后怎么处理?有两种策略:

    • 直接结束

    • 抛出异常

对应的API和策略名如下:

那么该如何用代码来表示这些失败策略,并让使用者自由选择呢?

可以用一种设计模式:策略模式 。同时,我们还需要定义一个失败策略的**枚举。**在MyLock注解中定义这个枚举类型的参数,供用户选择。

然后直接将失败策略定义到枚举中:

java 复制代码
public enum MyLockStrategy {
    SKIP_FAST() {
        @Override
        public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
            return lock.tryLock(0, prop.leaseTime(), prop.unit());
        }
    },
    FAIL_FAST() {
        @Override
        public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
            boolean isLock = lock.tryLock(0, prop.leaseTime(), prop.unit());
            if (!isLock) {
                throw new InterruptedException("请求太频繁");
            }
            return true;
        }
    },
    KEEP_TRYING() {
        @Override
        public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
            lock.lock(prop.leaseTime(), prop.unit());
            return true;
        }
    },
    SKIP_AFTER_RETRY_TIMEOUT() {
        @Override
        public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
            return lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
        }
    },
    FAIL_AFTER_RETRY_TIMEOUT() {
        @Override
        public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
            boolean isLock = lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
            if (!isLock) {
                throw new InterruptedException("请求太频繁");
            }
            return true;
        }
    },
    ;

    public abstract boolean tryLock(RLock lock, MyLock prop) throws InterruptedException;
}

然后,在MyLock注解中添加枚举参数:

这个时候,我们就可以在使用锁的时候自由选择锁类型、锁策略了。

1.1.7.基于SPEL的动态锁名

现在还剩下最后一个问题,就是锁名称的问题。

在当前业务中,我们的锁对象本来应该是动态获取的。而加锁是基于注解参数添加的,在编码时就需要指定。怎么办?

Spring中提供了一种表达式语法,称为SPEL表达式,可以执行java代码,获取任意参数。

思路:

我们可以让用户指定锁名称参数时不要写死,而是基于SPEL表达式。在创建锁对象时,解析SPEL表达式,动态获取锁名称。

简单使用介绍:

四、总结:

Redisson 是基于 Redis 实现的 Java 客户端,其分布式锁是业界主流的分布式锁解决方案,核心优势是封装了 Redis 分布式锁的底层细节,提供高可用、易使用、功能丰富的分布式锁能力,以下是核心要点:

一、核心特性(对比原生 Redis 锁)
特性 原生 Redis 锁(SET NX EX) Redisson 分布式锁
自动续期 ❌ 需手动实现(避免锁过期) ✅ 看门狗(Watch Dog)自动续期
可重入性 ❌ 需手动维护线程标识 + 重入次数 ✅ 内置可重入锁(RedissonLock)
公平锁 ❌ 无法保证 ✅ 支持公平锁(FairLock)
联锁 / 红锁 ❌ 需手动封装 ✅ 内置联锁(MultiLock)、红锁(RedLock)
异常恢复 ❌ 锁超时 / 宕机易出现死锁 ✅ 基于 Redis 过期时间 + 续期机制避免死锁
易用性 ❌ 需手动处理加锁 / 释放 / 异常 ✅ 注解 / API 一键使用,自动处理异常

Redisson 分布式锁是原生 Redis 锁的增强版 ,核心解决了手动实现 Redis 锁的续期、可重入、死锁等问题。其核心机制是Lua 脚本保证原子性 + 看门狗自动续期 + Redis 过期时间,兼顾性能与可靠性。

相关推荐
`Jay4 小时前
Python Redis连接池&账号管理池
redis·分布式·爬虫·python·学习
rannn_1115 小时前
【Redis|实战篇4】黑马点评|分布式锁
java·数据库·redis·分布式·后端
mcooiedo6 小时前
RabbitMQ高级特性----生产者确认机制
分布式·rabbitmq
Thomas.Sir6 小时前
深入剖析 Redis 的三种集群方式以及实战配置
redis·分布式·集群·高可用
0xDevNull6 小时前
RabbitMQ 完整技术指南
分布式·rabbitmq
姓蔡小朋友7 小时前
RabbitMQ
分布式·rabbitmq
深蓝轨迹7 小时前
Redis 分布式锁实现流程
数据库·redis·分布式
2301_767902648 小时前
ceph分布式存储(三)
分布式·ceph