前言
- Redisson 解决「setnx 命名非原子性」的思路和方案
Redisson 不是靠 setnx 命令来实现原子性操作的( setnx 命令也也无法保证原子性 ),Redisson 是靠 Lua 脚本来实现的原子性操作。 - Redisson 解决「过期自动删除时长」问题的思路和方案
Redisson 中客户端一旦加锁成功,就会启动一个后台线程(惯例称之为 watch dog 看门狗)。watch dog 线程默认会每隔 10 秒检查一下,如果锁 key 还存在,那么它会不断的延长锁 key 的生存时间,直到你的代码中去删除锁 key 。 - Redisson 解决「查 - 删 非原子性」问题的思路和方案
Redisson 的上锁和解锁操作都是通过 Lua 脚本实现的。Redis 中 执行 Lua 脚本能保证原子性,整段 Lua 脚本的执行是原子性的,在其执行期间 Redis 不会再去执行其它命令。
引入 redisson 包
jedis 要比lettuce好一点
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions> <!-- 从依赖关系中排除 -->
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.15.6</version>
</dependency>
配置 Redisson Client
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("root")
.setDatabase(0)
.setKeepAlive(true);
return Redisson.create(config);
}
}
使用 Redisson Client
hello表示用这个当一个锁标识,只有当锁标识一样线程才会串行化,必须要将第一个线程执行完了才可以执行第二个线程
当我们hello.lock()没有指定多少时间自动释放的时候,watch dog 线程默认会每隔 10 秒检查一下,如果锁 key 还存在,那么它会不断的延长锁 key 的生存时间,直到你的代码中去删除锁 key 。也就是主动去hello.unlock();
RLock hello = redissonClient.getLock("hello");
// 上锁方式一
hello.lock();
// 睡眠100秒
TimeUnit.SECONDS.sleep(100);
// 释放锁
hello.unlock();
当然你也可以在 lock 时指定超时自动解锁时间:
// 加锁以后 10 秒钟自动解锁
hello.lock(10, TimeUnit.SECONDS);
这种情况下,如果你有意或无意没有调用 unlock 进行解锁,那么 10 秒后,Redis 也会自动删除代表这个锁的键值对。
当两个不同的线程对同一个锁进行 lock 时,第二个线程的上锁操作会失败。而上锁失败的默认行为是阻塞等待,直到前一个线程释放掉锁。
这种情况下,如果你不愿意等待,那么你可以调用 tryLock() 方法上锁。tryLock 上锁会立刻(或最多等一段时间)返回,而不会一直等(直到所得持有线程释放)。
// 拿不到就立刻返回
hello.tryLock();
// 拿不到最多等 1 秒。1 秒内始终拿不到,就返回
hello.tryLock(1, TimeUnit.SECONDS);
// 拿不到最多等 1 秒。1 秒内始终拿不到,就返回。
// 如果拿到了,自动在 10 秒后释放。
hello.tryLock(1, 10, TimeUnit.SECONDS);
你通过 RedissonClient 拿到的锁都是「可重入锁」。
当然,如果你对一个锁反复上锁,那么逻辑上,你应该对它执行同样多次的解锁操作。
hello.lock(); System.out.println("lock success!");
hello.lock(); System.out.println("lock success!");
hello.lock(); System.out.println("lock success!");
hello.unlock();
hello.unlock();
hello.unlock();
Redisson 向redis 中加入的键值对是一个 hash 结构。
- key : getlock 方法的参数
- fieid: uuid + “:” + 当前进程的线程id
- value :被上锁的次数
Lock
lock锁当两个线程并行时,当一个线程的锁没有释放,第二个线程会一直等待,等到第一个线程释放锁之后才可以执行业务
@RequestMapping(value = "/{s}", method = RequestMethod.GET)
public String getToken(@PathVariable String s) {
new Thread(() -> {
RLock rlock = redissonClient.getLock(s);
System.out.println("线程1准备申请锁");
rlock.lock();
System.out.println("线程1获得锁");
System.out.println("线程1睡前");
try {
TimeUnit.SECONDS.sleep(10);
System.out.println("线程1睡后");
rlock.unlock();
System.out.println("线程1释放锁");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
new Thread(() -> {
RLock rlock = redissonClient.getLock(s);
System.out.println("线程2准备申请锁");
rlock.lock();
System.out.println("线程2获得锁");
rlock.unlock();
System.out.println("线程2释放锁");
}).start();
return s;
}
控制台输出的结果
tryLock
他是一个boolean值,获取锁返回true,负责返回fales,trylock它不会像lock傻傻的一直等待
@RequestMapping(value = "/{s}", method = RequestMethod.GET)
public String getToken(@PathVariable String s) {
new Thread(() -> {
RLock rlock = redissonClient.getLock(s);
System.out.println("线程1准备申请锁");
if (rlock.tryLock()) {
System.out.println("线程1获得锁");
System.out.println("线程1睡前");
try {
TimeUnit.SECONDS.sleep(10);
System.out.println("线程1睡后");
rlock.unlock();
System.out.println("线程1释放锁");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
} else {
System.out.println("线程1没有获得锁,算球");
}
}).start();
new Thread(() -> {
RLock rlock = redissonClient.getLock(s);
System.out.println("线程2准备申请锁");
if (rlock.tryLock()) {
System.out.println("线程2获得锁");
rlock.unlock();
System.out.println("线程2释放锁");
} else {
System.out.println("线程2获取不到锁,算球");
}
}).start();
return s;
}
控制台输出的结果
Redisson 的上锁原理
Redisson 在上锁时,向 Redis 中添加的简直对的键是 UUID + : + threadId 拼接而成的字符串;值是这个锁的上锁次数。
Redisson 如何保证线程间的互斥以及锁的重入( 反复上锁 )?
因为代表这锁的键值对的键中含有线程 ID ,因此,当你执行上锁操作时,Redisson 会判断你是否是锁的持有者,即,当前线程的 ID 是否和键值对中的线程 ID 一样。
如果当前执行 lock 的线程 ID 和之前执行 lock 成功的线程的 ID 不一致,则意味着是「第二个人在申请锁」,那么就 lock 失败;如果 ID 是一样的,那么就是「同一个」在反复 lock,那么就累加锁的上锁次数,即实现了重入。
watch dog 自动延期机制
如果在使用 lock/tryLock 方法时,你指定了超时自动删除时间,那么到期之后,Redis 会自动将代表锁的键值对给删除掉。
如果,你在使用 lock/tryLock 方法时,没有指定超时自动删除时间,那么,就完全依靠你的手动删除( unlock 方法 ),那么,这种情况下你会遇到一个问题:如果你有意或无意中忘记了 unlock 释放锁,那么锁背后的键值对将会在 Redis 中长期存在!
再次强调
Redisson 看门狗(watch dog)在指定了加锁时间时,是不会对锁时间自动续租的。
在 watch dog 机制中,有一个被「隐瞒」的细节:表面上看,你的 lock 方法没有指定锁定时长,但是 Redisson 去 Redis 中添加代表锁的键值对时,它还是添加了自动删除时间。默认 30 秒(可配置)。
这意味着,如果,你没有主动 unlock 进行解锁,那么这个代表锁的键值对也会在 30 秒之后被 Redis 自动删除,但是很显然,并没有。这正是因为 Redisson 利用 watch dog 机制对它进行了续期( 使用 Redis 的 expire 命令重新指定新的过期时间)。
Redisson 的 watch dog 实现核心代码如下图源码所示:
- 当你调用 lock 方法上锁,且没有指定锁定时间时,Redisson 在向 Redis 添加完键值对之后会调用到上面的 renewExpiration() 方法;
- 在 renewExpiration 方法中,Redisson 向线程池中添加了一段「代码」,并要求其在 30/3 秒之后( internalLockLesaseTime / 3 )执行;
- 这段代码在被执行时,它为代表锁的键值对重新设置过期时间( 30 秒 ),并且递归调用了自己,将自己又一次交给线程池在 10 秒之后执行。
逻辑上,变相地就是实现了一个 10 秒续期一次的定时任务。Redisson 会不停地为这个键值对重置过期删除时间,直到你在代码层面调用了 unlock 删除了这个键值对为止。
无休止地续期会不会导致代表锁的键值对永远存在?
watch dog 利用这这样的一个隐含逻辑:如果 watch dog 线程( 执行续期的线程 )还存在,那就意味着这个项目仍然是在正常运行的,项目正常运行,那么意味着一切正常,只是执行业务的线程没有执行完而已。
如果整个项目挂掉了,那么 watch dog 线程自然也就挂掉了,watch dog 线程挂掉了,那么就没有无限续期了,那么最多 30 秒后那个键值对也就被 Redis 删除了。
有没有可能项目的进程还在,但是持有锁的线程挂掉了?这是 bug ,应该解决!
Redisson 执行的 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]);