Redis--分布式锁、Redisson RLock、RedLock

1,934 阅读10分钟

分布式锁需要具备的特性

  • 互斥性: 任意时刻,只有一个客户端能持有锁。
  • 锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
  • 可重入性:一个线程如果获取了锁之后,可以再次对其请求加锁。
  • 高性能和高可用:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
  • 安全性:锁只能被持有的客户端删除,不能被其他客户端删除

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模式)

  1. 加锁、重入锁
    1. 加锁的时候会根据的锁名字(redissonClient.getLock("Lock_Name")),计算出来属于redis集群中的哪个槽(redis集群槽16384个)。
    2. 然后从集群中找出这个槽所在的master机器,接着就是根据clientId(创建客户端的时候的一个UUID)拼接上线程id生成一个key。
    3. 接着使用一段lua脚本加锁,内部是使用的hash结构,key是锁名字,然后对应的map key就是clientId+线程id,map value就是1,这个1是与可重入有关的,还会设置过期时间,默认是30s。
    4. 如果锁不存在(exists Lock_Name == 0)。就会执行hset Lock_Name clientId+线程id 1pexpire Lock_Name 30000ms这两个命令,最后返回null,首次加锁逻辑就是这样的。
    5. 如果锁存在,就自增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]);
    
  2. 创建定时任务(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(自定义过期时间)。
  3. 互斥机制,其他线程自旋,等待锁
    • 这个时候其他线程或者是其他客户端尝试获取这个redis分布式锁,就会失败,然后就会返回一个ttl,这个ttl就是过期时间(return redis.call('pttl', KEYS[1]))。它就会循环等待这个ttl时间过后,然后再尝试加下锁,如果不行再返回ttl,这个时候再等待,就是这样一直循环。

详细解锁(unlock)原理(Redis luster模式)

  1. 跟加锁一样,根据的锁名字(redissonClient.getLock("Lock_Name")),计算出来属于redis集群中的哪个槽
  2. 然后从集群中找出这个槽所在的master,根据clientId+线程id作为key,使用lua脚本解锁。
  3. 如果map的值大于0,说明加锁不止一次,也就是加了重入锁,这个时候就会重置一下过期时间。如果小于等于0,就说明这个锁要释放了,这个时候就会删除这个key,并发布一个锁销毁的通知给那些其他未获取到锁的线程订阅者(publish命令)。
  4. 解锁成功后销毁定时任务(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实例。然后获取分布式锁:

  1. 获取当前时间戳,单位是毫秒
  2. 按顺序向每个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,假设超时时间是50ms)。如果超时,跳过该master节点,尽快去尝试下一个master节点。
  3. 客户端使用当前时间减去开始获取锁时间(即步骤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脚本执行判断是不是当前线程加的锁释放锁两个操作保证原子性。