1.简介
在我们的日常开发中,经常会遇到一个进程中多个线程同时竞争某一资源,比如说一个需求要求我们先删除老的文件夹及其中的文件,之后再生成新的文件夹及新的文件,之后对其进行打包操作。此时若我们没有采用就锁,就会可能出现刚生成新的文件之后,一个请求进来又将其删除,导致打包报错。
若是项目只在一台服务器上启动并且将文件夹生成在自己的盘上,则我们可以通过加上synchronize关键字或ReentrantLock锁等操作。就可以进行加锁确保安全。
但是假设我们有两台服务器都在启动项目并且生成的文件夹在共享的盘中,那我们再使用以上的加锁方式就没用了,还是会出现上述问题,此时就可以使用redis分布式锁。
分布式锁所需要的基本条件:
1)在分布式高并发的条件下,同一时间只能有一个线程获取锁。
2)具备锁失效机制,防止死锁。
3)具有可重入性。
2.redis分布式锁的实现原理
redis分布式锁的本质是执行setnx命令:setnx key value ,此命令是set if not exists,当且仅当 key 不存在时,将 key 的值设为 value。若给定的 key 已经存在,则不做任何动作:
比如说我们执行命令:setnx test 1 ,设置key=test,value=1的锁。
由上图可以看出:
setnx text 1 :返回1,说明该进程获得锁,并且设置了key = text , value = 1.
setnx text 2:返回0,说明其他进程已经获得了锁,本线程未能做任何操作,value也没有被修改。
TTL表示限制时间:-1 表示未设置失效时间,一直存在。
当线程执行完操作后需要释放锁,此时需要执行命令:del key,即删除锁,之后其他线程再执行 setnx key value 就可以获取到锁了:
此时就完成了最基本的redis分布式锁的操作。
但是以上操作有个缺点:假设获取到锁的线程还没执行完,服务器就宕机了,此时锁还没被释放,会一直存在于redis中,其他的线程就永远无法获取到该锁,该锁进入了死锁的情况。
这种情况redis已经帮我们解决了,我们可以通过 set key value ex time nx 命令,在设置锁的时候给锁加个限制时间,使其到时间自动解锁,将锁删除。
比如说我们执行命令:set test 1 ex 100 nx ,设置key=test,value=1的锁,并给他100秒的时间限制,到时间锁会自动删除。
由上图可以看出,set key value ex time nx 和 setnx key value 唯一的区别在于一个有时间限制,一个没有时间限制,其他的使用都相同。
redis分布式锁的本质就是基于以上这些redis命令操作。
3.redis分布式锁的通常实现方式
1)RedisTemplate
public class RedisTemplateTest {
@Autowired
RedisTemplate<String, String> redisTemplate;
@GetMapping("/redisTemplateTest")
public void redisTemplateTest() {
System.out.println("尝试获取锁");
Boolean lock = redisTemplate.opsForValue().setIfAbsent("redisTemplateTest锁住了", "value");
try {
if (lock) {
System.out.println("redisTemplateTest获取到锁了");
redisTemplate.delete("redisTemplateTest锁住了");
System.out.println("redisTemplateTest释放锁");
}else{
redisTemplateTest();
}
} catch (Exception e) {
redisTemplate.delete("redisTemplateTest锁住了");
e.printStackTrace();
}
}
}
从上图我们可以看出,多线程同时进入方法后,一个线程被锁锁住,直到执行完方法后,释放锁,其他线程才能再起获取锁。
以上代码的锁并没有添加时间限制,如要添加时间限制只需将代码稍稍修改:
Boolean lock = redisTemplate.opsForValue().setIfAbsent("redisTemplateTest锁住了", "value", 10, TimeUnit.SECONDS);
但是这种方法有很大的隐性缺陷:因为我们在方法内使用了递归调用自己。如果锁住的代码执行时间过长就会发生 StackOverflowError (堆栈溢出),下面我们演示一下该种情况:
public class RedisTemplateTest {
@Autowired
RedisTemplate<String, String> redisTemplate;
@GetMapping("/redisTemplateTest")
public void redisTemplateTest() {
System.out.println("尝试获取锁");
Boolean lock = redisTemplate.opsForValue().setIfAbsent("redisTemplateTest锁住了", "value");
try {
if (lock) {
System.out.println("redisTemplateTest获取到锁了");
Thread.sleep(40000);
redisTemplate.delete("redisTemplateTest锁住了");
System.out.println("redisTemplateTest释放锁");
}else{
redisTemplateTest();
}
} catch (Exception e) {
redisTemplate.delete("redisTemplateTest锁住了");
e.printStackTrace();
}
}
}
从上图我们可以看出,递归调用次数过多,导致StackOverflowError (堆栈溢出),纵使我们可以在调用递归方法前让线程先休息一会,也是治标不治本的方法。所以使用RedisTemplate这种加锁方式主要适用于那些方法只需调用成功一次即可的需求,对于那些每个请求都需成功的需求不适用。
除了存在 StackOverflowError (堆栈溢出)这个隐性缺陷外,还有另外一个隐性缺陷:若我们给锁设置有效时间过短,就有可能发生持有锁的线程还没执行完方法,锁就失效了,其他线程重新获取到锁。下面我们演示一下:
public class RedisTemplateTest {
@Autowired
RedisTemplate<String, String> redisTemplate;
@GetMapping("/redisTemplateTest")
public void redisTemplateTest() {
System.out.println("尝试获取锁");
Boolean lock = redisTemplate.opsForValue().setIfAbsent("redisTemplateTest锁住了", "value", 5, TimeUnit.SECONDS);
try {
if (lock) {
System.out.println("redisTemplateTest获取到锁了");
Thread.sleep(10000);
redisTemplate.delete("redisTemplateTest锁住了");
System.out.println("redisTemplateTest释放锁");
}else{
Thread.sleep(1000);
redisTemplateTest();
}
} catch (Exception e) {
redisTemplate.delete("redisTemplateTest锁住了");
e.printStackTrace();
}
}
}
在以上代码中我们将锁设置5秒的有效时间,获取锁后休息了10秒才释放锁。
从上图我们可以看出线程并没有依次获取锁,执行代码,删除锁。而是在锁是失效后,其他线程就获取到锁执行。
扩展:
Boolean lock = redisTemplate.opsForValue().setIfAbsent("redisTemplateTest锁住了", "value", 10, TimeUnit.SECONDS);
给锁设置超时时间的方法,设置锁并返回的lock有可能不是 true 或 false,而是null。
对此详情可以看一下这本篇文章:
redisTemplate 使用 setIfAbsent 返回 null 问题原理及解决办法
2)redisson
public class RedissonTest {
@Autowired
Redisson redisson;
@GetMapping("/redissonTest")
public void redissonTest(){
RLock lock = redisson.getLock("redissonTest获取锁");
try {
System.out.println("尝试获取锁");
lock.lock();
System.out.println("redissonTest锁住了,休息中");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("redissonTest释放锁");
lock.unlock();
}
}
}
从上图我们可以看出,多线程同时进入方法后,一个线程被锁锁住,直到执行完方法后,释放锁,其他线程才能再起获取锁。
以上代码的锁并没有添加时间限制,但是 redisson 的锁如果不传入具体时间会默认设置30秒的限制防止死锁,所以不需要担心服务器宕机导致死锁的发生,如要添加时间限制只需将代码稍稍修改:
lock.lock(5,TimeUnit.SECONDS);
使用 redisson 与使用 redisTemplate 相比:首先已经去掉了递归调用这一缺陷,其次就代码未执行完锁失效这一缺陷,当我们使用 redisson 锁的时候,不传入限制时间,他会自动开启 “看门狗” 自动延期机制,默认每隔10s检查一次,若线程还在运行则将锁的有效时间设置为30s,直至线程主动释放锁或项目停止,锁限制时间到redis主动释放锁。
综合考虑以上两种使用 redis 分布式锁的方式,使用 redisson 是优于直接使用 redisTemplate 的,所以建议项目中如果要使用 redis 分布式锁,请使用 redisson jar包。