分布式锁需要具备的特性
- 互斥性: 任意时刻,只有一个客户端能持有锁。
- 锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
- 可重入性:一个线程如果获取了锁之后,可以再次对其请求加锁。
- 高性能和高可用:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
- 安全性:锁只能被持有的客户端删除,不能被其他客户端删除
Redis实现分布式锁方案有哪些?
- SETNX + EXPIRE
- SETNX + value(系统时间+过期时间)
- 使用Lua脚本(包含SETNX + EXPIRE两条指令)
- SET的扩展命令(SET EX PX NX)
- SET EX PX NX + 校验唯一随机值,再释放锁
这几种方案都有一些问题。不具备原子性、没有保存持有者的唯一标识、存在业务未执行完,锁却到期释放等问题。
Redisson RLock
Redisson RLock简介、可重入锁解释
Redisson的watch dog(看门狗)机制解决了业务未执行完,锁却到期释放的问题。Redission 基于高性能异步无锁Java Redis客户端和Netty框架。
redisson 基于redis集群模式实现的分布式可重入锁是使用的hash数据结构,key是自定义的,map key是客户端某个线程的唯一标识,map value就是重入次数。使用的lua脚本操作 加锁、重置失效时间、解锁
可重入锁:也叫做递归锁。指的是同一线程外层函数获得锁之后 ,内层递归函数仍然可以获取该锁,但不受影响。
加锁流程
- 线程获取锁成功则执行lua脚本,保存数据到redis。
- 如果获取失败: 一直通过while循环尝试获取锁(可自定义等待时间,超时后返回失败),获取成功后,执行lua脚本,保存数据到redis数据库。
- Redisson提供的分布式锁支持锁自动续期,如果线程业务没有执行完,那么redisson会自动给redis中的目标key延长超时时间,这就是Watch Dog 机制
Wath Dog的自动延期机制
- 线程加锁成功后,就会启动一个watch dog看门狗,它是一个后台线程,是业务线程的守护线程。
- rlock的超时时间默认时30秒,看门狗会每隔10秒(超时时间/3)检查一下,如果线程还持有锁,就延长锁生存时间(恢复到30秒)。
- 看门狗的续期时间可以通过修改Config.lockWatchdogTimeout来另行指定。
详细加锁(lock)原理(Redis luster模式)
- 加锁、重入锁
- 加锁的时候会根据的锁名字(
redissonClient.getLock("Lock_Name")),计算出来属于redis集群中的哪个槽(redis集群槽16384个)。 - 然后从集群中找出这个槽所在的master机器,接着就是根据clientId(创建客户端的时候的一个UUID)拼接上线程id生成一个key。
- 接着使用一段lua脚本加锁,内部是使用的hash结构,key是锁名字,然后对应的map key就是clientId+线程id,map value就是1,这个1是与可重入有关的,还会设置过期时间,默认是30s。
- 如果锁不存在(
exists Lock_Name == 0)。就会执行hset Lock_Name clientId+线程id 1和pexpire Lock_Name 30000ms这两个命令,最后返回null,首次加锁逻辑就是这样的。 - 如果锁存在,就自增1,重置一下过期时间是30s,最后return null。这就是重入机制(可重入锁)
--KEYS[1] 锁名字,也就是"Lock_Name" --ARGV[2] clientId+线程id --ARGV[1] 是过期时间,默认是30s --如果锁不存在 if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; --如果锁存在 if (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]); - 加锁的时候会根据的锁名字(
- 创建定时任务(watch dog 看门狗)
--如果这个锁还存在,就重置一下过期时间,然后return 1。 if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;- 加锁成功之后,会创建一个定时任务(watch dog 看门狗),每10s(自定义过期时间/3)执行一次,不断为这个锁续期,看看如果这个锁还存在的话,就重置一下过期时间为30s(自定义过期时间)。
- 互斥机制,其他线程自旋,等待锁
- 这个时候其他线程或者是其他客户端尝试获取这个redis分布式锁,就会失败,然后就会返回一个ttl,这个ttl就是过期时间(
return redis.call('pttl', KEYS[1]))。它就会循环等待这个ttl时间过后,然后再尝试加下锁,如果不行再返回ttl,这个时候再等待,就是这样一直循环。
- 这个时候其他线程或者是其他客户端尝试获取这个redis分布式锁,就会失败,然后就会返回一个ttl,这个ttl就是过期时间(
详细解锁(unlock)原理(Redis luster模式)
- 跟加锁一样,根据的锁名字(
redissonClient.getLock("Lock_Name")),计算出来属于redis集群中的哪个槽 - 然后从集群中找出这个槽所在的master,根据clientId+线程id作为key,使用lua脚本解锁。
- 如果map的值大于0,说明加锁不止一次,也就是加了重入锁,这个时候就会重置一下过期时间。如果小于等于0,就说明这个锁要释放了,这个时候就会删除这个key,并发布一个锁销毁的通知给那些其他未获取到锁的线程订阅者(
publish命令)。 - 解锁成功后销毁定时任务(watch dog)
--KEYS[1]是锁名字
--KEYS[2]是 redisson_lock__channel:{锁名字} 这么一个东西,他其实也是个key,可以理解为主题, 发布订阅用的
--ARGV[1]是解锁的标识符
--ARGV[2] 过期时间
--ARGV[3] clientId+线程Id
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;
Java代码
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.9.1</version>
</dependency>
public void fun() throws Exception{
RLock lock = redissonClient.getLock("redisKey");// 拿锁失败时会不停的重试
// 具有Watch Dog 自动延期机制 默认续30s 每隔30/3=10 秒续到30s
lock.lock();
// 尝试拿锁10s后停止重试,返回false 具有Watch Dog 自动延期机制 默认续30s
boolean res1 = lock.tryLock(10, TimeUnit.SECONDS);
// 没有Watch Dog ,10s后自动释放
lock.lock(10, TimeUnit.SECONDS);
// 尝试拿锁100s后停止重试,返回false 没有Watch Dog ,10s后自动释放
boolean res2 = lock.tryLock(100, 10, TimeUnit.SECONDS);
lock.unlock();
}
Redisson锁的缺点:主从同步、锁遇到故障转移
在极端情况下
客户端A加锁成功后,master节点数据会异步复制到slave节点,此时当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点;
客户端B再次加锁,在新的master节点上加锁也也会成功,这个时候客户端B也会认为加锁成功,出现两个节点同时持有一把锁的问题;
就出现脏数据,丧失了互斥性。
解决这个问题,就用到RedLock算法。
RedLock算法(Redis Distributed Lock)
概念
普通的redis分布式锁,是在redis集群中根据hash算法选择一台redis实例创建一个锁就可以了
RedLock算法思想,不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁(n / 2 + 1),必须在大多数redis节点上都成功创建锁,才能算这个整体的RedLock加锁成功,避免说仅仅在一个redis实例上加锁。从而避免A加锁成功后master节点宕机导致B成功加锁到新的master节点上的问题
RedLock缺点、存在的问题、RedLock已被弃用
- 红锁是很少使用的。这是因为使用了红锁后会影响高并发环境下的性能。
- 使用红锁时,需要提供多套Redis的主从部署架构,同时,这多套Redis主从架构中的Master节点必须都是独立的,相互之间没有任何数据交互。
- 使用红锁后,当加锁成功的RLock个数不超过总数的一半时,会返回加锁失败,即使在业务层面任务加锁成功了,但是红锁也会返回加锁失败的结果。 实际场景中,一般都是要保证Redis集群的可靠性,而不使用RedLock算法实现的分布式锁
Redisson RedLock 是基于联锁 MultiLock 实现的,但是使用过程中需要自己判断 key 落在哪个节点上,对使用者不是很友好。Redisson RedLock 已经被弃用。直接使用普通的加锁,会基于 wait 机制将锁同步到从节点,不能保证绝对的一致性。是最大限度的保证一致性。
实现逻辑(简版)
场景是假设有一个redis cluster,有3个redis master实例。然后获取分布式锁:
- 获取当前时间戳,单位是毫秒
- 按顺序向每个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,假设超时时间是50ms)。如果超时,跳过该master节点,尽快去尝试下一个master节点。
- 客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(10s > 30ms+40ms+50ms+4m0s+50ms) 注意事项:
- 如果取到了锁,key的真正有效时间就变啦,需要减去获取锁所使用的时间。
- 如果获取锁失败(没有在至少N/2+1个master实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止漏掉)。
- 线程A创建了一把分布式锁,线程B就得不断轮询去尝试获取锁。
不常用的几种分布式锁方案
SETNX + EXPIRE(不是原子操作,死锁风险)
setnx和expire两个命令分开了,「不是原子操作」。如果执行完setnx加锁,expire命令设置超时时间时失败,发生异常锁得不到释放,会造成死锁
SETNX + value(系统时间+过期时间)
为了解决发生异常锁得不到释放的场景,过期时间客户端生成,把过期时间放到setnx的value值里面。
存在问题:
- 多客户端时间必须同步,但是时间流速不一定相同。
- 锁过期时间可能被其他客户端修改。
- 锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁。
使用Lua脚本(包含SETNX + EXPIRE两条指令)
Lua脚本来保证setnx和expire两条指令的原子性。
SET的扩展命令(SET EX PX NX)
SET key value[EX seconds][PX milliseconds][NX|XX]也是原子性的。
- NX :表示key不存在的时候,才能set成功,保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
- EX seconds :设定key的过期时间,时间单位是秒。
- PX milliseconds: 设定key的过期时间,单位为毫秒
- XX: 仅当key存在时设置值
存在问题:锁没有保存持有者的唯一标识
SET EX PX NX + 校验唯一随机值,再释放锁
给value值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下。
用lua脚本执行判断是不是当前线程加的锁 和 释放锁两个操作保证原子性。