分布式锁的实现方案中,Redisson 以其强大的功能、出色的性能和极高的易用性脱颖而出,成为了开发者们的得力助手。Redisson 不仅仅是一个简单的分布式锁工具,它更像是一套完整的分布式协调框架,提供了丰富多样的分布式对象和服务,极大地简化了分布式系统的开发过程。
一、详解Redisson 分布式锁使用和实现
- 前置集成Redisson 基础配置 使用Redisson时我们优先需要引入其依赖:
然后配置Redis基本配置信息,这里笔者以单体架构为例给出redis的配置示例:
spring.redis.host=localhost spring.redis.port=6379
- 分布式锁的基本使用 RLock继承了JUC包下的Lock接口,所以使用起来和JUC包下的几个lock类似,这里我们也给出相应的基本代码示例:
CountDownLatch countDownLatch = new CountDownLatch(2);
//声明一把分布式锁
RLock lock = redissonClient.getLock("lock");
new Thread(() -> {
try {
//上锁
lock.lock();
log.info("lock lock success");
ThreadUtil.sleep(1, TimeUnit.MINUTES);
countDownLatch.countDown();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}).start();
new Thread(() -> {
try {
//休眠5s让上一个线程先取锁
ThreadUtil.sleep(5, TimeUnit.SECONDS);
//上锁
if (lock.tryLock()) {
log.info("try lock success");
//成功后执行业务逻辑然后释放锁
ThreadUtil.sleep(1, TimeUnit.MINUTES);
lock.unlock();
} else {
log.info("try lock fail");
}
countDownLatch.countDown();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
countDownLatch.await();
对应的输出结果如下,可以看到第一个线程基于redisson上锁成功后,第二个线程就无法上锁了:
- 公平锁的使用 默认情况下Redisson分布式锁是非公平的,即任意时刻任意一个请求都可以在锁释放后争抢分布式锁,对此redisson给出了公平锁的实现,如下代码所示,笔者通过getFairLock声明一把公平锁,让声明5个线程进行争抢:
int size = 5; //声明分布式锁 RLock reentrantLock = redissonClient.getFairLock("lock"); //创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(size);
CountDownLatch countDownLatch = new CountDownLatch(size);
//遍历线程池,让池内的线程争抢分布式锁
for (int i = 0; i < size; i++) {
threadPool.submit(() -> {
try {
reentrantLock.lock();
log.info("reentrantLock.lock success");
} catch (Exception e) {
log.error("reentrantLock.lock error", e);
} finally {
reentrantLock.unlock();
log.info("reentrantLock.unlock success");
countDownLatch.countDown();
}
});
}
countDownLatch.await();
可以看到,笔者通过调试的方式顺序让线程争抢分布式锁,最终输出结果也是按照先来后到的方式获取锁和释放锁:
- 联锁的使用 联锁顾名思义,只有一次性获取多把锁之后才能算成功,对应的代码示例如下:
RLock lock1 = redissonClient.getFairLock("lock-1"); RLock lock2 = redissonClient.getFairLock("lock-2"); RLock lock3 = redissonClient.getFairLock("lock-3");
RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2, lock3);
try {
// 同时加锁:lock1 lock2 lock3
// 所有的锁都上锁成功才算成功。
boolean isLocked = multiLock.tryLock(1, TimeUnit.SECONDS);
if (isLocked) {
log.info("try lock success");
multiLock.unlock();
} else {
log.info("try lock fail");
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
5. 读写锁基本使用 Redisson也提供一把使用的分布式读写锁,和常规的读写锁一样,redisson读写锁也具备如下几个特性:
多个客户端可以同时持有读锁不互斥。 上了读锁之后,其他客户端无法上写锁。 上了写锁之后,其他客户端无法上读写锁。 总的来说,redisson读写锁的特点就是写与读写互斥,读之间不互斥,对应的我们也给出一段读写锁的使用示例:
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("readWriteLock");
CountDownLatch countDownLatch = new CountDownLatch(4);
new Thread(() -> {
if (readWriteLock.writeLock().tryLock()) {
log.info("try write lock success");
} else {
log.info("try write lock fail");
}
countDownLatch.countDown();
}).start();
new Thread(() -> {
if (readWriteLock.writeLock().tryLock()) {
log.info("try write lock success");
} else {
log.info("try write lock fail");
}
countDownLatch.countDown();
}).start();
new Thread(() -> {
if (readWriteLock.readLock().tryLock()) {
log.info("try read lock success");
} else {
log.info("try read lock fail");
}
countDownLatch.countDown();
}).start();
new Thread(() -> {
if (readWriteLock.readLock().tryLock()) {
log.info("try read lock success");
} else {
log.info("try read lock fail");
}
countDownLatch.countDown();
}).start();
countDownLatch.await();
从输出结果可以看出,某个连接成功上了写锁之后,其他连接都无法持有这把锁:
二、详解Redisson常见问题
- Redisson和Jedis有什么区别 (1) 分布式集合的支持:Redisson按照Java的语义和规范实现了各种java集合对象的实现,包括multimap、priorityQueue、DelayQueue等设置是原子类,而Jedis仅仅支持一些比较常见的java集合类,例如Map、Set、List等。
(2) 分布式锁和同步器:Redisson支持各种常见的java锁和同步工具如FairLock、MultiLock、Semaphore、CountdownLatch等,而后者则都不支持。
(3) 分布式对象:Redisson支持各种publish/subscribe、bloomFilter、RateLimiter、Id generator等强大的功能,而后者仅仅支持java的原子类以及HyperLogLog等。
(4) 高级缓存特性:Redisson支持多种缓存功能,例如read-through/write-through/write-behind等,而后者不支持这些功能,具体可以参考:blog.csdn.net/HalfImmorta…
(5) API架构:前者自持线程安全、异步接口、响应式流接口和Rxjava3接口,而后者不支持。
(6) 分布式服务:Redisson支持ExecutorService、MapReduce、SchedulerService等架构,而后者都不支持这些分布式服务。
(7) 框架支持:前者支持Spring Cache、hibernate Cache、Mybatis Cache,而后者仅仅支持Spring session和spring cache。
(8) Redisson和后者都支持认证和ssl。
(9) 序列化:Redisson支持多种编码和解码器如json、jdk、avro等序列化,而后者仅仅支持json等简单的序列化。
- Redisson如何实现分布式锁 该问题实际是两个问题,即分布式和锁,针对分布式问题,redisson底层已经针对主从、集群等不同的架构做了很好的封装,可以较好的保证分布式架构下锁的单例。
再来说说锁的问题,针对分布式取锁这一功能点,redisson上锁的几个逻辑分支为:
判断这把锁是否存在,若不存在说明我们是第一个取锁的,基于redis的hincrby指令创建这把锁结构,key为锁名称,也就是我们的lock结构为字典结构,field为我们这个线程名(这个线程是有随机数的可以保证唯一),然后再通过pexpire设置这个当前持有锁的线程最大超时时间,以我们上述基础示例那段代码为例,对应的指令就是:
hincrby lock 当前取锁的线程名 1 pexpire lock 当前取锁的线程名
若发现锁存在且通过hexists看到持有锁的线程是我们当前线程,说明本次是锁重入,同样基于hincrby 和pexpire 进行锁续约。 若发现锁存在且持有锁的不是自己,则通过pttl得出持有锁的线程的超期时间让当前上锁失败的线程按照自己的逻辑进行进一步处理。 有了上述的思路之后,redisson为了保证操作的原子性,将上述三个逻辑分支思路以lua脚本的形式进行了进一步的封装,由此保证了分布式环境下上锁操作的原子性:
对应的我们也给出redisson对于这段代码的核心实现部分,即位于RedissonLock的tryLockInnerAsync方法,逻辑和笔者说明的基本是一致的,读者可以参考笔者的注释了解一下细节:
RFuture tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand command) { return commandExecutor.syncedEval(getRawName(), LongCodec.INSTANCE, command, //如果分布式锁不存在,或者存在且持有锁的是自己,则进入if分支 "if ((redis.call('exists', KEYS[1]) == 0) " + "or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " + //通过hincrby设置锁持有者为当前线程,这个锁结构是一个字典key为锁的名称,field为当前线程,将这个field的对应的value自增1 "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + //设置锁这个key的到期时间 "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + //如果发现锁存在且持有锁的不是自己则返回锁的到期时间 "return redis.call('pttl', KEYS[1]);", Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId)); }
-
Redisson如何实现分布式锁可重入 上一个问题中的分支2逻辑已经说明了,即判断锁是否存在,如果存在且锁的持有者是自己则自增一个自己持有锁的次数标识再次重入,对应的lua脚本上一步已给出,读者可回头翻看一下:
-
Redisson如何实现公平锁 公平锁核心逻辑也是一个lua脚本,脚本比较长,笔者这里直接将这段脚本抽取出来逐步分析,首先来到下面这段脚本,在此之前我们先给出这段脚本对应的参数说明方便后续的讲解:
KEYS数组:该数组记录了使用公平锁所有涉及到的key信息,按照lua脚本的规范,索引是从1开始,按顺序keys数组分别存储的是如下数据:
- keys[1]:也就是我们的分布式锁名称,即lock
- keys[2]:因为没上到锁而进入的等待队列,key名称为redisson_lock_queue:{lock},这个数据结构笔者这里就称之为等待队列
- keys[3]:记录每个进入等待队列的线程需要等待的时间,key名称为redisson_lock_timeout:{lock},这个列表我们就称之为超时清单
ARGV[1]:记录分布式锁使用的租期,默认是30s。 ARGV[2]:记录当前希望上锁的线程名称 ARGV[3]:指定上锁的最大等待时长,即如果当前上锁失败,线程进入等待的时长,默认为5min。 ARGV[4]:当前时间 有了上述的参数的前置铺垫之后,我们就可以开始逐段分析脚本的步骤,首先这段脚本会处于一个循环自旋,它只有在触发如下两个条件的时候跳出循环:
等待队列中没有元素了,说明当前线程无需等待直接退出循环进入后续步骤取锁。 查看当前线程的等待超时时间,如果小于当前时间则说明这个线程等待太长了,直接从等待列表和超时清单中移除。 对应的lua代码段如下:
while true do -- 查看等待队列中是否存在元素,如果队列为空,则说明无需等待直接退出循环,尝试拿锁 local firstThreadId2 = redis.call('lindex', KEYS[2], 0); if firstThreadId2 == false then break; end -- 如果等待队列有元素,则到超时队列KEYS[3]中获取其超时时间并和ARGV[3]即当前时间进行比较,如果小于当前时间则说明这个线程等待太长了,直接从超时清单和等待列表中移除 local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2)); if timeout <= tonumber(ARGV[4]) then redis.call('zrem', KEYS[3], firstThreadId2); redis.call('lpop', KEYS[2]); else break; end end
结束上一步的循环之后,进入如下逻辑:
如果当前没有人持有锁KEYS[1] 且等待队列为空或者等待队列没有线程则将持有锁的人设置为自己,并更新等待清单中其他线程的超时时间。 如果当前有人持有锁且持有锁的是当前线程,则说明是重入,则通过hincrby到分布式锁结构中更新自己的上锁次数为2,再通过pexpire延长持有锁的到期时间。
-- 如果当前没有人持有锁KEYS[1] 且等待队列为空或者等待队列没有线程则进入该逻辑 if (redis.call('exists', KEYS[1]) == 0) and ((redis.call('exists', KEYS[2]) == 0) or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then -- 将自己从等待队列和超时清单中移除 redis.call('lpop', KEYS[2]); redis.call('zrem', KEYS[3], ARGV[2]); -- 遍历超时清单,更新等待清单中所有元素的等待时长 local keys = redis.call('zrange', KEYS[3], 0, -1); for i = 1, #keys, 1 do redis.call('zincrby', KEYS[3], -tonumber(ARGV[3]), keys[i]); end -- 将持有锁的线程设置为自己,上锁次数为1 redis.call('hset', KEYS[1], ARGV[2], 1); -- 设置自己持有锁的时间为 ARGV[1]即30s,若30s后没有续期则释放该锁 redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end -- 如果持有锁的是自己,则增加重入次数并延长超时时间 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
如果没有进入上述步骤的分支中,则进入下面这段判断:
从超时清单中查看是否有自己,如果有则获取自己的超时时间,并减去当前时间和等待时间获该线程还需要等待的时长。 若没有则看看等待队列中最后一个元素的超时时间,并基于这个超时时间获取自己的等待时长,如果超时清单中没有元素,则直接基于分布式锁lock中持有锁线程的到期时间获取自己的等待时长。 基于等待时长获取自己的超时时间并将自己存入等待队列和超时清单中。
-- 查看超时队列是否有自己,如果有则返回还需等待的时间 local timeout = redis.call('zscore', KEYS[3], ARGV[2]); if timeout ~= false then return timeout - tonumber(ARGV[3]) - tonumber(ARGV[4]); end
-- 获取等待队列中最后一个线程 local lastThreadId = redis.call('lindex', KEYS[2], -1); local ttl; -- 如果该线程存在且不是自己,则基于该线程的等待超时时间减去当前时间得到我们的线程还需要等待的时长 if lastThreadId ~= false and lastThreadId ~= ARGV[2] then ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]); else -- 如果等待队列没有元素,则直接到分布式锁lock中获取持有锁的线程的超期时间得到自己的等待时长 ttl = redis.call('pttl', KEYS[1]); end -- 基于上一步的ttl+等待时间+当前时间得到超时时间并将自己存入等待列表和超时清单 local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]); if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then redis.call('rpush', KEYS[2], ARGV[2]); end return ttl;
经过上一个步骤的逐步拆解分析,我们已经将公平锁的整体流程整理完成,来小结一下整体过程:
循环等待其他线程释放分布式锁或者自己从等待清单中移除。 判断是否有人持有锁,如果没有则我们自己上锁并设置超时时间,如果有且是自己则更新上锁次数和续约时间,如果不符合这几个要求进入步骤3。 查看超时清单中是否有自己,如果有则计算出还需要等待的时长并返回,如果没有则进入步骤4。 从等待队列中获取最后一个等待的线程,基于它的等待时间计算出自己的等待时长并存入等待队列和超时清单,反之进入步骤5。 来到这一步说明等待队列没有元素,直接基于分布式锁中持有锁的线程的到期时间设置自己的等待时间并入等待队列和超时清单。 可以看出,redisson通过列表和超时清单按序管理了各个线程的等待实现,保证了分布式锁争抢的公平性:
- Redisson的watchdog机制是什么?底层是如何实现的? redisson在设计初期考虑到客户端因为各种客观原因导致锁未能及时释放导致其他连接无法持有锁的情况提出了续期的概念,即客户端上锁后会默认分配一个续期,在这段时间内客户端要定期向redis告知自己仍然需要这把锁并进行续约。
例如上文中的线程1持有锁之后,会基于当前时间+30s得出锁到期时间,随后在这个需求的三分之一也就是每隔10s向redis表明自己还存活着,不断延长自己的到期时间,知道线程1完成后主动释放这把锁:
这也就意味着如果30s秒内,线程1出现以下情况,这把锁就会被自动释放:
用户主动设置超时时间,redission就不会自动续约 没有定期续命 续期执行失败 在此之后,其他线程就可以抢锁,由此避免了死锁问题。这也就是我们常说的看门狗机制,这段代码的实现可以在RedissonLock的tryAcquireOnceAsync方法中看到,通过tryLockInnerAsync完成上锁并成功后,redisson就会基于当前线程的信息通过scheduleExpirationRenewal提交一个定时续约的定时任务:
private RFuture tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { CompletionStage acquiredFuture;
if (leaseTime > 0) {
//......
} else {
//提交一个异步抢分布式锁的任务
acquiredFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
}
acquiredFuture = handleNoSync(threadId, acquiredFuture);
//基于thenApply处理抢锁任务的回调 CompletionStage f = acquiredFuture.thenApply(acquired -> { // lock acquired if (acquired) { if (leaseTime > 0) { //..... } else { //如果上锁成功则提交一个续约的定时任务 scheduleExpirationRenewal(threadId); } } return acquired; }); return new CompletableFutureWrapper<>(f); }
我们步入scheduleExpirationRenewal即可看到该方法内部的核心实现renewExpiration这个方法,可以看到该方法会基于续约时间的三分之一定期执行renewExpirationAsync方法进行续约:
private void renewExpiration() { //...... //基于超时时间的三分之一生成一个定时任务 Timeout task = getServiceManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { //...... //调用renewExpirationAsync执行锁续期 CompletionStage future = renewExpirationAsync(threadId); future.whenComplete((res, e) -> { //..... //如果上锁成功则递归提交一个renewExpiration等待下一次续约 if (res) { // reschedule itself renewExpiration(); } else {//如果上锁失败则释放锁 cancelExpirationRenewal(null); } }); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
最后我们给出renewExpirationAsync查看的续约的具体实现,可以看到逻辑非常直观:
调用hexists查看分布式锁的持有者是否是自己 如果是则调用pexpire设置延长续期:
protected CompletionStage renewExpirationAsync(long threadId) { return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, //查看持有锁的是否是自己,其中KEYS[1]是Collections.singletonList(getRawName())即锁的名称 "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + //调用pexpire延长时间,KEYS[1]是Collections.singletonList(getRawName()),而ARGV[1]是internalLockLeaseTime "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return 1; " + "end; " + "return 0;", Collections.singletonList(getRawName()), internalLockLeaseTime, getLockName(threadId)); }
- 什么是RedLock,其实现思路是什么 红锁是redis作者Antirez提供的一个多节点分布式锁的算法,主要用于解决集群环境下分布式锁一致性问题,其做法大体思路如下::
客户端设置基于redis服务端获取起始时间,并基于超时时间算出取锁最长等待时间。 基于这个时间点向redis集群节点发起上锁请求。 当得到半数以上节点同一之后意为取锁成功。
执行业务操作。 完成后释放锁,注意这里释放的操作不提供可靠释放,仅仅向上锁的节点发出释放请求:
对此我们也给出redisson的使用示例:
RLock rLock1 = redissonClient1.getLock("lock1"); RLock rLock2 = redissonClient2.getLock("lock2"); RLock rLock3 = redissonClient3.getLock("lock3"); RedissonRedLock redLock = new RedissonRedLock(rLock1, rLock2, rLock3);
boolean lockResult = redLock.tryLock();
if (lockResult) {
try{
//....
} finally {
redLock.unlock();
}
}
7. Redisson 中为什么要废弃 RedLock 总体来说有以下几个缺陷:
缺乏认证 维护和操作复杂 被分布式系统指明研究者Martin 批评,指明某些场景不能正确提供锁服务。 存在安全漏洞 这里我们针对第3点进行相应的补充,按照Antirez的说法,red lock实际上是无法在NPC三种异常情况做出正确响应,而NPC对应含义是:
N(Network Delay):网络延迟 P(Process Pause):进程暂停 C(Clock Drift):时钟飘逸 他基于反证法提出了下面两个场景:
假设我们有一组redis集群,集群中有5个节点分别是a、b、c、d、e,现在有两个线程尝试获取红锁,线程1先到达,成功获取到a、b、d 3个节点的锁,假设在此期间线程1在使用分布式锁因为程序STW等原因导致系统阻塞未能及时续约,线程2在此时就可以同时获取到a、b、d3个节点的分布式锁,导致锁互斥失败:
还是以上述的部署架构为例,假设线程1针对a、b、d上锁成功,此时a节点因为某些原因将时钟向前调整了一些,导致a节点提前超时,线程2基于a、c、d还是会拿到分布式锁,又一次导致互斥失败:
同时为了实现一个分布式互斥问题,提出红锁这样一个复杂的实现方案,不仅增加了系统的复杂度,涉及多个网络节点的通信开销也导致分布式锁的执行性能下降**。**