Redis实现的分布式锁

分布式锁的基本原理

在集群环境下我们部署了多个tomcat,每个tomcat都有属于自己的jvm,在不同tomcat中synchronized锁的对象是自身jvm中的对象,所以synchronized锁就会失效,这个时候我们就需要分布式锁去解决这个问题

分布式锁就是足分布式系统或集群模式下多进程可见并且互斥的锁。分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路

分布式锁应该满足的条件

可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思

互斥:互斥是分布式锁的最基本的条件,使得程序串行执行

高可用:程序不易崩溃,时时刻刻都保证较高的可用性

高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能

安全性:安全也是程序中必不可少的一环

Redis实现的分布式锁

Redis实现分布式锁

redis的setNx可以保证只有一个线程可以创建key,如果已经存在key就不会再覆盖,利用这个特性我们可以实现分布式锁,只有当setNx设置锁成功的时候才代表线程竞争锁成功,否则竞争锁失败,为了防止死锁我们通常会为锁设置一个有效期

Redis分布式锁误删问题

线程竞争锁成功后在锁到期后还没完成业务,其他线程在锁超时释放后竞争到锁,这时候原线程完成业务,将后一天线程的锁删除

image-20240331095145843

解决方法:value设置为线程id,每个线程只能删除自己线程id的锁,但是还有极端情况,当线程判断锁是自己后锁过期,其他线程竞争成功后锁被删除,所以我们使用lua脚本处理redis保证原子性操作

1
2
3
4
5
6
7
8
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

基于setnx实现的分布式锁存在的问题

重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。

不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。

**超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患

主从一致性: 如果Regis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。

这个时候我们可以使用redission解决

Redission

使用

引入依赖

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>

配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class RedissonConfig {

@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.150.101:6379")
.setPassword("123321");
// 创建RedissonClient对象
return Redisson.create(config);
}
}

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Resource
private RedissionClient redissonClient;

@Test
void testRedisson() throws Exception{
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}

}
}

可重入性

redission使用hash结构表示锁,大key表示锁是否存在,小key表示当前这把锁被哪个线程持有

锁重试和WatchDog机制

通过追溯tryLock的源码可以看到,redission尝试获取锁也是使用lua脚本进行调用

img

img

阅读上述代码,我们发现,redission在获取锁失败后,会订阅锁释放的信号,并且在等待时间超过限制后主动释放并且取消订阅,如果第二次还是没有成功获取锁,那么将进入死循环,直到成功获取锁对象或等待时间超时

img

而为了防止线程阻塞导致的超时释放,通过追踪源码我们发现在线程成功竞争到锁后会提交一个回调函数

img

img

在回调函数中,每隔watchDog默认锁释放时间的三分之一,就会刷新锁的释放时间,只要线程没有宕机,那么锁就会一直刷新

主从一致性

为了提高redis的可用性,我们会搭建集群,如果主机没来得及将锁同步给从机就宕机了,那么下一个主节点选出来后,就会造成数据不一致的情况,为了解决这个问题redission提出了MultiLock锁,不再使用主从,只有全部节点的锁都获取成功才算获取锁成功