生产级Redis 高并发分布式锁实战1:高并发分布式锁如何实现 https://www.cnblogs.com/yizhiamumu/p/16556153.html
生产级Redis 高并发分布式锁实战2:缓存架构设计问题优化 https://www.cnblogs.com/yizhiamumu/p/16556667.html
总结篇3:redis 典型缓存架构设计问题及性能优化 https://www.cnblogs.com/yizhiamumu/p/16557996.html
总结篇4:redis 核心数据存储结构及核心业务模型实现应用场景 https://www.cnblogs.com/yizhiamumu/p/16566540.html
DB\redis\zookeeper分布式锁设计 https://www.cnblogs.com/yizhiamumu/p/16663243.html
在缓存和数据库双写场景下,一致性是如何保证的 https://www.cnblogs.com/yizhiamumu/p/16686751.html
如何保证 Redis 的高并发和高可用?讨论redis的单点,高可用,集群 https://www.cnblogs.com/yizhiamumu/p/16586968.html
分布式缓存应用场景与redis持久化机制 https://www.cnblogs.com/yizhiamumu/p/16702154.html
Redisson 源码分析及实际应用场景介绍 https://www.cnblogs.com/yizhiamumu/p/16706048.html
Redis 高可用方案原理初探 https://www.cnblogs.com/yizhiamumu/p/16709290.html
RedisCluster集群架构原理与通信原理 https://www.cnblogs.com/yizhiamumu/p/16704556.html
一、分布式锁的选择场景
1.1 出现背景
随着互联网技术快速发展,数据规模增大,分布式系统越来越普及,一个应用往往会部署在多台机器上(多节点),在有些场景中,为了保证数据不重复,要求在同一时刻,同一任务只在一个节点上运行,即保证某一方法同一时刻只能被一个线程执行。
在单机环境中,应用是在同一进程下的,只需要保证单进程多线程环境中的线程安全性,通过 JAVA 提供的 volatile、ReentrantLock、synchronized 以及 concurrent 并发包下一些线程安全的类等就可以做到。而在多机部署环境中,不同机器不同进程,就需要在多进程下保证线程的安全性了。因此,分布式锁应运而生。
1.2 应用场景
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。
在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证,这个时候,便需要使用到分布式锁。
1.3 实现分布式锁的三种选择
- 基于数据库实现分布式锁
- 基于zookeeper实现分布式锁
- 基于Redis缓存实现分布式锁
上文我们有讨论过,可以参考。DB\redis\zookeeper分布式锁设计 https://www.cnblogs.com/yizhiamumu/p/16663243.html
从性能角度考虑,基于 Redis 实现性能会更好。
1.4 基于 Redis 实现分布式锁的三种方案
- Redis 实现分布式锁(实现一)
- Redisson 实现分布式锁(红锁 RedissonRedLock)(实现二)
- Redisson 实现分布式可重入锁(RedissonLock)(实现三)
1.5 分布式锁需满足四个条件
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。
- 具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁
二、用 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] then return redis.call("del",KEYS[1]) else return 0 end
这种实现方式主要有以下几个要点:
-
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; /** * 获取分布式锁(加锁代码) * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @param expireTime 超期时间 * @return 是否获取成功 */ 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; } /** * 释放分布式锁(解锁代码) * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @return 是否释放成功 */ 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/
对如上5点做出解释:
redis红锁算法:
在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、如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的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实现分布式锁必要的Config Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:5379").setPassword("123456").setDatabase(0); // 2.构造RedissonClient RedissonClient 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()
@Override public 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
@Override public 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 acquired if (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() { @Override public 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 itself renewExpiration(); } 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 acquired if (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 acquired if (ttl == null) { break; } // waiting for message if (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
@Override public 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
@Override public <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)
@Override public 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);
}
@Override
protected RedissonLockEntry createEntry(RPromise<RedissonLockEntry> newPromise) {
return new RedissonLockEntry(newPromise);
}
@Override
protected 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)及源码分析
这里以三个单机模式为例,需要特别注意的是他们完全互相独立,不存在主从复制或者其他集群协调机制。
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 节点。 比如可以搭建3个 或者5个 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 iterator
while (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.场景介绍
假设有这样一个场景,有一个订单,需要在超时30分钟后进行关闭。这个时候我们最先想到的应该是采用定时任务去进行轮训判断,但是呢,每个订单的创建时间是不一样的,这个时间怎么确定才好呢,5分钟。。1分钟。。执行一次吗。这样就会非常影响性能。且时间误差很大。基于以上业务需要我们想到了有以下解决方案。
- JDK延迟队列,但是数据都在内存中,重启后什么都没了。
- MQ中的延迟队列,比如RocketMQ。
- 基于Redisson的延迟队列
2.JDK延迟队列
我们首先来回顾下JDK的延迟队列
基于延迟队列要实现接口Delayed
,并且实现getDelay
方法和compareTo
方法
getDelay
主要是计算返回剩余时间,单位时间戳(毫秒)延迟任务是否到时就是按照这个方法判断如果返回的是负数则说明到期否则还没到期compareTo
主要是自定义实现比较方法返回 1 0 -1三个参数@ToString public 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; } /** * 剩余时间 = 到期时间 - 当前时间 */ @Override public long getDelay(TimeUnit unit) { return unit.convert(this.expire - System.currentTimeMillis(), TimeUnit.MILLISECONDS); } /** * 优先级规则:两个任务比较,时间短的优先执行 */ @Override public int compareTo(Delayed o) { long f = this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS); return (int) f; }
订单的实体,为了简单就定义基础几个字段。
@Data public 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-07-01T15:00
当前时间:2022-07-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
@Configuration public class RedissonConfig { @Bean public 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-07-01T15:22:10.304
订单关闭时间2022-07-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中,那么即将过期的就在最上面。
直接上个图解
总结对比
通过分析源码可知,RedissonLock是可重入的,并且考虑了失败重试,可以设置锁的最大等待时间, 在实现上也做了一些优化,减少了无效的锁申请,提升了资源的利用率。
通过 Redisson 实现分布式可重入锁,比纯自己通过set key value px milliseconds nx +lua 实现的效果更好些。
需要特别注意的是,RedissonLock 同样没有解决 节点挂掉的时候,存在丢失锁的风险的问题。
所以 Redisson 提供了实现了redlock算法的 RedissonRedLock,RedissonRedLock 真正解决了单点失败的问题,代价是需要额外的为 RedissonRedLock 搭建Redis环境。
如果业务场景可以容忍这种小概率的错误,则推荐使用 RedissonLock, 如果无法容忍,则推荐使用 RedissonRedLock。