当前位置: 首页 > news >正文

【redis】从高并发场景下超卖问题到redis分布式锁

文章目录

  • 代码分析并解决超卖问题
  • 主从锁失效问题如何解决
  • 加锁后如何提升效率
  • 缓存、数据库双写不一致问题如何解决

案例是从诸葛老师那边看来的,感觉这些分析的过程,让人受益良多,特此总结并加上一些自己的理解,做篇记录。

这是段从redis获取库存数量,当库存数量大于0时,库存减1 并将新的库存值设置回redis的代码

代码分析并解决超卖问题

Q1: 这段代码有问题吗?
在这里插入图片描述
A1: 在高并发的场景下会有问题。

这里有个概念,我们都知道堆是线程共享的,而栈是线程不共享的,我们说栈是线程安全的,但是这个安全是怎么理解呢?我们先来看看下列一段简单的代码:

@Slf4j
@RestController
@RequestMapping("/stack")
public class StackController {

    @GetMapping
    public void test() {
        int a = 0;
        for (int i = 0; i < 10; i++) {
            a = i;
            // 记录a的输出值
            log.error(String.valueOf(a));
        }
    }
}

在这段代码中,我们知道日志中记录从0到9次数都是相等的,一个线程执行下来 那就是各记录一次,根据我们日常开发经验 ,也从不会去怀疑这种写法有什么问题,这就是因为栈内是安全的,带给我们足够的安全感。

接着我们使用jmeter模拟高并发场景:

0秒内访问200次
在这里插入图片描述
我们将日志结果复制到notepad++中,便于观察:

这个有个notepad++使用的小细节,我们日志格式输出是这样的
(当然 你去修改日志格式也可以)
在这里插入图片描述
我们可以在notepad++中使用正则表达式,例如我使用的是

// 第一个 'r'之前的字符 
^([^s]*)r

在这里插入图片描述
这样就没有多余的其它数字干扰我们

我们从0到9分别计数,发现数量都为200(不一 一截图了 感兴趣可以去自己操作一遍)
在这里插入图片描述
假设线程不是安全的,那么次数就可能出现不等。

我们知道 堆内存是线程共享,不安全的,如果我们将代码改成如下:

@Slf4j
@RestController
@RequestMapping("/stack")
public class StackController {
	// 成员变量 位于堆内存
    int a = 0;
    
    @GetMapping
    public void test() {
      
        for (int i = 0; i < 10; i++) {
            a = i;
            log.error(String.valueOf(a));
        }
    }
}

此时计数就出现了不相等的情况,出现了线程安全问题。

在这里插入图片描述
回到一开始的问题,线程安全怎么理解?thread1读到了thread2修改后的值,和我们要的结果产生了偏差。那我们Q1中的代码片段,明明在栈内存中,为什么会有问题呢? 这就只可意会 , 不可言传了~

咳咳,正经分析:

在这里插入图片描述

我们假设thread1 和 thread2 同时查询redis,stock都为200,那么realStock都为199, 那么将数据set回redis的时候,明明是2个线程减库存了,值正确应该是198,而不是199。在这个过程中,严格来说是并发安全问题,和上面我们举例的线程安全问题还是有那么点区别的,每个线程它计算的199有错吗?没有错。对它自己来说,结果是正确的,问题就出现在并发场景下,两个结果都没错的线程 在一起就是错。

那么我们该怎么解决呢?首先最容易想到的 应该就是加锁了

Q2: 这段代码会有问题吗? (除性能问题外)
在这里插入图片描述
A2: 在跨进程中会有问题(分布式场景下),上面代码只能锁同一个jvm。

在分布式环境中,处理跨进程的任务 我们一般都需要借助第三方工具,例如分布式事务中,我们使用seata,seata起到一个调度的功能;例如集群环境中,我们可以使用注册中心 作为一个容器 从而对每次客户端的访问进行分发请求。 同样的,我们要实现分布式锁,那同样的需要借助外部的力量。
redis自带一个命令: setnx (set if not exist),如果存在则不再插入; 区别于set命令插入并替换。

接着将代码稍作修改 如下:

Q3: 这段代码有什么问题?
在这里插入图片描述
A3:可能出现锁无法释放的问题,在delete执行前,出现了异常,那这个锁就无法删除了。

这个时候,我们或许能想起 在IO流中,我们close方法一定要写在finally中, 这里锁释放也是一样的,我们再将代码修改一下

Q4: 这段代码有什么问题
在这里插入图片描述

A4: 仍然可能出现锁释放的问题,例如在极端环境下,执行到finally突然服务挂了,那锁就没有成功释放

这个时候我们或许能想到设置超时时间, 这就和java中的 ReenttrantLock 锁,有点类似。如果熟悉阿里规范的同学,或许知道 写ReenttrantLock锁时,规定加锁的业务代码必须写在try catch里面, 锁释放写在finally里面。

Q5: 这段代码有问题吗
在这里插入图片描述
A5: 当代码执行完set,并即将设置过期时间时,如果这时服务挂了,那过期时间就设置失败,和Q4是一样的问题,根本原因在于set和expire不是原子执行的。

那么我们将set和expire原子执行一下,redisTemplate也提供了这个方法,把代码再改造一下

Q6: 这段代码有什么问题?
在这里插入图片描述
A6: 可能会删除其它线程的锁。

例如thread1 总共需要执行15s, 而锁在10s就过期了;假设在第10s时thread2执行进来,加锁成功,接着thread1就把thread2加的锁给删除了。那有同学可能就会说,把时间设置长一些,可即使是时间设置长,也只是减小概率,并没有完全解决问题,在高并发场景下,数据库资源紧张、Full Gc 等各种原因 都可能导致暂时卡顿,请求时间延长。

既然删掉了别人的锁,那么带上自己的id ,删除的时候 把自己id对应的锁删除 是不是就解决了呢?

Q7: 这段代码有什么问题?
在这里插入图片描述
A7 :这里虽然加上了UUID(不考虑UUID重复),但如果执行完equlas判断,突然卡顿,准备执行delete方法时 超时时间过了,一样会删除别的线程的锁, equals和delete也需要原子执行。

当然 到这里为止,应对并发量不高的情况,那已经够了。

如果继续优化,这时候我们就可以引入redisson了,三行代码完成我们上述逻辑,并解决超时问题。

getLock 获取锁,lock 加锁,unlock 释放锁。

最终代码:
在这里插入图片描述
redisson是如何实现分布式锁的?

第一步: 加锁时使用lua脚本,lua脚本是原子执行的 ,带上当前线程id
设置过期时间(默认是30s,可以修改)
在这里插入图片描述
第二步: 开启一个新的线程(定时器),在过期时间的1/3 (10s)检查主线程是否还持有锁,如果持有,将过期时间重新设置为30s,实现锁续命。
在这里插入图片描述

那最终代码有没有问题呢?但问题仍然存在,我们知道分布式环境中,redis会有主从节点,当主节点加锁成功,会马上返回结果,这时还没将key同步到从节点,如果这个瞬间 主节点挂了,那就会出现 主从锁失效 问题。

主从锁失效问题如何解决

这里我们引入一个概念:CAP原则

CAP原则,指的是在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。

很显然 redisson满足的是AP原则, 如果要完全保证一致性,我们可以使用zookeeper,
zk满足CP原则,zk需要同步至少半数以上节点同步成功,才会返回结果。

那如果我们即想用redisson 又想解决主从锁丢失问题,该怎么办呢?
我们可以引入 redlock ,实现思想和zk类似, 不过redlock的redis都是对等节点,不是主从节点。根据诸葛老师的话,虽然可以解决 但是不推荐,redlock还有些待商榷的问题。

加锁后如何提升效率

现在问题解决了,那么性能问题如何优化呢?

我们可以采用分段锁的思想,在库存初始化的时候,就进行分段,例如200的库存,分成10份20的库存,分别进行加锁。

分段锁的几个重点:拆库存,随机扣减(单个库存够的情况),合并扣减(单个库存不够的情况)

缓存、数据库双写不一致问题如何解决

产生原因还原1:
例如 thread1 执行两步操作:写数据库stock = 10 , 更新缓存 stock = 10;
thread2 执行两步操作: stock = 6 ,更新缓存 stock = 6;
thread1 执行完写数据库后,发生了卡顿,这时thread2执行完了,实际库存应该是6,但接着thread1恢复执行,将缓存修改为10。

产生原因还原2:
thread1 写数据库stock = 10 ,删除缓存,

thread2查缓存(空),查数据库 stock = 10 ,更新缓存

thread3 写数据库 stock=6 ,删除缓存

如果thread3 在thread2 查数据库和更新缓存过程中执行完毕,那一样出现了不一致的问题。

解决方案:
1.延迟双删 (同样可能会有问题 就像上文提到的超时问题,时间是不可控的)
2. 内存队列 针对同一个key的操作 串行化运行 (但有性能问题)
3. 分布式锁 和内存队列类似,也有性能问题

相关文章:

  • vscode安装及c++配置
  • 【Java Web项目】基于WebSocket的Web聊天室
  • Ansible自动化运维
  • 多线程【锁策略与CAS的ABA问题】
  • 清华系激光雷达公司,成了量产元年最大的黑马
  • 基于springboot的球员转会管理系统的设计与实现-计算机毕业设计源码+LW文档
  • 【Arduino与MFRC522握手】
  • 生成式对抗网络(GAN)【第六章】
  • 了解 Flutter 开发者们的 IDE 使用情况
  • linux下的权限
  • AI 杀疯了,NovelAI开源教程
  • 【深度学习】卷积神经网络之图像分类|CNN、AlexNet、VGG、GoogLeNet、ResNet
  • 手把手完成智慧路灯的开发,完成设备上云【华为云IoT】
  • SpringBoot数据层测试事务回滚
  • 【计算机毕业设计选题】10套易过的精品毕设项目分享(源码+论文)
  • hexo+github搭建个人博客
  • Dubbo 整合 Pinpoint 做分布式服务请求跟踪
  • HTML-表单
  • Java面向对象及其三大特征
  • JS正则表达式精简教程(JavaScript RegExp 对象)
  • Swoft 源码剖析 - 代码自动更新机制
  • tweak 支持第三方库
  • 阿里云容器服务区块链解决方案全新升级 支持Hyperledger Fabric v1.1
  • 湖南卫视:中国白领因网络偷菜成当代最寂寞的人?
  • 极限编程 (Extreme Programming) - 发布计划 (Release Planning)
  • 将 Measurements 和 Units 应用到物理学
  • 码农张的Bug人生 - 初来乍到
  • 排序(1):冒泡排序
  • 前端js -- this指向总结。
  • 入口文件开始,分析Vue源码实现
  • 阿里云IoT边缘计算助力企业零改造实现远程运维 ...
  • ​Linux Ubuntu环境下使用docker构建spark运行环境(超级详细)
  • ![CDATA[ ]] 是什么东东
  • # Python csv、xlsx、json、二进制(MP3) 文件读写基本使用
  • #Datawhale AI夏令营第4期#多模态大模型复盘
  • #Spring-boot高级
  • #微信小程序(布局、渲染层基础知识)
  • (el-Transfer)操作(不使用 ts):Element-plus 中 Select 组件动态设置 options 值需求的解决过程
  • (libusb) usb口自动刷新
  • (NSDate) 时间 (time )比较
  • (八)光盘的挂载与解挂、挂载CentOS镜像、rpm安装软件详细学习笔记
  • (超详细)语音信号处理之特征提取
  • (每日持续更新)jdk api之StringBufferInputStream基础、应用、实战
  • (实测可用)(3)Git的使用——RT Thread Stdio添加的软件包,github与gitee冲突造成无法上传文件到gitee
  • (一)appium-desktop定位元素原理
  • ******IT公司面试题汇总+优秀技术博客汇总
  • .bat批处理(十一):替换字符串中包含百分号%的子串
  • .NET DevOps 接入指南 | 1. GitLab 安装
  • .Net Remoting常用部署结构
  • .NET国产化改造探索(一)、VMware安装银河麒麟
  • .net最好用的JSON类Newtonsoft.Json获取多级数据SelectToken
  • /usr/bin/env: node: No such file or directory
  • @configuration注解_2w字长文给你讲透了配置类为什么要添加 @Configuration注解
  • [Algorithm][动态规划][路径问题][不同路径][不同路径Ⅱ][珠宝的最高价值]详细讲解
  • [android] 请求码和结果码的作用