Redlock:Redis集群分布式锁

前言

分布式锁是一种非常有用的技术手段。实现高效的分布式锁有三个属性需要考虑:
● 安全属性:互斥,不管什么时候,只有一个客户端持有锁
● 效率属性A:不会死锁
● 效率属性B:容错,只要大多数redis节点能够正常工作,客户端端都能获取和释放锁。

普通版:单机redis分布式锁
说道Redis分布式锁大部分人都会想到: setnx+lua或者set+lua,加上过期时间
大多都是使用的下面的keyset方法,具体实现过程这里就不再概述。

  • 实现比较轻,大多数时候能满足需求;因为是单机单实例部署,如果redis服务宕机,那么所有需要获取分布式锁的地方均无法获取锁,将全部阻塞,需要做好降级处理。
  • 当锁过期后,执行任务的进程还没有执行完,但是锁因为自动过期已经解锁,可能被其它进程重新加锁,这就造成多个进程同时获取到了锁,这需要额外的方案来解决这种问题。
  • 在集群模式时由于复制延迟,以及主节点挡掉,会造成锁丢失或者解锁延迟现象

事实上这类琐最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出 现锁丢失的情况:

  • 在Redis的master节点上拿到了锁;
  • 但是这个加锁的key还没有同步到slave节点;
  • master故障,发生故障转移,slave节点升级为master节点;
  • 导致锁丢失。

正因为如此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。笔者认为,Redlock也是Redis所有分布式锁实现方式中唯一能让面试官高潮的方式。

基于单Redis节点的分布式锁的算法就描述完了。这里面有好几个问题需要重点分析一下。

  • 首先第一个问题,这个锁必须要设置一个过期时间。否则的话,当一个客户端获取锁成功之后,假如它崩溃了,或者由于发生了网络分割(network partition)导致它再也无法和Redis节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得锁了。antirez在后面的分析中也特别强调了这一点,而且把这个过期时间称为锁的有效时间(lock validity time)。获得锁的客户端必须在这个时间之内完成对共享资源的访问。

  • 第二个问题,第一步获取锁的操作,网上不少文章把它实现成了两个Redis命令:

SETNX resource_name my_random_value
EXPIRE resource_name 30

虽然这两个命令和前面算法描述中的一个SET命令执行效果相同,但却不是原子的。如果客户端在执行完SETNX后崩溃了,那么就没有机会执行EXPIRE了,导致它一直持有这个锁。

  • 第三个问题,也是antirez指出的,设置一个随机字符串my_random_value是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁。假如获取锁时SET的不是一个随机字符串,而是一个固定值,那么可能会发生下面的执行序列:
    客户端1获取锁成功。
    客户端1在某个操作上阻塞了很长时间。
    过期时间到了,锁自动释放了。
    客户端2获取到了对应同一个资源的锁。
    客户端1从阻塞中恢复过来,释放掉了客户端2持有的锁。
    之后,客户端2在访问共享资源的时候,就没有锁为它提供保护了。

  • 第四个问题,释放锁的操作必须使用Lua脚本来实现。释放锁其实包含三步操作:’GET’、判断和’DEL’,用Lua脚本来实现能保证这三步的原子性。否则,如果把这三步操作放到客户端逻辑中去执行的话,就有可能发生与前面第三个问题类似的执行序列:
    客户端1获取锁成功。
    客户端1访问共享资源。
    客户端1为了释放锁,先执行’GET’操作获取随机字符串的值。
    客户端1判断随机字符串的值,与预期的值相等。
    客户端1由于某个原因阻塞住了很长时间。
    过期时间到了,锁自动释放了。
    客户端2获取到了对应同一个资源的锁。
    客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。
    实际上,在上述第三个问题和第四个问题的分析中,如果不是客户端阻塞住了,而是出现了大的网络延迟,也有可能导致类似的执行序列发生。

前面的四个问题,只要实现分布式锁的时候加以注意,就都能够被正确处理。但除此之外,antirez还指出了一个问题,是由failover引起的,却是基于单Redis节点的分布式锁无法解决的。正是这个问题催生了Redlock的出现。

更多专业性问题参考:
https://www.jianshu.com/p/dd66bdd18a56

集群分布式锁

在redis集群模式下创建锁和解锁的方案,用到的redis命令依然和普通模式一样,唯一不同的在于集群模式下的数据清理方式,基本命令如下。

SET key value [EX seconds] [PX milliseconds] [NX|XX]
Set the string value of a key
SET 指令可以将字符串的value和key绑定在一起。
EX seconds:设置key的过时时间,单位为秒。
PX milliseconds:设置key的过期时间,单位为毫秒。
NX:(if Not eXist)只有键key不存在的时候才会设置key的值
XX:只有键key存在的时候才会设置key的值

NX通常用于实现锁机制,X

lua脚本

- 获取锁(unique_value可以是UUID等)
SET key_name unique_value NX PX 30000
- 释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get",KEYS[1]) == ARGV[1] then
 return redis.call("del",KEYS[1])
else
return 0
end

Redlock实现

antirez提出的redlock算法大概是这样的:
在Redis的分布式环境中,我们假设最孤立现象(最苛刻环境下):有N个Redis master。这些节点完全互相独立(正常集群不会做成这么孤立),不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
在这里插入图片描述
这里把上图中的各个redis主从连线去掉,就变成各个独立的集群了(实现孤立场景:集群之间掉线不通等极端情况)

为了取到锁,客户端应该执行以下操作:

  • 获取当前Unix时间,以毫秒为单位。
  • 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
  • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

实现代码

POM依赖

<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
 <groupId>org.redisson</groupId>
 <artifactId>redisson</artifactId>
 <version>3.3.2</version>
</dependency>
Config config = new Config();

config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
 .setMasterName("masterName")
 .setPassword("password").setDatabase(0);

RedissonClient redissonClient = Redisson.create(config);
// 还可以getFairLock(), getReadWriteLock()
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
boolean isLock;
try {
    
    
 isLock = redLock.tryLock();//使用默认方式获取:默认租约时间(leaseTime)是LOCKEXPIRATIONINTERVAL_SECONDS,即30s
 // 因为上一行获取到了,这里获取不到:如果获取到了就500ms, 就认为获取锁失败。10000ms即10s是锁失效时间。
 isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
 if (isLock) {
    
     //如果获取成功
 //TODO if get lock success, do something;

 }
} catch (Exception e) {
    
    

} finally {
    
    
 // 无论如何, 最后都要解锁
 redLock.unlock();
}

key对应唯一值
防止误删除锁:实现分布式锁的一个非常重要的点就是set的value要具有唯一性,redisson的value是怎样保证value的唯一性呢?
答案是UUID+threadId。入口在redissonClient.getLock(“REDLOCK_KEY”),源码在Redisson.java和RedissonLock.java中:

获取锁
设置过期时间:代码为redLock.tryLock()或者redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS),两者的最终核心源码都是下面这段代码,只不过前者获取锁的默认租约时间(leaseTime)是LOCKEXPIRATIONINTERVAL_SECONDS,即30s:

参考

https://redis.io/topics/distlock
https://www.jianshu.com/p/dd66bdd18a56

猜你喜欢

转载自blog.csdn.net/zjcjava/article/details/103025997