谈谈对Redis分布式锁的理解


Figure1

在分布式场景下,分布式锁对于大家来说是再熟悉不过的了。什么是分布式锁呢?顾名思义,就是分布式场景下使用的锁。比如一个集群,有三台机器,三台机器会对同一个缓存中的共享资源进行操作,此时就需要加锁,同一时刻只允许集群中的一台实例对这个共享资源进行操作,这个锁就是分布式锁。分布式锁有多种实现方式,比如使用Redis或者Zookeeper等等。今天笔者会根据Redis官方文档关于分布式锁的说明来谈谈使用Redis实现的分布式锁。附上官方文档的链接:https://redis.io/topics/distlock。由于笔者从事Go语言开发,本文将使用Go语言代码来做说明。

我们要分两种情况来讨论分布式锁的实现,第一种情况是单实例Redis,也就是说我们将要操作的Redis集群只有一台实例;另一种是多实例Redis,Redis集群中有多台实例。

我们先从简单的单实例Redis说起。Redis集群只有一台实例的时候,我们就不需要考虑Redis集群内部的问题了,我们只要考虑业务代码中锁的实现就可以了。先看加锁的代码:

<code>SET resource_name my_random_value NX PX 30000/<code>

官方文档中给出了这条Redis命令,用于加锁。

resource_name就是锁的名称,也就是Key。

my_random_value就是对应的Value。这个value在各个client之间必须是唯一的,也就是说,这个value是代表这个client的一个唯一id。为什么必须是唯一的呢?笔者会在下文讲到解锁操作的时候说明原因。

NX就是"SET if Not eXists",这也是最关键的一条指令。如果这个Key不存在,则设置这个Key并返回1;如果存在,则无法设置这个Key并返回0。在分布式锁中,当一个client试图去获取锁,也就是要去SetNx,如果返回了1,那么就表示设置锁成功;而如果返回了0,就代表当前这个锁存在,这个锁存在就意味着它正在被另一个client持有,所以此时客户端就得等待重新去获取锁。

PX就是expire,也就是这个锁的过期时间。为何要设置过期时间呢?我们看下面这段问题代码。

Figure2

在这段代码中,先进行了上锁,然后执行业务逻辑,然后再释放锁。然而,执行业务逻辑的时候,报错了,导致代码crash了,后续的解锁操作也无法执行。造成的后果就是这把锁永远也不会被释放,也就是死锁了。所以,设置锁过期时间的目的就是为了预防这种case,不能说因为业务代码本身的问题导致锁不可用了。设置了过期时间之后,假设遇上的这种问题代码,也不会死锁,因为锁到期了会自动释放。

SET key value NX PX millisecond 这条指令是保证原子性的。早期时候的Redis不支持这条指令,有些代码采用的方法是这样:

<code>SETNX key value
EXPIRE key millisecond/<code>

这种做法不保证原子性,假设第一条指令执行成功了,第二条指令执行失败了,那么后果就是这把锁永远不会过期,也就是死锁。所以说,设置锁操作和设置锁过期时间操作,必须保证原子性。

说完了加锁,我们再来说说解锁。

解锁说起来很简单,就是把那个Key给删了就搞定了。然而,现实应用中肯定不是这么简单的。

先来说说上文中提到的,为什么锁的value必须是代表持有锁的client的唯一id。答案是,为了防止一个client持有的锁,被另一个client给释放了。我们看下边这段问题代码。

Figure3

在这段代码中,先进行上锁,然后在代码退出的时候(defer 相当于 finally)进行解锁。可是如果上锁的时候,刚好网不好,上锁失败了,此时代码就会退出。然而,假设就在这个client上锁失败到代码退出的这一个时间间隔里面,另一个client进行上锁操作并且上锁成功了。另一个client上锁成功之后,这个上锁失败的client执行了解锁操作,也就是DEL Key,那么后果便是,这个上锁失败的client,把刚刚上锁成功的client持有的锁给释放了,这就很尴尬了。为了预防这种bad case,我们要做的就是,让client在释放锁之前,先判断一下,将要释放的锁,是不是自己的锁,如果是自己的锁才能释放,否则,不能动人家的锁。那么如何判断呢,当然就是通过value来判断了。

解锁步骤如下:

client根据锁的key获取value。client判断这个value和自己持有的那个value是否保持一致。如果一致,则断定这把锁是自己的,执行DEL key操作释放锁;如果不一致,则直接退出该流程。

必须注意的是,以上三个步骤必须保证原子性。为什么呢?设想一种bad case。假设这个解锁流程不是原子性的,如下面代码所示。

Figure4

假设此时锁是由这个client持有的,当这个client执行到GET key命令获取到value之后到执行DEL key命令这段时间间隔内,这个key过期了;与此同时,另一个client1试图获取锁并且获取成功了。然后client由于判断到clientId和value是相等的,进行了DEL key操作,造成的后果便是,client把client1刚刚拿到的锁给释放了。所以,释放锁的三个步骤必须保证原子性。如何保证呢?当然是使用LUA脚本。

Figure5

如图Figure5便是使用的LUA脚本。具体代码实现如下图Figure6所示。

Figure6

从获取值,判断值到删除Key,这一流程是保证原子性的。

那么锁的过期时间,我们怎么设定呢?其实不能高度依赖于这个过期时间。为什么这么说呢?因为代码执行的时候,难免会遇到一些不可预料的case导致执行时间变长。假设锁的过期时间设置为两秒,结果业务逻辑不小心执行了四秒,不就出问题了。此时,我们就需要一个monitor,来监控当前业务执行情况,如果发现要超时了,事情还没做完,那么就需要续租这把锁了。也就是说,要重新设置这把锁的过期时间来保障业务逻辑的正确执行。如下图代码所示。

Figure11

笔者写了个demo。代码中fn就是要执行的业务函数逻辑,在执行之前,开启一个新的协程(可理解为线程)来作为monitor。然后执行业务代码。业务逻辑协程和监控协程通过Go语言的通道进行通信。当业务代码执行完成之后,便会通知monitor结束并退出。monitor中,每个一段时间,如果没有收到业务逻辑发来的退出指令,就认为,时间不够用了,便会进行一次续租,延长锁的过期时间。这样就可以保证业务逻辑顺利执行完成。可能有读者会问,如果业务逻辑一直不结束,锁不就一直无法释放了吗?那就是业务逻辑的问题了,不是锁的问题。

上文中讲的Redis锁的实现,主要是针对单Redis实例的情况下的。然而,如果操作的Redis集群中有多个Redis实例,就不是这么简单的了。因为这个时候,我们就要考虑到Redis集群内部可能会出现的问题了。

Redis集群内部有一个主从同步的机制,写入Redis的时候,写的是主机,然后主机会异步地同步写入的内容到从机。假设主机宕机了,剩余的从机就会进行一次主机选举,选举出新的主机来接收请求。

此时设想一种bad case。client1要加锁,向Redis主机写入键值对。就在成功写入主机到主机准备同步给从机的那一瞬间,主机宕机了。此时client1并不知道这件事情,它认为自己已经持有了这把锁了。此时Redis集群内部剩余的从机进行了一次Leader选举,选举出了新的主机。然而,新的主机内部并没有client1刚刚设置的锁,因为原先的主机还没来得及同步消息给从机就挂了。此时,client2试图去获取锁并且成功了,此时的锁写入在了新的主机中。那么后果便是,两个client同时在操作一个共享资源。

这种由于Redis集群本身的问题造成的bad case着实很头疼。Redis官方文档给出的解决方案是: RedLock

Figure7

先说一个前提。Redis使用多实例集群的目的就是保证Redis的高可用性。如果一个Redis节点挂了,由于有多个节点,Redis服务依旧可用。但是,按照传统的主从同步的方式,如上文中所说,会使得Redis分布式锁出现问题。按照RedLock算法的逻辑,为了既保证高可用又能防止多Redis实例造成问题,要做的就是,多个Redis节点相互之间独立,没有主从关系。假设分布式锁要使用五个Redis节点来保证高可用性,那么这五个Redis节点都是主节点,他们相互之间保持独立。

我们接着说RedLock算法,如图Figure7所示。此时我们的Redis集群有N个节点,按照上面所说的,他们都是主节点,相互之间独立。当client进行加锁操作的时候,它会对所有的节点都进行加锁,也就是设置Key Value。对所有的节点进行加锁,固然会有一笔时间开销。所以成功加锁之后,Redis锁实际过期时间是client原本设定的过期时间,减去加锁花去的时间。RedLock算法也采用了大多数原则,官方文档给出的说法是:As long as the majority of Redis nodes are up, clients are able to acquire and release locks. 只要大多数Redis节点可用,则可以获取锁成功。Figure7中的步骤5写到:只要client成功地在至少(N/2 + 1)个节点上成功写入的键值对,那么加锁成功。否则,加锁失败。而且加锁失败的时候,还要把刚刚成功写入键值对的那几个节点中的键值对给DEL掉,不然会影响其它client获取锁。

大体思路说完了,接下来看看代码吧。官方文档中给出了不同语言版本的RedLock实现,笔者下载了Go语言版本来说说具体实现吧。为了方便理解,笔者在代码中新增了一些注释。

Figure8

Figure8是锁的结构体,结构体中的每一个字段笔者都进行了注释说明。其中的factor字段是用于官方文档中提到的补偿时间漂移:just a few milliseconds in order to compensate for clock drift between processes。笔者也并不熟悉所谓的clock drift,此处不做说明,读者可自行去了解。

Figure9

Figure9是加锁操作,每一个步骤都进行了说明。该步骤遵循Figure7中所说的步骤。其中的

acquirerelease操作便是按照上文中讲述单节点Redis环境下的加锁和解锁操作。

Figure10

Figure10是解锁操作,上文中已经说明。

RedLock算法不能说彻底解决了Redis多节点引发的分布式锁问题,它是让这样的bad case变得更加不容易发生了而已。在现实工作中,我们其实不会自己去实现这样一套逻辑,大型互联网公司中,都会有一个完善的团队来保障各基础服务的可靠性。笔者目前就职于BATTMD中的某个大厂,在进行redis操作时,可以视redis集群为一个高度可靠的“单节点”,因为redis团队会封装好一切。