目录
五、分布式锁失效问题(连锁,multilock,redlock)
一、什么是分布式锁?
在传统的java进程中,我们常常用Synchronized三、详解Synchronized-CSDN博客或者ReentrantLock五、详解ReentrantLock-CSDN博客来对临界区进行加锁,防止多个线程之间并行访问,导致数据读写异常。但是这种锁的粒度仅限于当前jvm中,在工业生产环境下,往往一个web项目会部署多台机器,也就意味着会有多个jvm。那么这几个jvm是独立的,这就导致上述的锁失效。
分布式锁是一种在分布式系统环境下,通过多个节点对共享资源进行访问控制的机制。在分布式系统中,为了防止多个节点同时操作同一份数据,从而导致数据的不一致,需要使用分布式锁来确保在同一时刻只有一个节点能够操作数据。
二、常见的分布式锁实现方式
分布式锁的实现方式有很多种,常见的有基于数据库的分布式锁、基于缓存(如Redis)的分布式锁、基于ZooKeeper的分布式锁等。
而在互联网工业生产环境中,由于对性能的要求,基本上都在使用Redis来进行分布式锁。
三、Redis分布式锁的实现
3.1 setnx和expire命令
在redis的命令 一、Redis常用命令-CSDN博客 中,我们学到过setnx命令。setnx命令常用于实现分布式锁。具体来说,多个客户端可以尝试使用setnx命令来创建相同的锁(key),只有一个客户端的操作会成功,成功的客户端将获得锁,失败的客户端将不会获得锁。这样可以确保同时只有一个客户端能够操作被锁住的资源,避免并发操作导致的数据不一致。
但是setnx的命令没有设置超时时间,因此当前的key会一直存放在redis中,除非有线程主动的del key。因此,在添加setnx key 的时候,需要在后面设置expire key 过期时间。
如果 setnx key 和 expire key 中间,服务挂了,那么当前key将一直存在Redis,导致其他线程无法加锁。
因此,可以使用set命令,因为set命令后面有很多参数:
set key value ex 10 nx。这样,设置值和设置过期时间将是一个原子的操作。
3.2 setnx分布式锁的缺点
1、setnx需要手动释放,即使设置了过期时间,这个时间也没有一个标准。如果时间过短,其他线程就可以获取锁,如果时间太长,其他线程就得等。
2、不支持重入。相同的线程重复进行加锁的时候,会被阻塞住。
3、锁误删。这个和redis过期时间有关,例如:a线程加了锁,然后执行逻辑,由于redis的过期时间很短,a还没执行完,锁就过期了。此时b线程加锁成功,开始处理逻辑。那么这个时候a线程执行结束,执行finally的锁释放。这就导致a线程释放了b线程加的锁。
下面给出一个简单的demo:
class LockTest{
private RedisClient redisClient;
private static final UUID = UUID.uuid();
//加锁
public boolean tryLock(String lockName, long expireTime){
String value = UUID + System.currentThread(); //防止锁误删
return redisClient.set(lockName, value, expireTime); //设置redis的key和value以及过期时间
}
//解锁
public void unLock(String lockName){
String targetValue = UUID + System.currentThread();
Strign value = redisClient.get(lockName);
if(targetValue.equals(value)){
redisClient.delete(locaName);
}
}
}
四、Redisson分布式锁
4.1 lua脚本
在上述demo中,尤其是在unlock的方法中,redisClient.get() 和redisClient.delete()方法是隔开的。如果get方法之后,线程被挂起,那么delete()方法还是可能被误删。
根本原因就是读写操作不是原子的。如果get和delete处于原子操作中,那么就不会出现误删的现象。
在redis中,支持lua脚本语言,而lua脚本在redis执行过程是一个原子的,也就意味着,要么整个lua脚本全执行,要么全不执行。
具体的lua脚本的语法这里不做多说明,大家可以自行查询。
这里将上面的delete方法进行修改:
public void unLock(String lockName){
String targetValue = UUID + System.currentThread();
redisClient.eval("local id = redis.call('get', KEYS[1])
local targetId = ARGV[1]if(id == targetId) then
return redis.call('del', KEYS[1])end return 0",
Lists.newArrayList(lockName), Lists.newArrayList(targetValue));
}
4.2 源码解析
4.2.1 redisson的demo
先配置redisson的依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.x.x</version>
</dependency>
创建config对象,配置要连接的redis
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
RedissonClient redisson = Redisson.create(config);
之后开始进行api的使用。
4.2.2 redisson分布式锁
如何实现可重入
当利用redisson进行lock的时候,实际上是可以进行可重入的,那么redisson是如何进行可重入的呢?我们可以先参考一下jvm的锁:
在reentrantlock中,是怎么实现可重入的呢?
1,判断是否加锁
2,判断加锁的线程是否为自己
3,如果是自己,则计数+1,然后放行
那么同样的在redisson中,我们也应该按照这个步骤来,因此我们需要在redis的中记录 线程id和重入的count。因此需要采用map结构来实现。
key | map | |
lock_name | field | value |
thread_id | count |
那么在上述描述中,需要经过3步骤才能加锁,如果我们利用java来操作redis,很可能导致中途中断的问题,导致数据不一致。因此我们需要使用lua脚本。下面给一个demo:
local lock_name = KEYS[1]; //获取锁的名称
local thread_name = ARGV[1]; //获取线程名称
local expire_time = ARGV[2]; //获取超时时间
//先判断锁是否存在
if(redis.call('exists', lock_name) == 0) then
//如果不存在当前锁,那么就可以直接加锁, 其中field为当前线程id
redis.call('hset', lock_name, thread_name, 1);
//设置过期时间
redis.call("expire", lock_name, expire_time);
return 1; //加锁成功
end
//如果有锁,判断当前加锁的线程是否为自己
if(redis.call("hexists", lock_name, thread_name) == 1) then
//如果是自己,则将value的值+1
redis.call("hincrby", lock_name, thread_name, '1');
redis.call("expire", lock_name, expire_time);
return 1; //重入成功
end;
return 0; //加锁失败
如何实现超时时间过短或过长 (看门狗🐶)
在上面的lua脚本中,实现了加锁的过程。但是超时时间是自定义的。那么这时间过长或者过短都会有影响。我们从多线程的视角去分析。
1、 存在A.B.C三个线程。这个三个线程都去加锁,也就是执行上面lua脚本。那么其中就会只有一个线程拿到锁,其他两个线程获取不到锁。我们假设A线程获取了锁,BC线程没有获取锁。
2、针对A线程来说,拿到锁了,就直接返回,去处理自己的事情。
3、但是针对BC线程,没有拿到锁,那么BC线程就应该等待,然后过一段时间在重试。那么BC需要等待多长时间呢? 很明显,BC不能一直等待,最好的等待时间就是锁有效期时间。因此上面的lua脚本应该修改,即如果获取锁成功,返回nil。如果获取锁失败,返回ttl。这样bc线程就等待ttl。
4、如果A线程提前释放了锁,也就是锁超时时间设置的比较大,A处理完了,那么释放锁。这次BC还在等待呢,这就浪费了资源。此时redis通过消息队列的方式通知BC线程唤醒,从而继续争夺锁。
5、如果A线程执行比较长,锁过期有设置的比较短。此时BC已经超时唤醒了,但是A还没结束。按理说bc应该继续等待,但是redis中的锁已经过期了,那么BC怎么能继续等待呢? 答案就是不让redis中的锁过期,利用watchdog。
6、在第2步的时候,A线程获取锁之后,在返回之前。会注入一个Timer,这个Timer经过一段时间(设置的超时时间/3,注意,如果手动设置超时时间,看门狗逻辑不生效,只有非手动设置,看门狗逻辑生效,默认超时时间30秒)就会来检查一下锁。如果A的锁存在,说明A线程还没释放锁,那么Timer就会将锁的过期重新设置,然后再继续添加一个新的Timer,重复操作。这样,会一直弥补锁的时间。这样BC就会一直等待。
五、分布式锁失效问题(连锁,multilock,redlock)
分布式锁的失效指的是,当我针对主redis机器进行加锁之后,从redis节点没有同步到锁信息。导致多个线程都加锁成功。
1、当主从发生延迟的时候,就会导致锁失效。
2、当主节点写入成功之后,宕机,也会导致锁失效。
那么redis是如何解决这个问题的呢?坦白讲,redis没有解决【主从之间的原因】导致锁失效的情况。因为根本解决不了,因为网络延迟,主宕机都是人为不可控。
但是redis从另一个角度进行了优化,既然主从存在延迟,和数据不一致问题。那么redis就不进行主从架构了,而是利用多节点node进行处理。这个有点类似于zk。
当client向redis写入锁的时候,会向所有redis节点都写入,如果超过一半的redis写入成功,那么就算加锁成功。但是这就需要部署多台redis,且加锁的时候,需要写入多个redis,这就导致效率问题。那这样一来还不如使用zk。