• 微服务架构之:Redis的分布式锁---搭建生产可用的Redis分布式锁


    集群架构下的并发问题

        在单体架构上,乐观锁和悲观锁可以锁住并发情况下的同步代码块,我们多使用synchronized来对方法加锁。但是在配上负载均衡的集群模式下,普通的synchronized是无法锁住从两台服务器同时进入的请求
        这是在了解秒杀项目的难点之一:一人一单的并发安全问题在使用集群架构出现的难点。我们先从单体项目出发,单体项目很好理解,假如有俩线程:线程1查询订单,判断是否存在。然后第二个线程之后在去查询订单,判断是否存在,在synchronized的作用下两个线程是不会发生问题的。
    在这里插入图片描述

        那现在,我们不再是一台服务器,而是多台。在当前这一个JVM内部,锁的原理是在JVM内部维护了一个锁监视器对象,监视器的对象用的是userId,它是在我们的常量池里面。那么在这个JVM内部是维护了这一个常量池子,当ID相同的情况下,他们永远都是同一个锁,也就是说锁的监视器是同一个。所以无论是线程1也好,线程2也好,他们俩要获取锁的时候,锁监视器就会记录线程ID,当另一个线程再来获取锁的时候肯定是不行的,因为锁监视器已经记录一个了。

        但是,当我们部署一个新的服务器的时候,也就是部署了一个新的JVM。两个JVM也拥有各自的常量池,JVM2用userId作为锁的时候,它的监视器对象就会拥有一个新的锁监视器,跟JVM1的监视器不是同一个。现在当我们线程3来获取锁的时候走的是自己的监视器,那这个监视器显示的是空的呀,所以也能获取锁成功,当然了线程4失败是没问题的。也就是说在JVM内部锁监视器能保证这些线程互斥,但是多个JVM就会有多个JVM监视器, 有多少个锁监视器就会有多少个线程成功进入同步代码块
    在这里插入图片描述

        所以我们要解决的问题就是在多个JVM的情况下让这些锁监视器使用同一把锁。

    分布式锁的实现原理和不同方式的实现对比

        synchronized就是利用JVM内部的锁监视器来控制线程的,在JVM内部,因为只有一个锁监视器,所以只会有一个线程获取到锁,可以实现线程阶段互斥。但是当有多个JVM的时候,就会有多个锁监视器,这时候synchronized就会显得苍白无力,JVM内部的锁监视器直接作废。所以锁的监视器一定要在JVM的外部,让所有JVM都去找独一无二的锁监视器来获取锁,这样也就只有一个线程获取锁,也就实现了多JVM的线程互斥。
    在这里插入图片描述

        所以满足在分布式系统或集群模式下多线程可见并且互斥的锁就是分布式锁
    在这里插入图片描述

        分布式锁核心是实现多进程之间的互斥,而满足这一点的方式有很多,常见的有三种:Mysql、Redis。Redis里有setnx互斥命令,王redis面set数据的时候,只有没数据的时候才会set成功,有数据就会set失败。
    在这里插入图片描述

    基于Redis实现的分布式锁

        实现分布式锁肯定要实现两个基本方法,获取锁释放锁

    获取锁

    • 互斥条件:确保只能有一个线程获取到锁。
    • 非阻塞:尝试一次,成功返回true,失败返回false。
      这个我们可以用redis的setnx,这个可以确保只有一个可以返回1。
      在这里插入图片描述
      在这里插入图片描述
      确保原子性,利用EX。
      在这里插入图片描述

    释放锁

    • 手动释放 del lock
      在这里插入图片描述
      思考问题:如果在获取锁后redis宕机了,那么这个释放锁动作就永远得不到执行,其他线程进不来,我这服务已经挂了,整个线程进入死锁状态。所以需要在获取锁时添加过期时间,避免服务器宕机引起的死锁。

    Redis分布式锁1.0版

    在这里插入图片描述
    锁的名称不能写死,不同的业务有不同的锁。
    在这里插入图片描述
    在这里插入图片描述

    基于Redis分布式锁1.0版的误删问题

        现在的Redis锁处于正常的工作状态,假如现在来了一个线程1,它想要获取到setnx,那么作为第一个的线程是肯定可以获取成功的。然后拿到锁后线程1就开始执行他的业务,其他线程想要获取锁就去阻塞状态等待。
        但是由于某种原因,线程1的业务产生了阻塞,那么这样的话它锁的持有周期就会变长,如果这个阻塞时间过长甚至超过了我们设置的最大超时时间,那么这个时候也会触发锁的超时释放。总的来说就是业务没完,超时时间将锁释放
        那么释放后其他线程就会趁机而入,现在线程2来获取,获取成功,然后开始执行它的业务。万万不巧的是,线程1的业务也阻塞完成了,开始了它的释放,这个删锁的过程也很巧的把线程2刚拿到的锁给释放了
        那么姗姗来迟的线程3页理所应当的拿到了锁开始了它的业务。此时此刻,有两个线程同时进入了锁,线程安全问题有可能再次发生。
        这个问题的本质就是由于业务阻塞导致锁提前释放, 等待线程1醒来后删掉的不是自己的锁而是线程2的锁。
    在这里插入图片描述

    解决误删问题,Redis分布式锁2.0版

        其实自信分析误删问题,就会发现他们只需要在释放锁的时候验证当前的锁id是不是自己线程的id,所以我们在加锁的时候加上Thread的id即可。
    在这里插入图片描述

    1. 在获取锁时存入线程标示(用UUID表示
    2. 在释放锁时先获取锁的线程标示,判断是否与当前线程一致。

    在这里插入图片描述
        为了确保不会出现两个相同版本号的线程,UUID还是很不错的一个方法,这里用了之后可以转化为String类型然后再去掉横线。最后在UUID后面拼接线程ID即可,完美的保证了线程ID的唯一性。

    在这里插入图片描述

        在释放锁的时候就要去先做判断,步骤为获取线程标示→获取锁标示→判断是否一致(释放/跳过)
    在这里插入图片描述

    基于Redis分布式锁2.0版的原子性问题

        就在我们刚更新完Redis2.0后,万万没想到,新的问题又出来了了。是这样的,Redis也是正常的工作状态,现在来了一个线程1,他来获取锁然后执行业务,这段业务非常流程,没有阻塞的过程,所以在执行完代码后就开始了它的释放锁操作
        而要释放锁就要先判断锁标示,这个判断也是没有问题的,因为锁就是他自己的。紧接着他就要执行释放锁的动作,但是万万没想到,就在要释放时,产生了阻塞,就在我纳闷我也没写与操作系统内核态交互的代码啊,为什么就阻塞了呢?我的日志告诉了我,我们JVM刚很不巧的执行了FULL GC,无论是CMS垃圾回收器还是G1垃圾回收器都有独自标记和独自清除的两端STW,所以很不巧,就在我代码执行到释放锁的时候,JVM为了回收垃圾自己进入了阻塞…
        所以接下来发生的事不用想就知道了,线程1又触发了超时时间,锁自己释放。珊珊来迟的线程2又不巧的获取到锁,就在准备执行业务代码的时候,线程1又醒了,他很坚定的删了以为是自己的锁,但那个锁确实线程2的… 然后线程2与线程3又开始互相撕逼起来,线程安全问题又来了…qwq
    在这里插入图片描述

    解决原子性问题(Lua),Redis分布式锁3.0版

        说道原子性,想到的相比都是事务,那redis有没有事务呢?肯定是有的,不够这个事务跟我们所了解的mysql事务ACID是有很大差别的。redis的事务首先是能够保证原子性的,但是无法保证事务的一致性,而且redis事务的多个操作其实是一个批处理,实在最终一次性执行。那也就是说我们没办法先查询判断、最后在释放锁,因为做这些动作是拿不到结果的,他是最终一次性执行,所以是没办法把他们俩放到一个事务中,只能利用redis的乐观锁做一些判断,来确保在释放的时候没有人来进行修改,但是这样做会复杂很多,所以这里可以利用乐观锁来维护原子性,但是会拉低性能,很麻烦。
        这里推荐使用Lua脚本来实现。Redis是提供Lua脚本功能的,Lua脚本其实就是在一个脚本中编写多条Redis命令,确保命令执行时的原子性。可以参考这个网页来简单学习下如何维护原子性的操作https://www.runoob.com/lua/lua-tutorial.html
        其他语法不需要学习,只需要会用redis提供的调用函数:redis.call('命令名称','key','其他参数',...),比如我们要执行set name Jack,则脚本是这样:
    在这里插入图片描述

    先执行set、在执行get、最后用变量来接收:
    在这里插入图片描述

    写好脚本后,需要用Redis命令来调用脚本。
    在这里插入图片描述
    比如执行redis.call(‘set’,‘name’,‘jack’),语法为:
    在这里插入图片描述

        
        
        
        参数的设置很重要,如果不想写死,基本上set后面的两个值都需要传参,而且可能还不止set一个命令。所以我们可以设置数组,key类型的参数会放入KEYS数组,其他参数放入ARGV数组。Lua语言的数组从1开始而不是从0开始。
    在这里插入图片描述

    所以在原子性与一致性的保障下,我们释放锁的流程就变成了:

    1. 获取锁的线程标示
    2. 判断是否与指定标示(当前线程的标示)一致。
    3. 如果一致则释放锁
    4. 如果不一致则什么都不做

    使用Lua语言先编写逻辑代码

    先使用Lua脚本编写下逻辑代码

    -- 锁的key 先写死
    local key = "lock:order:5"
    
    -- 当前线程的标示 格式是UUID-线程id
    local threadId = "asdwiwahsjdwa-33";
    
    -- 获取锁的线程标示
    local id = redis.call('get',key)
    
    --比较线程标示与锁的标示是否一致
    if(id == threadId) then
        --释放锁
        return redis.call('del',key)
    end 
    return 0
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    因为我们的key和线程的id是存放到KEYS和ARGV数组里面的,所以定义的步骤可以取消,直接在获取标示的时候传参来表示。简化后就是:

    -- 先取,然后进行比较
    -- 这里的KEYS[1]就是锁的key,ARGV[1]就是当前的线程标示。
    
    -- 获取锁的线程标示 
    local id = redis.call('get',KEYS[1])
    
    --比较线程标示与锁的标示是否一致
    if(id == ARGV[1]) then
        --释放锁
        return redis.call('del',KEYS[1])
    end 
    return 0
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    使用Java(RedisTemplate)调用Lua脚本

    @Override
    public <T> T excute(RedisScript<T> script,List<T> keys,Object... args){
          return scriptExcutor.excute(script,keys,args);
    }
    
    • 1
    • 2
    • 3
    • 4

    在这里插入图片描述

        在编写代码的时候,一定要先在resources里存放lua,然后再java代码里调用,不要在java代码里编写lua。
    在这里插入图片描述

        scriptExcutor.excute(script,keys,args)三个参数,就是脚本,key,arg… 。RedisScript是个类,我们写的Lua是个文件,显然这个类就要加载文件。所以我们就得提前吧文件读取好,所以我们要在全局变量中定义。使用RedisScript的实现类DefaultRedisScript类定义,调用他的setLocation()来设置脚本的位置。最后可以给它定义返回值的类型,使用setReturnType()
    在这里插入图片描述

        所以在使用起来就很方便了,第一个参数就传UNLOCK_SCRIPT 脚本
        第二个参数就是key的集合(也就是key数组吧,key[1]),就是我们定义的“lock:+业务名称”。很简单,使用Collections工具类提供的singletionList();字符串转集合的方法就行。
        第三参数就是args就更简单了,就是我们定义的格式UUID+ThreadId,之前已经定义过全局变量了。直接调用就行了。
    在这里插入图片描述
    然后也就不需要返回值了吧,因为你线程id不等于锁的id本来就是当前线程出现问题了,当前线程作废就行。

    总结

        现在的话,只有当前线程能删自己获得的锁,原子性也得到了解决。锁已经达到了生产可用的标准了。

        总结思路:

    • 利用set nx ex获取锁,并设置锁的超时时间,保存线程标示。
    • 释放锁时先判断线程标示是否与自己的锁一致,并采用了Lua脚本保证了原子性、一致性的删除。

        特性:

    • 利用setnx满足互斥
    • 利用set nx ex保证故障时锁依然能释放,避免死锁,提高安全性
    • 利用Redis集群(主从复制)保证高可用和高并发
  • 相关阅读:
    2022-2028全球直角变速箱行业调研及趋势分析报告
    PostgreSQL 给表添加自增字段脚本
    Istio典型应用场景
    05 【Sass语法介绍-插值】
    c++学习之 继承的方式
    STM32时钟系统
    D. Balanced Round
    JAVA编程题-求矩阵螺旋值
    计算机毕业设计ssm基于java网上心理咨询系统50fxl系统+程序+源码+lw+远程部署
    PCL 点云超体素分割
  • 原文地址:https://blog.csdn.net/qq_53999369/article/details/126394583