分布式锁
Carbda Lv3

关于实现强一致性的手段,可以使用多种方式来进行实现,有分布式事务,有一致性算法,还有分布式锁等等。

什么时候需要加锁?

先给出答案:

  1. 有并发,多线程
  2. 有写操作
  3. 有竞争关系

那如何上锁呢?

在单机环境下,也就是单个JVM环境下多线程对共享资源的并发更新处理,我们可以简单地使用JDK提供的ReentrantLock对共享资源进行加锁处理。

那如果是在微服务架构多实例的环境下,每一个服务都有多个节点,我们如果还是按照之前的方式来做,会出现这样的情况:

这个时候再用ReentrantLock就没办法控制了,因为这时候这些任务是跨JVM的,不再是简单的单体应用了,需要协同多个节点信息,共同获取锁的竞争情况。

这时候就需要另一种形式的锁——分布式锁:

通常是把锁和应用分开部署,把这个锁做成一个公用的组件,然后多个不同应用的不同节点,都去共同访问这个组件(这个组件有多种实现方式,有些可能并不是严格意义上的分布式锁,这里为了方便演示,我们暂不做严格区分,统称为分布式锁)。

分布式锁实现方式

基于数据库实现

第一种方式,我们可以利用数据库来实现,比如说我们创建一张表,每条记录代表一个共享资源的锁,其中有一个status字段代表锁的状态,L 代表 Locked ,U 代表 Unlocked。

那比如有一个线程要来更新商品库存,它先根据商品ID找到代表该共享资源的锁,然后执行下面这个语句

1
2
3
4
5
update  T t 
set t.status = 'L'
where t.resource_id = '123456'
and t.owner = 'new owner'
and t.status = 'U';

如果这条语句执行成功了并且返回的影响记录数是1,那么说明了获取锁成功了,就可以继续执行更新商品库存的操作,然后释放锁时,则将status从 L 改为 U 即可.

我们上面只说了上锁和解锁操作,那如果这个锁已经被其他任务占用了,也就是 status = ‘L’,这个时候这个语句就更新不到数据,也就意味着获取不到锁,程序是不是只能等着,那要怎么等?这是我们面临的一个问题,因为数据库和我们的应用程序之间,除了发出执行语句和返回结果,基本就没有其他交互了,它很难给应用程序发出通知,这样就很难做到通过事件监听机制来获取到释放锁的事件,所以程序只能轮询地去尝试获取锁。

这会导致一个致命的问题,就是这种类似自旋锁的阻塞方式,对数据库资源消耗极大,原本数据库的性能相对较差,即便加上连接池,性能也远无法跟一些缓存中间件相比,而现在程序为了抢锁拼命发出update语句,对数据库性能来说更是雪上加霜,而在分布式环境中,尤其需要使用分布式锁的场景,基本上都是要求支持高并发的,这就出现一个悖论了,这一点基本上也宣告了数据库在大部分需要分布式锁的场景中都用不上。

基于单机版Redis实现

既然数据库性能不够好,我们看一下用缓存中间件,也就是我们最经常使用的Redis,如果用来实现锁要怎么样做。Redis的特点就是性能非常好,拿它跟数据库比的话,你会发现它的性能好到爆炸。有些同学平时可能也有用过Redis来实现锁,但是你采用的实现方式很有可能并不是真正的分布式锁,通常我们称它为单机版的Redis锁更合适,我们先来了解这个单机版的锁,因为这种实现方式在实际的应用中也用的很多。后面再对比一下它与Redis作者提出的Redlock的具体区别。

对于单机版Redis锁的实现主要有以下几个步骤

第一步会先向Redis获取锁,然后返回是否获取成功,如果获取成功了那就可以开始操作共享资源,这段时间这个锁就被占用了。操作完成之后就可以释放锁,最后判断一下锁是否释放成功。大体就分为获取锁、使用锁、还有释放锁这三大步骤。

那么这三个步骤使用Redis是如何实现呢?

首先获取锁,获取锁只需要下面这一条命令即可

1
SET key value NX PX|EX time
变量 解析
key 这个是作为锁的唯一标识,用于获取和释放锁,为了在不同使用者之间保持一致,直接以共享资源命名会更好。
value 这个是作为使用者的唯一标识,用来表示当前持有锁的是具体哪个使用者,可以起到一个标记的作用,为什么要这样呢,一会我们看一下锁的释放就知道。
NX 它是Redis的语义,表示这个key不存在的时候才能set成功,这里起到了互斥性的保证,满足一个锁最基本的特性。(如果不加这个语义限制,那么第一个线程获取锁之后,任务还没执行完,第二个线程再来获取,就会把值给覆盖掉,那么就起不到互斥的效果。)
PX|EX 是缓存过期时间的设置,表示多少毫秒或者多少秒过期,是一个时间单位的区别

那如何释放锁呢,通常我们会使用引入Lua脚本,我们看一下下面这个语句块

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

那么这个lua脚本的语义是执行这个脚本时,当输入的KEYS[1]在Redis里面的值等于输入的AEGV[1]时,则删除这个原有的KEY,即代表释放锁操作(这里查不到返回0,也可能是因为锁已经过期了,前面我们获取锁的时候设置了过期时间)

  • KEYS[1]:它代表的是获取锁时输入的key,也就是共享资源名称
  • ARGV[1]:它代表的是获取锁时输入的value,这个value的唯一性决定了使用者只能删除自身已经获取的锁,不会误删除别人的。

我们可以看到上面代码里面有一个判断,要保证获取锁和释放锁是同一个使用者。比如说有这种情况:

有一个客户端A获取到锁之后去执行业务操作,然后由于某些原因,这个操作的时间,耗时比较长,超过了锁的有效期,这个时候锁就自动释放了,那么这个时候另一个客户端B可能马上就获取到锁,然后也去执行业务逻辑,在它还没执行完的时候,客户端A的流程处理完了,然后就执行到释放锁的步骤,这个时候如果没有上面说的那个判断,那么就有可能发生这样的情况:客户端A,把客户端B持有的锁,给释放掉了!

那么除了正常的获取锁和释放锁之外,单机版的Redis锁有没有哪些地方需要注意的呢?我们先来思考一下这个问题:

为什么需要设置缓存的过期时间?这里是作为锁的有效期

定义了这个锁,它对应的操作在正常情况下所需要的操作时间,如果超过了这个时间,锁就会被自动释放掉

我们想象一下这种场景,当一个使用者获取锁成功之后,假如它崩溃了(导致它崩溃有很多原因比如发生网络分区,应用发生GC把业务执行流程给阻塞住了,或者时钟发生变化导致它无法和Redis节点进行通信,发生这些情况我们就简单说它崩溃了)这时会发生什么情况呢,这个时候这个对应的锁就一直不会过期了,因为有互斥的机制所以其他使用者尝试获取锁都set不成功,也无办法释放,因为释放时会判断使用者是否是锁的持有者。因此我们可以看到,获取锁一定要给它设置过期时间,也就是这个锁是有租期的,使用者必须在这个规定的租期内完成对共享资源的操作,租期一到,如果使用者没有主动释放,那么锁也会自动过期。

那第二个问题,为什么释放锁的时候,要引入Lua脚本?

这里我们先说一下结论,再来解释一下为什么。其实这里是为了保证操作原子性。包括获取锁的set命令,也需要原子性的保障。

假如不考虑原子性,我们上面的获取锁和释放锁,按照功能逻辑的话,是不是换成以下的写法也可以:

1
2
3
4
5
6
7
8
9
SET key value NX PX|EX time
=>
set key value;
expire key time;

code
=>
get key == value
del key

这样会有什么问题呢,我们先看看获取锁的命令,使用者执行第一条成功了才会执行第二条,那如果执行第一条成功之后使用者崩溃了,当它再连上的时候是不是就变成了我们上面说的那种情况,没有设置锁的过期时间。

那释放锁的过程,拆成两条命令之后,又会导致什么问题呢,我们来看一下这种场景

假如使用者A完成任务之后准备释放自己持有的锁,它先通过get key得到一个值,用来判断出这个锁确实是自己持有的锁,并且还没有释放,这时候A由于某种原因,它还是崩溃了,造成崩溃的原因我们上面说了有多种情况,就阻塞了一段时间,在这段时间锁恰好因为超时自动释放掉了,然后,使用者B刚好来获取锁,也就是执行了上面的set命令,然后呢使用者A恢复了,比如GC完成,然后 就开始执行它的第二步操作,也就是del key操作,那是不是刚好,就把使用者B的锁给删除掉了。相当于锁的持有者和释放者就不一致了,从而导致了锁状态出现错乱。

前面我们从锁的获取和释放流程,结合Redis命令的特性,分析了单机版Redis为什么要这么实现,分析了这种实现方式的必要性以及可能出现的异常场景。那么我们再从更宏观的维度来看,这种单机版Redis锁最大的风险是什么呢?

如果这个Redis实例挂了,那就意味着整个锁机制失效了,这时使用者无法获取和释放锁,进一步导致使用者无法正常使用共享资源,从而出现阻塞、访问失败或者访问冲突等异常;还有可能因为共享资源失去了锁的保护 ,引起数据不一致,导致业务上一系列连锁反应。

那如何规避这种单点的问题呢?

有的同学可能会首先想到使用持久化机制。

那么这种方式其实是通过利用Redis本身的AOF持久化机制,来保存每一条请求,如果Redis挂了,这个时候直接重新拉起,再通过AOF文件进行数据恢复。

但这种方式还是有一些缺点的:

假如说我们把AOF的同步机制设置为每秒钟同步一次,那这种情况下Redis的AOF持久化机制并不能保证完全不丢数据,也就是可能恢复之后少了某个锁的数据,这样其他使用者就可以获取到这个锁,导致状态错乱

假如说我们把它设置为Always,就是每个操作都要同步,这样的话会严重降低Redis的性能,发挥不出它的优势。

还有一点就是AOF文件的恢复一般比较耗时,这个时间不可控,取决于文件的大小,也就是文件越大,所需要的恢复时间越长,那恢复期间锁就是不可用的状态。

第二种是使用主从高可用,将单点变成多点模式来解决单点故障的风险,也就是:

使用主从(或者一主多从)进行高可用部署,当主节点挂了,从节点接手相关任务并保持锁机不变。

那这种方式也是存在一些问题的:

首先主从复制它是异步的,所以这种方式也会存在数据丢失的风险。然后主从高可用机制它发现主节点不可用,到完成主从切换也是需要一定时间的,这个时间跟锁的过期时间需要平衡好,否则当从节点接受之后,这个锁的状态及正确性是不可控的。

从上面的分析我们可以看到,单机版Redis在高可用方面还是存在不少问题的。如果我们的应用场景需要支持高并发,并且对它在这些特殊情况下的问题可以容忍的话,那用这种方式也没有问题,比较它的实现方式相对简单,并且性能也比较好,所以主要还是要结合业务场景来进行选择。

那么有没有更具高可用的分布式锁实现方式呢?接下来我们继续介绍Redlock的运行原理和机制,它在高可用性方面有更好的保障,当然相对也有一些实现代价,相比之下它会复杂一些。

基于Redis的高可用分布式锁——RedLock

RedLock基本情况

  1. Redis作者提出来的高可用分布式锁
  2. 由多个完全独立的Redis节点组成,注意是完全独立,而不是主从关系或者集群关系,并且一般是要求分开机器部署的
  3. 利用分布式高可以系统中大多数存活即可用的原则来保证锁的高可用
  4. 针对每个单独的节点,获取锁和释放锁的操作,完全采用我们上面描述的单机版的方式

RedLock工作流程

获取锁

  1. 获取当前时间T1,作为后续的计时依据;

  2. 按顺序地,依次向5个独立的节点来尝试获取锁

(SET resource_name my_random_value NX PX 30000)

  1. 计算获取锁总共花了多少时间,判断获取锁成功与否

    1. 时间:T2-T1
    2. 多数节点的锁(N/2+1)
  2. 当获取锁成功后的有效时间,要从初始的时间减去第三步算出来的消耗时间

  3. 如果没能获取锁成功,尽快释放掉锁。

这里需要注意两点:

  1. 为什么要顺序地向节点发起命令,那么我们反过来想,假如不顺序地发起命令会产生什么问题?那么我们想一下假如有3个客户端同时来抢锁,客户端A先获取到1号和2号节点,客户端B先获取到3号4号节点,客户端C先获取到5号节点,那么这时候就满足不了多数原则,5个节点的情况下,最少需要3个节点都获取到锁,才可以满足
  2. 客户端在向每个节点尝试获取锁的时候,有一个超时时间限制,而且这个时间远小于锁的有效期,比如说几毫秒到几十毫秒之间,这样的机制是为了防止在向某一个节点获取锁的时候,等待的时间过长,从而导致获取锁的整体时间过长。比如说在获取锁的时候,有的节点会出现问题导致连接不上,那么这个时候就应该尽快地转移到下一个节点继续尝试,因为最终的结果我们只需要满足多数可用原则即可

释放锁

向所有节点发起释放锁的操作,不管这些节点有没有成功设置过

正常情况下RedLock的运行状态

client1和client2,对Redis节点A-E进行抢锁操作,如图,client1先抢到节点ABC,超过半数,因此持有分布式锁,在持有锁期间,client2抢锁都是失败的,当时序=6时,client1才处理完业务流程释放分布式锁,这时候client2才有可能抢锁成功。

那么RedLock的主要流程就是这样,获取锁和释放锁,那么这个号称是真正的分布式锁,它相比前面单机版的锁,很明显的一个点就是它不再是单点的是吧,所以在高可用性上面,它是比单机版的锁有提升的。

此外,除了redis以外 ,其实我们可以用ZooKeeper来实现分布式锁 。实际上这Redis实现分布式锁的方式虽然性能比较高,但是在一些特殊场景下,它还是不够健壮,相比之下,ZooKeeper它的设计定位就是用来做分布式协调的工作,更加注重一致性,非常适合用来做分布式锁,总的来说使用ZooKeeper去实现分布式锁相比Redis的话会更加健壮一些。