分布式锁
我们已经发现在集群模式下,Synchronized的锁失效了,Synchronized只能够保证单个JVM内部的多个线程的互斥,而没有办法让我们集群下的多个JVM内部的线程互斥。
如果想要解决这种情况,我们必须要使用分布式锁。
分布式锁工作原理:
Synchronized锁利用JVM的锁监视器来控制线程,在JVM的内部只有一个锁监视器,所以只能有一个线程获取锁,可以实现线程间的互斥,当有多个JVM的时候,就会有多个锁监视器,就会有多个线程获取锁,这样就没有办法实现多JVM进程之间的互斥。
要想解决这个问题,就不能够使用JVM内部的锁监视器,必须让多个JVM去使用同一个锁监视器,因此,这个锁监视器一定独立于JVM之间,可以被多JVM进程识别到。此时无论是资源内部的还是多JVM的进程,都应该来找这个锁监视器来获取锁。这样只会有一个线程能够获取锁。就能够实现多JVM进程之间的互斥。
过程分析:
假设有两个JVM,JVM1中的线程A,来获取锁,就需要去寻找外部的锁监视器,一旦获取成功,就回去记录当前获取锁的是这个线程A,在高并发情况下,此时如果有其他线程也来获取锁,比如JVM2线程中的线程C,但因为外部的锁监视器中已经记录锁的持有者了,因此线程C获取锁失败,则需要等待锁释放,当线程A释放锁成功后,线程C来获取锁,之后执行自己的业务。此时,无论是单个JVM内部的线程互斥,还是多个jvm之间的线程互斥,都会被同一个锁监视器监视,达到一个互斥效果。也就是一个线程能拿到线程锁。这样就避免了并发安全问题的发生。
总结:
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
特性:
-
多线程可见:使用redis,能够让所有JVM都能看到
-
互斥:必须确保不管谁来访问,只能够有一个线程拿到,其余都要失败
-
高可用:大多数情况下获取锁的时候都应该是成功的,
-
高性能:因为加锁本身就会影响业务的性能,因此获取锁的速度需要做到高并发
-
安全性:需要考虑获取锁之后的异常情况处理
以上是分布式锁需要满足的基本特性,除此之外,还有一些功能性的特征,
-
是否满足可重入性
-
阻塞锁还是非阻塞锁
-
公平锁还是非公平锁
分布式锁的实现
分布锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用MySQL本身的互斥锁机制 | 利用setnx的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时机制,到期释放 | 临时节点,断开连接自动释放 |
MySQL:
互斥锁机制:MySQL事务在执行写操作时,MySQL会自动分配互斥锁,在多个事务之间就是互斥的。
举例:我们可以在业务执行之前去MySQL中申请一个互斥锁,再去执行业务,当业务执行完之后,再去提交事务,当业务抛出异常时,会自动触发回滚,锁同时也释放。
可用性:MySQL支持主从模式,可用性良好
安全性:当MySQL断开连接后,锁也会自动释放
Redis:
互斥:setnx:值再往redis数据库set数据时,只有数据不存在时才能set成功,如果存在,则set失败,假如在多线程高并发场景下,所有线程是同一个key,则只能有一个线程可以成功,这就实现了互斥。
释放锁只需要将该key的值删除即可,这样其他线程就可以往redis数据库中存储了
可用性:不仅支持主从模式,还支持集群模式、哨兵模式。
安全性:利用redis的key过期机制,可以给key上设置过期时间,这样当服务宕机时,就算没有删除该key,一旦过期,就会自动释放
Zookeeper:
互斥:Zookeeper内部可以去创建数据节点,节点具备唯一性与有序性,还可以创建临时节点,唯一性即在创建节点时,节点不能重复。
有序性即每创建一个节点,节点的Id是递增的。
利用有序性实现互斥:在多线程高并发场景下,在Zookeeper中创建节点,每个节点的id就是单调递增的,如果约定ID最小的那个线程获取锁成功,这样一来就实现互斥。
利用唯一性实现互斥:在多线程高并发场景下,在Zookeeper中创建节点,节点名称设置一样,这样一来就只有一个线程可以成功。
一般情况下利用有序性实现互斥。
释放锁就只需要将节点删除即可。
可用性:支持集群模式
性能:Zookeeper的集群模式强调 强一致性,而这种强一致性会导致主从之间做数据同步需要消耗一定的时间,性能相对来讲较于redis差一些。
安全性:一般创建的都是临时节点,就算服务宕机,当指定时间,节点就会自动释放。
基于Redis的分布式锁
思路:
实现分布式锁时需要实现的两个基本方法:
-
获取锁:
-
互斥:确保只能有一个线程获取锁(利用setnx命令实现)
-
注意事项:必须确保获取锁的动作与和添加过期时间的动作保证原子性(要么都成功,要么都失败)
解决方案:在set命令后可以跟许多参数,其中就包括ex(设置过期时间),以及nx(互斥效果)。
我们可以在一个set命令中通过设置参数同时实现互斥效果,以及设置过期时间,这样就可以实现原子性的操作。
# 添加锁,NX是互斥,EX是设置超时时间 set key value NX EX 10
-
释放锁:
-
手动释放(del命令实现)(理想情况)
-
设置过期值令其超时释放(利用expire命令设置过期时间)(紧急情况)
-
发现问题:在获取锁失败之后,线程应该做什么?
在JDK中提供的锁有两种机制:
-
阻塞:获取锁失败,进行阻塞等待,等到锁被释放为止
-
非阻塞:获取锁失败,结束返回一个结果。
而在redis获取锁失败后,会进行一个非阻塞的机制,因为阻塞线程使用的是操作系统底层原语mtuex
,每次阻塞需要操作系统从用户态切换到内核态,比较消耗性能,其次,阻塞等待实现较为麻烦。
实现流程:
线程开启,尝试获取锁,执行命令,结果有两种,OK,NIL,NIL代表失败,证明锁已经被获取,OK代表获取互斥锁成功,则执行业务,业务执行完之后,释放锁。如果在执行业务期间出现服务宕机情况,一旦到达过期时间,锁自动释放,避免死锁的发生。
代码实现:
案例:基于redis实现分布式锁初级版本
需求:定义一个类,实现下面接口,利用redis实现分布式锁功能
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁的过期时间,单位秒,过期后自动释放
* @return true代表获取锁成功,false代表获取锁失败 采用非阻塞模式,获取锁只尝试一次,返回结果
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
代码展示:
@RequiredArgsConstructor
public class SimpleRedisLock implements ILock{
//业务名称
private final String name;
//锁的key前缀
private final String KEY_PREFIX = "lock:";
private final StringRedisTemplate stringRedisTemplate;
//注意事项:设置锁的名称应该与业务相关,不能将锁名称写死,不能让所有业务获取同一把锁
// 锁的业务应该与锁的名称相关,不同的业务获取不同的锁
@Override
public boolean tryLock(long timeoutSec) {
// 获取锁的key值
String key = KEY_PREFIX + name;
// 获取当前线程的id,作为线程标识,可以识别哪个线程拿到的锁
long value = Thread.currentThread().getId();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, value+ "", timeoutSec, TimeUnit.SECONDS);
//自动拆箱可能导致空指针,因此使用工具类判断
return Boolean.TRUE.equals(flag);
}
@Override
public void unlock() {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
进行测试:
在模拟集群下再次进行测试:
//创建锁对象,锁的范围缩小到每个用户,避免锁的粒度太大,提升并发效率
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁,设置超时时间与你的业务执行时间相关
boolean isLock = lock.tryLock(1200);
//判断是否获取锁成功
if (!isLock) {
//获取锁失败,返回错误或者重试
return Result.fail("不允许重复下单");
}
//利用实现类自调用,防止事务自调用问题
//第二种方法:创建代理对象,利用代理对象调用方法,防止事务自调用问题
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, seckillVoucher);
} catch (IllegalStateException e) {
//释放锁
lock.unlock();
}
测试:
打开ApiFox发送两个请求,在idea中打上断点,查看运行状态:
这样就证明现在只有一个服务请求成功了。因为虽然有两个进程,但是是从同一台redis机器上去获取锁,并且获取锁的名字一致。
这就是redis分布式锁的实现思路。
Redis分布式锁误删问题
问题发现:
在多线程高并发场景下,线程一来获取锁,因为只有一个线程,因此获取成功,开始执行业务,业务发生堵塞,堵塞时间超过过期时间,锁被提前释放,线程二获取锁成功,开始执行业务,在执行业务期间,线程一业务执行结束,释放锁,但是释放的是线程二的锁,因此线程三获取锁成功,开始执行业务,这样就又出现了并行执行的现象,并发安全问题。
原因:线程一在释放锁的时候并没有检查是否是自己的锁,如果在释放锁的时候进行判断,判断锁的标识是否一致,锁是否为自身的锁。如果是可以删除,如果不是就不可以。
修改业务流程:
在业务完成后检查锁上的唯一标识是否相同,是则释放,如果不是,说明自己的锁已经释放。
案例:改进Redis的分布式锁
需求:修改之前的分布式锁实现,满足:
-
在获取锁时存入线程标识(可以使用UUID表示)
-
在释放锁时现获取锁的线程标识,判断是否与当前线程标识相同
-
如果一直则释放锁
-
如果不一直则不释放锁
-
注意事项:存储线程标识时,最好使用UUID,之前使用的线程ID就是一个递增数字,在JVM内部每创建一个线程,数字就会递增。如果是在集群的情况下,多个JVM进程,那么线程ID一定有重复的。不具备唯一性,因此不能使用。因此我们可以在创建锁时,在线程ID后面加上UUID,两者结合,用UUID区分JVM进程,用线程ID区分线程。
代码实现:
@RequiredArgsConstructor
public class SimpleRedisLock implements ILock{
//业务名称
private final String name;
//锁的key前缀
private final String KEY_PREFIX = "lock:";
//线程标识 toString("true") 去掉符号UUID的-
private final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
private final StringRedisTemplate stringRedisTemplate;
//注意事项:设置锁的名称应该与业务相关,不能将锁名称写死,不能让所有业务获取同一把锁
// 锁的业务应该与锁的名称相关,不同的业务获取不同的锁
@Override
public boolean tryLock(long timeoutSec) {
// 获取锁的key值
String key = KEY_PREFIX + name;
// 获取当前线程的id,作为线程标识,可以识别哪个线程拿到的锁
String value = ID_PREFIX + Thread.currentThread().getId();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS);
//自动拆箱可能导致空指针,因此使用工具类判断
return Boolean.TRUE.equals(flag);
}
@Override
public void unlock() {
//获取线程标识
String value = ID_PREFIX + Thread.currentThread().getId();
//获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断标识是否一致
if (value.equals(id)) {
//一致,释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
开始测试:
开启两个服务,利用ApiFox发送两个请求:
让其中一个服务拿到锁:
并将锁删除,用来模拟业务操作时间大于超时时间
再让另一个服务拿到锁:
在让第一个服务继续进行到释放锁阶段:拿到标识,进行判断:
发现不一致,则直接结束,没有权限删除:
再让第二个服务进行到锁释放阶段:
表示一致,进行释放锁阶段。
至此,锁误删问题解决。
分布式锁原子性问题
在前面解决误删问题时,我们在unlock中拿到redis锁中的值,并在进行判断之后进行了释放锁。
这两者都是redis数据库的操作,在程序拿到redis锁中的标识后,进行判断,判断成功后才会进行释放。
如果在判断完成之后,释放锁时服务产生了堵塞(堵塞可能原因:在JVM内部会有守护线程(gc线程),用来回收,在其进行full GC时,会阻塞所有的代码),堵塞时间一旦超出过期时间,锁就会被释放,其他线程就有乘虚而入了,这时的第一个线程已经进行了对标识的判断。
因此,在堵塞结束后,不会再进行判断,会直接释放锁,这时释放的锁就不是自己的锁,又一次发生了误删问题。而此时redis中已经没有锁,就意味着又有线程可以获得锁并执行业务,这样就又出现了并发安全问题。
如图所示:
问题原因:判断锁标识和释放是两个动作,这两个动作之间产生阻塞。
解决方案:确保判断锁识别的动作和释放锁的动作是一个原子性操作。
一想到原子性操作就会想到MySQL中事务的进行就是原子性的,要么一起成功,要么一起失败。
而redis中的事务首先是能够保证原子性,但无法保证一致性,而且事务里面的多个操作是一个批处理,整合到一块,最终一次性去处理。因此无法将判断锁识别的动作和释放锁的动作放在同一个事务中,因为做查询动作时无法拿到结果,他是最终整合一次性执行。
因此只能利用redis中的乐观锁去做判断,确保我释放时没有被修改。但这种做法比较复杂。因此这里可以使用redis的Lua脚本来完成。
Redis的Lua脚本
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,基础语法参考菜鸟教程:Lua 教程
在使用JAVA语言调用redis时是靠spring框架中的redisTemplate,那么使用Lua脚本语言如何调用redis呢?
Lua官方给我们提供了一个内置的函数方便调用redis:
# 执行redis命令
redis.call('命令名称','key','其他函数',...)
类似于Java的面向对象,调用call方法。
举例说明:执行set name Jack 脚本:
redis.call('set','name','jack')
脚本的实质:在脚本中写大量的命令,用来实现一个复杂业务逻辑。
脚本举例:
#先执行redis命令 set name Jack
redis.call('set','name','jack')
#执行 get name
local name = redis.call('get','name')
#返回
return name
利用Lua语言写完脚本后,需要使用redis命令来调用脚本,调用脚本的常见命令如下:
举例说明:
执行:redis.call('set','name','jack')
脚本,语法如下:
脚本内容需要用双引号修饰,因为脚本本质是一个字符串,用双引号修饰是声明脚本内容。
执行脚本后面的数字时参数的数量,指该脚本中的key类型参数数量,实例脚本中并没有任何key类型参数,因此是0.
脚本可以理解为函数,而参数就是函数中的变量,因此如果没有参数就意味着脚本内容全部写死,那脚本的拓展性就很差,
如果在实例脚本中的name与Jack传参,就意味着这个脚本可以给不同的key赋不同的值,这样脚本的拓展性就得到了很大的增强。
代码展示:无参脚本
有参脚本:
Redis中的参数分为两类(redis是key-value结构):
-
key类型的参数
-
其他类型的参数
众所周知:redis命令中有mset
命令,就可以添加多个key与value,那么我们的脚本里也可能有这种命令,可能脚本中key的个数不定,这时候就需要区分有几个key,以及有几个value。因此我们需要得知哪些是key类型参数,哪些是其他类型参数
如果为1,则往后的一个参数是key类型参数,以此类推。
如果在脚本中传了许多key,许多其他参数,这些参数在脚本中如何索取参数?
这时候需要有一个占位符去获取参数。
在脚本中传参后,key类型参数会放在keys数组,其他参数会放在ARGV数组,在脚本中可以在keys和ARGV数组获取这些参数:
举例说明:
这就是redis的Lua脚本的基本用法。
回顾:
释放锁的业务流程:
-
获取锁中的线程标识
-
判断是否于指定的标识(当前线程标识)一致
-
如果一致则释放锁
-
如果不一致则什么都不做
将该业务流程用Lua脚本编译:
-- 锁的key
local key = KEYS[1]
-- 当前线程标识
local threadId = ARGV[1]
-- 获取锁的线程标识,即key中value
local id = redis.call('get',KEYS[1])
-- 比较线程标识与锁中的标识是否一致
if(id == ARGV[1]) then
-- 释放锁
return redis.call('del',KEYS[1])
end
return 0
简化得:
if(redis.call('get',KEYS[1]) == ARGV[1]) then
-- 释放锁
return redis.call('del',KEYS[1])
end
return 0
Java调用Lua脚本改造分布式锁
案例:再次改进Redis锁的分布式锁
需求:基于Lua脚本实现分布式锁的释放锁逻辑
提示:RedisTemplate调用Lua脚本的API如下:
与redis脚本命令对应如下
修改释放锁的业务代码:
public void unlock() {
//调用Lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
注意事项:我们将Lua脚本写到一个文件中,那么程序运行到unlock方法时就需要去做一次IO流,每执行一次就需要去读取文件一次,所以我们直接提前将脚本文件定义为常量。
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
进行测试:与之前测试结果一致。
Lua脚本根本目的是为了解决判断线程标识与释放锁动作的原子性问题。因此避免了线程并发安全问题。
到此时实现的分布式锁已经是一个生产可用、相对完善的分布式锁了。
总结:
基于Redis分布式锁的实现思路:
-
利用set nx ex 获取锁,并设置过期时间,保存线程标识
-
释放锁先判断线程标识是否与自己一直,一致则删除锁。
特性:
-
利用set nx 满足互斥性
-
利用set ex 保证故障时锁依然能释放、避免死锁,提高安全性
-
利用redis集群模式保证高可用性和高并发特性。
基于redis的分布式锁优化
基于setnx实现的分布式锁存在下面的问题:
-
不可重入问题:同一个线程无法获取同一把锁
可重入就是指同一个线程可以多次获取同一把锁。
举例说明:
方法a调用方法b,在方法a重要先获取锁,然后执行业务,去调用方法b,然后方法b也需要获取同一把锁,在这种情况下,如果锁是不可重入的,方法a中获取锁之后,方法b就无法获取锁,只能等待方法a中锁的释放,但锁无法释放,因为方法a的业务没有完成,于是就会出现死锁的情况。
在这种场景下,我们要求的锁是可重入锁。
-
不可重试问题:获取锁只尝试一次就返回false,没有重试机制。
在许多业务中,不能说获取锁失败后直接返回,不去重试,如果获取锁失败,等待一段时间后再去重试,如果成功了,在去执行业务。
在大多数业务中要求的锁是可重试的锁。
-
超时释放问题:锁超时释放虽然是可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。
-
主从一致性问题:如果redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并未同步主从的锁数据,则会出现并发安全问题
拓展知识:主从模式可以理解为读写模式,写操作在主节点上执行,读操作在从节点上执行,主从数据会去进行同步,保证主从之间的数据是一致的,提高整个服务的并发能力和高可用性,如果主节点宕机,我们还可以从节点中选出一个作为新的主节点,增强了集群的可用性。
但是主从之间的数据同步是有延迟的,所以在极端情况下,可能会发生:
在高并发多线程场景中,其中一个线程在主节点中获取了锁,获取锁是写操作,在主节点完成,倘若在主节点尚未同步给从节点时,主节点服务宕机,此时会去选择一个新的从节点作为主节点,但主节点并未数据同步,从节点上并没有锁的标识,即:这时其他线程就可以趁虚而入,拿到锁,执行业务。就会出现并发同步问题,因为不止一个线程才能拿到锁。
这种情况概率较低,因为主从之间的数据同步中的延迟较低,往往是在毫秒级别或者更低。
以上的四个问题,要么发生概率极低,要么就是不一定有这样的需求。
这些都是功能上的拓展,并不是说必须实现。
但如果业务中有要求,那么就必须要实现,但是这些问题解决方案十分复杂,不建议自己定义去实现,可以尝试寻找前辈留下的方法。
Redisson
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。他不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包括各种分布式锁的实现。
Redisson是一个在redis基础上实现的一个分布式工具的集合,在分布式系统下用到的绝大多数工具都在其中,包括分布式锁
官方文档:Redisson
关于分布式锁文档:锁和同步器 - Redisson 参考指南
Redisson入门
1.引入依赖:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.49.0</version> </dependency>
2.配置Redisson客户端
有两种方式:
-
利用Java配置方式实现:
@Configuration public class RedisConfig { @Bean public RedissonClient redissonClient() { //配置类 Config config = new Config(); //添加REdis地址,这里添加了单点的地址,也可以使用config.userClusterServers()添加集群地址 config.useSingleServer().setAddress("redis://127.0.0.1:6379"); //创建客户端 return Redisson.create(config); } }
-
利用配置文件和springboot去整合实现。springboot官方也提供了一个关于REdisson的起步依赖
但是这种方式不推荐使用,因为会去替代spring官方提供的对于redis的配置与实现。因此建议使用第一种方案。
3.使用Redisson的分布式锁
@Resource
private RedissonClient redissonCLient;
@Test
void test Redisson() throws InterruptedExpection{
//获得锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
//尝试获取锁,参数分别为:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimUnit.SECONDS);
//判断锁是否成功
if(isLock){
try{
System.out.println("执行业务");
}fianlly{
//释放锁
lock.unlock();
}
}
}
案例展示:
private final RedissonClient redissonClient;
...
//创建锁对象,锁的范围缩小到每个用户,避免锁的粒度太大,提升并发效率
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁,参数waitTime默认值为-1,代表不等待获取锁失败则立即返回,不重试。 leaseTime参数为锁自动释放时间,默认值为30s,
boolean isLock = lock.tryLock();
//判断是否获取锁成功
if (!isLock) {
//获取锁失败,返回错误或者重试
return Result.fail("不允许重复下单");
}
//利用实现类自调用,防止事务自调用问题
//第二种方法:创建代理对象,利用代理对象调用方法,防止事务自调用问题
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, seckillVoucher);
}finally {
//释放锁
lock.unlock();
}
进行测试,添加订单成功。
现在利用Jmeter进行并发测试:
显示只有一个线程成功。
检查数据库:
测试成功,没有并发安全问题。
Redisson可重入锁原理
案例说明:
首先会去创建一个锁的对象,进行测试,在方法1中首先尝试获取锁,获取锁之后去执行业务,业务中的方法2也需要去获取锁,但是获取锁其实就是redis数据库中的setnx命令,因为是在同一线程,key相同,方法一获得锁,那么方法2就获取锁失败,这就是不可重入问题。
RLock lock = redissonClient.getLock("anyLock");
@Test
public void method1() {
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败,1");
return;
}
try {
log.info("获取锁成功,1");
method2();
} finally {
//释放锁
log.info("释放锁,1");
lock.unlock();
}
}
@Test
public void method2() {
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败,2");
return;
}
try {
log.info("获取锁成功,2");
} finally {
log.info("释放锁,2");
//释放锁
lock.unlock();
}
}
解决方案:
可以参考jdk里提供的ReentrantLock(可重入锁)简单了解一下原理:可重入锁就是在获取锁的时候,当判断锁是否被获取的同时,检查获取锁的是哪个线程,如果是同一个线程的话,就让其通过获取锁,这样就需要在线程获取锁的时候添加一个计数器记录重入的次数,在有线程获取锁时就会累加,释放锁时减一,这就是可重入的基本原理。
这就需要我们在锁中不仅记录获取锁的线程,还需要记录该线程重入的次数。我们现在需要往一个key中存储两个value,那么现在的String结构就不够用了,因此我们需要换成hash类型。
具体实现原理:
在高并发多线程场景中,线程一尝试获取锁,发现没有人获取锁,则获取成功,将线程的标识记录下来,此时如果线程一中的业务执行时还需要获取锁,发现有人获得锁,这时在进行一次判断,判断得到的线程标识与锁中存储的线程标识对比,如果相同,则允许其获取锁,并且在重入次数加一,如果业务执行完成,则开始释放锁,释放锁之前需要进行判断,先将重入次数减一,在判断重入次数是否为0,如果为0,执行释放锁方法,如果没有,说明还有业务在使用锁。
业务流程:
首先判断锁是否存在(用exists命令判断),返回两种结果,存在或者不存在,如果不存在,获取锁并添加线程标识,设置有效时间,执行业务,如果锁已经存在,判断锁标识是否是同一线程,如果是同一个线程,只需要将重入次数加一,再重置有效时间,执行业务,业务完成后,需要释放锁,释放锁需要判断,需要先判断锁的线程标识是否一致,如果不一致,说明锁已经释放,如果标识一致,先将重入次数减一,再去进行判断重入次数的值,如果不为0,则证明不是最外层的业务,需要去重置锁的有效期,给后续业务执行留下充足的执行时间,如果为0,说明已经到最外层,此时可以直接释放锁。至此,业务完成。
业务流程图:
这样的逻辑使用Java代码实现无法保证其操作之间的原子性,因此要采用Lua脚本来编译。
获取锁的Lua脚本:
local key = KEYS[1]; -- 锁的key
local value = ARGV[1]; -- 线程唯一标识
local expireTime = ARGV[2]; -- 锁的过期时间
-- 判断是否存在
if (redis.call('exists',key) == 0)
then
-- 不存在,设置锁
redis.call('set',key,value,'1');
-- 设置锁的过期时间
redis.call('expire',key,expireTime);
-- 返回true
return 1;
end
-- 存在,判断value是否一致
if (redis.call('hexists',key,value) == 1)
then
--一致 ,获取锁,重入次数+1
redis.call('hincrby',key,'count',1);
-- 设置锁的过期时间
redis.call('expire',key,expireTime);
-- 返回true
return 1;
end
return 0;
释放锁的Lua脚本:
local key = KEYS[1] -- 锁的key
local value = ARGV[1] -- 线程唯一标识
local expireTime = ARGV[2] -- 锁的过期时间
if(redis.call('hexists',key,value) == 0)
then
return nil -- 锁不存在,返回0
end
-- 是自己的锁,则重入次数-1
local count = redis.call('hincrby',key,value,-1)
-- 重入次数减为0,则删除锁
if (count > 0) then
-- 大于0,说明不能释放锁,重置有效期后返回
redis.call('expire',key,expireTime)
return nil
else -- 重入次数减为0,则删除锁
redis.call('del',key)
return nil
end
对Redisson锁进行测试,看是否满足可重入锁:
@Slf4j
@SpringBootTest
public class ReentrantLockTest {
@Resource
private RedissonClient redissonClient;
private RLock lock;
@BeforeEach
void setUp(){
lock = redissonClient.getLock("lock:");
}
@Test
public void method1() {
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败,1");
return;
}
try {
log.info("获取锁成功,1");
method2();
log.info("执行业务逻辑,1");
} finally {
//释放锁
log.warn("释放锁,1");
lock.unlock();
}
}
@Test
public void method2() {
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败,2");
return;
}
try {
log.info("获取锁成功,2");
log.info("执行业务逻辑,2");
} finally {
log.info("释放锁,2");
//释放锁
lock.unlock();
}
}
}
进行测试:测试成功
同一个线程的两个方法成功获得锁,可重入次数变为2,并在释放锁时,第一个业务释放锁时,可重入次数减一,第二个业务释放锁时,可重入数减一,变为零。因此锁被释放
解析源码得:
根据测试案例中的tryLock来追踪redissonLock的源码:
我们选择的是较为基础的RedissonLock
继续向下追踪:
依旧是RedissonLock类
至此:
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return this.evalWriteSyncedNoRetryAsync(this.getRawName(), LongCodec.INSTANCE, command,
"if ((redis.call('exists', KEYS[1]) == 0) or (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.singletonList(this.getRawName()), new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)});
}
我们发现这段硬编码就是Lua脚本,并且与我们开始写的获取锁的逻辑别无二致。
再看释放锁:同上所示,直接找到最底层,中间就不展示了:
至此:RedissonLock的可重入锁的原理解析完毕。
Redisson的锁重试和WatchDog机制
解决了不可重入的问题,但是依然还存在几个问题:
-
不可重试问题:获取锁只尝试一次就返回false,没有重试机制
-
超时释放问题:锁超时释放虽然是可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。
-
主从一致性问题:如果redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并未同步主从的锁数据,则会出现并发安全问题
那么这三个问题在redisson内部是如何解决的呢?
我们还是需要去根据以上案例来跟踪源码:
我们在实例案例中的tryLock()开始使用的是空参,但是时可以传递参数的,共计三个参数,第一个参数waitTime,获取锁的最大等待时长,一旦传递出这个参数,那么在该线程第一次获取锁失败后就不会立即返回,而是在等待时间内,不断地去尝试,在指定时间过去之后,才会返回false,一旦传入第一个参数,就会变成一个可重试锁。
第二个参数:LeaseTime,锁自动失效释放的时间,最后一个参数是时间的单位。
开始跟踪源码:
继续追踪到最底层:
这种设计的巧妙之处就在于利用了消息订阅以及信号量的机制,并不是无休止的盲目等待机制,无休止的等待是对CPU的一种浪费,而现在所用的机制就是等待其他线程释放再去尝试,对CPU较为友好。
这就是redisson锁的重试机制。
锁超时机制(WatchDog机制)
如果获取锁成功后,执行事务时发生堵塞,导致ttl到期,这时其他线程发现锁过期,则重试获取锁,这样又导致了线程安全问题。
因此我们必须确保锁是因为业务执行完释放,而不能因为阻塞释放。
跟踪源码
当锁的持有时间未明确指定(leaseTime <= 0)时,Redisson 会自动启用看门狗机制。
scheduleExpirationRenewal(threadId) 的作用是:为当前线程持有的锁启动一个定时任务,定期刷新锁的过期时间,防止锁因超时而被释放
该方法用于调度锁的自动续期任务。具体功能如下: renewalScheduler.renewLock(...):调用续期调度器,对指定的锁进行自动续期;
getRawName():获取锁的原始名称;
threadId:当前持有锁的线程ID;
getLockName(threadId):生成该线程持有的锁的具体名称。
该方法用于续订指定锁的持有时间。其逻辑如下: 如果当前没有任务(reference为null),则创建一个新的LockTask任务; 获取当前的任务对象; 调用add方法将锁信息添加到任务中,用于后续自动续租。
创建一个新的LockEntry对象,并将其赋值给变量entry。 调用entry对象的addThreadId方法,将threadId和lockName添加到entry中。这可能意味着记录哪个线程持有哪个锁。 最后,调用另一个重载版本的add方法,传入rawName, lockName, threadId, 和新创建的entry对象。这个重载版本的方法可能负责将锁条目存储到某个数据结构中,比如一个映射表或集合。
使用threadId2counter.compute方法来更新与threadId关联的计数器。 compute方法接受两个参数:键(threadId)和一个BiFunction,该函数定义了如何计算新的值。 在BiFunction中: 使用Optional.ofNullable(counter).orElse(0)来获取当前计数器的值。如果计数器不存在,则默认为0。 将计数器的值加1。(可重入锁) 将threadId添加到threadsQueue队列中。 返回更新后的计数器值。 使用threadId2lockName.putIfAbsent方法来确保每个threadId只与一个lockName关联。 如果threadId在threadId2lockName映射中已经存在,则不会进行任何操作。 如果threadId在threadId2lockName映射中不存在,则将threadId和lockName添加到映射中。 总结来说,这个方法的主要功能是: 更新与特定线程ID关联的计数器,每次调用时计数器加1。 将线程ID添加到一个队列中。 确保每个线程ID只与一个锁名称关联,如果该线程ID尚未关联任何锁名称,则进行关联。
我也没有怎么搞懂,其实就是一旦线程成功获取锁,Redisson 会启动一个后台线程(看门狗)来监控和续期该锁。 看门狗机制会在锁的过期时间到达之前自动续期锁的过期时间。
并且看门狗机制还可以判断重入次数。并将每一个业务的锁分配好。
释放锁的原理流程图
释放锁的原理流程图
总结:
redisson分布式锁原理:
-
可重入:利用hash结构记录线程id和重入次数,当线程获取锁失败后去判断锁包含的线程id是否一致,如果一致,则让其获取锁,并且重入次数加一。在释放锁时,先去判断锁中的线程ID,再去判断重入次数是否为0,如果为0,则释放,不为0,则无视
-
可重试:利用信号量和pubsub功能实现等待、唤醒、获取锁失败的重试机制。
在第一次获取锁失败以后,并不是直接返回false,尝试获取锁的返回结果就是ttl(剩余有效时间),如果返回值为null。则获取锁成功,若不为null,则获取锁失败,
获取锁失败后会开启一个订阅功能,就是去接收其他线程释放锁时发送的消息,再进行判断,在指定时间内没有获取到释放锁的消息时则取消订阅,并且返回false.
如果成功,在去判断等待消耗的时间,如果时间超时,则返回false,如果时间还剩余,则开启while(true)循环.
进行再次尝试获取锁,其中会一直判断时间是否超时,如果再次失败,则会等待一段时间,其中如果剩余时间大于ttl时间,则等待ttl时间后再次重试,这里采用了信号量的方案,去获取其他线程释放锁后释放的信号量,获得后就会再次尝试获取锁,直到时间超时,则返回false,没有则重复循环获取。
这种设计的巧妙之处就在于利用了消息订阅以及信号量的机制,并不是无休止的盲目等待机制,无休止的等待是对CPU的一种浪费,而现在所用的机制就是等待其他线程释放再去尝试,对CPU较为友好。
-
超时续约:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间.
redisson的multiLock原理
此时还剩下一个主从一致性问题我们还没有解决。
问题分析:
redisson分布式锁主从一致性问题
在之前的学习中,采用的都是单节点的redis,如果redis出现故障,项目中依赖于redis的业务都会出现问题,包括分布式锁。
而在实际开发中肯定是不被允许的,为了提高Redis的高可用性,在实际开发中会去搭建redis的主从模式:
有多台Redis机器,不过角色不同,一台redis机器作为主节点(Master),剩下的作为从节点(Slave),主从职责也不一样,往往会做读写的分离,主节点负责写操作,从节点负责读操作,主从节点之间需要做到数据同步,但毕竟不在同一个机器上,主从数据同步或有一定的延迟,尽管延时很短,但是存在,即使主从数据同步不及时,也只会产生旧读,而不会产生脏读,但是在一些较为严谨的业务中,就会出现主从不一致问题。
主从不一致问题:如果有Java应用,现在要获取锁,执行set命令,属于写操作,需要在主节点上执行,并且主节点会去保存锁的标识,而后主节点就会向从节点进行同步,如果此时主节点发生故障,导致数据未同步,而此时redis中会有哨兵去监控集群状态,当其发现主节点故障后,会先断开连接,再从从节点中选出一个作为主节点,但是数据未同步,锁的标识就丢失了,即锁失效,此时其他线程就可以获取锁,就会出现并发安全问题。
解决方案思路:每一个redis机器之间没有主从关系,每一个都可以去做读写操作,在改变获取锁的方式,从前是先找到master节点,再里面获取锁,现在必须依次向多个redis节点获取锁(写入set命令)。都保存锁的标识。
该方案思路解决了主从一致性问题以及可用性问题。
如果还不放心,还可以在每个节点后面再加几个从节点,形成主从关系,这样也不会产生主从一致性问题,如果出现上述场景,主从数据不同步,这时其他线程想要获取锁,不会成功,因为需要在每一个节点都拿到锁才算成功。
方案优点:不仅保存了这种主从同步机制,确保了整个redis集群的高可用性,同时也避免了主从一致引发的锁失效问题。
方案名:multiLock(连锁)把多个独立的锁联合在一起,变成一个联合起来的锁。
使用multiLock方案较为灵活,可以随意建立独立节点,可以不建立主从关系。
测试:
利用docker开启另外两个redis节点:
在配置类中去配置
@Bean
public RedissonClient redissonClient() {
//配置类
Config config = new Config();
//添加REdis地址,这里添加了单点的地址,也可以使用config.userClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
//创建客户端
return Redisson.create(config);
}
@Bean
public RedissonClient redissonClient2() {
//配置类
Config config = new Config();
//添加REdis地址,这里添加了单点的地址,也可以使用config.userClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://0.0.0.0:6380");
//创建客户端
return Redisson.create(config);
}
@Bean
public RedissonClient redissonClient3() {
//配置类
Config config = new Config();
//添加REdis地址,这里添加了单点的地址,也可以使用config.userClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://0.0.0.0:6381");
//创建客户端
return Redisson.create(config);
}
在测试类进行测试:
创建联锁:
void setUp(){
RLock lock1 = redissonClient.getLock("order:");
RLock lock2 = redissonClient2.getLock("order:");
RLock lock3 = redissonClient3.getLock("order:");
//创建联锁 multiLock
lock = redissonClient.getMultiLock(lock1, lock2, lock3);
}
测试:
public void method1() throws InterruptedException {
boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
if (!isLock) {
log.error("获取锁失败,1");
return;
}
try {
log.info("获取锁成功,1");
method2();
log.info("执行业务逻辑,1");
} finally {
//释放锁
log.warn("释放锁,1");
lock.unlock();
}
}
测试成功。
接下来进行源码分析:
如果开始获取锁失败
以上就是获取锁以及需要重试的流程
总结:
-
不可重入redis分布式锁:
-
原理:利用setex的互斥性;利用ex避免死锁;释放锁时判断线程标示
-
缺陷:不可重入,无法重试、锁超时失效
-
-
可重入的Redis分布式锁
-
原理:利用hash结构,记录线程标示和重入次数;利用watchDog机制延续锁时间;利用信号量控制锁重试等待
-
缺陷:redis宕机引起锁失效问题
-
-
redisson的multiLock:
-
原理:多个独立的redis节点,必须在所有节点都获取重入锁,才算获取锁成功
-
缺陷:运维成本高,实现复杂
-
希望对大家有所帮助