Redis实现分布式锁-原理-问题详解


在高并发场景中,往往存在多个线程对共享资源的读取,我们一般通过对共享资源加锁的方式,来实现高并发场景下的业务的数据的安全性。

其中加锁的方式又分为很多种,一般可以分为两类:悲观锁乐观锁

乐观锁和悲观锁

悲观锁: 认为线程安全问题一定会发生,因此在操作数据之前会使得当前线程获取到锁,确保线程串行执行。例如:java中的Synchronized、Lock都属于悲观锁。它的优点是实现起来简单粗暴,但是性能一般。

乐观锁:认为线程安全问题不一定会发生,因此只是在更新数据时去判断,之前的获取到的数据和此时要更新的数据的值有没有被修改。1 如果没有修改则认为时安全的,自己才更新数据。 2 如果已经被其它线程修改,则说明发生了安全问题,此时就需要进行重试或者抛出异常。例如:Java中的CAS机制就是乐观锁的思想。乐观锁的优点是性能较好,但是存在成功率低的问题。

分布式锁

我们之前所使用的锁,都是在单机上使用的锁,在JAVA里也就是单个JVM里对象加锁,这样的话,当我们的系统采用集群方式进行部署时,此时,相当于与拥有了多个JVM,这样原本要求在整个系统中具有互斥性的锁,在集群模式下,如果使用JVM中的锁,比如Synchronized进行加锁时,这样每一个JVM中的其中的一个线程都能获取到一个在其JVM的锁对象,并不能达到在整个系统级别上的锁的互斥性,如此加锁应用到分布式系统中必定会出现问题,所以需要分布式锁在整个系统级别实现互斥锁,即多个JVM使用同一把锁。

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

我们可以利用Mysql或者Redis来实现分布式锁。Mysql可以利用其本身的互斥机制,Redis可以利用setnx命令来实现。

Redis实现互斥锁

使用redis实现锁,只要满足锁的基本特性互斥性就可以很简单地实现锁。

获取锁

  • 互斥:确保只能有一个线程获取锁。

我们可以使用redis中提供的setnx命令实现互斥锁,setnx命令的set命令的另一种版本,命令如下所示:

# 添加锁,利用setnx的互斥特性
setnx lock t1
# 添加过期时间,避免宕机引起的死锁
expire lock 10

意思是创建键为lock,值为t1的数据,如果键lock不存在的话,则创建成功,如果存在则创建失败。如果创建失败则说明其他的线程已经获得了lock这把锁,通过这条命令就可以达到锁的护互斥效果。另外我们还要给锁添加过期时间,避免宕机引起的死锁。

另外,我们上边的代码是两行,不能保证操作的原子性,也就是setnx lock t1执行完之后,redis如果宕机,那么就没法去执行第二句expire lock 10, 所以在创建锁的时候需要使用如下的命令如实现上边命令的功能。

set lock t1 nx ex 10

释放锁

  • 手动释放
del key
  • 超时释放:获取锁的时候添加一个超时时间即可
expire lock 10

整个redis实现锁的过程如下:
在这里插入图片描述

Redis实现分布式锁

使用Redis实现分布式锁,我们只要在前边 Redis实现互斥锁 上进行设计,使其满足分布式锁的基本要求即可。

我们只需要考虑如何设计命令set lock t1 nx ex 10中的锁名称lock,以及锁的值t1,即可。

一般来讲,我们每一个业务都需要一把锁,所以锁名称lock就可以根据业务名称+用户id来命名。
那么锁的值一般设置为当前线程的标识可以为名称或者ID。

目前来讲,已经设计好了一个简单的Redis的分布式锁,但是如此设置会在释放锁的时候出现问题。下来将分情况进行说明并给出解决方法。

锁误删的问题–通过判断锁是否是自己的解决

在这里插入图片描述
如上图所示,多个线程同时获取同一把锁的情况。

首先线程1获取到了Redis锁,但是因为业务阻塞花费了很长的时间,这个时间超过了之前设置的锁的过期时间,导致线程1业务未完成,它获取到的锁就已经被释放掉了。此时,线程2尝试获取锁获取到了,并开始执行业务,在这期间,线程1业务执行完成了,然后执行了释放锁的操作,相当于误删了线程2所获取到的锁(因为线程1的锁,超时过期了),此时线程3来获取锁,发现可以获取到锁,就获取锁,执行和线程2相似的业务,注意,本来在加锁的前提下,线程2和线程3的执行是不能同时发生的,而此时在并发情况下同时发生了,就会出现并发读写问题。这就是锁误删的情况。

解决方法:线程在释放锁的时候需要根据锁中的值,判断一下是否是自己的锁,如果是则进行释放,从而避免发生误删的情况。

改进的情况就如下图所示。另外,为了在集群模式下,线程区分是不是自己的锁,之前的想法是将线程的ID或者名字放入锁的值中,但是不同的JVM可能会存在相同的值,因为可以使用uuid+线程标识作为锁的值,来在集群中唯一确定锁的拥有线程。
在这里插入图片描述

通过判断锁是否自己的解决锁误删所存在的问题–Lua脚本解决

通过上一部分的分析,我们可以看到,我们可以通过使线程在释放锁之前进行判断标识的情况之后进行删除,如果是自己的,则进行释放,如果不是自己的,则不释放,从而防止误删。

但是,这其中还是存在问题,因为经过改进之后的释放锁的操作变成了两步,即1.判断锁标识 2.释放锁,这两步操作并不是原子性的,所以在有些情况下还是会出现锁误删的情况,如下图所示:
在这里插入图片描述
线程1获取锁,执行业务,业务执行完毕之后,需要释放锁,其先进行了锁标识的判断,在其将要进行释放锁操作时,出现了阻塞,比如说发生了gc,并且时间较长,在阻塞的这段时间里,线程1的锁因为超时自动释放掉了。此时线程2尝试获取锁,并且成功获取,开始执行业务,在这期间,线程1阻塞结束,执行释放锁操作,因为线程1的锁已经被释放掉了,线程1也经过了锁标识的判断,所以线程1就直接进行释放锁的操作,如此就会释放掉线程2的锁,这样在线程2还未执行完业务时,线程3可以获得锁了,就可以和线程2同时执行,这样又发生了并发读写问题。

导致上述问题的原因在于:1.判断锁标识 2.释放锁这两个操作不是原子性的,我们可以通过编写Lua脚本来实现。

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

在实际使用的时候可以通过Java调用该脚本实现安全释放锁的功能。

通过Redis中setnex实现的分布式锁的问题以及可以直接使用的基于Redis的分布式锁的实现工具Redisson解决问题

通过Redis中setnex实现的分布式锁的问题

  • 不可重入: 同一个线程无法多次获取同一把锁
  • 不可重试: 获取锁只尝试一次就返回false,没有重试机制
  • 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。
  • 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁失效。

基于Redis的分布式锁的实现工具-Redisson

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

博客补充(9-7)Redisson对于Redis使用setnx实现分布式锁的问题的解决

  • 不可重入问题:类似于ReentrantLock机制,使用hash结构记录线程ID和可重入次数,每次重入次数+1,释放-1.
  • 不可重试问题:利用信号量和PubSub(发布订阅)实现等待唤醒,获取锁失败的重试机制。在第一次获取锁失败之后,会去等待锁的持有者的消息,当锁持有者释放掉锁时,会发布消息,当我们获取到这条消息之后,就可以重新获取锁。如果在等待时间结束之后还没有获取到锁,就不再重试。
  • 超时释放问题:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间。通过设置定时任务实现。
  • 主从一致性问题:利用multiLock机制解决。当使用单主多从的结构部署时,如果在获取锁之后,主突然宕机,从还未同步主的锁,将会出现问题。此时可以使用多主多从的形式,规避此问题。即使用多个独立的Redis结点,只有在所有节点都获取锁成功时,才算获取锁成功。

注:如内容表述有误,望指正,共同进步,谢谢!

猜你喜欢

转载自blog.csdn.net/qq_36944952/article/details/126022716