面试题:Redis如何实现分布式锁!

kevinaoc
发布于 2022-4-10 16:45
浏览
0收藏

前言

之前的个人网站已经下线了,https://upheart.cn/,维护太花时间了,之后会把网站内容全部搬到公众号中,内容也会比网站内容更丰富,更全面,带大家真正的吊打面试官!

文章内容会尽量少废话,多干货!

开始吧!

为什么需要分布式锁

使用分布式锁的目的,无外乎就是保证同一时间只有一个客户端可以对共享资源进行操作

我们在分布式应用进行逻辑处理时经常会遇到并发问题。

比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题,因为读取和保存状态这两个操作不是原子的。

这个时候就要使用到分布式锁来限制程序的并发执行。redis作为一个缓存中间件系统,就能提供这种分布式锁机制,

其本质就是在redis里面占一个坑,当别的进程也要来占坑时,发现已经被占领了,就只要等待稍后再尝试

一般来说,生产环境可用的分布式锁需要满足以下几点:

  • 互斥性,互斥是锁的基本特征,同一时刻只能有一个线程持有锁,执行临界操作;
  • 超时释放,超时释放是锁的另一个必备特性,可以对比 MySQL InnoDB 引擎中的 innodb_lock_wait_timeout配置,通过超时释放,防止不必要的线程等待和资源浪费;
  • 可重入性,在分布式环境下,同一个节点上的同一个线程如果获取了锁之后,再次请求还是可以成功;

实现方式

使用SETNX实现

SETNX的使用方式为:SETNX key value,只在键key不存在的情况下,将键key的值设置为value,若键key存在,则SETNX不做任何动作。

boolean result = jedis.setnx("lock-key",true)== 1L;
if  (result) {
    try {
        // do something
    } finally {
        jedis.del("lock-key");
    }
 }

这种方案有一个致命问题,就是某个线程在获取锁之后由于某些异常因素(比如宕机)而不能正常的执行解锁操作,那么这个锁就永远释放不掉了。

为此,我们可以为这个锁加上一个超时时间

执行 SET key value EX seconds 的效果等同于执行 SETEX key seconds value

执行 SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value

String result = jedis.set("lock-key",true, 5);
if ("OK".equals(result)) {
    try {
        // do something
    } finally {
        jedis.del("lock-key");
    }
}

方案看上去很完美,但实际上还是会有问题

试想一下,某线程A获取了锁并且设置了过期时间为10s,然后在执行业务逻辑的时候耗费了15s,此时线程A获取的锁早已被Redis的过期机制自动释放了

在线程A获取锁并经过10s之后,改锁可能已经被其它线程获取到了。当线程A执行完业务逻辑准备解锁(DEL key)的时候,有可能删除掉的是其它线程已经获取到的锁。

所以最好的方式是在解锁时判断锁是否是自己的,我们可以在设置key的时候将value设置为一个唯一值uniqueValue(可以是随机值、UUID、或者机器号+线程号的组合、签名等)。

当解锁时,也就是删除key的时候先判断一下key对应的value是否等于先前设置的值,如果相等才能删除key

String velue= String.valueOf(System.currentTimeMillis())
String result = jedis.set("lock-key",velue, 5);
if ("OK".equals(result)) {
    try {
        // do something
    } finally {
       //非原子操作
       if(jedis.get("lock-key")==value){
          jedis.del("lock-key");
        }    
    }
}

这里我们一眼就可以看出问题来:GET和DEL是两个分开的操作,在GET执行之后且在DEL执行之前的间隙是可能会发生异常的。

如果我们只要保证解锁的代码是原子性的就能解决问题了

这里我们引入了一种新的方式,就是Lua脚本,示例如下:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

其中ARGV[1]表示设置key时指定的唯一值。

由于Lua脚本的原子性,在Redis执行该脚本的过程中,其他客户端的命令都需要等待该Lua脚本执行完才能执行。

确保过期时间大于业务执行时间

为了防止多个线程同时执行业务代码,需要确保过期时间大于业务执行时间

增加一个boolean类型的属性isOpenExpirationRenewal,用来标识是否开启定时刷新过期时间

在增加一个scheduleExpirationRenewal方法用于开启刷新过期时间的线程

加锁代码在获取锁成功后将isOpenExpirationRenewal置为true,并且调用scheduleExpirationRenewal方法,开启刷新过期时间的线程

解锁代码增加一行代码,将isOpenExpirationRenewal属性置为false,停止刷新过期时间的线程轮询

Redisson实现

获取锁成功就会开启一个定时任务,定时任务会定期检查去续期

该定时调度每次调用的时间差是internalLockLeaseTime / 3,也就10秒

默认情况下,加锁的时间是30秒.如果加锁的业务没有执行完,那么到 30-10 = 20秒的时候,就会进行一次续期,把锁重置成30秒

RedLock

在集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生

Redlock算法就是为了解决这个问题

使用 Redlock,需要提供多个 Redis 实例,这些实例之前相互独立没有主从关系。同很多分布式算法一样,redlock 也使用大多数机制

加锁时,它会向过半节点发送 set指令,只要过半节点 set 成功,那就认为加锁成功。释放锁时,需要向所有节点发送 del 指令。不过 Redlock 算法还需要考虑出错重试、时钟漂移等很多细节问题,同时因为 Redlock 需要向多个节点进行读写,意味着相比单实例 Redis 性能会下降一些

Redlock 算法是在单 Redis 节点基础上引入的高可用模式,Redlock 基于 N 个完全独立的 Redis 节点,一般是大于 3 的奇数个(通常情况下 N 可以设置为 5),可以基本保证集群内各个节点不会同时宕机。

假设当前集群有 5 个节点,运行 Redlock 算法的客户端依次执行下面各个步骤,来完成获取锁的操作

  • 客户端记录当前系统时间,以毫秒为单位;
  • 依次尝试从 5 个 Redis 实例中,使用相同的 key 获取锁,当向 Redis 请求获取锁时,客户端应该设置一个网络连接和响应超时时间,超时时间应该小于锁的失效时间,避免因为网络故障出现的问题;
  • 客户端使用当前时间减去开始获取锁时间就得到了获取锁使用的时间,当且仅当从半数以上的 Redis 节点获取到锁,并且当使用的时间小于锁失效时间时,锁才算获取成功;
  • 如果获取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间,减少超时的几率;
  • 如果获取锁失败,客户端应该在所有的 Redis 实例上进行解锁,即使是上一步操作请求失败的节点,防止因为服务端响应消息丢失,但是实际数据添加成功导致的不一致。

也就是说,假设锁30秒过期,三个节点加锁花了31秒,自然是加锁失败了

在 Redis 官方推荐的 Java 客户端 Redisson 中,内置了对 RedLock 的实现

https://redis.io/topics/distlock

https://github.com/redisson/redisson/wiki

RedLock问题:

RedLock 只是保证了锁的高可用性,并没有保证锁的正确性

RedLock 是一个严重依赖系统时钟的分布式系统

Martin 对 RedLock 的批评:

  • 对于提升效率的场景下,RedLock 太重。
  • 对于对正确性要求极高的场景下,RedLock 并不能保证正确性。

本文转载自微信公众号「月伴飞鱼」

原文链接:https://mp.weixin.qq.com/s/VHRO754P2jIjb3H4xhU4hg.

分类
收藏
回复
举报
回复
    相关推荐