redis--黑马点评--分布式锁实现详解
分布式锁
我们已经发现在集群模式下,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();}
代码展示:
@RequiredArgsConstructorpublic class SimpleRedisLock implements ILock{//业务名称private final String name;//锁的key前缀private final String KEY_PREFIX = "lock:";private final StringRedisTemplate stringRedisTemplate;//注意事项:设置锁的名称应该与业务相关,不能将锁名称写死,不能让所有业务获取同一把锁// 锁的业务应该与锁的名称相关,不同的业务获取不同的锁@Overridepublic 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);}@Overridepublic 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区分线程。
代码实现:
@RequiredArgsConstructorpublic 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;//注意事项:设置锁的名称应该与业务相关,不能将锁名称写死,不能让所有业务获取同一把锁// 锁的业务应该与锁的名称相关,不同的业务获取不同的锁@Overridepublic 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);}@Overridepublic 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 Jackredis.call('set','name','jack')#执行 get namelocal 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脚本编译:
-- 锁的keylocal key = KEYS[1]-- 当前线程标识local threadId = ARGV[1]-- 获取锁的线程标识,即key中valuelocal id = redis.call('get',KEYS[1])-- 比较线程标识与锁中的标识是否一致if(id == ARGV[1]) then-- 释放锁return redis.call('del',KEYS[1])endreturn 0
简化得:
if(redis.call('get',KEYS[1]) == ARGV[1]) then-- 释放锁return redis.call('del',KEYS[1])endreturn 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集群模式保证高可用性和高并发特性。
既然是较为完善,则说明还有进步的空间,下一篇就是分布式锁的优化。
希望对大家有所帮助!