• 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
    
    • 1
    • 2
    • 3
    • 4

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

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

    set lock t1 nx ex 10
    
    • 1

    释放锁

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

    整个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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

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

    经典白学之通过Redis中setnex实现的分布式锁的问题以及可以使用的线程的基于Redis的分布式锁的实现工具

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

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

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

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

  • 相关阅读:
    应急响应-网站入侵篡改指南_Webshell内存马查杀_漏洞排查_时间分析
    数字化时代,企业为什么需要商业智能BI
    使用transformers过程中出现的bug
    PT_离散型随机变量下的分布:几何/超几何/幂律
    stm32f4xx-USART串口
    LeetCode 308 周赛
    【Flink读写外部系统】Flink异步访问外部系统_mysql
    浮点数算法:争议和限制
    hive笔记八:自定义函数-自定义UDF函数/自定义UDTF函数
    vue elementui 自定义loading显示的icon 文本 背景颜色
  • 原文地址:https://blog.csdn.net/qq_36944952/article/details/126022716