大厂面试问题详解:如何用Redis实现分布式锁?

说一个常见的面试问题:

一个简单的答案是使用Redission客户端。Redis中的锁定方案是Redis分布式锁定的详细方案。

那么,为什么Redission中的锁方案是完美的呢?

正好我有丰富的使用Redis做分布式锁的经验。在实际工作中,我也探索过很多用Redis做分布式锁的方案,血泪教训无数。

所以,在说Redission锁为什么完美之前,我先给大家展示一下我用Redis做分布式锁时遇到的问题。

我曾经用Redis做分布式锁,解决一个用户抢优惠券的问题。这个业务需求是这样的:用户收到一张优惠券,优惠券的数量必须减一。如果优惠券被抢,则不允许用户再次抢。

实现的时候,先从数据库中读取优惠券的数量来判断。当优惠券大于0时,允许领取优惠券。然后,优惠券的数量减少一个,并写回数据库。

当时由于请求量大,我们用了三台服务器做分流。

这时就会出现一个问题:

如果其中一个服务器上的应用A获得了优惠券数量,由于处理相关的业务逻辑,它没有及时更新数据库中的优惠券数量;当应用程序A处理业务逻辑时,另一台服务器上的应用程序B更新优惠券的数量。然后,当应用程序A更新数据库中的优惠券数量时,它将覆盖应用程序B更新的优惠券数量..

看到这里,有些人可能想知道为什么这里不直接使用SQL:

原因是没有分布式锁的协调,优惠券数量可能直接为负数。因为目前优惠券数量为1时,如果两个用户同时通过两个服务器请求抢优惠券,都满足优惠券大于0的每一个条件,然后都执行这个SQL语句,优惠券数量直接变成-1。

还有人说可以使用乐观锁定,比如使用下面的SQL:

这样,在一定概率下,很可能数据不会一直更新,导致重试时间长。

所以综合考虑,我们采用Redis分布式锁,防止多个客户端通过互斥的方式同时更新优惠券数量。

当时我们首先想到的是用Redis的setnx命令,其实就是set if not exists的简称。

当密钥设置值成功时,返回1,否则返回0。所以这里设置setnx成功可以表示为获取锁,如果失败则表示已经有锁,可以视为获取锁失败。

如果要释放锁,执行task del指令并删除键。

利用这个特性,我们可以让系统在执行优惠券逻辑之前先执行Redis中的setnx指令。然后根据指令的执行结果,判断是否获得锁。如果获得,则继续执行该业务,执行后使用del指令释放锁。如果你没有得到它,等待一定的时间,并再次得到锁。

乍一看,这一切都没有错,setnx指令的使用也确实达到了预期的互斥效果。

但是,这是建立在所有运行环境都正常的基础上的。一旦运行环境异常,问题就产生了。

想想看,如果持有锁的应用突然崩溃,或者所在的服务器宕机,会发生什么?

这会造成死锁——持有锁的应用程序无法释放锁,其他应用程序根本没有机会获得锁。这会造成巨大的在线事故,我们应该改进方案来解决这个问题。

怎么解决?我们可以看到死锁的根本原因是一旦持有锁的应用程序出现问题,它就不会释放锁。从这个方向考虑,可以在Redis上给key一个到期时间。

在这种情况下,即使有问题,一段时间后也会释放密钥。这能解决问题吗?事实上,每个人都有。

但是,因为setnx指令本身不能设置超时,所以通常有两种方法可以做到这一点:

1.采用lua脚本。使用setnx指令后,expire命令用于设置密钥的过期时间。

2.直接使用set(key,value,NX,EX,timeout)指令,同时设置锁和超时。

以上两种方法都可以。

释放锁脚本有两种方法,直接调用Redis的del指令即可。

到目前为止,我们的锁不仅起到了互斥的效果,而且不会因为某些持有锁的系统出现问题而导致死锁。这样完美吗?

假设有这样一种情况,持有锁的应用程序超过了我们设置的超时怎么办?会有两种情况:

第一种情况很正常。因为毕竟你有加班,钥匙正常清零也是顺理成章的。

但最可怕的是第二种情况,set键依然存在。这是什么意思?说明现有密钥是由另一个应用程序设置的。

这时如果持有锁的应用超时调用del指令删除锁,就会误删除别人设置的锁,直接导致系统业务出现问题。

因此,为了解决这个问题,我们需要继续对Redis脚本进行修改...毁了它,累了...

首先,我们应该让应用程序设置一个惟一的值,只有应用程序在获得锁时才知道这个值。

通过这个唯一的值,系统可以在释放锁时识别锁是否是自己设置的。如果是自己设置的,解锁,也就是删除钥匙;;如果不是,什么都不做。

脚本如下:

或者

这里ARGV[1]是一个可以传入的参数变量,可以传入一个唯一的值。例如,一个只有你知道的UUID值,或者一个只有你通过雪球算法持有的唯一ID。

释放锁的脚本更改为:

如你所见,从业务的角度来看,无论如何,我们的分布式锁都能满足真正的业务需求。可以互斥,不死锁,不误删别人的锁,只有自己的锁,才可以释放。

一切都是那么美好!!!

不幸的是,还有一个隐患我们没有排除。这个隐患就是Redis本身。

要知道,lua脚本都是用在Redis的情况下。一旦Redis本身出现问题,我们的分布式锁无法使用,分布式锁无法使用,将对业务的正常运行造成重大影响,这是我们无法接受的。

因此,我们需要使Redis高度可用。一般来说,主从式集群用于解决Redis的高可用性问题。

但是,搞主从集群会引入新的问题。主要问题是Redis的主从数据同步延迟。这种延迟会产生一个边界条件:当主服务器上的Redis被锁定,但是锁定数据还没有同步到从服务器时,主服务器就关闭了。随后,从机被提升为主机。此时,没有主机之前设置的锁数据-锁丢失...遗失...遗失...

至此,终于可以引入Redission(开源Redis客户端)了。让我们看看它是如何实现Redis分布式锁的。

在Redission中实现分布式锁的思路很简单。无论是主从集群还是Redis集群,都会逐个执行为集群中的每个Redis设置Redis锁的脚本,即集群中的每个Redis都会包含设置锁的数据。

下面通过一个例子来介绍一下。

假设Redis集群中有五台机器,根据评估,将锁的超时设置为10秒比较合适。

步骤1,我们先来计算集群的总等待时间。集群的总等待时间是5秒(锁的超时时间是10秒/2)。

第二步,用5秒除以5台机器的数量,结果是1秒。这1秒是连接每个Redis的可接受等待时间。

第三步,依次连接五个Redis,执行lua脚本设置锁,然后做出判断:

对了,在很多业务逻辑中,锁的超时其实是不需要的。

例如,在凌晨成批处理的任务可能需要分布式锁,以确保任务不会重复执行。目前,还不清楚这项任务需要多长时间。如果在这里设置了分布式锁的超时,就没有多大意义了。但是,如果没有设置超时,就会导致死锁。

所以解决这个问题的一般方法是,每个带锁的客户端启动一个后台线程,通过执行特定的lua脚本,不断刷新Redis中的key超时,这样在任务完成之前就不会清除key。

脚本如下:

其中,ARGV[1]是一个可以传入的参数变量,表示持有锁的系统的唯一值,即只有持有锁的客户端才能刷新密钥的超时值。

至此,一个完整的分布式锁完成了。将实施方案总结如下:

这种分布式锁满足以下四个条件:

当然,在Redission中的脚本中,为了保证可以重新进入锁,对lua脚本进行了一定程度的修改,现在下面贴出完整的lua脚本。

获取锁的Lua脚本:

对应刷新锁定超时的脚本:

释放锁的相应脚本:

到目前为止,使用Redis作为分布式锁的详细方案已经写好了。

我不仅写了一步一步的坎坷经历,还列了各种问题的细节和解决方法。希望大家看完都能有所收获。

最后提醒一下,用Redis集群做分布式锁是有争议的,需要根据实际情况做出更好的选择和权衡。

原创blogs.com/siyuanwai/p/16011836.html