Redis实现分布式锁的原理:常见问题解析及解决方案、源码解析Redisson的使用

news2024/12/23 19:00:39

 0、引言:分布式锁的引出

        锁常常用于多线程并发的场景下保证数据的一致性,例如防止超卖、一人一单等场景需求 。通过加锁可以解决在单机情况下安全问题,但是在集群模式下就不行了。集群模式,即部署了多个服务器、并配置了负载均衡后,原来加的锁会失效,具体原因如下:

        由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的;

        但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥。

        这就是集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。

 

 

1、分布式锁的基本原理

         Redis:redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法(具体使用可看我的这篇文章http://t.csdn.cn/U7Z6y),如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁。同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性

Redis锁的代码实现

private static final String KEY_PREFIX="lock:"
@Override
public boolean tryLock(long timeoutSec) {
    // 获取线程标示
    String threadId = Thread.currentThread().getId()
    // 获取锁
    Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}

public void unlock() {
    //通过del删除锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
}

2. 分布式锁将遇到的问题与解决方案

2.1 如何避免死锁

         当用户1拿到锁以后,若进程挂了、或因为别的原因,没有机会主动释放锁,会导致已经获得锁的客户端一直占用锁,其他客户端永远无法获取到锁

解决方案:
        为了解决以上死锁问题,最容易想到的方案是:在申请锁时,在Redis中实现时,给锁设置一个过期时间,假设操作共享资源的时间不会超过10s,那么加锁时,给这个key设置10s过期即可。并且Redis 2.6.12之后,Redis扩展了SET命令的参数,可以在SET的同时指定EXPIRE时间,这条操作是原子的,例如以下命令是设置锁的过期时间为10秒。

SET lock_key 1 EX 10 NX

 2.2 Redis分布式锁误删

        对于2.1情况下,并不是完美的解决方案,例如在遇到这种情况时:

       1. 持有锁的线程1在锁的内部出现了阻塞,而他的锁超时自动释放(del了),这时其他线程,线程2来尝试获得锁,就拿到了这把锁(setnx了);

       2.然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除(把线程2的setnx的值del了)。

       3. 因为线程2还没有执行完,其锁就被释放,如果此时线程3进入获取到了锁,则两个线程会同时操作数据,造成不安全的情况。
     

 解决方案:

        在每一次释放锁之前,判断当前的锁是否属于自己这个线程,这样就避免了释放别人锁的情况。
        核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。

改进后的代码: 

private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
   // 获取线程标示
   String threadId = ID_PREFIX + Thread.currentThread().getId();
   // 获取锁
   Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
   return Boolean.TRUE.equals(success);
}

public void unlock() {
    // 获取线程标示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁中的标示
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    // 判断标示是否一致
    if(threadId.equals(id)) {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

 2.3 Redis分布式锁原子性问题

        在2.1、2.2两次改进的基础上,感觉没什么问题了,但又有新的神秘bug出现:对于上述的操作流程如下图所示,我们说假如啊,假如,在程序进入最后的“判断锁标识是否是自己”这个判断句,已经进入了(已经判断完以后),就要执行释放锁操作了:
        

        但是由于判断id、删除id,这两个步骤并不是原子性的:假如在del的时候发生了阻塞,而导致超时释放锁,将造成以下后果:
        此时线程2获取到了锁,正在嘎嘎执行业务的时候,线程1的del阻塞结束了但由于在判断句内部,这个锁仍然会被释放(即线程2的锁仍然被认为是线程1的,被释放了)
        这时候线程3进来,又会发生一样的安全问题。

  解决方案:Lua脚本解决多命令原子性问题

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

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

对应的,用java代码调用此脚本的方法如下:    

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

public void unlock() {
    // 调用lua脚本
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX + name),
            ID_PREFIX + Thread.currentThread().getId());
}

3、redission替代setnx分布式锁

        实际上,setnx这种分布式锁的实现方式存在以下问题:

        实现Redis的分布式锁,除了自己基于redis client原生api来实现之外,其实还可以使用开源框架:Redission
        Redisson是一个企业级的开源Redis Client,也提供了分布式锁的支持,并且拥有上述Redis没有的优点。

3.1 Redisson实现可重入锁(01)与锁重试(02)

        在redission中也支持支持可重入锁。在分布式锁中,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持有。value标识这把锁有多少个方法正在使用:

源码解析:tryAcquire、tryAcquireAsync实现

        watiTime是传入的等待时间:超过这个时间,线程还拿不到锁,那就不等了,获取锁失败。
        要注意区分,这个时间并不是锁的有效时间、超时释放的最大存活时间。

        如上图源码,实现可重入的方式是通过String字符串的方式替代lua脚本,这个地方一共有3个参数:

        KEYS[1] : 锁名称

        ARGV[1]: 锁失效时间

        ARGV[2]: id + ":" + threadId; 锁的小key

        exists: 判断数据是否存在 name:

        是lock是否存在,如果==0,就表示当前这把锁不存在;

        redis.call('hset', KEYS[1], ARGV[2], 1);此时他就开始往redis里边去写数据 ,写成一个hash结构:

        Lock{

                id+":"+threadld:1

        }

        如果当前这把锁存在,则第一个条件不满足,再判断:

        redis.call('hexists', KEYS[1], ARGV[2]) == 1    

        此时需要通过大key+小key判断当前这把锁是否是属于自己的,如果是自己的,则进行:

        redis.call('hincrby', KEYS[1], ARGV[2], 1)

        将当前这个锁的value进行+1 ,即重入

        成功拿到锁,则返回null

        没成功拿到锁:        

        redis.call('pexpire', KEYS[1], ARGV[1]);

        然后再对其设置过期时间,如果以上两个条件都不满足,则表示当前这把锁抢锁失败,最后返回pttl,即为当前这把锁的剩余有效期。

        返回ttl后,会在源码处进行while(true)的自旋重复获取锁:

//能运行到这里,说明time剩余等待时间仍然>0
while (true) {
                long currentTime = System.currentTimeMillis();
                ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
                // lock acquired
                if (ttl == null) {
                    return true;
                }

                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(waitTime, unit, threadId);
                    return false;
                }

                // waiting for message
                currentTime = System.currentTimeMillis();
                if (ttl >= 0 && ttl < time) {
                    subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
                    subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                }

                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(waitTime, unit, threadId);
                    return false;
                }
            }
        } finally {
            unsubscribe(subscribeFuture, threadId);
        }
}

3.2 WatchDog防止锁超时释放机制(03)

        在3.1节中,不论是直接一次性获取到锁(ttl为null),还是说在while循环反复尝试后获得到锁,都是可以拿到锁,然后去执行业务的。但锁有自己的寿命,运行一定时间会自己超时释放:

        我们希望锁的释放是因为业务执行完释放,而不是因为阻塞超时导致的释放。
        因此,我们把锁的寿命无限延长:

原理:

        Redisson提供的分布式锁是支持锁自动续期的,也就是说,如果线程仍旧没有执行完,那么redisson会自动给redis中的目标key延长超时时间锁不会因为超时而被释放。这在Redisson中称之为 Watch Dog 机制。       

        默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。另外Redisson 还提供了可以指定leaseTime参数的加锁方法来指定加锁的时间。超过这个时间后锁便自动解开了,不会延长锁的有效期

注意:这个30秒不是你传的leaseTime参数为30,而是你不传leaseTime或者传-1时,Redisson配置中默认给你的30秒。所以,如果你想解决由于线程执行慢或者阻塞,造成锁超时释放的问题,就不要在两个方法中传release。

 源码解析:

 private RFuture<Boolean> tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
//如果自己设置了leaseTime(且不是-1)
//那么直接return,也就不会执行看门狗的延时函数scheduleExpirationRenewal了
        if (leaseTime != -1) {
            return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
        }
        //如果获取锁成功,ttlRemainingFuture 就是null,失败则为剩余有效时间
        RFuture<Boolean> ttlRemainingFuture = tryLockInnerAsync(waitTime,
                                                    commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
                                                    TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            if (e != null) {
                return;
            }

            // lock acquired 如果已经获取锁成功了,解决有效期的问题:scheduleExpirationRenewal
            if (ttlRemaining) {
                scheduleExpirationRenewal(threadId);
                //这是针对当前线程,无限延长对应的锁的寿命。
                //但也不能一直无限长寿命,在业务结束后,线程主动释放锁以后,
                //将关闭看门狗。
            }
        });
        //不管怎样,都会return回去,返回获取锁的结果如何。
        return ttlRemainingFuture;
    }

3.3 总结

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/613017.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

记录使用Echarts-gl实现3D地图

一、前言 最近项目需要做个大屏展示的&#xff0c;开始做了第一版用户觉得地图太过于单调了&#xff0c;给我发了一个视频&#xff0c;让我参考着做。我看着视频上的地图旋转了方向、地图有标记、看着像是3D的&#xff08;视频上的地图使用多个图层叠加起来、CSS样式做了旋转&…

Nginx网络服务——location规则与rewrite重写

Nginx网络服务——location规则与rewrite重写 一、Nginx中location与rewrite1.location与rewrite常用的正则表达式2. location与rewrite的联系和区别 二、location的匹配规则1.location 的匹配分类2.location 常用的匹配规则3.location 优先级4.location匹配规则优先通用的总结…

【知识图谱搭建到应用】--知识存储--04

文章目录 Mysqljenafuseki数据存储数据建模数据映射注意事项 py2neoneo4jPy2neo与Neo4j的版本问题Py2neo导入三元组数据批量导入csv文件 rdflib库 前面几篇在讲述骗理论的内容&#xff0c;本片主要描述如何将清洗过的结构化数据存储在转换成三元组并存储起来&#xff0c;并于后…

ChatGPT与软件架构(4) - 架构师提示工程指南

架构师可以通过各种类型的对话提示&#xff0c;提升驱动ChatGPT对话输出的质量&#xff0c;更好的利用AI能力辅助架构设计。原文: Software Architects’ Guide to Enhancing ChatGPT Interactions With Prompt Types Robert Stump Unsplash 前言 随着ChatGPT等人工智能语言模型…

12.数据结构之AVL树

前言 提到平衡二叉查找树&#xff0c;不得不提二叉查找树。二叉查找树&#xff0c;说简单点&#xff0c;其实就是将我们的数据节点&#xff0c;有序的维护为一个树形结构。这样我们查的时候&#xff0c;那么我们查找某个节点在不在集合中的时间复杂度实际上就是树的高度。如果…

华为OD机试真题 Java 实现【玩牌高手】【2023 B卷 100分】,附详细解题思路

一、题目描述 给定一个长度为n的整型数组&#xff0c;表示一个选手在n轮内可选择的牌面分数。选手基于规则选牌&#xff0c; 请计算所有轮结束后其可以获得的最高总分数。 选择规则如下&#xff1a; 在每轮里选手可以选择获取该轮牌面&#xff0c;则其总分数加上该轮牌面分…

python笔记 第二章 变量

系列文章目录 第一章 初识python 文章目录 2.1变量2.1.1变量的作用2.1.2定义变量标识符命名习惯使用变量 2.2 认识bugDebug工具Debug工具使用步骤: 2.3 数据类型 2.1变量 目标 变量的作用定义变量认识数据类型 2.1.1变量的作用 变量就是一个存储数据的的时候当前数据所在的…

Java基础——堆和栈、static关键字、静态变量和成员变量的区别

Java程序运行顺序&#xff1a;Java应用程序—虚拟机—操作系统—硬件 Java中栈内存用来存储局部变量和方法调用&#xff0c;堆内存用来存储Java中的对象&#xff0c;成员变量、局部变量、类变量指向的对象都存储在堆内存中。 static关键字&#xff1a; 随着类的加载而加载优先…

ISATAP隧道配置与验证

ISATAP隧道配置与验证 【实验目的】 熟悉IPv6ISATAP隧道的概念。 掌握IPv6和IPv4共存的实现方法。 掌握IPv6 ISATAP地址编址规则。 掌握IPv6 ISATAP隧道的配置。 验证配置。 【实验拓扑】 设备参数如下表所示。 设备 接口 IP地址 子网掩码 默认网关 R1 S0/0 192.…

【内存管理大猫腻:从“越界”到“内存泄漏”应有尽有】

本章重点 什么是动态内存 为什么要有动态内存 什么是野指针 对应到C空间布局&#xff0c; malloc 在哪里申请空间 常见的内存错误和对策 C中动态内存“管理”体现在哪 什么是动态内存 动态内存是指在程序运行时&#xff0c;根据需要动态分配的内存空间。 #include <stdio.h&…

1.链表的实现:不带哨兵

一、链表linked list 1.定义 链表是数据元素的线性集合&#xff0c;其每个元素都指向下一个元素&#xff0c;元素存储上并不连续,链表逻辑连续。 2.分类 ①单向链表&#xff1a;每个元素只知道其下一个元素是谁 ②双向链表: 每个元素知道其上一个元素和下一个元素 ③循环链…

Java - Stream流详解

文章目录 前言 大家好,好久不见了,最近由于实训的影响导致拖更了,在更新这一次估计javaSE基本上就算是完结了,还有一些落下的后面也会补上的,下次见面就是数据结构了 尽情期待吧!那么就让我们步入Stream流的学习吧! 一、Stream流是什么&#xff1f; Stream流是Java 8中的一个…

【openEuler 20.03 TLS编译openGauss2.0.0源码】

openEuler 20.03 TLS编译openGauss2.0.0源码 一、安装环境二、安装前准备二、安装步骤 一、安装环境 项目Value操作系统openEuler 20.03 64bit with ARMopenGauss2.0.0openGauss-third_party2.0.0 二、安装前准备 项目Value购买华为ECS鲲鹏 8vCPU32G 100M/s带宽 openEuler 2…

使用CubeMX配置STM32驱动HC-SR04超声波模块

文章目录 前言1 使用STM32CubeMX初始化代码1.1 时钟配置1.2 设置定时器1.3 触发引脚1.4 串口配置 2 代码编写2.1 添加驱动文件2.2 修改main.c 3 实现效果参考 前言 硬件选择 stm32f103c8t6&#xff08;最小板&#xff09;hc-sr04超声波模块 软件环境 stm32cubeIDE 1.12.1 …

【Linux】TCP网络套接字编程+协议定制+序列化和反序列化

悟已往之不谏&#xff0c;知来者之可追。抓不住的就放手&#xff0c;属于你的都在路上…… 文章目录 一、TCP网络套接字编程1.日志等级分类的日志输出API2.单进程版本的服务器客户端通信3.多进程版本和多线程版本4.线程池版本5.守护进程化的线程池服务器6.三次握手和四次挥手的…

python编程——pycharm的安装与使用

作者&#xff1a;Insist-- 个人主页&#xff1a;insist--个人主页 本文专栏&#xff1a;python专栏 专栏介绍&#xff1a;本专栏为免费专栏&#xff0c;并且会持续更新python基础知识&#xff0c;欢迎各位订阅关注。 目录 一、python IDLE的使用 二、pycharm的安装与使用 1、…

十分钟带你看懂——Python测试框架之pytest最全讲

pytest特短 pytest是一个非常成熟的全功能的Python测试框架&#xff0c;主要有以下几个特点&#xff1a; 简单灵活&#xff0c;容易上手 支持参数化 能够支持简单的单元测试和复杂的功能测试&#xff0c;还可以用来做selenium/appnium等自动化测试、接口自动化测试&#xff08…

重磅版本发布|三大关键特性带你认识 Milvus 2.2.9 :JSON、PartitionKey、Dynamic Schema

亮点颇多、精彩程度堪比大版本的 Milvus 2.2.9 来啦&#xff01; 随着 LLM 的持续火爆&#xff0c;众多应用开发者将目光投向了向量数据库领域&#xff0c;而作为开源向量数据库的领先者&#xff0c;Milvus 也充分吸收了大量来自社区、用户、AI 从业者的建议&#xff0c;把重心…

非常简单就能理解的 链表带环问题 你也能轻松学会!

文章目录 判断链表是否带环若链表带环找出环的入口其他高频的面试问题 判断链表是否带环 题目描述&#xff1a; 给定一个链表&#xff0c;判断链表中是否有环。 思路&#xff1a; 可以明确的是&#xff1a;若一个链表带环&#xff0c;那么用指针一直顺着链表遍历&#xff0c…

《嵌入式系统》知识总结10:使用位带操作操纵GPIO

位操作 汇编层面 外设控制常要针对字中某个位&#xff08;Bit&#xff09;操作 以字节编址的存储器地址空间中&#xff0c;需要3步骤&#xff08;读出-修改-写回&#xff09; 1.&#xff08;从外设&#xff09;读取包含该位的字节数据 2. 设置该位为0或1、同时屏蔽其他位&am…