B站"黑马点评"学习笔记
一、Redis实现分布式锁
在高并发场景下,我们经常会见到线程之间由于争抢资源而发生的线程问题,在这种情况下如果放任不管,很容易出现线程问题,对整个项目造成影响。而传统的syn锁在服务器集群环境下,因为不同服务器的jvm不同 ,锁监视器不同,而无法对不同服务器的线程起作用,因此可以用redis来实现分布式锁,避免集群环境下的线程争抢资源引发的线程安全问题。
1.实现原理
redis中的setnx指令,其含义是当这个key已经存有value时,不能再存其它value;不存在时则可以正常存入。而redis又是在内存中存储与处理数据的,在集群模式下对所有服务器都可见,因此可以利用setnx指令来实现针对于服务器集群模式下的分布式锁。
2.核心思路
实现分布式锁时需要实现的两个基本方法:
-
获取锁:
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
-
释放锁:
- 手动释放
- 超时释放:获取锁时添加一个超时时间
核心思路:
我们利用redis的setnx方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的线程,等待一定时间后重试即可。还需要加入一个自动过期时间,防止当前业务出现问题阻塞,后续其他线程一直获取不到锁引发的死锁问题。
上锁实现代码:
java
//利用setnx方法进行加锁,同时增加过期时间,防止死锁,因为上锁逻辑只有一行代码,所以此方法可以保证加锁和增加过期时间具有原子性
private static final String KEY_PREFIX="lock:"
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示,这时获得的线程标识只是起到value的作用,无法区分是不是这个线程自己的锁,因为不同jvm也可能会产生线程id相同的线程
// 所以下面还需要进行改进
String threadId = Thread.currentThread().getId()
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
返回值是布尔类型的,这样就可以在调取锁后根据是否成功获取锁来执行相应的业务逻辑。
释放锁就很简单了,直接将这个setnx存的锁删除就可以了
java
public void unlock() {
//通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
删除之后对应的key存的值就为空,这样其他线程就可以在上锁逻辑中获取到锁了,这就是基本的分布式锁实现方法,但其实还存在一些问题。
二、Redis分布式锁误删情况说明与解决
1.误删问题发生原因
有这样一种情况,线程A在拿到锁后执行业务代码,但由于某种未知原因导致阻塞,阻塞时间超过了redis分布式锁的自动施放时间。锁自动释放后,线程B又拿到了锁,开始执行业务,但此时线程A的阻塞突然结束了,于是线程A又开始正常执行,一直到释放锁的逻辑,此时问题就发生了,线程A执行的释放锁逻辑,其实是把线程B拿到的锁给删除了,这样就出现了锁的误删问题。
2.解决办法
出现这种问题的原因在于线程无法判断当前删除的锁是不是自己的锁,只需要在删除之前再加一段判断逻辑,判断当前需要删除的锁是不是自己的锁,是就删除,不是就不做处理。
- 添加锁代码修改:
java
//此时添加的UUID作为线程标识,可以区分当前锁是不是这个线程自己的锁
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
- 释放锁代码修改:
java
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
这样就有效解决了锁的误删问题。
三、分布式锁的原子性问题
1.误删原因
上面的操作看似已经解决了分布式锁的潜在问题,但其实还有一种极端情况,当线程A执行到释放锁的业务后,判断当前锁就是自己的锁,正准备删除,但此时设置的锁的自动释放时间恰好到期,redis自己将锁删除了,线程B又可以获取到锁了,但此时删锁的业务还是需要执行,于是线程A就又把线程B的锁给删了,于是就再次出现了误删问题。
2.解决办法
出现问题的根本原因其实还是代码的原子性问题 ,代码执行过程中很容易有其他线程过来争抢资源,想彻底解决误删问题,还是要从根本解决------保证代码的原子性 。可以借助Lua脚本实现,将判断锁是否是自己的 ,释放锁这两步逻辑用Lua脚本实现,再通过java去调用Lua文件,这样在java代码来看这两步就变成了一行代码,也就是变成了一个步骤,这样就保证了代码的原子性。
lua
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
这就是Lua实现的逻辑,下面用java去调用
java
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用lua脚本,三个参数,DefaultRedisScript实例化对象,keys集合,可变参数value
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
这样就保证了这段代码的原子性,能有效避免误删问题。