一、说明
1、Redisson简介
Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。充分的利用了Redis键值数据库提供的一系列优势,基于Java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。Redisson采用了基于NIO的Netty框架。
官网: https://github.com/redisson/redisson
官网文档:https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95
如果在公司里落地生产环境用分布式锁的时候,一定是会用开源类库的,比如Redis分布式锁,一般就是用Redisson框架就好了,非常的简便易用。大家如果有兴趣,可以去看看Redisson的官网,看看如何在项目中引入Redisson的依赖,然后基于Redis实现分布式锁的加锁与释放锁。
下面给大家看一段简单的使用代码片段,先直观的感受一下:
RLock lock = redisson.getLock("AS.厂站A.pic.fac.g");//获取锁
lock.lock();//加锁
lock.unlock();//解锁
怎么样,上面那段代码,是不是感觉简单的不行!
此外,人家还支持redis单实例、redis哨兵、redis cluster、redis master-slave等各种部署架构,都可以给你完美实现。
2、Redisson实现Redis分布式锁的原理
以下内容参考下面的文章,加上一些自己的理解:https://mp.weixin.qq.com/s/y_Uw3P2Ll7wvk_j5Fdlusw
Redis发展到现在,几种常见的部署架构有:
单机模式;
主从模式;
哨兵模式;
集群模式;
1)加锁机制
客户端1要加锁时。根据当前redis部署环境的不同,需要构造并配置不同的Config实例。如果单节点部署的redis环境,只需对单节点发送lua脚本并执行即可;如果redis部署是哨兵模式或者集群模式,根据Redlock算法要求:
最低保证分布式锁的有效性及安全性的要求如下:
- 互斥;任何时刻只能有一个client获取锁
- 释放死锁;即使锁定资源的服务崩溃或者分区,仍然能释放锁
- 容错性;只要多数redis节点(一半以上)在使用,client就可以获取和释放锁
antirez提出的redlock算法大概是这样的:
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
获取当前Unix时间,以毫秒为单位。
依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
不过,不用担心,redisson已经有对redlock算法进行了封装,使得使用redisson的开发者不需要自己实现上面算法要求的繁琐细节。只需要根据redis环境配置不同Config实例即可,接下来的加锁/解锁操作没有区别。
1)加锁解锁的实现原理(lua脚本第一部分if)
客户端A加锁时,会发送一段lua脚本到redis上,那段lua脚本如下:
"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; " +
"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; " +
"return redis.call('pttl', KEYS[1]);"
1、为啥要用lua脚本呢?答:因为一大坨复杂的业务逻辑,可以通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。
2、这段lua脚本是什么意思呢?答:
- KEYS[1]代表的是你加锁的那个key,比如说:RLock lock = redisson.getLock("myLock");这里你自己设置了加锁的那个锁key就是“myLock”。
- ARGV[1]代表的就是锁key的默认生存时间,默认30秒。
- ARGV[2]代表的是加锁的客户端的ID,类似于下面这样(UUID+线程ID):8743c9c0-0795-4907-87fd-6c719a6b4586:1
给大家解释一下,第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁key不存在的话,你就进行加锁。
如何加锁呢?很简单,用下面的命令:hset,myLock,8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
通过这个命令向redis设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:
myLock:
{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1":1
}
其中redis的key是myLock,value是一个hash,key是ARGV[2]即加锁的客户端的ID,value是锁重入次数,上图代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁、加锁计数器为1。
接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。
好了,到此为止,ok,加锁完成了。
2)锁互斥机制实现
那么在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?
很简单,第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。
接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。
所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。
此时客户端2会进入一个while循环,不停的尝试加锁。
3)watch dog自动延期机制
客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?
简单!只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
4)可重入加锁机制(lua脚本第二部分if)
那如果客户端1都已经持有了这把锁了,结果可重入的加锁会怎么样呢?
这时我们来继续分析一下上面那段lua脚本。
第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了。
第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”
此时就会执行可重入加锁的逻辑,他会用:
hincrby myLock
8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
通过这个命令,对客户端1的加锁次数,累加1。
此时myLock数据结构变为下面这样:
myLock:
{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1":2
}
大家看到了吧,那个myLock的hash数据结构中的那个客户端ID,就对应着加锁的次数
5)释放锁机制
如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。
其实说白了,就是每次都对myLock数据结构中的那个加锁次数减1。
如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:
“del myLock”命令,从redis里删除这个key。
然后呢,另外的客户端2就可以尝试完成加锁了(貌似不支持公平锁)。
这就是所谓的分布式锁的开源Redisson框架的实现机制。
一般我们在生产系统中,可以用Redisson框架提供的这个类库来基于redis进行分布式锁的加锁与释放锁。
6)Redis分布式锁的缺点
其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。
但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。
接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。
此时就会导致多个客户端对一个分布式锁完成了加锁。
这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。
所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁
7)一些redission关键实现代码展示
- redission封装的redlock算法实现的分布式锁用法
RLock redisLock = redisCache.getRedisLock(redisKey);
if (redisLock.isLocked()) {
//分布式锁已经被占用
throw CommonException.REPEAT_ACTION_ERROR;
}
try {
//尝试加锁 第一个时间是加锁等待时间,第二个redis锁失效时间 第三个是时间单位
boolean isLocked = redisLock.tryLock(3L, 10L, TimeUnit.SECONDS);
if (isLocked) {
//锁获取成功 执行你的业务逻辑
} else {
//否则报错重复加锁
throw CommonException.REPEAT_ACTION_ERROR;
}
} catch (InterruptedException e) {
throw CommonException.REPEAT_ACTION_ERROR;
} finally {
//最后解锁
redisLock.unlock();
}
- 唯一ID
实现分布式锁的一个非常重要的点就是set的value要具有唯一性,redisson的value是怎样保证value的唯一性呢?答案是UUID+threadId。入口在redissonClient.getLock(“REDLOCK_KEY”),源码在Redisson.java和RedissonLock.java中:
protected final UUID id = UUID.randomUUID();
String getLockName(long threadId) {
return id + ":" + threadId;
}
- 获取锁
获取锁的代码为redLock.tryLock()或者redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS),两者的最终核心源码都是下面这段代码,只不过前者获取锁的默认租约时间(leaseTime)是LOCK_EXPIRATION_INTERVAL_SECONDS,即30s。
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"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; " +
"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; " +
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
获取锁的命令中,
KEYS[1]就是Collections.singletonList(getName()),表示分布式锁的key,即REDLOCK_KEY;
ARGV[1]就是internalLockLeaseTime,即锁的租约时间,默认30s;
ARGV[2]就是getLockName(threadId),是获取锁时set的唯一值,即UUID+threadId:
- 释放锁
释放锁的代码为redLock.unlock(),核心源码如下:
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
- Redlock分布式锁
既然核心变化是使用了RedissonRedLock,那么我们看一下它的源码有什么不同。这个类是RedissonMultiLock的子类,所以调用tryLock方法时,事实上调用了RedissonMultiLock的tryLock方法,精简源码如下:
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// 实现要点之允许加锁失败节点限制(N-(N/2+1))
int failedLocksLimit = failedLocksLimit();
List<RLock> acquiredLocks = new ArrayList<RLock>(locks.size());
// 实现要点之遍历所有节点通过EVAL命令执行lua加锁
for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
RLock lock = iterator.next();
boolean lockAcquired;
try {
// 对节点尝试加锁
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
} catch (RedisConnectionClosedException|RedisResponseTimeoutException e) {
// 如果抛出这类异常,为了防止加锁成功,但是响应失败,需要解锁
unlockInner(Arrays.asList(lock));
lockAcquired = false;
} catch (Exception e) {
// 抛出异常表示获取锁失败
lockAcquired = false;
}
if (lockAcquired) {
// 成功获取锁集合
acquiredLocks.add(lock);
} else {
// 如果达到了允许加锁失败节点限制,那么break,即此次Redlock加锁失败
if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
break;
}
}
}
return true;
}
3、使用Redisson实现分布式锁
1)引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.10.6</version>
</dependency>
2)初始化redisson客户端
Redis发展到现在,几种常见的部署架构有:
单机模式;
主从模式;
哨兵模式;
集群模式;
我们首先基于这些架构讲解Redisson普通分布式锁实现,需要注意的是,只有充分了解普通分布式锁是如何实现的,才能更好的了解Redlock分布式锁的实现,因为Redlock分布式锁的实现完全基于普通分布式锁。
- 单机模式redis部署
public void init() throws IOException {
Config config = new Config();
config.useSingleServer()
.setAddress(host)
.setPassword(password)
.setDatabase(0)
.setFailedAttempts(3)
.setPingTimeout(3000)
.setTimeout(5000)
.setSubscriptionConnectionMinimumIdleSize(1)
.setSubscriptionConnectionPoolSize(256)
.setConnectTimeout(connectTimeout)
.setReconnectionTimeout(3000)
.setConnectionPoolSize(256)
.setConnectionMinimumIdleSize(1)
.setRetryAttempts(3)
.setRetryInterval(3000)
.setIdleConnectionTimeout(30000)
.setClientName("com.fshows.bankliquidation.redisclient");
if (redisson == null) {
redisson = Redisson.create(config);
LOGGER.info("redis连接成功,server={}", host);
} else {
LOGGER.warn("redis 重复连接,config={}", config);
}
}
- 哨兵模式
即sentinel模式,实现代码和单机模式几乎一样,唯一的不同就是Config的构造
Config config = new Config();
config.useSentinelServers().addSentinelAddress(
"redis://172.29.3.245:26378","redis://172.29.3.245:26379", "redis://172.29.3.245:26380")
.setMasterName("mymaster")
.setPassword("a123456").setDatabase(0);
- 集群模式
Config config = new Config();
config.useClusterServers().addNodeAddress(
"redis://172.29.3.245:6375","redis://172.29.3.245:6376", "redis://172.29.3.245:6377",
"redis://172.29.3.245:6378","redis://172.29.3.245:6379", "redis://172.29.3.245:6380")
.setPassword("a123456").setScanInterval(5000);
- 主从模式
Config config = new Config();
//单节点
config.useMasterSlaveServers()
普通分布式实现非常简单,无论是那种架构,向Redis通过EVAL命令执行LUA脚本即可
3)通过Config对象创建RedissonClient
RedissonClient redissonClient = Redisson.create(config);
4)通过RedissonClient提供的接口获取锁对象并且进行加锁、解锁
RLock lock = redissonClient.getLock(lockKey);
lock.lock();
lock.unlock();