Redisson 源码分析及实际应用场景之实现延迟队列
Redisson 源码分析及实际应用场景之实现延迟队列
一、分布式锁的选择场景
1.1 出现背景
随着业务的发展,数据规模增大,一个应用往往会部署在多台机器上(多节点),分布式系统越来越普及。在很多的场景中,为了保证数据不重复,要求在同一时刻,同一任务只在一个节点上运行,即保证某一方法同一时刻只能被一个线程执行。
在单机环境中,应用是在同一进程下的,只需要保证单进程多线程环境中的线程安全性,通过 JAVA 提供的 volatile、ReentrantLock、synchronized 以及 concurrent 并发包下一些线程安全的类等就可以做到。而在多机部署环境中,不同机器不同进程,就需要在多进程下保证线程的安全性了。因此,分布式锁应运而生。
1.2 应用场景
在某些场景中,多个进程必须以互斥的方式独占共享资源,如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证,这时用分布式锁是最直接有效的。分布式锁是控制分布式系统之间同步访问共享资源的一种方式。
1.3 实现分布式锁的三种选择
基于数据库实现分布式锁
基于zookeeper实现分布式锁
基于Redis缓存实现分布式锁
上文我们有讨论过,可以参考。DB\redis\zookeeper分布式锁设计
一般我们从性能角度考虑,基于 Redis 实现性能会更好。
1.4 基于 Redis 实现分布式锁的三种方案
Redis 实现分布式锁
Redisson 实现分布式锁(红锁 RedissonRedLock)
Redisson 实现分布式可重入锁(RedissonLock)
1.5 分布式锁需满足条件
为了确保分布式锁可用,我们要确保锁的实现同时满足以下条件:
1、互斥性。在分布上系统环境下,一个方法在同一时间只能被一个机器上的一个线程访问
2、高可用的获取锁和释放锁
3、高效的获取锁和释放锁
4、具备可重入特性(重新进入,由多个任务并发使用,不比担心数据错误)
5、具备锁的失效机制,防止死锁
6、具备非阻塞锁的特性,没有获取到锁,直接返回获取锁失败
二、用 Redis 实现分布式锁
主要思路
通过 set key value px milliseconds nx 命令实现加锁, 通过Lua脚本实现解锁。核心实现命令如下:
//获取锁(unique_value可以是UUID等)SET resource_name unique_value NX PX 30000//释放锁(lua脚本中,一定要比较value,防止误解锁)if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])elsereturn 0end
redis 实现分布式锁主要有以下几个要点:
set 命令要用 set key value px milliseconds nx,替代 setnx + expire,原子性问题。 value可以使用UUID.randomUUID().toString()方法生成,可以标识锁是属于哪个请求加的,以便解锁; 释放锁时要验证 value 值,防止误解锁; 通过 Lua 脚本来避免 Check And Set 模型的并发问题,因为在释放锁的时候因为涉及到多个Redis操作 (利用了eval命令执行Lua脚本的原子性);
完整代码实现如下:
public class RedisTool {private static final String LOCK_SUCCESS = "OK";private static final String SET_IF_NOT_EXIST = "NX";private static final String SET_WITH_EXPIRE_TIME = "PX";private static final Long RELEASE_SUCCESS = 1L;/*** 获取分布式锁(加锁代码)*/public static boolean getDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);if (LOCK_SUCCESS.equals(result)) {return true;}return false;}/*** 释放分布式锁(解锁代码)*/public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";Object result = jedis.eval(script, Collections.singletonList(lockKey), C ollections.singletonList(requestId));if (RELEASE_SUCCESS.equals(result)) {return true;}return false;}}
加锁代码分析
首先,set()+NX,如果已有key存在,则函数不会调用成功。也就是只有一个客户端能持有锁,满足互斥性。
其次,对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。
最后,我们将value赋值为requestId来标识锁是属于哪个请求加的,在客户端在解锁的时候就可以进行校验是否是同一个客户端。
解锁代码分析
将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。
在执行的时候,首先会获取锁对应的value值,检查是否与requestId相等,如果相等则解锁(删除key)。
这种方式仍存在单点风险
如果存储锁对应key的那个节点挂了的话,就可能存在丢失锁的风险,导致出现多个客户端持有锁的情况,这样就不能实现资源的独享了。
客户端A从master获取到锁
在master将锁同步到slave之前,master宕掉了(Redis的主从同步通常是异步的)。
主从切换,slave节点被晋级为master节点
客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。导致存在同一时刻存不止一个线程获取到锁的情况。
所以在这种实现之下,不论Redis的部署架构是单机模式、主从模式、哨兵模式还是集群模式,都存在这种风险。因为Redis的主从同步是异步的。
Redis 之父 antirez 提出了 redlock算法 可以解决这个问题。
三、redis 官网解释
对于Redis分布式锁(红锁)官网定义:
https://redis.io/docs/reference/patterns/distributed-locks/
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
1、获取当前时间,以毫秒为单位。2、依次尝试从5个实例,使用相同的key和随机值(Redisson中给出的是UUID + ThreadId)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。假如锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在一直等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。3、客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。4、如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。5、如果因为某些原因,获取锁失败,客户端应该在所有的Redis实例上进行解锁即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁。
四、Redisson 实现分布式可重入锁及源码分析 (RedissonLock)
什么是 Redisson
我们先看下有关Redisson实现分布式锁的核心类之间的关系,如下图:
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。
引申:java 中的锁
公平锁:是指多个线程按照申请锁的顺序来获取锁,类似于排队买饭,先来后到,先来先服务,就是公平的,也就是队列
非公平锁:是指多个线程获取锁的顺序,并不是按照申请锁的顺序,有可能申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转,或者饥饿的线程(也就是某个线程一直得不到锁)
区别是:公平锁就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列中的第一个,就占用锁,否者就会加入到等待队列中,以后安装FIFO的规则从队列中取到自己。Java ReenttrantLock通过构造函数指定该锁是否公平,默认是非公平锁,因为非公平锁的优点在于吞吐量比公平锁大,对于synchronized而言,也是一种非公平锁
可重入锁
已获取锁的线程可以进入该锁锁住的同步代码;
同一线程外层函数获得锁之后,内层递归函数仍然能获取锁的代码。同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
可重入锁最大作用就是规避死锁;
自旋锁
尝试获取锁的线程不会立刻阻塞,而是通过循环的方式等待获取锁。而不用再操作系统层面被挂起(用户态-》内核态),避免了上下文切换。
场景:线程执行任务的时间较短,可以较快的释放锁,在循环等待期间就可以获取到锁了。
独占锁:指的是该锁一次只能被一个线程持有,对于ReentrantLock和synchronized都是独占锁。
共享锁:该锁可以被多个线程共同持有。
读写锁 ReentrantWriteReadLock其内部的读锁是共享锁,写锁是独占锁;
Java中的synchronized和 ReentrantLock都是可重入锁(可递归锁)。可重入锁的意义在于防止死锁;
由于synchronized是基于monitor机制实现的,它只支持非公平锁;
ReentrantLock同时支持公平锁和非公平锁,它依赖LockSupport的park()和unpark()方法实现。
Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
Redisson 分布式重入锁用法
Redisson 支持单点模式、主从模式、哨兵模式、集群模式,这里以单点模式为例:
// 1.构造redisson实现分布式锁必要的ConfigConfig config = new Config();config.useSingleServer().setAddress("redis://127.0.0.1:5379").setPassword("123456").setDatabase(0);// 2.构造RedissonClientRedissonClient redissonClient = Redisson.create(config);// 3.获取锁对象实例(无法保证是按线程的顺序获取到)RLock rLock = redissonClient.getLock(lockKey);try {/*** 4.尝试获取锁* waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败* leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)*/boolean res = rLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS);if (res) {//成功获得锁,在这里处理业务}} catch (Exception e) {throw new RuntimeException("aquire lock fail");}finally{//无论如何, 最后都要解锁rLock.unlock();}
加锁源码分析
rLock.lock();是加锁的核心代码,我们一起来看看调用栈 加锁的核心方法是:
1.通过 getLock 方法获取对象
org.redisson.Redisson#getLock()
@Overridepublic RLock getLock(String name) {/*** 构造并返回一个 RedissonLock 对象* commandExecutor: 与 Redis 节点通信并发送指令的真正实现。需要说明一下,CommandExecutor 实现是通过 eval 命令来执行 Lua 脚本* name: 锁的全局名称* id: Redisson 客户端唯一标识,实际上就是一个 UUID.randomUUID()*/return new RedissonLock(commandExecutor, name, id);}
2.通过tryLock方法尝试获取锁
org.redisson.RedissonLock#tryLock
@Overridepublic boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {//取得最大等待时间long time = unit.toMillis(waitTime);//记录下当前时间long current = System.currentTimeMillis();//取得当前线程id(判断是否可重入锁的关键)long threadId = Thread.currentThread().getId();//1.尝试申请锁,返回还剩余的锁过期时间Long ttl = tryAcquire(leaseTime, unit, threadId);//2.如果为空,表示申请锁成功if (ttl == null) {return true;}//3.申请锁的耗时如果大于等于最大等待时间,则申请锁失败time -= System.currentTimeMillis() - current;if (time <= 0) {/*** 通过 promise.trySuccess 设置异步执行的结果为null* Promise从Uncompleted-->Completed ,通知 Future 异步执行已完成*/acquireFailed(threadId);return false;}current = System.currentTimeMillis();/*** 4.订阅锁释放事件,并通过await方法阻塞等待锁释放,有效的解决了无效的锁申请浪费资源的问题:* 基于信息量,当锁被其它资源占用时,当前线程通过 Redis 的 channel 订阅锁的释放事件,一旦锁释放会发消息通知待等待的线程进行竞争* 当 this.await返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败* 当 this.await返回true,进入循环尝试获取锁*/RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);//await 方法内部是用CountDownLatch来实现阻塞,获取subscribe异步执行的结果(应用了Netty 的 Future)if (!await(subscribeFuture, time, TimeUnit.MILLISECONDS)) {if (!subscribeFuture.cancel(false)) {subscribeFuture.onComplete((res, e) -> {if (e == null) {unsubscribe(subscribeFuture, threadId);}});}acquireFailed(threadId);return false;}try {//计算获取锁的总耗时,如果大于等于最大等待时间,则获取锁失败time -= System.currentTimeMillis() - current;if (time <= 0) {acquireFailed(threadId);return false;}/*** 5.收到锁释放的信号后,在最大等待时间之内,循环一次接着一次的尝试获取锁* 获取锁成功,则立马返回true,* 若在最大等待时间之内还没获取到锁,则认为获取锁失败,返回false结束循环*/while (true) {long currentTime = System.currentTimeMillis();// 再次尝试申请锁ttl = tryAcquire(leaseTime, unit, threadId);// 成功获取锁则直接返回true结束循环if (ttl == null) {return true;}//超过最大等待时间则返回false结束循环,获取锁失败time -= System.currentTimeMillis() - currentTime;if (time <= 0) {acquireFailed(threadId);return false;}/*** 6.阻塞等待锁(通过信号量(共享锁)阻塞,等待解锁消息):*/currentTime = System.currentTimeMillis();if (ttl >= 0 && ttl < time) {//如果剩余时间(ttl)小于wait time ,就在 ttl 时间内,从Entry的信号量获取一个许可(除非被中断或者一直没有可用的许可)。getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);} else {//则就在wait time 时间范围内等待可以通过信号量getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);}//7.更新剩余的等待时间(最大等待时间-已经消耗的阻塞时间)time -= System.currentTimeMillis() - currentTime;if (time <= 0) {acquireFailed(threadId);return false;}}} finally {//7.无论是否获得锁,都要取消订阅解锁消息unsubscribe(subscribeFuture, threadId);}}
其中 tryAcquire 内部通过调用 tryLockInnerAsync 实现申请锁的逻辑。
申请锁并返回锁有效期还剩余的时间,如果为空说明锁未被其它线程申请则直接获取并返回,如果获取到时间,则进入等待竞争逻辑。
org.redisson.RedissonLock#tryLockInnerAsync
加锁流程图:
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {internalLockLeaseTime = unit.toMillis(leaseTime);/*** 通过 EVAL 命令执行 Lua 脚本获取锁,保证了原子性*/return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,// 1.如果缓存中的key不存在,则执行 hset 命令(hset key UUID+threadId 1),然后通过 pexpire 命令设置锁的过期时间(即锁的租约时间)// 返回空值 nil ,表示获取锁成功"if (redis.call('exists', KEYS[1]) == 0) then " +"redis.call('hset', KEYS[1], ARGV[2], 1); " +"redis.call('pexpire', KEYS[1], ARGV[1]); " +"return nil; " +"end; " +// 如果key已经存在,并且value也匹配,表示是当前线程持有的锁,则执行 hincrby 命令,重入次数加1,并且设置失效时间"if (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; " +//如果key已经存在,但是value不匹配,说明锁已经被其他线程持有,通过 pttl 命令获取锁的剩余存活时间并返回,至此获取锁失败"return redis.call('pttl', KEYS[1]);",//这三个参数分别对应KEYS[1],ARGV[1]和ARGV[2]Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));}
参数说明:
KEYS[1]就是Collections.singletonList(getName()),表示分布式锁的key;
ARGV[1]就是internalLockLeaseTime,即锁的租约时间(持有锁的有效时间),默认30s;
ARGV[2]就是getLockName(threadId),是获取锁时set的唯一值 value,即UUID+threadId。
加锁核心方法:org.redisson.RedissonLock#tryLockInnerAsync其实它的本质是调用一段 LUA 脚本进行加锁, 需要注意的是这个地方使用的数据类型是 hash。
锁续期设计
锁的续期是在 org.redisson.RedissonLock#tryAcquireAsync方法中调用 scheduleExpirationRenewal实现的。
续期需要注意的是,看门狗是设置在主线程的延迟队列的线程中。
tryAcquireAsync 代码如下:
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {RFuture<Long> ttlRemainingFuture;if (leaseTime != -1) {ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);} else {ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);}CompletionStage<Long> f = ttlRemainingFuture.thenApply(ttlRemaining -> {// lock acquiredif (ttlRemaining == null) {if (leaseTime != -1) {internalLockLeaseTime = unit.toMillis(leaseTime);} else {// 锁过期时间续期scheduleExpirationRenewal(threadId);}}return ttlRemaining;});return new CompletableFutureWrapper<>(f);}
锁续期 scheduleExpirationRenewal代码如下
protected void scheduleExpirationRenewal(long threadId) {ExpirationEntry entry = new ExpirationEntry();ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);if (oldEntry != null) {oldEntry.addThreadId(threadId);} else {entry.addThreadId(threadId);try {renewExpiration();} finally {if (Thread.currentThread().isInterrupted()) {cancelExpirationRenewal(threadId);}}}}
然后在调用 renewExpiration(); 执行续期逻辑, 其实这里是一个定时任务 + 递归的方式实现续期的,用定时任务的好处就是不用去开 N 个字线程,只需要创建对应的任务对象即可。
备注:如果超级极端的情况下 N 把锁,同时加锁,同时需求。我们可以考虑在锁的有效期上,给它加一个浮动时间比如 100 - 500ms. 这样就能一定程度上避免 (参考的是缓存失效/击穿的解决方案)
private void renewExpiration() {ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());if (ee == null) {return;}// 创建延迟任务Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {@Overridepublic void run(Timeout timeout) throws Exception {ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());if (ent == null) {return;}Long threadId = ent.getFirstThreadId();if (threadId == null) {return;}// 真正的续期,调用 LUA 脚本续期RFuture<Boolean> future = renewExpirationAsync(threadId);future.whenComplete((res, e) -> {if (e != null) {log.error("Can't update lock " + getRawName() + " expiration", e);EXPIRATION_RENEWAL_MAP.remove(getEntryName());return;}// 如果续期成功if (res) {// reschedule itselfrenewExpiration();} else {cancelExpirationRenewal(null);}});}}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);ee.setTimeout(task);}
renewExpirationAsync方法, 里面还是一段 LUA 脚本,进行重新设置锁的过期时间
protected RFuture<Boolean> renewExpirationAsync(long threadId) {return eval(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +"redis.call('pexpire', KEYS[1], ARGV[1]); " +"return 1; " +"end; " +"return 0;",Collections.singletonList(getRawName()),internalLockLeaseTime, getLockName(threadId));}
锁的自旋重试
org.redisson.RedissonLock#lock(long, java.util.concurrent.TimeUnit, boolean)在执行获取锁失败的时候,会进入重试。其实这里就会执行 while (true) 逻辑
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {long threadId = Thread.currentThread().getId();Long ttl = tryAcquire(-1, leaseTime, unit, threadId);// lock acquiredif (ttl == null) {return;}CompletableFuture<RedissonLockEntry> future = subscribe(threadId);RedissonLockEntry entry;if (interruptibly) {entry = commandExecutor.getInterrupted(future);} else {entry = commandExecutor.get(future);}try {while (true) {ttl = tryAcquire(-1, leaseTime, unit, threadId);// lock acquiredif (ttl == null) {break;}// waiting for messageif (ttl >= 0) {try {// 阻塞锁的超时时间,等锁过期后再尝试加锁entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);} catch (InterruptedException e) {if (interruptibly) {throw e;}entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);}} else {if (interruptibly) {entry.getLatch().acquire();} else {entry.getLatch().acquireUninterruptibly();}}}} finally {unsubscribe(entry, threadId);}// get(lockAsync(leaseTime, unit));}
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);其实这里就是一个间歇性自旋。等到上次锁过期的时间,在唤醒进行抢锁 entry.getLatch().acquire();
还有一个逻辑就是
CompletableFuture future = subscribe(threadId);这里其实是会订阅一个消息,如果解锁过后,会发布解锁的消息。
解锁源码分析
unlock 内部通过 get(unlockAsync(Thread.currentThread().getId())) 调用 unlockInnerAsync 解锁。
org.redisson.RedissonLock#unlock
@Overridepublic void unlock() {try {get(unlockAsync(Thread.currentThread().getId()));} catch (RedisException e) {if (e.getCause() instanceof IllegalMonitorStateException) {throw (IllegalMonitorStateException) e.getCause();} else {throw e;}}}
get方法利用是 CountDownLatch 在异步调用结果返回前将当前线程阻塞,然后通过 Netty 的 FutureListener 在异步调用完成后解除阻塞,并返回调用结果。
org.redisson.command.CommandAsyncService#get
@Overridepublic <V> V get(RFuture<V> future) {if (!future.isDone()) { //任务还没完成// 设置一个单线程的同步控制器CountDownLatch l = new CountDownLatch(1);future.onComplete((res, e) -> {//操作完成时,唤醒在await()方法中等待的线程l.countDown();});boolean interrupted = false;while (!future.isDone()) {try {//阻塞等待l.await();} catch (InterruptedException e) {interrupted = true;break;}}if (interrupted) {Thread.currentThread().interrupt();}}if (future.isSuccess()) {return future.getNow();}throw convertException(future);}
org.redisson.RedissonLock#unlockInnerAsync
解锁流程图:
实现源码可以看到,解锁还是LUA 的执行方式。
protected RFuture<Boolean> unlockInnerAsync(long threadId) {/*** 通过 EVAL 命令执行 Lua 脚本获取锁,保证了原子性*/return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,//如果分布式锁存在,但是value不匹配,表示锁已经被其他线程占用,无权释放锁,那么直接返回空值(解铃还须系铃人)"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +"return nil;" +"end; " +//如果value匹配,则就是当前线程占有分布式锁,那么将重入次数减1"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +//重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只能更新失效时间,还不能删除"if (counter > 0) then " +"redis.call('pexpire', KEYS[1], ARGV[2]); " +"return 0; " +"else " +//重入次数减1后的值如果为0,这时就可以删除这个KEY,并发布解锁消息,返回1"redis.call('del', KEYS[1]); " +"redis.call('publish', KEYS[2], ARGV[1]); " +"return 1; "+"end; " +"return nil;",//这5个参数分别对应KEYS[1],KEYS[2],ARGV[1],ARGV[2]和ARGV[3]Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));}
撤销锁续期
核心方法 org.redisson.RedissonBaseLock#unlockAsync(long)
@Overridepublic RFuture<Void> unlockAsync(long threadId) {// 解锁RFuture<Boolean> future = unlockInnerAsync(threadId);// 撤销续期CompletionStage<Void> f = future.handle((opStatus, e) -> {cancelExpirationRenewal(threadId);if (e != null) {throw new CompletionException(e);}if (opStatus == null) {IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "+ id + " thread-id: " + threadId);throw new CompletionException(cause);}return null;});return new CompletableFutureWrapper<>(f);}
解锁成功消息处理
org.redisson.pubsub#onMessage 中回去唤醒阻塞的线程,让执行前面的锁自旋逻辑,具体代码如下:
public class LockPubSub extends PublishSubscribe<RedissonLockEntry> {public static final Long UNLOCK_MESSAGE = 0L;public static final Long READ_UNLOCK_MESSAGE = 1L;public LockPubSub(PublishSubscribeService service) {super(service);}@Overrideprotected RedissonLockEntry createEntry(RPromise<RedissonLockEntry> newPromise) {return new RedissonLockEntry(newPromise);}@Overrideprotected void onMessage(RedissonLockEntry value, Long message) {/*** 判断是否是解锁消息*/if (message.equals(UNLOCK_MESSAGE)) {Runnable runnableToExecute = value.getListeners().poll();if (runnableToExecute != null) {runnableToExecute.run();}/*** 释放一个信号量,唤醒等待的entry.getLatch().tryAcquire去再次尝试申请锁*/value.getLatch().release();} else if (message.equals(READ_UNLOCK_MESSAGE)) {while (true) {/*** 如果还有其他Listeners回调,则也唤醒执行*/Runnable runnableToExecute = value.getListeners().poll();if (runnableToExecute == null) {break;}runnableToExecute.run();}value.getLatch().release(value.getLatch().getQueueLength());}}}
五、用 Redisson 实现分布式锁(红锁 RedissonRedLock)及源码分析
我们以三个单机模式为demo,注意他们完全互相独立,不存在主从复制或者其他集群协调机制。
Config config1 = new Config();config1.useSingleServer().setAddress("redis://172.0.0.1:5378").setPassword("a123456").setDatabase(0);RedissonClient redissonClient1 = Redisson.create(config1);Config config2 = new Config();config2.useSingleServer().setAddress("redis://172.0.0.1:5379").setPassword("a123456").setDatabase(0);RedissonClient redissonClient2 = Redisson.create(config2);Config config3 = new Config();config3.useSingleServer().setAddress("redis://172.0.0.1:5380").setPassword("a123456").setDatabase(0);RedissonClient redissonClient3 = Redisson.create(config3);/*** 获取多个 RLock 对象*/RLock lock1 = redissonClient1.getLock(lockKey);RLock lock2 = redissonClient2.getLock(lockKey);RLock lock3 = redissonClient3.getLock(lockKey);/*** 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)*/RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);try {/*** 4.尝试获取锁* waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败* leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)*/boolean res = redLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS);if (res) {//成功获得锁,在这里处理业务}} catch (Exception e) {throw new RuntimeException("aquire lock fail");}finally{//无论如何, 最后都要解锁redLock.unlock();}
最核心的变化就是需要构建多个 RLock ,然后根据多个 RLock 构建成一个 RedissonRedLock,
因为 redLock 算法是建立在多个互相独立的 Redis 环境之上的(为了区分可以叫为 Redission node),Redission node 节点既可以是单机模式(single),也可以是主从模式(master/salve),哨兵模式(sentinal),或者集群模式(cluster)。这就意味着,不能跟以往这样只搭建 1个 cluster、或 1个 sentinel 集群,或是1套主从架构就了事了,需要为 RedissonRedLock 额外搭建多几套独立的 Redission 节点。
下图是一个利用多个 Redission node 最终 组成 RedLock分布式锁的例子,注意:每个 Redission node 是互相独立的,不存在任何复制或者其他隐含的分布式协调机制。
# Redisson 实现redlock算法源码分析(RedLock)
加锁核心代码
org.redisson.RedissonMultiLock#tryLock
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {long newLeaseTime = -1;if (leaseTime != -1) {newLeaseTime = unit.toMillis(waitTime)*2;}long time = System.currentTimeMillis();long remainTime = -1;if (waitTime != -1) {remainTime = unit.toMillis(waitTime);}long lockWaitTime = calcLockWaitTime(remainTime);/*** 1. 允许加锁失败节点个数限制(N-(N/2+1))*/int failedLocksLimit = failedLocksLimit();/*** 2. 遍历所有节点通过EVAL命令执行lua加锁*/List<RLock> acquiredLocks = new ArrayList<>(locks.size());for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {RLock lock = iterator.next();boolean lockAcquired;/*** 3.对节点尝试加锁*/try {if (waitTime == -1 && leaseTime == -1) {lockAcquired = lock.tryLock();} else {long awaitTime = Math.min(lockWaitTime, remainTime);lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);}} catch (RedisResponseTimeoutException e) {// 如果抛出这类异常,为了防止加锁成功,但是响应失败,需要解锁所有节点unlockInner(Arrays.asList(lock));lockAcquired = false;} catch (Exception e) {// 抛出异常表示获取锁失败lockAcquired = false;}if (lockAcquired) {/***4. 如果获取到锁则添加到已获取锁集合中*/acquiredLocks.add(lock);} else {/*** 5. 计算已经申请锁失败的节点是否已经到达 允许加锁失败节点个数限制 (N-(N/2+1))* 如果已经到达, 就认定最终申请锁失败,则没有必要继续从后面的节点申请了* 因为 Redlock 算法要求至少N/2+1 个节点都加锁成功,才算最终的锁申请成功*/if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {break;}if (failedLocksLimit == 0) {unlockInner(acquiredLocks);if (waitTime == -1 && leaseTime == -1) {return false;}failedLocksLimit = failedLocksLimit();acquiredLocks.clear();// reset iteratorwhile (iterator.hasPrevious()) {iterator.previous();}} else {failedLocksLimit--;}}/*** 6.计算 目前从各个节点获取锁已经消耗的总时间,如果已经等于最大等待时间,则认定最终申请锁失败,返回false*/if (remainTime != -1) {remainTime -= System.currentTimeMillis() - time;time = System.currentTimeMillis();if (remainTime <= 0) {unlockInner(acquiredLocks);return false;}}}if (leaseTime != -1) {List<RFuture<Boolean>> futures = new ArrayList<>(acquiredLocks.size());for (RLock rLock : acquiredLocks) {RFuture<Boolean> future = ((RedissonLock) rLock).expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS);futures.add(future);}for (RFuture<Boolean> rFuture : futures) {rFuture.syncUninterruptibly();}}/*** 7.如果逻辑正常执行完则认为最终申请锁成功,返回true*/return true;}
六:Redisson 应用场景之Redisson实现延迟队列
redisson 分布式锁原理描述
先线程 1 获取锁,如果获取锁成功,那么会开启一个后台线程,每次间隔 10 秒进行续期。
并发情况,线程 2 会进行加锁,如果无法获取锁,那么就会进行自旋等待,等待到达一定次数过后,就会进行线程阻塞,并且订阅解锁消息。
当线程 1 释放锁之后,会触发 redis 的解锁消息,消息的观察者会观察到然后去唤醒解锁的逻辑,线程 2 继续竞争锁。
对于锁的重入,Redisson 是通过 hash 为数据类型的,会存储当前线程的 tid (本质是生成的 uuid 唯一id).
1.场景介绍
假设有这样一个场景,有一个订单,需要在超时15分钟,或者30分钟后进行关闭。我们最先想到的应该是采用定时任务去进行轮训判断,但是呢,每个订单的创建时间是不一样的,这个时间怎么确定才好呢,1分钟执行一次吗。这样就会非常影响性能。且时间误差很大。基于以上业务需要我们想到了有以下解决方案。
JDK延迟队列,但是数据都在内存中,重启后容易发生数据丢失。
消息队列MQ中的延迟队列。
基于Redisson的延迟队列
2.JDK延迟队列
我们首先来回顾下JDK的延迟队列
基于延迟队列要实现接口Delayed,并且实现getDelay方法和compareTo方法
getDelay主要是计算返回剩余时间,单位时间戳(毫秒)延迟任务是否到时就是按照这个方法判断如果返回的是负数则说明到期否则还没到期compareTo主要是自定义实现比较方法返回 1 0 -1三个参数
@ToStringpublic class MyDelayed<T> implements Delayed {/*** 延迟时间*/Long delayTime;/*** 过期时间*/Long expire;/*** 数据*/T t;public MyDelayed(long delayTime, T t) {this.delayTime = delayTime;// 过期时间 = 当前时间 + 延迟时间this.expire = System.currentTimeMillis() + delayTime;this.t = t;}/*** 剩余时间 = 到期时间 - 当前时间*/@Overridepublic long getDelay(TimeUnit unit) {return unit.convert(this.expire - System.currentTimeMillis(), TimeUnit.MILLISECONDS);}/*** 优先级规则:两个任务比较,时间短的优先执行*/@Overridepublic int compareTo(Delayed o) {long f = this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS);return (int) f;}
订单的实体,为了简单就定义基础几个字段。
@Datapublic class OrderInfo implements Serializable {private static final long serialVersionUID = -2837036864073566484L;/*** 订单id*/private Long id;/*** 订单金额*/private Double salary;/*** 订单创建时间 对于java8LocalDateTime 以下注解序列化反序列化用到*/@JsonDeserialize(using = LocalDateTimeDeserializer.class)@JsonSerialize(using = LocalDateTimeSerializer.class)private LocalDateTime createTime;}
为了简单我们定义延迟时间为10s
public static void main(String[] args) throws InterruptedException {OrderInfo orderInfo = new OrderInfo();orderInfo.setCreateTime(LocalDateTimeUtil.parse("2022-07-01 15:00:00", "yyyy-MM-dd HH:mm:ss"));MyDelayed<OrderInfo> myDelayed = new MyDelayed<>(10000L,orderInfo);DelayQueue<MyDelayed<OrderInfo>> queue = new DelayQueue<>();queue.add(myDelayed);System.out.println(queue.take().getT().getCreateTime());System.out.println("当前时间:" + LocalDateTime.now());}
输出结果
2022-09-01T15:00
当前时间:2022-09-01T15:10:37.375
3.基于Redisson的延迟队列
我们基于Redisson的延迟队列来说。
其实Redisson延迟队列内部也是基于redis来实现的,我们先来进行整合使用看看效果。基于springboot
1.依赖:
<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.16.7</version></dependency>
2.创建redisson.yml
# 单节点配置singleServerConfig:# 连接空闲超时,单位:毫秒idleConnectionTimeout: 10000# 连接超时,单位:毫秒connectTimeout: 10000# 命令等待超时,单位:毫秒timeout: 3000# 命令失败重试次数,如果尝试达到 retryAttempts(命令失败重试次数) 仍然不能将命令发送至某个指定的节点时,将抛出错误。# 如果尝试在此限制之内发送成功,则开始启用 timeout(命令等待超时) 计时。retryAttempts: 3# 命令重试发送时间间隔,单位:毫秒retryInterval: 1500# 密码password:# 单个连接最大订阅数量subscriptionsPerConnection: 5# 客户端名称clientName: null# 节点地址address: redis://127.0.0.1:6379# 发布和订阅连接的最小空闲连接数subscriptionConnectionMinimumIdleSize: 1# 发布和订阅连接池大小subscriptionConnectionPoolSize: 50# 最小空闲连接数connectionMinimumIdleSize: 32# 连接池大小connectionPoolSize: 64# 数据库编号database: 0# DNS监测时间间隔,单位:毫秒dnsMonitoringInterval: 5000# 线程池数量,默认值: 当前处理核数量 * 2#threads: 0# Netty线程池数量,默认值: 当前处理核数量 * 2#nettyThreads: 0# 编码codec: !<org.redisson.codec.JsonJacksonCodec> {}# 传输模式transportMode : "NIO"
3.创建配置类RedissonConfig,这里是为了读取我们刚刚创建在配置文件中的yml
@Configurationpublic class RedissonConfig {@Beanpublic RedissonClient redissonClient() throws IOException {Config config = Config.fromYAML(RedissonConfig.class.getClassLoader().getResource("redisson.yml"));;return Redisson.create(config);}}
4.测试
// redisson 延迟队列// Redisson的延时队列是对另一个队列的再包装,使用时要先将延时消息添加到延时队列中,// 当延时队列中的消息达到设定的延时时间后,该延时消息才会进行进入到被包装队列中,因此,我们只需要对被包装队列进行监听即可。RBlockingQueue<OrderInfo> blockingFairQueue = redissonClient.getBlockingQueue("my-test");RDelayedQueue<OrderInfo> delayedQueue = redissonClient.getDelayedQueue(blockingFairQueue);OrderInfo orderInfo = new OrderInfo();// 订单生成时间orderInfo.setCreateTime(LocalDateTime.now());// 10秒钟以后将消息发送到指定队列delayedQueue.offer(orderInfo, 10, TimeUnit.SECONDS);RBlockingQueue<OrderInfo> outQueue = redissonClient.getBlockingQueue("my-test");OrderInfo orderInfo2 = outQueue.take();System.out.println("订单生成时间" + orderInfo2.getCreateTime());System.out.println("订单关闭时间" + LocalDateTime.now());// 在该对象不再需要的情况下,应该主动销毁。仅在相关的Redisson对象也需要关闭的时候可以不用主动销毁delayedQueue.destroy();
控制台输出:
订单生成时间2022-09-01T15:22:10.304
订单关闭时间2022-09-01T15:22:20.414
4.深入探究Redisson的延迟队列实现原理
我们首先来了解两个API
RBlockingQueue 就是目标队列
RDelayedQueue 就是中转队列
那么为什么会涉及到两个队列呢,这两个队列到底有什么用呢?
首先我们实际操作的是RBlockingQueue阻塞队列,并不是RDelayedQueue队列,RDelayedQueue对接主要是提供中间转发的一个队列,类似中间商的意思
画个小图理解下
这里不难看出我们都是基于RBlockingQueue 目标队列在进行消费,而RDelayedQueue就是会把过期的消息放入到我们的目标队列中
我们只要从RBlockingQueue队列中取数据即可。
好像还是不够深入,我们接着看。我们知道Redisson是基于redis来实现的那么我们看看里面到底做了什么事
打开redis客户端,执行monitor命令,看下在执行上面订单操作时redis到底执行了哪些命令
monitor命令可以看到操作redis时执行了什么命令
// 这里订阅了一个固定的队列 redisson_delay_queue_channel:{my-test},为了开启进程里面的延时任务"SUBSCRIBE" "redisson_delay_queue_channel:{my-test}"// Redis Zrangebyscore 返回有序集合中指定分数区间的成员列表。有序集成员按分数值递增(从小到大)次序排列。// redisson_delay_queue_channel:{my-test} 是一个zset,当有延时数据存入Redisson队列时,就会在此队列中插入 数据,排序分数为延时的时间戳(毫秒 以下同理)。"zrangebyscore" "redisson_delay_queue_timeout:{my-test}" "0" "1656404479385" "limit" "0" "100"// 取出第一个数,也就是判断上面执行的操作是否有下一页。(因为刚刚开始总是0的)除非是之前的操作(zrangebyscore)没有取完"zrange" "redisson_delay_queue_timeout:{my-test}" "0" "0" "WITHSCORES"// 往zset里面设置 数据过期的时间戳(当前执行的时间戳+延时的时间毫秒值)内容就是订单数据"zadd" "redisson_delay_queue_timeout:{my-test}" "1656404489400" "b\x99M9\x9b\x0c\xd3\xc3\\\x00\x00\x00{\"@class\":\"com.example.mytest.domain.OrderInfo\",\"createTime\":[2022,6,28,16,21,19,400000000]}"// 同步一份数据到list队列"rpush" "redisson_delay_queue:{my-test}" "b\x99M9\x9b\x0c\xd3\xc3\\\x00\x00\x00{\"@class\":\"com.example.mytest.domain.OrderInfo\",\"createTime\":[2022,6,28,16,21,19,400000000]}"// 取出排序好的第一个数据,也就是最临近要触发的数据,然后发送通知"zrange" "redisson_delay_queue_timeout:{my-test}" "0" "0"// 发送通知 之前第一步 SUBSCRIBE 订阅了 客户端收到通知后,就在自己进程里面开启延时任务(HashedWheelTimer),到时间后就可以从redis取数据发送"publish" "redisson_delay_queue_channel:{my-test}" "1656404489400"// 这里就是取数据环节了"BLPOP" "my-test" "0"// 在范围 0-过期时间 取出100条数据"zrangebyscore" "redisson_delay_queue_timeout:{my-test}" "0" "1656404489444" "limit" "0" "100"// 将上面取到的数据push到阻塞队列 很显然能看到 com.example.mytest.domain.OrderInfo 是我们的订单数据"rpush" "my-test" "{\"@class\":\"com.example.mytest.domain.OrderInfo\",\"createTime\":[2022,6,28,16,21,19,400000000]}"// 删除数据"lrem" "redisson_delay_queue:{my-test}" "1" "b\x99M9\x9b\x0c\xd3\xc3\\\x00\x00\x00{\"@class\":\"com.example.mytest.domain.OrderInfo\",\"createTime\":[2022,6,28,16,21,19,400000000]}""zrem" "redisson_delay_queue_timeout:{my-test}" "b\x99M9\x9b\x0c\xd3\xc3\\\x00\x00\x00{\"@class\":\"com.example.mytest.domain.OrderInfo\",\"createTime\":[2022,6,28,16,21,19,400000000]}"// 取zset第一个数据,有的话继续上面逻辑取数据"zrange" "redisson_delay_queue_timeout:{my-test}" "0" "0" "WITHSCORES"// 退订"UNSUBSCRIBE" "redisson_delay_queue_channel:{my-test}"
我们知道Zset是按照分数升序的也就是最小的分数在最前面,基于这个特点,利用过期时间的时间戳作为分数放入到Zset中,那么即将过期的就在最上面。
直接上图
redisson 延时队列原理小结
客户端启动,redisson先订阅一个key,同时 BLPOP key 0 无限监听一个阻塞队列(等里面有数据了就返回)。
当有数据put时,redisson先把数据放到一个zset集合(按延时到期时间的时间戳为分数排序),同时发布上面订阅的key,发布内容为数据到期的timeout,此时客户端进程开启一个延时任务,延时时间为发布的timeout。
客户端进程的延时任务到了时间执行,从zset分页取出过了当前时间的数据,然后将数据rpush到第一步的阻塞队列里。然后将当前数据从zset移除,取完之后,又执行 BLPOP key 0 无限监听一个阻塞队列。
上一步客户端监听的阻塞队列返回取到数据,回调到 RBlockingQueue 的 take方法。于是,我们就收到了数据。
大致原理就是这样,redisson不是通过轮询zset的,将延时任务执行放到进程里面实现,只有到时间才会取redis zset。
总结对比
通过分析源码可知,RedissonLock是可重入的,并且考虑了失败重试,可以设置锁的最大等待时间, 在实现上也做了一些优化,减少了无效的锁申请,提升了资源的利用率。
通过 Redisson 实现分布式可重入锁,比通过set key value px milliseconds nx +lua 实现的效果更好些。
但是,RedissonLock 同样没有解决 节点挂掉的时候,存在丢失锁的风险的问题。所以 Redisson 提供了实现了redlock算法的 RedissonRedLock。RedissonRedLock 真正解决了单点失败的问题,代价是需要额外的为 RedissonRedLock 搭建Redis环境。
如果业务场景可以容忍这种小概率的错误,则推荐使用 RedissonLock, 如果无法容忍,则推荐使用 RedissonRedLock。
未完待续...
文:一只阿木木
欢迎转发、交流小数据。
松花酿酒,春水煎茶。
眉上风止,见字如晤。
一只阿木木