Redis分布式锁存在哪些问题,该如何解决?

假设有这样一个场景,在一个购票软件上买一张票,但是此时剩余票数只有一张或几张,这个时候有几十个人都在同时使用这个软件购票。在不考虑任何影响下,正常的逻辑是首先判断当前是否还有剩余的票,如果有,那么就进行购买并扣减库存数,否则就会提示票数不足,购买失败。伪代码如下:

void buyTicket() {
    int stockNum = byTicketMapper.selectStockNum();
    if(stockNum>0){
        //TODO 买票流程....
        byTicketMapper.reduceStock(); // 扣减库存
    }else{
        log.info("=====>票卖完了<====");
    }
}

这段代码在逻辑上没有问题,但是在并发场景下,可能会存在一个严重的问题。当剩余票数为1时,有A,B两个用户同时点击了购买按钮,A用户通过了库存大于0的校验并开始执行购票逻辑,但是由于一些原因造成A用户的购票线程有短暂的阻塞。

而在这个阻塞的过程中,用户B发起了购买请求,并且也通过了库存大于0的校验,直到整个购买流程执行完成并且扣减了库存。那么这个时候剩余库存刚好为0,不会再有用户发起购买请求,这时用户A的购买请求阻塞被唤醒,因为在此之前已经校验过库存大于0,所以执行完购买流程后,库存还会被扣减一次。那么此时的库存为-1,这就是常听到的超卖问题。

图片

为了避免这个问题,我们可以通过加锁了方式,来保证并发的安全性。像JVM提供的内置锁synchronized,JUC提供的重入锁ReentrantLock,但是这两种锁只能保证单机环境下并发安全问题,一般在实际工作中很少会部署单节点的项目,通常都是多节点集群部署,这两个锁就失去了意义。这个时候就可以借助redis来实现分布式锁。

setnx

在集群部署的情况下,通常使用redis来实现分布式锁。其中redis提供了setnx命令,标识只有key不存在时才能设值成功,从而达到加锁的效果。

下面通过redis来改造上述的代码,其方式是购票线程首先获取锁,如果获取锁成功,那么继续执行购票业务流程,直到所有流程执行完成并扣减库存后,最终在释放锁。如果获取锁失败,那么就给出一个友好的系统提示。

void buyTicket() {
    // 获取锁
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1");
    if (lock) {
        int stockNum = byTicketMapper.selectStockNum();
        if(stockNum>0){
            //TODO 买票流程....
            byTicketMapper.reduceStock(); // 扣减库存
        }else{
            log.info("=====>票卖完了<====");
        }
        // 释放锁
        redisTemplate.delete("lock");
    } else {
        log.info("=====>系统繁忙,请稍后!<====");
    }
}

1问题1:死锁问题

通过上面的一顿梭哈,你以为这样就可以了吗,其实不然。设想一下,如果线程A在获取锁成功后,在执行购票的逻辑中出现了异常,那么这个时候就会造成锁得不到释放,其他线程始终获取不到锁,这就造成严重的死锁问题。

为了避免死锁问题的出现,我们可以对异常进行捕获,在finally中去释放锁,这样不管业务执行成功或失败,最后都会去释放锁。

void buyTicket() {
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1");
    if (lock) {
        try {
            int stockNum = byTicketMapper.selectStockNum();
            if (stockNum > 0) {
                //TODO 买票流程....
                byTicketMapper.reduceStock(); // 扣减库存
            } else {
                log.info("=====>票卖完了<====");
            }
        }finally {
            redisTemplate.delete("lock");   // 释放锁
        }
    } else {
        log.info("=====>系统繁忙,请稍后!<====");
    }
}

你以为这就结束了吗?死锁就不会发生了吗?如果你认为这样就能避免死锁的发生,那你就太不细心啦。如果在程序刚想像执行释放锁的逻辑时,redis服务突然宕机了,那么这时锁释放就失败了。在将redis服务重启后,加锁的数据又被恢复了,这样又出现了死锁的现象。

为了避免这个问题,可以为锁设置一个过期时间,这样即使redis重启恢复数据后,也会很快的过期掉。不过需要注意的是,在设置锁的过期时间时,一定要保证原子性操作,不然还是会出现死锁问题。

//不是原子操作,会出现死锁问题
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1");
//如果刚要执行该语句时,redis宕机了。上面的锁无法释放
redisTemplate.expire("lock",Duration.ofSeconds(5L));

//原子操作
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1", Duration.ofSeconds(5L));

2问题2:锁被其他线程释放问题

经过上面的又一顿梭哈,死锁的问题可以避免了,这样在高并发的情况下就能安全的执行了吗。如果锁的过期时间设置了5秒,当A线程发起购票请求并获取到了锁,但是A线程在执行购票流程时花费了6秒,此时线程A的锁已经过期。

这时线程B重新获取了锁并且也开始执行购票流程,但是A线程要比B线程执行的要快,当A线程释放锁时,问题就出现了。由于A线程执行的过程锁已经过期了,那么在执行释放锁的流程时,最终被释放的是线程B的锁,这就导致B的锁被A线程释放问题。

图片

对于这个现象,可以给每个锁设置一个唯一标识,比如像UUID,线程ID。在释放锁时,校验一下这个锁的标识是否为需要删除的锁,如果是,在进行锁的释放。

public void buyTicket() {
    String uuid = UUID.randomUUID().toString();
    // 为锁设置一个唯一标识
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, Duration.ofSeconds(5L));
    if (lock) {
        try {
            int stockNum = byTicketMapper.selectStockNum();
            if (stockNum > 0) {
                //TODO 买票流程....
                byTicketMapper.reduceStock(); // 扣减库存
            } else {
                log.info("=====>票卖完了<====");
            }
        }finally {
            String lockValue = redisTemplate.opsForValue().get("lock");
            if(lockValue.equals(uuid)){ //校验标识,通过则释放锁
                redisTemplate.delete("lock");   
            }
        }
    } else {
        log.info("=====>系统繁忙,请稍后!<====");
    }
}

3问题3:锁续期问题

使用setnx命令做分布式锁时,无法避免的一个问题就是:线程尚未执行完成,但是锁已经过期。在解决锁被其他线程误删的代码中,并不是100%能解决的,问题点在于下面这段代码。

如果线程A已经执行到了if语句并且通过了判断,当刚要执行释放锁的逻辑时,线程A的锁过期了并且线程B重新获取到了锁,那么线程A在释放锁时,释放的是B的锁。

为了完全能够解决这个问题,可以采用锁续期的方式,其实现方式是单独开一个线程用来定时监听线程的锁是否还被持有,如果还持有,那么就给这把锁增加一些过期时间,这样就不会出现上述问题了。目前市面上已经为我们提供了锁自动续期的中间件,比如redisson

 String lockValue = redisTemplate.opsForValue().get("lock");
  if(lockValue.equals(uuid)){ // 线程A的锁过期
      redisTemplate.delete("lock");   // 线程A删除了线程B的锁
   }
Redisson

redisson一般使用最多的场景就是分布式锁了,它不仅保证了并发场景下线程安全的问题,也解决了锁续期的问题。使用方式也比较简单,以3.5.7版本为例,首先需要配置redisson信息,根据自己的redis集群模式自由选择配置。在配置完成后,再来改造上面的购票方法。

@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    // 单机配置
    config.useSingleServer().setAddress("redis://127.0.0.1:3306").setDatabase(0);
    // 主从配置
    // config.useMasterSlaveServers().setMasterAddress("").addSlaveAddress("","");
    // 哨兵配置
    // config.useSentinelServers().addSentinelAddress("").setMasterName("");
    // Cluster配置
    //config.useClusterServers().addNodeAddress("");
    return Redisson.create(config);
}

对于redisson使用起来也非常简单,通过getLock方法获取到RLock对象。通过RLock的tryLock或lock方法来进行加锁(底层都是通过Lua脚本来实现的)。当获取到锁并且扣减库存后,可以使用unlock方法进行锁释放。

void buyTicket() {
    RLock lock = redissonClient.getLock("lock");
    if (lock.tryLock()) {  // 获取锁
        try {
            int stockNum = byTicketMapper.selectStockNum();
            if (stockNum > 0) {
                //TODO 买票流程....
                byTicketMapper.reduceStock(); // 扣减库存
            } else {
                log.info("=====>票卖完了<====");
            }
        } finally {
            lock.unlock(); //释放锁
        }
    } else {
        log.info("=====>系统繁忙,请稍后!<====");
    }
}
Watch Dog机制

那redisson是如何做到锁续期的呢?其实在redisson内部有一个看watch dog机制(看门狗机制),但是看门狗机制并不是在加锁时就能启动的。需要注意的是在加锁时,如果使用tryLock(long t1,long t2, TimeUnit unit)或lock(long t1,long t2, TimeUnit unit)方法并且将t2参数值设为了一个不为-1的值,那么看门口将无法生效。

看门狗在启动后会监听主线程还在执行,如果还在执行那么将会通过Lua脚本每10秒给锁续期30秒。watchlog的延时时间默认为30秒,这个值可以在配置config时自己定义。

private RFuture<Boolean> tryAcquireOnceAsync(long leaseTime, TimeUnit unit, final long threadId) {
    if (leaseTime != -1L) { // 如果leaseTime不是-1,那么将无法使用看门狗
        return this.tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
    } else {
        RFuture<Boolean> ttlRemainingFuture = this.tryLockInnerAsync(this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
        ttlRemainingFuture.addListener(new FutureListener<Boolean>() {
            public void operationComplete(Future<Boolean> future) throws Exception {
                if (future.isSuccess()) {
                    Boolean ttlRemaining = (Boolean)future.getNow();
                    if (ttlRemaining) {
                        // 看门口机制
                        RedissonLock.this.scheduleExpirationRenewal(threadId);
                    }

                }
            }
        });
        return ttlRemainingFuture;
    }
}
private long lockWatchdogTimeout = 30000L; //默认30秒
private void scheduleExpirationRenewal(final long threadId) {
    if (!expirationRenewalMap.containsKey(this.getEntryName())) {
        // 每10秒执行续期
        Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            public void run(Timeout timeout) throws Exception {
            // 通过LUA脚本为锁续期
                RFuture<Boolean> future = RedissonLock.this.commandExecutor.evalWriteAsync(RedissonLock.this.getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;", Collections.singletonList(RedissonLock.this.getName()), new Object[]{RedissonLock.this.internalLockLeaseTime, RedissonLock.this.getLockName(threadId)});
                future.addListener(new FutureListener<Boolean>() {
                    public void operationComplete(Future<Boolean> future) throws Exception {
                        RedissonLock.expirationRenewalMap.remove(RedissonLock.this.getEntryName());
                        if (!future.isSuccess()) {
                            RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", future.cause());
                        } else {
                            if ((Boolean)future.getNow()) {
                                RedissonLock.this.scheduleExpirationRenewal(threadId);
                            }

                        }
                    }
                });
            }
        }, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS); // 每10秒执行一次
        if (expirationRenewalMap.putIfAbsent(this.getEntryName(), task) != null) {
            task.cancel();
        }

    }
}

4问题4:主从切换导致锁丢失问题

虽然redisson帮助我们解决了锁续期的问题,但是在redis集群架构中,由于主从复制具有一定的延时,那么在极端情况下就会出现这样一个问题:当一个线程获取锁成功,并且成功向主节点保存了锁信息,当主节点还未像从节点同步锁信息时,主节点宕机了,那么当发生故障转移从节点切换为主节点时,线程加的锁就丢失了。

为了解决这个问题,redis引入了红锁RedLock,RedLock与大多数中间件的选举机制类似,采用过半的方式来决定操作成功还是不成功。

RedLock
  • 加锁

RedLock在工作中,并不接受redis的集群架构,无论是主从,哨兵还是Cluster。每台redis服务都是独立的,都是一台独立的Master节点。

在加锁的过程中,RedLock会记录开始加锁时的时间以及加锁成功后的时间,这两个时间差就是一台机器加锁成功所需要的时间。比如启动了5个redis服务,线程A设置锁的超时时间为5秒,当像第一台redis服务加锁成功后花费了1秒,像第二台服务加锁成功后也花费了一秒。

这个时候加到第二台机器时,已经花费了两秒的时间,但是加锁数并未过半,还需要加锁一台才能完全算加锁成功,这个时候第三台机器加锁成功又花费了1秒。那么总的加锁时间就是3秒,锁的实际过期时间就为2秒。

特别需要注意的是,在向redis服务建立网络连接时,要设置一个超时时间,避免redis服务宕机时,客户端还在傻傻的等待回应,这里超时时间官方给到建议是5-50毫秒之间,当连接超时时,客户端会继续向下一个节点发起连接。

图片

  • 加锁失败

如果因为某些原因,获取锁失败(加锁没有超半数或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁,即便某些Redis实例根本就没有加锁成功。

  • 失败重试

在并发场景下,RedLock会出现这样一个问题,比如有三个线程同时去获取了同一张票的锁,此时A线程已经成功给redis-1和reids-2加上了锁,线程B已经成功给redis-3,reids-4加上了锁,线程C成功的给reids-5加上了锁,这个时候三个线程再去加锁时,没有机器可加了,发现加锁成功数都未过半,那么就导致客户端始终获取不到锁。

图片

当客户端无法取到锁时,应该在随机延迟一定时间,然后进行重试,防止多个客户端在同时抢夺同一资源的锁。

  • 释放锁

释放锁比较简单,向所有的Redis实例发送释放锁命令即可,不用关心之前有没有从Redis实例成功获取到锁.

在了解了RedLock后,最后再来改造购票的代码逻辑。首先需要根据redis的实例数来定义对应的Bean实例,redis的实例最少要有三台。

@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    // 单机配置
    config.useSingleServer().setAddress("redis://192.168.36.128:3306").setDatabase(0);
    return Redisson.create(config);
}

@Bean
public RedissonClient redissonClient2() {
    Config config = new Config();
    // 单机配置
    config.useSingleServer().setAddress("redis://192.168.36.130:3306").setDatabase(0);
    return Redisson.create(config);
}

@Bean
public RedissonClient redissonClient3() {
    Config config = new Config();
    // 单机配置
    config.useSingleServer().setAddress("redis://192.168.36.131:3306").setDatabase(0);
    return Redisson.create(config);
}

在配置完成后,为每台实例都设置同一把锁,最后在调用RedissonRedLock提供的tryLock和unlock进行加锁和解锁。

void buyTicket(){
    RLock lock = redissonClient.getLock("lock");
    RLock lock2 = redissonClient2.getLock("lock");
    RLock lock3 = redissonClient3.getLock("lock");
    RedissonRedLock redLock = new RedissonRedLock(lock,lock2,lock3); // 分别像三台实例加锁
    if (redLock.tryLock()) {
        try {
            int stockNum = byTicketMapper.selectStockNum();
            if (stockNum > 0) {
                //TODO 买票流程....
                byTicketMapper.reduceStock(); // 扣减库存
            } else {
                log.info("=====>票卖完了<====");
            }
        } finally {
            redLock.unlock();  //释放锁
        }
    } else {
        log.info("=====>系统繁忙,请稍后!<====");
    }
}

总结

在使用reids做分布式锁时,并没有想象中的那么简单,高并发场景下容易出现死锁,锁被其他线程误删,锁续期,锁丢失等问题,在实际开发中应该考虑到这些问题并根据相应的解决办法来解决这些问题,从而保证系统的安全性。本文中可能会存在一些遗漏或错误,后续会继续跟进。

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

《Global illumination with radiance regression functions》

总结一下最近看的这篇结合神经网络的全局光照论文。 论文的主要思想是利用了神经网络的非线性特性去拟合全局光照中的间接光照部分&#xff0c;采用了基础的2层MLP去训练&#xff0c;最终能实现一些点光源、glossy材质的光照渲染。为了更好的理解、其输入输出表示如下。 首先…

如何解决Session共享问题?

解决会话&#xff08;Session&#xff09;共享问题&#xff0c;特别是在分布式或负载均衡环境中&#xff0c;通常涉及一些关键策略。 以下是一些常用的方法来解决会话共享问题&#xff1a; 粘性会话&#xff08;Sticky Sessions&#xff09;&#xff1a; 描述&#xff1a;粘性会…

好用的硬盘分区工具,傲梅分区助手 V10.2

傲梅分区助手软件可以帮助用户在硬盘上创建、调整、合并、删除分区&#xff0c;以及管理磁盘空间等操作。它可以帮助你进行硬盘无损分区操作。 支持系统 目前这款软件支持 Windows 7、Windows 8、Windows 10、Windows 11 等个人系统&#xff0c;还支持 Windows 2012/2016/2019…

PixPin带有截图/贴图/长截图/文字识别/标注的截图工具,很好用

官网地址&#xff1a;PixPin 截图/贴图/长截图/文字识别/标注 | PixPin 截图/贴图/长截图/文字识别/标注 确实挺好用的&#xff0c;推荐一下

camera卷帘快门(Rolling Shutter)与全局快门(Global Shutter)

首先来看一下什么叫快门&#xff1a; 快门是照相机用来控制感光元件有效曝光时间的装置。可以理解为光线要想打到相机传感器上必经的一道门。如果快门关着&#xff0c;那么光线进不去&#xff0c;感光元件就无法曝光&#xff1b;门开了&#xff0c;光线进来了&#xff0c;感光元…

世微 DW01 锂电池保护IC 充电器检测过充保护

一、 描述 DW01A 是一个锂电池保护电路&#xff0c;为避免锂电池因过充电、过放电、电流过大导致电池寿命缩短或电池被损坏而设计的。它具有高精确度的电压检测与时间延迟电路。 二、 主要特点 工作电流低 过充检测 4.3V&#xff0c;过充释放 4.05V&#xff1b; 过放检测 2.4…

从零开始的开发教学:搭建企业内训APP

随着企业内训需求的不断增加&#xff0c;搭建一款高效、灵活的企业内训APP成为许多公司的迫切需求。本文将带领读者一步步从零开始&#xff0c;通过简明扼要的教学&#xff0c;构建一款符合企业需求的内训应用程序。 第一步&#xff1a;明确需求和目标 在着手开发之前&#x…

clickhouse函数记录

日期函数 SELECT formatDateTime(create_time,%Y-%m-%d) AS time FROM xx.xx;

Next.js 学习笔记(一)——安装

安装 系统要求&#xff1a; Node.js 18.17 或更高版本支持 macOS、Windows&#xff08;包括 WSL&#xff09;和 Linux 自动安装 我们建议使用 create-next-app 启动一个新的 Next.js 应用程序&#xff0c;该应用程序会自动为你设置所有内容。要创建项目&#xff0c;请运行&…

浅析LDPC软解码对SSD延迟的影响-part1

此前&#xff0c;存储随笔有发布一篇关于SSD QoS相关问题&#xff0c;文章中有从以下方面做了全景的分析&#xff1a; 扩展阅读&#xff1a; 全景解析SSD IO QoS性能优化 SSD基础架构与NAND IO并发问题探讨 本文主要在之前文章的基础上&#xff0c;再做个补充&#xff0c;本…

移动端适配rem(Vant)

需要注意 该插件不能转换行内样式中的px 利用vant提供的 首先安装 可以看到 第二步配置 1.安装 npm install postcss-pxtorem -D 2.在项目根目录创建.postcssrc.js文件 配置完毕&#xff0c;重新启动服务&#xff08;红色是警告&#xff0c;是因为vue-cli已经配置过了&am…

生产环境_Apache Spark技术大牛的实践:使用DataFrame API计算唯一值数量并展示技术(属性报告)

业务背景 给前端提供算法集成好的数据&#xff0c;对算法处理后的数据进行进一步删选展示 可以使用下面代码运行一下看看结果&#xff0c;听有趣的&#xff0c;我写的代码中计算了不同字段的值的数量&#xff0c;并生成了一个显示字符串来描述这些数据的分布情况然后使用"…

Buck电源设计常见的一些问题(二)MOS管炸机问题

MOS管炸机问题 1.概述2.MOS管的相关参数3.过电压失效4.过电流失效5.静电放电和热失效1.概述 在我们做电源产品或者电机控制器时候,经常会坏MOS管。我相信90%以上的硬件工程师在职场生涯中都会遇到这类问题。然而这类问题也总是让人防不胜防。经常我们都会开玩笑的说,没烧过管…

Spring AOP 和 Spring Boot 统一功能处理

文章目录 Spring AOP 是什么什么是 AOPAOP 组成切面&#xff08;Aspect&#xff09;连接点&#xff08;Join Point&#xff09;切点&#xff08;Pointcut&#xff09;通知&#xff08;Advice&#xff09; 实现 Spring AOP添加 Spring AOP 框架支持execution表达式定义切面、切点…

初识SpringSecurity

目录 前言 特点 快速开始 导入依赖 运行项目 访问服务 权限控制 实现UserDetails接口 添加SecurityConfig配置类 测试接口DemoController 设置权限控制authorizeHttpRequests 结果分析 总结 前言 Spring Security是一个强大且高度可定制的身份验证和访问控制框架…

labelme标注json文件检查标注标签(修改imageWidth,imagePath,imageHeight)

# !/usr/bin/env python # -*- encoding: utf-8 -*- #---wzhimport os import json# 这里写你自己的存放照片和json文件的路径 json_dir =rC:\Users\Lenovo\Desktop\json3 json_files = os.listdir(json_dir

MBA-数学题概念和公式

{}公差大于零的等差数列:多个数字组成的数列&#xff0c;两两之间差相等,且后值减前值大于0&#xff0c;如&#xff1a;{-2,0,2,4}为公差数列为2的等差数列.因数是指整数a除以整数b(b≠0) 的商正好是整数而没有余数&#xff0c;10的因数为 2和5圆柱体表面积 2πr 2πrh球体表名…

【LeetCode刷题】--157.用Read4读取N个字符

157.用Read4读取N个字符 /*** The read4 API is defined in the parent class Reader4.* int read4(char[] buf4);*/public class Solution extends Reader4 {/*** param buf Destination buffer* param n Number of characters to read* return The number of actual…

天猫数据分析(天猫数据查询平台):11月天猫啤酒市场销售数据分析报告

在酒类市场中&#xff0c;被视作“气氛担当”的啤酒&#xff0c;是派对聚会或者自饮场景中的常客&#xff0c;消费人群广泛&#xff0c;如今&#xff0c;啤酒市场已进入存量时代&#xff0c;市场中啤酒的销售也在稳步增长。 鲸参谋数据显示&#xff0c;今年11月份&#xff0c;天…

LeetCode(64)分隔链表【链表】【中等】

目录 1.题目2.答案3.提交结果截图 链接&#xff1a; 分隔链表 1.题目 给你一个链表的头节点 head 和一个特定值 x &#xff0c;请你对链表进行分隔&#xff0c;使得所有 小于 x 的节点都出现在 大于或等于 x 的节点之前。 你应当 保留 两个分区中每个节点的初始相对位置。 示…