redission分布式锁的介绍及使用

张开发
2026/4/18 21:51:12 15 分钟阅读

分享文章

redission分布式锁的介绍及使用
一、开篇分布式场景下的 Redis 进阶之路1.什么是redissionRedisson是一个基于Redis的工具包功能非常强大。将JDK中很多常见的队列、锁、对象都基于Redis实现了对应的分布式版本。2.为什么使用redission2.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 groupIdorg.redisson/groupId artifactIdredisson/artifactId /dependency然后是配置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); } }最后是基本用法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.定义注解注解本身起到标记作用同时还要带上锁参数锁名称锁等待时间锁超时时间时间单位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.定义切面我们还需要定义一个环绕增强的切面实现加锁、释放锁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.锁对象工厂然后定义一个锁工厂用于根据锁类型创建锁对象Component public class MyLockFactory { private final MapMyLockType, FunctionString, 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内部持有了一个Mapkey是锁类型枚举值是创建锁对象的Function。注意这里不是存锁对象因为锁对象必须是多例的不同业务用不同锁对象同一个业务用相同锁对象。MyLockFactory内部的Map采用了EnumMap。只有当Key是枚举类型时可以使用EnumMap其底层不是hash表而是简单的数组。由于枚举项数量固定因此这个数组长度就等于枚举项个数然后按照枚举项序号作为角标依次存入数组。这样就能根据枚举项序号作为角标快速定位到数组中的数据。1.1.5.3.改造切面代码我们将锁对象工厂注入MyLockAspect然后就可以利用工厂来获取锁对象了此时在业务中就能通过注解来指定自己要用的锁类型了1.1.6.锁失败策略多线程争抢锁大部分线程会获取锁失败而失败后的处理方案和策略是多种多样的。目前我们获取锁失败后就是直接抛出异常没有其它策略这与实际需求不一定相符。1.1.6.1.策略分析接下来我们就分析一下锁失败的处理策略有哪些。大的方面来说获取锁失败要从两方面来考虑获取锁失败是否要重试有三种策略不重试对应APIlock.tryLock(0, 10, SECONDS)也就是waitTime小于等于0有限次数重试对应APIlock.tryLock(5, 10, SECONDS)也就是waitTime大于0重试一定waitTime时间后结束无限重试对应APIlock.lock(10, SECONDS), lock就是无限重试重试失败后怎么处理有两种策略直接结束抛出异常对应的API和策略名如下那么该如何用代码来表示这些失败策略并让使用者自由选择呢可以用一种设计模式策略模式。同时我们还需要定义一个失败策略的枚举。在MyLock注解中定义这个枚举类型的参数供用户选择。然后直接将失败策略定义到枚举中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 EXRedisson 分布式锁自动续期❌ 需手动实现避免锁过期✅ 看门狗Watch Dog自动续期可重入性❌ 需手动维护线程标识 重入次数✅ 内置可重入锁RedissonLock公平锁❌ 无法保证✅ 支持公平锁FairLock联锁 / 红锁❌ 需手动封装✅ 内置联锁MultiLock、红锁RedLock异常恢复❌ 锁超时 / 宕机易出现死锁✅ 基于 Redis 过期时间 续期机制避免死锁易用性❌ 需手动处理加锁 / 释放 / 异常✅ 注解 / API 一键使用自动处理异常Redisson 分布式锁是原生 Redis 锁的增强版核心解决了手动实现 Redis 锁的续期、可重入、死锁等问题。其核心机制是Lua 脚本保证原子性 看门狗自动续期 Redis 过期时间兼顾性能与可靠性。

更多文章