分布式锁是一种用于在分布式系统中实现并发控制的机制,用于确保在多个节点上同时进行的操作不会发生冲突或数据不一致的情况。分布式锁通常用于解决多个客户端同时对共享资源进行操作时可能出现的竞态条件和数据一致性问题
背景介绍:
当我们下单操作后,需要对Redis中的库存信息进行删减,虽然Redis是线程安全的,但在JAVA中下单操作是分为两个步骤,首先是先要判断存入Redis中的库存是否充足,充足后再进行扣减,此处查询库存和扣减库存是两步操作,所以在这两步操作之间,就有可能在并发情况下导致线程安全问题而导致“超卖等现象”。在单体Redis情况下可以使用Lua脚本解决扣减库存问题,而在读写分离的集群情况下就只能使用分布式锁来解决了。
解决方案:
Redisson集成Reids分布式锁原理:
redis命令说明:
(1)setnx命令:set if not exists,当且仅当 key 不存在时,将 key 的值设为 value。若给定的 key 已经存在,则 SETNX 不做任何动作。
返回1,说明该进程获得锁,将 key 的值设为 value
返回0,说明其他进程已经获得了锁,进程不能进入临界区。
命令格式:setnx lock.key lock.value
(2)get命令:获取key的值,如果存在,则返回;如果不存在,则返回nil
命令格式:get lock.key
(3)getset命令:该方法是原子的,对key设置newValue这个值,并且返回key原来的旧值。
命令格式:getset lock.key newValue
(4)del命令:删除redis中指定的key
命令格式:del lock.key
使用setnx方法自定义的分布式锁,仍然会有许多的问题:
基于Redisson看门狗的分布式锁
1、redisson原理:
redisson在获取锁之后,会维护一个看门狗线程,当锁即将过期还没有释放时,不断的延长锁key的生存时间
2、加锁机制:
线程去获取锁,获取成功:执行lua脚本,保存数据到redis数据库。
线程去获取锁,获取失败:一直通过while循环尝试获取锁,获取成功后,执行lua脚本,保存数据到redis数据库。
3、watch dog自动延期机制:
看门狗启动后,对整体性能也会有一定影响,默认情况下看门狗线程是不启动的。如果使用redisson进行加锁的同时设置了锁的过期时间,也会导致看门狗机制失效。加锁的时间默认是30秒,如果加锁的业务没有执行完,那么每隔 30 ÷ 3 = 10秒,就会进行一次续期,把锁重置成30秒,保证解锁前锁不会自动失效。
redisson在获取锁之后,会维护一个看门狗线程,在每一个锁设置的过期时间的1/3处,如果线程还没执行完任务,则不断延长锁的有效期。看门狗的检查锁超时时间默认是30秒,可以通过 lockWactchdogTimeout 参数来改变。
4、redisson分布式锁的关键点:
a. 对key不设置过期时间,由Redisson在加锁成功后给维护一个watchdog看门狗,watchdog负责定时监听并处理,在锁没有被释放且快要过期的时候自动对锁进行续期,保证解锁前锁不会自动失效
b. 通过Lua脚本实现了加锁和解锁的原子操作
c. 通过记录获取锁的客户端id,每次加锁时判断是否是当前客户端已经获得锁,实现了可重入锁。
💡有关看门狗机制对于锁释放机制的会不会造成死锁问题:
首先看门狗的开启条件就是不设置过期时间,然后看门狗会给锁设置默认的30秒超时时间,如果业务在续期时间中没有执行完毕则会进行续期。那么就会出现这种情况:①系统出现一些异常导致该业务时间特别长、②如果持有锁的线程宕机怎么办。
对于第一点,我个人认为可以在业务逻辑中进行判断,如果出现特殊异常情况,那么可以添加手动释放锁的逻辑,并进行异常处理。那么如果不进行处理,就有可能会导致该锁不断续期,导致其他线程都被阻塞。
对于第二点,当时我个人想法是认为如果持有锁线程宕机,看门狗线程会认为线程业务没有执行完而一直续期导致死锁问题。其关键就是需要了解看门狗线程能不能够察觉到线程是宕机还是未处理完,后续通过进一步了解发现并不会出现这种情况,因为其看门狗是守护线程。
守护线程:
守护线程是一种特殊的线程,其作用主要是服务其他线程(即用户线程)。守护线程的生命周期依赖于用户线程,当用户线程结束时,守护线程也会随之结束。那么对于这个问题就迎刃而解了。
首先看门狗的线程就是通过判断Redisson的实例有没有没释放,从而来判断当前线程是否需要锁续期,并且如果用户线程宕机掉后,看门狗线程也会终止,那么锁到了超时时间后就会被释放,不用担心死锁问题。
JAVA项目使用Redisson分布式锁
导入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.13.0</version>
</dependency>
配置类:
/**Redisson配置类
*/
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.xx.xx:6379"); //自己的地址
// 创建RedissonClient对象
return Redisson.create(config);
}
}
Redisson提供的分布式锁最常用的api:
1.RLock rLock=redissonClient.getLock(); //设置锁对象
2.boolean isLock = rLock.tryLock();//线程尝试获取锁
3.rLock.unlock()//释放锁
测试demo:
@Test
public void testRLock(){
//此处是下订业务请求进入
//1.设置锁
RLock rLock=redissonClient.getLock(LOCK+userid); //模拟id //此处id应该是产品的唯一id
//2.尝试获取锁
boolean isLock = rLock.tryLock(); //非阻塞式锁
// rLock.lock(); lock没有返回值
//tryLock和lock的区别就是tryLock将尝试在不阻塞的情况下获取锁,而lock将阻塞直到获得锁。
//3.锁判断是否获取成功
if (!isLock){
//获取锁失败,代表有其他的线程抢到了锁,正在执行秒杀业务
//如果使用tryLock()当其他线程没有抢到锁时则执行该代码 tryLock()是非阻塞的 没抢到锁直接返回false
System.out.println("获取锁失败 直接返回失败信息");
}
//获取锁成功
try {
//执行秒杀下单业务
//此处可以使用lua脚本执行扣减库存也可以不使用lua脚本扣减 因为如果即使jvm线程轮巡到其他线程 但其他线程无法获取到锁 所以不会进行执行 但还是推荐使用lua脚本实现插查扣一体
System.out.println("线程获取锁成功 执行秒杀扣减库存业务");
} catch (Exception e) {
e.printStackTrace();
}finally {
//判断当前线程是否持有锁
//isHeldByCurrentThread()用于判断当前线程是否持有锁,而isLocked()用于判断锁是否被任何线程持有。
//
if(rLock.isLocked()&rLock.isHeldByCurrentThread()) {
rLock.unlock();//释放锁
System.out.println("当前线程锁释放");
//TODO 日志打印?
}
}
}
lock() 方法是阻塞获取锁的方式,如果当前锁被其他线程持有,则当前线程会一直阻塞等待获取锁,直到获取到锁或者发生超时或中断等情况才会结束等待。该方法获取到锁之后可以保证线程对共享资源的访问是互斥的,适用于需要确保共享资源只能被一个线程访问的场景。Redisson 的 lock() 方法支持可重入锁和公平锁等特性,可以更好地满足多线程并发访问的需求。
而 tryLock() 方法是一种非阻塞获取锁的方式,在尝试获取锁时不会阻塞当前线程,而是立即返回获取锁的结果,如果获取成功则返回 true,否则返回 false。Redisson 的 tryLock() 方法支持加锁时间限制、等待时间限制以及可重入等特性,可以更好地控制获取锁的过程和等待时间,避免程序出现长时间无法响应等问题。
另外,需要注意的是,tryLock()方法可以设置等待时间,如果在等待时间内没有获取到锁,则返回false。例如:
boolean isLocked = lock.tryLock(10, TimeUnit.SECONDS);
上面的代码中,tryLock()方法会等待10秒钟,如果在10秒钟内没有获取到锁,则返回false
进行断点调试后,
可以看到在redis中存入了改锁的hash数据,
而执行释放锁后,就在redis中删除掉了。
锁释放
isHeldByCurrentThread()用于判断当前线程是否持有锁,而isLocked()用于判断锁是否被任何线程持有。
使用isLocked()有可能会导致其他线程误删锁,所以可以搭配isHeldByCurrentThread()使用
💡注意点
(1)返回值: lock() 是没有返回值的;tryLock() 的返回值是 boolean。
(2)时机:lock() 一直等锁释放;tryLock() 获取到锁返回true,获取不到锁并直接返回false。
(3)tryLock() 是可以被打断的,被中断的;lock是不可以。
整合项目业务类代码案例:
@Override
public Map<String, Object> addOrderIteminfo(String pid,String num) {
Map<String, Object> map=new HashMap<>();
long threadId = Thread.currentThread().getId();//线程id作为key --暂时模拟id
String key= SeckillConfig.LockKey+threadId;//锁的key
RLock lock = redissonClient.getLock(key);
boolean isLock = lock.tryLock(); //获取锁
if (!isLock){ //如果锁获取失败
log.info("当前线程锁获取失败");
map.put("code",0);
return map;//失败处理
}
//TODO 将订单写入到数据库中
try {
//执行lua脚本 实现数据查询和库存扣减
String result = redisTemplate.execute(SECKILL_SCRIPT, Collections.singletonList(key), num);
log.info("lua脚本运行结果:"+result);
if (result.equals("0")){//0表示出现下订单失败
map.put("code",0); //失败处理
return map;
}else {//表示库存扣减成功
//TODO 将订单写入订单详情表
map.put("code",1);
}
} catch (Exception e) {
e.printStackTrace();
}finally {
if (lock.isLocked()&lock.isHeldByCurrentThread()){
lock.unlock();//释放锁
}
}
return map;
}
对于项目业务重新思考:
在后续进一步了解后,其发现上述情况(Redis读写分离)对于到底是否需要使用分布式锁来保证并发安全问题进行重新思考,当两个线程同时读到了两个从库时,此时都发现还有最后一个库存,然后对主库执行写操作,在原本设想的情况下此时会对主节点进行两次扣减操作,那么就发生超卖了,所以要加入分布式锁,使得每一次从从节点读取数据和往主节点写数据只能由一个线程操作。
那么如果通过Lua脚本进行扣减呢? 那如果不加锁的情况下解决这个问题,同样还是和单节点执行的Lua脚本一致,先进行一次查询判断取出库存,然后再进行写操作,失败则返回0,那么两个线程必定会有一个线程返回0,那么返回0的线程就当做下单失败处理即可。但这样子做并没有提高很大的效率,主节点同样还是要进行一次读操作,而我们读写分离的本质思想就是缓解单节点的读写吞吐量的问题,减少主节点的一次读操作。但是如果主节点不进行判断的话那么两个线程则都会写入成功,那么就发生了超卖问题。
如上述的扣减Lua脚本所示,那么为了减少一次读操作,那么就在从库中进行读取数据进行判断,然后再对主库进行扣减的写操作。也就意味着主库只需要执行写命令即可,而从库只需要执行读命令即可,判断的逻辑交由Java端进行处理。这种才是符合搭建主从的目的。
那么为保证并发安全问题,那么就需要使用分布式锁来保证了。
当然同样也可以不加锁,就和单节点的Redis一致,通过Lua脚本来解决并发安全问题,但这又违反了读写分离的本质想法。
💡新问题:主从同步数据一致性问题
紧接上文,在上述的架构模式下,在提高了整体系统吞吐量的情况下,通过分布式锁来尽可能的保证不会出现并发安全问题,但紧接着又会有新的问题的出现——主从的数据一致性问题。
如图所示,当线程A抢到锁然后执行完读和写操作后,此时锁释放,线程B开始进行读操作,此时可能由于网络等因素,导致线程A写操作后的最新数据还尚未同步至从节点,那么线程B就会读取到旧的数据,并认为此时还有库存(假设线程A和B争抢最后一份),那么就会进行写操作,然后出现“超卖”现象。
如何解决延迟导致不一致问题?
任何的架构设计都要结合业务来分析,根据目前市面上许多业务场景来参考,如淘宝订单、帖子等都允许短时间的不一致,并且可以通过一些措施来弥补不一致问题。比如淘宝某秒杀因为这种不一致导致出现超卖问题时,可以在秒杀之后对整个秒杀订单进行一次校验,如若发现出现超卖现象则将多余的订单进行退回(当然这会牺牲掉这一很小部分的用户的体验),通过牺牲小部分用户体验来保全大部分用户的体验。所以具体的解决需要根据业务来进行讨论,依据业务来决定取舍。
PS:
个人亲身经历,在淘宝抢手机的时候,明明已经抢到并且下单支付了,结果隔天淘宝自动把我的订单给退回了。所以个人猜想淘宝也是这种类似的解决方案(鼠鼠个人想法,如有不对还请各位大佬斧正)。