谷粒商城--性能调优与压力测试--高级篇笔记五

谷粒商城–性能调优与压力测试–高级篇笔记五

1.压力测试

压力测试考察当前软硬件环境下系统所能承受的最大负荷并帮助找出系统瓶颈所在。 压测都是为了系统在线上的处理能力和稳定性维持在一个标准范围内, 做到心中有数。

使用压力测试, 我们有希望找到很多种用其他测试方法更难发现的错误。 有两种错误类型是:内存泄漏并发与同步

有效的压力测试系统将应用以下这些关键条件:重复并发量级随机变化

1.1 性能指标

  • 响应时间(Response Time:RT)
    响应时间指用户从客户端发起一个请求开始,到客户端接收到从服务器端返回的响应结束,整个过程所耗费的时间。

  • HPS(Hits Per Second):每秒点击次数,单位是次/秒。【不是特别重要】

  • TPS(Transaction per Second):系统每秒处理交易数,单位是笔/秒。

  • Qps(Query per Second):系统每秒处理查询次数,单位是次/秒。
    对于互联网业务中,如果某些业务有且仅有一个请求连接,那么TPS=QPS=HPS,一般情况下用 TPS来衡量整个业务流程,用QPS来衡量接口查询次数,用HPS来表示对服务器单击请求。

  • 无论TPS、QPS、HPS,此指标是衡量系统处理能力非常重要的指标,越大越好,根据经
    验,一般情况下:
    金融行业:1000TPS~5000OTPS,不包括互联网化的活动
    保险行业:100TPS~10000OTPS,不包括互联网化的活动
    制造行业:10TPS~5000TPS
    互联网电子商务:1000OTPS~1000000TPS
    互联网中型网站:1000TPS~50000TPS
    互联网小型网站:50OTPS~10000TPS

  • **最大响应时间(MaxResponse Time)**指用户发出请求或者指令到系统做出反应(响应)的最大时间。

  • **最少响应时间(Mininum ResponseTime)**指用户发出请求或者指令到系统做出反应(响应)的最少时间。

  • **90%响应时间(90%Response Time)**是指所有用户的响应时间进行排序,第90%的响应时间。

  • 从外部看,性能测试主要关注如下三个指标
    吞吐量:每秒钟系统能够处理的请求数、任务数。
    响应时间:服务处理一个请求或一个任务的耗时。
    错误率。一批请求中结果出错的请求所占比例。

1.2 JMeter 安装

JMeter下载地址

运行jmeter.bat

image-20211101101801200

1.3 JMeter 中文配置

image-20211101102029824

1.4 JMeter 压测示例

1.4.1 添加线程组

image-20211101104037076

image-20211101104400679

线程组参数详解:

  • 线程数: 虚拟用户数。 一个虚拟用户占用一个进程或线程。 设置多少虚拟用户数在这里也就是设置多少个线程数。
  • Ramp-Up Period(in seconds)准备时长: 设置的虚拟用户数需要多长时间全部启动。 如果线程数为 10, 准备时长为 2, 那么需要 2 秒钟启动 10 个线程, 也就是每秒钟启动 5 个线程。
  • 循环次数: 每个线程发送请求的次数。 如果线程数为 10, 循环次数为 100, 那么每个线程发送 100 次请求。 总请求数为 10*100=1000 。 如果勾选了“永远”, 那么所有线程会一直发送请求, 一到选择停止运行脚本。
  • Delay Thread creation until needed: 直到需要时延迟线程的创建。
  • 调度器: 设置线程组启动的开始时间和结束时间(配置调度器时, 需要勾选循环次数为永远)
  • 持续时间(秒) : 测试持续时间, 会覆盖结束时间
  • 启动延迟(秒) : 测试延迟启动时间, 会覆盖启动时间
  • 启动时间: 测试启动时间, 启动延迟会覆盖它。 当启动时间已过, 手动只需测试时当前时间也会覆盖它。
  • 结束时间: 测试结束时间, 持续时间会覆盖它。

1.4.2 添加 HTTP 请求

image-20211101104507326

image-20211101104933592

1.4.3 添加监听器

image-20211101105027448

  1. 结果树

image-20211101105131273

  1. 汇总报告

image-20211101105202063

3.聚合报告

image-20211101105229519

1.4.4 启动压测&查看分析结果

结果分析 :

  • 有错误率同开发确认, 确定是否允许错误的发生或者错误率允许在多大的范围内;

  • Throughput 吞吐量每秒请求的数大于并发数, 则可以慢慢的往上面增加; 若在压测的机器性能很好的情况下, 出现吞吐量小于并发数, 说明并发数不能再增加了, 可以慢慢的往下减, 找到最佳的并发数;

  • 压测结束, 登陆相应的 web 服务器查看 CPU 等性能指标, 进行数据的分析;

  • 最大的 tps, 不断的增加并发数, 加到 tps 达到一定值开始出现下降, 那么那个值就是
    最大的 tps。

  • 最大的并发数: 最大的并发数和最大的 tps 是不同的概率, 一般不断增加并发数, 达到一个值后, 服务器出现请求超时, 则可认为该值为最大的并发数。

  • 压测过程出现性能瓶颈, 若压力机任务管理器查看到的 cpu、 网络和 cpu 都正常, 未达 到 90%以上, 则可以说明服务器有问题, 压力机没有问题。

  • 影响性能考虑点包括:数据库、 应用程序、 中间件(tomact、 Nginx) 、 网络和操作系统等方面

  • 首先考虑自己的应用属于 CPU 密集型还是 IO 密集型

1.5 JMeter Address Already in use 错误解决

image-20211101221805884

出现原因

windows 本身提供的端口访问机制的问题。
Windows 提供给 TCP/IP 链接的端口为 1024-5000, 并且要四分钟来循环回收他们。 就导致我们在短时间内跑大量的请求时将端口占满了。

解决思路

扩大提供给 TCP/IP 链接的端口

缩短循环回收时间

计算机\HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters

  1. 右击 parameters, 添加一个新的 DWORD, 名字为 MaxUserPort

image-20211101222707498

  1. 然后双击 MaxUserPort, 输入数值数据为 65534, 基数选择十进制(如果是分布式运行的话, 控制机器和负载机器都需要这样操作哦)

image-20211101222843778

  1. 右击 parameters, 添加一个新的 DWORD, 名字为 TCPTimedWaitDelay

image-20211101223112662

  1. 重启系统并测试

2. 性能监控

2.1 jvm内存模型

image-20211031200619881

  • 程序计数器 Program Counter Register:
    • 记录的是正在执行的虚拟机字节码指令的地址,
    • 此内存区域是唯一一个在JAVA虚拟机规范中没有规定任何OutOfMemoryError的区域
  • 虚拟机: VM Stack
    • 描述的是 JAVA 方法执行的内存模型, 每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表, 操作数栈, 动态链接, 方法接口等信息
    •  局部变量表存储了编译期可知的各种基本数据类型、 对象引用
    •  线程请求的栈深度不够会报 StackOverflowError 异常
    •  栈动态扩展的容量不够会报 OutOfMemoryError 异常
    •  虚拟机栈是线程隔离的, 即每个线程都有自己独立的虚拟机栈
  • 本地方法: Native Stack
    • 本地方法栈类似于虚拟机栈, 只不过本地方法栈使用的是本地方法
  • 堆: Heap
    • 几乎所有的对象实例都在堆上分配内存

详细模型

image-20211031200905101

2.2 堆

所有的对象实例以及数组都要在堆上分配。 堆是垃圾收集器管理的主要区域, 也被称为“GC堆” ; 也是我们优化最多考虑的地方。
堆可以细分为:

  • 新生代

    • Eden 空间

    • From Survivor 空间

    • To Survivor 空间

  • 老年代

    • 永久代/元空间

    • Java8 以前永久代, 受 jvm 管理, java8 以后元空间, 直接使用物理内存。 因此,默认情况下, 元空间的大小仅受本地内存限制。

2.2.1垃圾回收

image-20211031201113805

image-20211031201137029

详细流程

image-20211031201216029

创建对象放到堆内存的流程:
1、放到eden区,如果放不下执行MinorGC
2、再判断是否放得下不,放判断老年代是否放得下,放不下执行 FullGC,【FullGC会先触发MinorGC】
3、如果老年代放不下OOM

旧对象:
1、放到幸存者区survivor,如果放得下放在to区【然后from 和 to转变身份】【超过阈值15放到老年代】
2、如果survivor放不下判断老年代是否放得下,放不下执行FullGC
3、如果老年代放不下OOM异常

2.3 jconsole 与 jvisualvm

2.3.1 jconsole

Jdk 的两个小工具 jconsole、 jvisualvm(升级版的 jconsole) ;通过命令行启动, 可监控本地和远程应用。 远程应用需要配置

2.3.1.1直接cmd输入jconsole

进入jconsole页面选择需要查看的进程

image-20211031202708349

2.3.3.2 首页情况

image-20211031203018071

2.3.3.3 内存情况

image-20211031203446300

2.3.2 jvisualvm

2.3.2.1 jvisualvm 能干什么

监控内存泄露, 跟踪垃圾回收, 执行时内存、 cpu 分析, 线程分析…

image-20211101095646712

  • 运行: 正在运行的
  • 休眠: sleep
  • 等待: wait
  • 驻留: 线程池里面的空闲线程
  • 监视: 阻塞的线程, 正在等待锁
2.3.2.2 Visual GC插件安装
  1. cmd 启动jvisualvm
  2. 安装 Visual GC

image-20211101093437530

2.3.2.3 插件安装失败问题

原因:

  1. 可能是因为更新链接配置的版本不对
  2. 自己有代理的话一定要关闭

image-20211101093711929

  1. 查看自己jdk版本

我的版本是281xxx

image-20211101093830346

  1. 打开网址 https://visualvm.github.io/pluginscenters.html

找到对应的版本复制链接

image-20211101094043497

  1. 修改配置的链接

image-20211101094207050

2.3.2.4 重启jvisualvm效果如下

image-20211101094841740

2.4 监控指标

2.4.1 中间件监控指标

image-20211102223020701

  • 当前正在运行的线程数不能超过设定的最大值。 一般情况下系统性能较好的情况下, 线程数最小值设置 50 和最大值设置 200 比较合适。
  • 当前运行的 JDBC 连接数不能超过设定的最大值。 一般情况下系统性能较好的情况下,JDBC 最小值设置 50 和最大值设置 200 比较合适。
  • G C 频率不能频繁, 特别是 FULL GC 更不能频繁, 一般情况下系统性能较好的情况下,JVM 最小堆大小和最大堆大小分别设置 1024M 比较合适。

2.4.2 数据库监控指标

image-20211102223143419

  • SQL 耗时越小越好, 一般情况下微秒级别。
  • 命中率越高越好, 一般情况下不能低于 95%。
  • 锁等待次数越低越好, 等待时间越短越好。

3. 项目调优

3.1 优化中间件对性能的影响

3.1.1 对nginx进行压测

#动态查看doker各个容器的状态
docker stats

初始状态

image-20211102221033326

image-20211102220552840

50个线程压测后状态

image-20211102221308812

得出结论nginx是CPU 密集型

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944

3.1.2 对网关进行压测

image-20211102223818641

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31

对cpu与内存进行监控

得出结论网关也是cup密集型

image-20211102224738695

对gc进行监控

发现网关的不断在进行轻gc,偶尔执行重gc

虽然轻gc的次数远远大于重gc的次数,但是所用时间并没有多多少

得出结论:

可以适当调大内存区的大小,避免gc次数太多而造成性能的下降

image-20211102224812541

3.1.3 对简单服务进行压测

gulimall-product/src/main/java/site/zhourui/gulimall/product/web/IndexController.java

//压测简单服务(无任何业务逻辑):
	@ResponseBody
    @GetMapping("/hello")
    public String hello() {
    
    
        return "hello";
    }

image-20211102222249599

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17

3.1.4 首页一级菜单渲染(thymeleaf 关闭缓存)

image-20211105145210580

image-20211105145126415

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17
首页一级菜单渲染 50 270(db,thymeleaf) 267 365

3.1.5 首页一级菜单渲染(thymeleaf 开启缓存)

thymeleaf开启缓存后吞吐量有一定的提升

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17
首页一级菜单渲染 50 270(db,thymeleaf) 267 365
首页渲染(开缓存) 50 290 251 365

3.1.6 首页一级菜单渲染(开缓存,加索引,关日志)

  • 开启缓存

      thymeleaf:
        cache: true
    
  • 对pms_category表的parent_cid加上索引

image-20211106164127889

  • 关日志
logging:
  level:
    site.zhourui.gulimall: error

数据库的优化(加索引)对性能的提升还是挺大的

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17
首页一级菜单渲染 50 270(db,thymeleaf) 267 365
首页渲染(开缓存) 50 290 251 365
首页渲染(开缓存、 优化数据库、 关日 志) 50 700 105 183

3.1.6 三级分类数据获取

主要是数据库导致的吞吐量降低,太慢了

localhost:10001/index/catalog.json

image-20211106154643409

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17
首页一级菜单渲染 50 270(db,thymeleaf) 267 365
首页渲染(开缓存) 50 290 251 365
首页渲染(开缓存、 优化数据库、 关日 志) 50 700 105 183
三级分类数据获取 50 2(db)

3.1.7 三级分类数据获取(加索引)

对pms_category表的parent_cid加上索引

image-20211106164127889

吞吐量有一定的提升

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17
首页一级菜单渲染 50 270(db,thymeleaf) 267 365
首页渲染(开缓存) 50 290 251 365
首页渲染(开缓存、 优化数据库、 关日 志) 50 700 105 183
三级分类数据获取 50 2(db)
三级分类数据获取(加索引) 50 8

3.1.8 三级分类数据获取(优化业务)

1)、优化业务逻辑:
1、一次性查询出来
2、将下面查库抽取为方法,不是真的查库baseMapper.selectList(new QueryWrapper().eq(“parent_cid”, level1.getCatId()));抽取为一个方法

将第一次查询的数据存起来,封装一个方法查询这个数据,就不会重复查询数据库

3.1.8.1 idea抽取方法

选中右键:refacto=》extract=》Method

优化业务后吞吐量有了质的飞越,说明业务对性能的影响也挺大的

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17
首页一级菜单渲染 50 270(db,thymeleaf) 267 365
首页渲染(开缓存) 50 290 251 365
首页渲染(开缓存、 优化数据库、 关日 志) 50 700 105 183
三级分类数据获取 50 2(db)
三级分类数据获取(加索引) 50 8
三级分类( 优化业 务) 50 111 571 896

3.1.9 三级分类数据获取(使用redis作为缓存)

吞吐量也有比较明显的提升

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17
首页一级菜单渲染 50 270(db,thymeleaf) 267 365
首页渲染(开缓存) 50 290 251 365
首页渲染(开缓存、 优化数据库、 关日 志) 50 700 105 183
三级分类数据获取 50 2(db)
三级分类数据获取(加索引) 50 8
三级分类( 优化业 务) 50 111 571 896
三 级 分 类 ( 使 用 redis 作为缓存) 50 411 153 217

3.1.10 首页全量数据获取(包括静态资源)

之前的压测都没有导入静态资源

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17
首页一级菜单渲染 50 270(db,thymeleaf) 267 365
首页渲染(开缓存) 50 290 251 365
首页渲染(开缓存、 优化数据库、 关日 志) 50 700 105 183
三级分类数据获取 50 2(db)
三级分类数据获取(加索引) 50 8
三级分类( 优化业 务) 50 111 571 896
三 级 分 类 ( 使 用 redis 作为缓存) 50 411 153 217
首页全量数据获取 50 7(静态资源)

3.1.11 Nginx+Gateway

3.1.12 Gateway+简单服务

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17
首页一级菜单渲染 50 270(db,thymeleaf) 267 365
首页渲染(开缓存) 50 290 251 365
首页渲染(开缓存、 优化数据库、 关日 志) 50 700 105 183
三级分类数据获取 50 2(db)
三级分类数据获取(加索引) 50 8
三级分类( 优化业 务) 50 111 571 896
三 级 分 类 ( 使 用 redis 作为缓存) 50 411 153 217
首页全量数据获取 50 7(静态资源)
Nginx+Gateway 50
Gateway+简单服务 50 3124 30 125

3.1.12 全链路

  • SQL 耗时越小越好, 一般情况下微秒级别。
  • 命中率越高越好, 一般情况下不能低于 95%。
  • 锁等待次数越低越好, 等待时间越短越好。
压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 2335 11 944
Gateway 50 10367 8 31
简单服务 50 11341 8 17
首页一级菜单渲染 50 270(db,thymeleaf) 267 365
首页渲染(开缓存) 50 290 251 365
首页渲染(开缓存、 优化数据库、 关日 志) 50 700 105 183
三级分类数据获取 50 2(db)
三级分类数据获取(加索引) 50 8
三级分类( 优化业 务) 50 111 571 896
三 级 分 类 ( 使 用 redis 作为缓存) 50 411 153 217
首页全量数据获取 50 7(静态资源)
Nginx+Gateway 50
Gateway+简单服务 50 3124 30 125
全链路简单服务 50 800 88 310
  • 中间件越多, 性能损失越大, 大多都损失在网络交互了;
  • 业务:
    • Db(MySQL 优化)
    • 模板的渲染速度(缓存)
    • 静态资源

3.1.13 动静分离

3.1.13.1 什么要动静分离

image-20211106170516846

为什么要进行动静分离?

如果是前后端分离的项目,那么就不会出现这个情况

未分离的项目静态资源放在后端,无论是动态请求还是静态请求都会来到后台,这极大的损耗了后台性能(大部分性能都用来处理静态请求)

动静分离后,后台只会处理动态请求,而静态资源直接由nginx返回

3.1.13.2 在nginx的html目录下新建一个static目录用来存放静态资源
mkdir /mydata/nginx/html/static

将gulimall-product的静态资源复制到该目录,并将本地静态资源删除

image-20211106171453399

3.1.13.3 修改之前的静态资源路径

gulimall-product/src/main/resources/templates/index.html

将原来的index/xxx路径修改为/static/index/xxx

image-20211106171931297

3.1.13.4 在nginx配置静态资源的路径映射
vim /mydata/nginx/conf/conf.d/gulimall.conf 

配置如下内容

#监听gulimall.com:80/static,返回root
location /static {
    
    
    root  /usr/share/nginx/html;
}

image-20211106173405714

3.1.13.5 测试

刷新后静态资源加载成功

image-20211106173554342

3.1.14 堆内存溢出问题

线上应用内存崩溃宕机

image-20211106175056213

原因:

服务分配的内存太小,导致新生代,老年代空间都满了,gc 后也没有空间

解决方案:

调大堆内存

-Xmx1024m -Xms1024m -Xmn512m

  • -Xms :初始堆大小
  • -Xmx :最大堆大小
  • -Xmn :堆中新生代初始及最大大小

image-20211106175433167

3.2 缓存与分布式锁

3.2.1 缓存

为了系统性能的提升, 我们一般都会将部分数据放入缓存中, 加速访问。 而 db 承担数据落盘工作。

3.2.1.1 哪些数据适合放入缓存
  • 即时性、 数据一致性要求不高的
  • 访问量大且更新频率不高的数据(读多, 写少)
    举例: 电商类应用, 商品分类, 商品列表等适合缓存并加一个失效时间(根据数据更新频率来定), 后台如果发布一个商品, 买家需要 5 分钟才能看到新的商品一般还是可以接受的。

image-20211107162021619

**注意:**在开发中, 凡是放入缓存中的数据我们都应该指定过期时间, 使其可以在系统即使没有主动更新数据也能自动触发数据加载进缓存的流程。 避免业务崩溃导致的数据永久不一致问题

3.2.1.2 本地缓存

本地缓存可以用map实现,将需要缓存的数据存入map,查询时先判断是否为空,不为空就直接从map中取值,不用查询数据库,不为空就需要查询数据,并将数据存入map中,下次查询就不用查询数据库

image-20211107162212174

本地缓存在分布式下的问题

  1. 集群下的本地缓存不共享,存在于jvm中【并且负载均衡到新的机器后会重新查询】
  2. 数据一致性:如果一台机器修改了数据库+缓存,但是集群下其他机器的缓存未修改所以分布式情况下不使用本地缓存

image-20211107162631149

3.2.1.3 分布式缓存(redis)

使用redis作为缓存中间件

redis内存不足时可以进行集群+分片操作

如:redis:
集群+分片【110000,1000120000】

image-20211107162900453

3.2.1.4 整合redis缓存测试
  1. 引入依赖

gulimall-product/pom.xml

		<!--redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
  1. 配置redis属性,host,port

    spring:
      redis:
        host: 192.168.157.128
        port: 6379
    
  2. 测试

gulimall-product/src/test/java/site/zhourui/gulimall/product/GulimallProductApplicationTests.java

 @Autowired
    StringRedisTemplate redisTemplate;
    @Test
    public void testRedis() {
    
    
        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        // 保存
        ops.set("hello", "world_" + UUID.randomUUID().toString());
        // 查询
        String hello = ops.get("hello");
        System.out.println(hello);

    }

image-20211107164223416


3.2.1.5 改造三级分类业务(redis缓存使用)
	Autowired
    StringRedisTemplate redisTemplate;

 	@Override
    public Map<String, List<Catalog2Vo>> getCatalogJson() {
    
    
        //1.加入缓存逻辑
        String catelogJSON = redisTemplate.opsForValue().get("catelogJSON");
        if (StringUtils.isEmpty(catelogJSON)){
    
    
            //2.缓存中没有,查询数据库
            Map<String, List<Catalog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb();
            //3.查到的数据再放入缓存,将对象转为json放在缓存中
            String s = JSON.toJSONString(catalogJsonFromDb);
            redisTemplate.opsForValue().set("catelogJSON",s);
        }
        Map<String, List<Catalog2Vo>> result = JSON.parseObject(catelogJSON, new TypeReference<Map<String, List<Catalog2Vo>>>() {
    
    
        });
        return result;
    }

    //从数据库查询并封装分类的数据
    public Map<String, List<Catalog2Vo>> getCatalogJsonFromDb() {
    
    
        // 一次性获取所有 数据
        List<CategoryEntity> selectList = baseMapper.selectList(null);
        System.out.println("调用了 getCatalogJson  查询了数据库........【三级分类】");
        // 1)、所有1级分类
        List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);

        // 2)、封装数据
        Map<String, List<Catalog2Vo>> collect = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), level1 -> {
    
    
            // 查到当前1级分类的2级分类
            List<CategoryEntity> category2level = getParent_cid(selectList, level1.getCatId());
            List<Catalog2Vo> catalog2Vos = null;
            if (category2level != null) {
    
    
                catalog2Vos = category2level.stream().map(level12 -> {
    
    
                    // 查询当前2级分类的3级分类
                    List<CategoryEntity> category3level = getParent_cid(selectList, level12.getCatId());
                    List<Catalog2Vo.Catalog3Vo> catalog3Vos = null;
                    if (category3level != null) {
    
    
                        catalog3Vos = category3level.stream().map(level13 -> {
    
    
                            return new Catalog2Vo.Catalog3Vo(level12.getCatId().toString(), level13.getCatId().toString(), level13.getName());
                        }).collect(Collectors.toList());
                    }
                    return new Catalog2Vo(level1.getCatId().toString(), catalog3Vos, level12.getCatId().toString(), level12.getName());
                }).collect(Collectors.toList());
            }
            return catalog2Vos;
        }));
        return collect;
    }

image-20211107172335638

3.2.1.5.1 压测出内存泄露及解决

image-20211107172654533

原因

  1. springboot2.0以后默认使用lettuce作为操作redis的客户端。他使用netty进行网络通信
  2. lettuce的bug导致netty堆外内存溢出 -Xmx1024m;netty如果没有指定堆外内存,默认使用-Xmx1024m,跟jvm设置的一样【迟早会出异常】

解决方案

  • 可以通过-Dio.netty.maxDirectMemory进行设置【仍然会异常】
  • 解决方案:不能使用-Dio.netty.maxDirectMemory
    1. 升级lettuce客户端;【2.3.2已解决】【lettuce使用netty吞吐量很大】
    2. 切换使用jedis客户端【这里学习一下如何使用jedis,但是最后不选用】

剔除lettuce-core,使用jedis

 		<!--redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!--jedis,redis客户端-->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>
3.2.1.6 RedisTemplate底层原理

0、Lettuce和Jedis是redis的客户端,RedisTemplate是对Lettuce和Jedis的再一层封装
1、RedisAutoConfiguration自动配置类,会导入Lettuce和Jedis的配置类
2、JedisConfiguration.java类会给容器放一个@Bean::JedisConnectionFactory

1597667784903

3.2.2 高并发下缓存失效问题

3.2.2.1 缓存穿透

缓存穿透:指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义

风险:利用不存在的数据进行攻击,数据库瞬时压力增大,最终导致崩溃 缓存

解决:null结果缓存,并加入短暂过期时间

image-20211107180305405

image-20211107173738491

3.2.2.2 缓存雪崩

缓存雪崩:缓存雪崩是指在我们设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

解决:原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体
失效的事件。

如果已经出现的情况:解决方法一:熔断、降级

image-20211107173950237

3.2.2.3 缓存击穿 【分布式锁】

一条数据过期了,高并发情况下导致所有请求到达DB
解决:加分布式锁,获取到锁,先查缓存,其他人就有数据,不用去DB

缓存击穿

  • 对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。
  • 如果这个key在大量请求同时进来前正好失效,那么所有对这个key的数据查询都落到db,我们称为缓存击穿

解决
加锁
大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去db

image-20211107174149755

3.2.3 分布式锁

3.2.3.1 本地锁 synchronized

image-20211109172726916

//从数据库查询并封装分类的数据
    public Map<String, List<Catalog2Vo>> getCatalogJsonFromDb() {
    
    
        //只要是同一把锁,就能锁住需要这个锁的所有线程
        //synchronized (this):SpringBoot所有组件在容器中都是单例的
        //TODO 本地锁:synchronized,JUC(Lock)

        synchronized (this) {
    
    
            //得到锁后,我们应该再去缓存确定一次,如果没有才需要继续查询
            String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
            if (!StringUtils.isEmpty(catalogJSON)) {
    
    
                Map<String, List<Catalog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catalog2Vo>>>() {
    
    
                });
                return result;
            }
            System.out.println("查询了数据库.....");

            // 一次性获取所有 数据
            List<CategoryEntity> selectList = baseMapper.selectList(null);
            System.out.println("调用了 getCatalogJson  查询了数据库........【三级分类】");
            // 1)、所有1级分类
            List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);

            // 2)、封装数据
            Map<String, List<Catalog2Vo>> collect = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), level1 -> {
    
    
                // 查到当前1级分类的2级分类
                List<CategoryEntity> category2level = getParent_cid(selectList, level1.getCatId());
                List<Catalog2Vo> catalog2Vos = null;
                if (category2level != null) {
    
    
                    catalog2Vos = category2level.stream().map(level12 -> {
    
    
                        // 查询当前2级分类的3级分类
                        List<CategoryEntity> category3level = getParent_cid(selectList, level12.getCatId());
                        List<Catalog2Vo.Catalog3Vo> catalog3Vos = null;
                        if (category3level != null) {
    
    
                            catalog3Vos = category3level.stream().map(level13 -> {
    
    
                                return new Catalog2Vo.Catalog3Vo(level12.getCatId().toString(), level13.getCatId().toString(), level13.getName());
                            }).collect(Collectors.toList());
                        }
                        return new Catalog2Vo(level1.getCatId().toString(), catalog3Vos, level12.getCatId().toString(), level12.getName());
                    }).collect(Collectors.toList());
                }
                return catalog2Vos;
            }));

            return collect;
        }
    }
3.2.3.2 本地锁时序问题

3.2.3.1的加锁没有完全锁住线程

原因:下图是3.2.3.1业务逻辑,在压测中当线程一加锁后执行查询数据库后锁就被释放了,在将结果放入redis缓存这个过程中下一个进程又拿到了锁,因此会打印两次查询了数据库.....

image-20211109181258973

image-20211109183001313

解决办法:

把存入缓存的操作放在锁中

image-20211109181733371

优化代码:

// TODO 产生堆外内存溢出:OutOfDirectMemoryError
    // 1)springboot2.0以后默认使用lettuce作为操作redis的客户端。他使用netty进行网络通信
    // 2)lettuce的bug导致netty堆外内存溢出 -Xmx1024m;netty如果没有指定堆外内存,默认使用-Xmx1024m,跟jvm设置的一样【迟早会出异常】
    //  可以通过-Dio.netty.maxDirectMemory进行设置【仍然会异常】
    //  解决方案:不能使用-Dio.netty.maxDirectMemory
    //  1)升级lettuce客户端;【2.3.2已解决】【lettuce使用netty吞吐量很大】
    //  2)切换使用jedis客户端【这里学习一下如何使用jedis,但是最后不选用】
    @Override
    public Map<String, List<Catalog2Vo>> getCatalogJson() {
    
    
        //1.加入缓存逻辑
        String catelogJSON = redisTemplate.opsForValue().get("catelogJSON");
        if (StringUtils.isEmpty(catelogJSON)){
    
    
            //2.缓存中没有,查询数据库
            System.out.println("缓存未命中.....查询数据库");
            Map<String, List<Catalog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb();
            //3.查到的数据再放入缓存,将对象转为json放在缓存中


        }
        System.out.println("缓存命中.....直接返回");
        Map<String, List<Catalog2Vo>> result = JSON.parseObject(catelogJSON, new TypeReference<Map<String, List<Catalog2Vo>>>() {
    
    
        });
        return result;
    }

    //从数据库查询并封装分类的数据
    public Map<String, List<Catalog2Vo>> getCatalogJsonFromDb() {
    
    
        //只要是同一把锁,就能锁住需要这个锁的所有线程
        //synchronized (this):SpringBoot所有组件在容器中都是单例的
        //TODO 本地锁:synchronized,JUC(Lock)

        synchronized (this) {
    
    
            //得到锁后,我们应该再去缓存确定一次,如果没有才需要继续查询
            String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
            if (!StringUtils.isEmpty(catalogJSON)) {
    
    
                Map<String, List<Catalog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catalog2Vo>>>() {
    
    
                });
                return result;
            }
            System.out.println("查询了数据库.....");

            // 一次性获取所有 数据
            List<CategoryEntity> selectList = baseMapper.selectList(null);
            System.out.println("调用了 getCatalogJson  查询了数据库........【三级分类】");
            // 1)、所有1级分类
            List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);

            // 2)、封装数据
            Map<String, List<Catalog2Vo>> collect = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), level1 -> {
    
    
                // 查到当前1级分类的2级分类
                List<CategoryEntity> category2level = getParent_cid(selectList, level1.getCatId());
                List<Catalog2Vo> catalog2Vos = null;
                if (category2level != null) {
    
    
                    catalog2Vos = category2level.stream().map(level12 -> {
    
    
                        // 查询当前2级分类的3级分类
                        List<CategoryEntity> category3level = getParent_cid(selectList, level12.getCatId());
                        List<Catalog2Vo.Catalog3Vo> catalog3Vos = null;
                        if (category3level != null) {
    
    
                            catalog3Vos = category3level.stream().map(level13 -> {
    
    
                                return new Catalog2Vo.Catalog3Vo(level12.getCatId().toString(), level13.getCatId().toString(), level13.getName());
                            }).collect(Collectors.toList());
                        }
                        return new Catalog2Vo(level1.getCatId().toString(), catalog3Vos, level12.getCatId().toString(), level12.getName());
                    }).collect(Collectors.toList());
                }
                return catalog2Vos;
            }));
//            redisTemplate.opsForValue().set("catelogJSON",s);
            String s = JSON.toJSONString(collect);
            redisTemplate.opsForValue().set("catelogJSON",s,1, TimeUnit.DAYS);
            return collect;
        }
    }

再次测试:

只有一次了

image-20211109183151106

3.2.3.3 本地锁在分布式场景的缺陷

本地锁只能锁住当前进程

image-20211109183833410

测试:新增几个容器

image-20211109184150575

压测

image-20211109184635402

10001商品服务

image-20211109184603290

10002商品服务

image-20211109184732822

10003商品服务

image-20211109184747202

每个服务都对数据库进行了一次查询操作,得出结论本地锁只能锁本地服务

3.2.3.4 分布式锁原理

分布式锁最重要的是要保证占锁与删锁的原子性

image-20211109211929574

redis set nx测试

使用redis set nx命令实现占锁

redis中文官方占锁

set nx测试

用xshell新增几个虚拟机会话

image-20211109213334964

打开撰写栏

image-20211109213451381

image-20211109213637864

进入redis-cli docker容器

docker exec -it redis redis-cli

测试set nx命令

set lock test NX

测试结果只有会话三返回ok,

nx实现了原子加锁

image-20211109214245449

分布式锁演进-阶段一

image-20211109212331785

测试代码 抽取getDataFromDb

问题:setnx占好了位,业务代码异常或者程序在页面过程中宕机。没有执行删除锁逻辑,这就造成了死锁

解决:设置锁的自动过期,即使没有删除,会自动删除 阶段二解决

//分布式锁
    public Map<String, List<Catalog2Vo>> getCatalogJsonFromDbWithRedisLock() {
    
    

            //1.占分布式锁,去redis占坑
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
        if (lock){
    
    
            //加锁成功...执行业务
            Map<String, List<Catalog2Vo>> dataFromDb = getDataFromDb();
            //删除锁
            redisTemplate.delete("lock");
            return dataFromDb;
        }else {
    
    
            //加锁失败...重试
            //休眠100ms重试
            return getCatalogJsonFromDbWithRedisLock();//自旋的方式
        }

    }

    private Map<String, List<Catalog2Vo>> getDataFromDb() {
    
    
        // 一次性获取所有 数据
        List<CategoryEntity> selectList = baseMapper.selectList(null);
        // 1)、所有1级分类
        List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);

        // 2)、封装数据
        Map<String, List<Catalog2Vo>> collect = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), level1 -> {
    
    
            // 查到当前1级分类的2级分类
            List<CategoryEntity> category2level = getParent_cid(selectList, level1.getCatId());
            List<Catalog2Vo> catalog2Vos = null;
            if (category2level != null) {
    
    
                catalog2Vos = category2level.stream().map(level12 -> {
    
    
                    // 查询当前2级分类的3级分类
                    List<CategoryEntity> category3level = getParent_cid(selectList, level12.getCatId());
                    List<Catalog2Vo.Catalog3Vo> catalog3Vos = null;
                    if (category3level != null) {
    
    
                        catalog3Vos = category3level.stream().map(level13 -> {
    
    
                            return new Catalog2Vo.Catalog3Vo(level12.getCatId().toString(), level13.getCatId().toString(), level13.getName());
                        }).collect(Collectors.toList());
                    }
                    return new Catalog2Vo(level1.getCatId().toString(), catalog3Vos, level12.getCatId().toString(), level12.getName());
                }).collect(Collectors.toList());
            }
            return catalog2Vos;
        }));
//            redisTemplate.opsForValue().set("catelogJSON",s);
        String s = JSON.toJSONString(collect);
        redisTemplate.opsForValue().set("catelogJSON",s,1, TimeUnit.DAYS);
        return collect;
    }
分布式锁演进-阶段二

image-20211109220306793

设置过期时间遇到的问题:

redisTemplate.expire("lock",30,TimeUnit.MINUTES);

setnx设置好,正要去设置过期时间,宕机。又死锁了。

解决
设置过期时间和占位必须是原子的。redis支持使用setnx ex命令 阶段三解决

//分布式锁
    public Map<String, List<Catalog2Vo>> getCatalogJsonFromDbWithRedisLock() {
    
    

            //1.占分布式锁,去redis占坑
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
        if (lock){
    
    
            //设置过期时间   发生断电
            redisTemplate.expire("lock",30,TimeUnit.MINUTES);
            //加锁成功...执行业务
            Map<String, List<Catalog2Vo>> dataFromDb = getDataFromDb();
            //删除锁
            redisTemplate.delete("lock");
            return dataFromDb;
        }else {
    
    
            //加锁失败...重试
            //休眠100ms重试
            return getCatalogJsonFromDbWithRedisLock();//自旋的方式
        }

    }
分布式锁演进-阶段三

redis加锁并设置过期时间(原子性)

redis操作

set lock test EX 300 NX

redisTemplate操作

redisTemplate.opsForValue().setIfAbsent("lock", "111",300,TimeUnit.SECONDS);

问题
删除锁直接删除???
如果由于业务时间很长,锁自己过期了,我们直接删除,有可能把别人正在持有的锁删除了。

解决
占锁的时候,值指定为uuid,每个人匹配是自己的锁才删除。 阶段四解决

image-20211109220820750

//分布式锁
    public Map<String, List<Catalog2Vo>> getCatalogJsonFromDbWithRedisLock() {
    
    

            //1.占分布式锁,去redis占坑
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111",300,TimeUnit.SECONDS);
        if (lock){
    
    
            //设置过期时间   发生断电
//            redisTemplate.expire("lock",30,TimeUnit.MINUTES);
            //加锁成功...执行业务
            Map<String, List<Catalog2Vo>> dataFromDb = getDataFromDb();
            //删除锁
            redisTemplate.delete("lock");
            return dataFromDb;
        }else {
    
    
            //加锁失败...重试
            //休眠100ms重试
            return getCatalogJsonFromDbWithRedisLock();//自旋的方式
        }

    }
分布式锁演进-阶段四

问题
如果正好判断是当前值,正要删除锁的时候,锁已经过期,别人已经设置到了新的值。那么我们删除的是别人的锁

解决
删除锁必须保证原子性。使用redis+Lua脚本完成 阶段五完成

lua脚本

if redis.call("get",KEYS[1]) == ARGV[1]
then
 return redis.call("del",KEYS[1])
else
 return 0
end

image-20211109221751089

//分布式锁
    public Map<String, List<Catalog2Vo>> getCatalogJsonFromDbWithRedisLock() {
    
    
        String uuid = UUID.randomUUID().toString();

        //1.占分布式锁,去redis占坑
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111",300,TimeUnit.SECONDS);
        if (lock){
    
    
            //设置过期时间   发生断电
//            redisTemplate.expire("lock",30,TimeUnit.MINUTES);
            //加锁成功...执行业务
            Map<String, List<Catalog2Vo>> dataFromDb = getDataFromDb();
            //删除锁
//            redisTemplate.delete("lock");
            String lockValue = redisTemplate.opsForValue().get("lock");
            if (uuid.equals(lockValue)){
    
    
                redisTemplate.delete("lock");
            }
            return dataFromDb;
        }else {
    
    
            //加锁失败...重试
            //休眠100ms重试
            return getCatalogJsonFromDbWithRedisLock();//自旋的方式
        }
    }
分布式锁演进-阶段五

lua脚本解锁保证了解锁的原子性

保证加锁【占位+过期时间】和删除锁【判断+删除】的原子性。更难的事情,锁的自动续期

image-20211109222702665

完整版代码

问题:业务执行时间比较长,可以延长过期时间

	//分布式锁
    public Map<String, List<Catalog2Vo>> getCatalogJsonFromDbWithRedisLock() {
    
    
        // 1、占本分布式锁。去redis占坑,同时设置过期时间
        String uuid = UUID.randomUUID().toString();
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
        if (lock) {
    
    
            // 加锁成功....执行业务【内部会判断一次redis是否有值】
            System.out.println("获取分布式锁成功....");
            Map<String, List<Catalog2Vo>> dataFromDB = null;
            try {
    
    
                dataFromDB = getDataFromDb();
            } finally {
    
    
                // 2、查询UUID是否是自己,是自己的lock就删除
                // 查询+删除 必须是原子操作:lua脚本解锁
                String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1]\n" +
                        "then\n" +
                        "    return redis.call('del',KEYS[1])\n" +
                        "else\n" +
                        "    return 0\n" +
                        "end";
                // 删除锁
                Long lock1 = redisTemplate.execute(new DefaultRedisScript<Long>(luaScript, Long.class),
                        Arrays.asList("lock"), uuid);
            }
            return dataFromDB;
        } else {
    
    
            System.out.println("获取分布式锁失败....等待重试...");
            // 加锁失败....重试
            // 休眠100ms重试
            try {
    
    
                Thread.sleep(200);
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }
            return getCatalogJsonFromDbWithRedisLock();// 自旋的方式
        }
    }

3.2.4 Redission 分布式锁

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。

本文我们仅关注分布式锁的实现,更多请参考Redisson官方文档

3.2.4.1 springboot整合Redission
  1. 导入依赖
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.4</version>
</dependency>
  1. 配置(单节点配置),多节点配置请参考官网文档
@Configuration
public class RedissonConfig {
    
    
    @Bean
    public RedissonClient redissonClient(){
    
    
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.56.102:6379");
        RedissonClient redisson = Redisson.create(config);
        return redisson;
    }
}
  1. 测试
@Autowired
    RedissonClient redissonClient;

    @Test
    public void name() {
    
    
        System.out.println(redissonClient);
    }

成功拿到redissonClient对象

image-20211111212833583

3.2.4.2 可重入锁(Reentrant Lock)

可重入锁: 当a业务包含b业务时,并且a业务与b业务都需要抢占统一资源,当a业务执行到b业务时,b业务发现该资源已上锁,如果是可重入锁b业务就可拿到锁,执行业务;反之如果此时b业务拿不到资源,就是不可重入锁,这样程序就会死锁.

如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,所以就设置了过期时间,但是如果业务执行时间过长,业务还未执行完锁就已经过期,那么就会出现解锁时解了其他线程的锁的情况。

所以Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。

在本次测试中CatalogJson-Lock的初始过期时间TTL为30s,但是每到20s(经过三分之一看门狗时间后)就会自动续借成30s

@ResponseBody
    @GetMapping("/hello")
    public String hello() {
    
    
        //1.获取一把锁,只要锁的名字一样,就是同一把锁
        RLock lock = redissonClient.getLock("my-lock");
        //加锁
        lock.lock();//阻塞式等待,默认加的锁等待时间为30s
        //1.锁的自动续期,如果在业务执行期间业务没有执行完成,redisson会为该锁自动续期
        //2.加锁的业务只要运行完成,就不会自动续期,即使不手动解锁,锁在默认的30s后会自动删除
        try {
    
    
            System.out.println("加锁成功,执行业务......"+Thread.currentThread().getId());
            Thread.sleep(30000);
        }catch (Exception e){
    
    

        }finally {
    
    
            //解锁,假设代码没有运行,redisson不会出现死锁
            System.out.println("锁释放..."+Thread.currentThread().getId());
            lock.unlock();
        }
        return "hello";
    }

加锁成功

image-20211111220436907

redisson分布式锁的自动续期

image-20211111220630138

image-20211111220738699

3.2.4.3 lock看门狗原理 --redision如何解决死锁
//在自定义锁的存在时间时不会自动解锁
lock.lock(10, TimeUnit.SECONDS);

lock()方法的两大特点:

1、会有一个看门狗机制,在我们业务运行期间,将我们的锁自动续期

2、为了防止死锁,加的锁默认是30秒的过期时间,即使由于我们的业务宕机,没有手动调用解锁代码,30s后redis也会对他自动解锁

3.2.4.4 读写锁(ReadWriteLock)可重入读写锁
    @GetMapping("/read")
    @ResponseBody
    public String read() {
    
    
        RReadWriteLock lock = redissonClient.getReadWriteLock("ReadWrite-Lock");
        RLock rLock = lock.readLock();
        String s = "";
        try {
    
    
            rLock.lock();
            System.out.println("读锁加锁"+Thread.currentThread().getId());
            Thread.sleep(5000);
            s= redisTemplate.opsForValue().get("lock-value");
        }finally {
    
    
            rLock.unlock();
            return "读取完成:"+s;
        }
    }

    @GetMapping("/write")
    @ResponseBody
    public String write() {
    
    
        RReadWriteLock lock = redissonClient.getReadWriteLock("ReadWrite-Lock");
        RLock wLock = lock.writeLock();
        String s = UUID.randomUUID().toString();
        try {
    
    
            wLock.lock();
            System.out.println("写锁加锁"+Thread.currentThread().getId());
            Thread.sleep(10000);
            redisTemplate.opsForValue().set("lock-value",s);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }finally {
    
    
            wLock.unlock();
            return "写入完成:"+s;
        }
    }

写锁会阻塞读锁,但是读锁不会阻塞读锁,但读锁会阻塞写锁

总之含有写的过程都会被阻塞,只有读读不会被阻塞

上锁时在redis的状态

image-20211111222449079

3.2.4.5 信号量(Semaphore)

信号量为存储在redis中的一个数字,当这个数字大于0时,即可以调用acquire()方法增加数量,也可以调用release()方法减少数量,但是当调用release()之后小于0的话方法就会阻塞,直到数字大于0

@GetMapping("/park")
    @ResponseBody
    public String park() {
    
    
        RSemaphore park = redissonClient.getSemaphore("park");
        try {
    
    
            park.acquire(2);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
        return "停进2";
    }

    @GetMapping("/go")
    @ResponseBody
    public String go() {
    
    
        RSemaphore park = redissonClient.getSemaphore("park");
        park.release(2);
        return "开走2";
    }

image-20211111223104101

3.2.4.6 闭锁(CountDownLatch)

可以理解为门栓,使用若干个门栓将当前方法阻塞,只有当全部门栓都被放开时,当前方法才能继续执行。

以下代码只有offLatch()被调用5次后 setLatch()才能继续执行

@GetMapping("/setLatch")
    @ResponseBody
    public String setLatch() {
    
    
        RCountDownLatch latch = redissonClient.getCountDownLatch("CountDownLatch");
        try {
    
    
            latch.trySetCount(5);
            latch.await();
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
        return "门栓被放开";
    }

    @GetMapping("/offLatch")
    @ResponseBody
    public String offLatch() {
    
    
        RCountDownLatch latch = redissonClient.getCountDownLatch("CountDownLatch");
        latch.countDown();
        return "门栓被放开1";
    }

image-20211111223330938

3.2.4.7 信号量与闭锁的区别

个人理解

他们都是标志位为0时解锁

但是信号量的标志位可以加,但是闭锁不能,闭锁是能减,直到标志位为0解锁

3.2.5 写操作—缓存数据一致性【必须满足最终一致性】

3.2.5.1 双写模式

双写模式:在数据库进行写操作的同时对缓存也进行写操作,确保缓存数据与数据库数据的一致性

问题: 两个线程同时进行写操作时由于缓存是存储在redis,写缓存时需要发送网络请求,导致虽然线程一先发送写缓存的网络请求但是比线程二发送的写缓存的网络请求后到达redis,造成数据被覆盖

是否满足最终一致性:满足,原因 缓存过期以后,又能得到最新的正确数据读到的最新数据有延迟:最终一致性

image-20211114152427158

3.2.5.2 失效模式

失效模式:在数据库进行更新操作时,删除原来的缓存,再次查询数据库就可以更新最新数据

存在问题

当两个请求同时修改数据库,一个请求已经更新成功并删除缓存时又有读数据的请求进来,这时候发现缓存中无数据就去数据库中查询并放入缓存,在放入缓存前第二个更新数据库的请求成功,这时候留在缓存中的数据依然是第一次数据更新的数据

解决方法

1、缓存的所有数据都有过期时间,数据过期下一次查询触发主动更新
2、读写数据的时候(并且写的不频繁),加上分布式的读写锁。

image-20211114153210946

3.2.5.3 解决方案
  • 无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?
    • 1、如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可
    • 2、如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
    • 3、缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
    • 4、通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可忽略);
  • 总结:
    • 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
    • 我们不应该过度设计,增加系统的复杂性
    • 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。

image-20211114153816851

3.2.6 SpringCache

SpringCache官方文档

  • Spring 从 3.1 开始定义了 org.springframework.cache.Cache和 org.springframework.cache.CacheManager 接口来统一不同的缓存技术;并支持使用 JCache(JSR-107) 注解简化我们开发;
  • Cache 接口为缓存的组件规范定义, 包含缓存的各种操作集合;Cache 接 口 下 Spring 提 供 了 各 种 xxxCache 的 实 现 ; 如 RedisCache , EhCacheCache ,ConcurrentMapCache 等;
  • 每次调用需要缓存功能的方法时, Spring 会检查检查指定参数的指定的目标方法是否已经被调用过; 如果有就直接从缓存中获取方法调用后的结果, 如果没有就调用方法并缓存结果后返回给用户。 下次调用直接从缓存中获取。
  • 使用 Spring 缓存抽象时我们需要关注以下两点;
    • 1、 确定方法需要被缓存以及他们的缓存策略
    • 2、 从缓存中读取之前缓存存储的数据
3.2.6.1 基础概念

image-20211114154005125

3.2.6.2 注解
Cache 缓存接口,定义缓存操作.实现有:RedisCache,RhCacheCache,ConcurrentMapCache等
CacheManager 缓存管理器,管理各种缓存组件
@Cacheable 主要针对方法配置,能够根据方法的请求参数对其结果进行缓存
@CacheEvict 清空缓存
@CachePut 保证方法被调用,又希望结果被缓存
@Caching 组合上面三个注解多个操作
@EnableCaching 开启基于注解的缓存
@CacheConfig 在类级别分享缓存的相同配置
keyGenerator 缓存数据是key生成策略
serialize 缓存数据是value序列化策略

image-20211114155805807

3.2.6.3 SpEL表达式语法

image-20211114155908241

3.2.6.4 整合SpringCache简化缓存开发
3.2.6.4.1 原理:

1597752954208

3.2.6.4.2 基础整合
  1. 导入依赖
	<!--Spring Cache-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
	<!--redis-->
    <dependency>
    	<groupId>org.springframework.boot</groupId>
    	<artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
  1. 配置使用redis作为缓存
spring:
  cache:
    type: redis
  1. 开启缓存功能 @EnableCaching

  2. 使用缓存注解

注解 作用
@Cacheable 主要针对方法配置,能够根据方法的请求参数对其结果进行缓存
@CacheEvict 清空缓存
@CachePut 保证方法被调用,又希望结果被缓存
@Caching 组合上面三个注解多个操作
@EnableCaching 开启基于注解的缓存
@CacheConfig 在类级别分享缓存的相同配置

​ @Cacheable:标注方法上:当前方法的结果存入缓存,如果缓存中有,方法不调用
​ @CacheEvict:触发将数据从缓存删除的操作
​ @CachePut:不影响方法执行更新缓存
​ @Caching:组合以上多个操作
​ @CacheConfig:在类级别共享缓存的相同配置

  1. 测试

getLevel1Categorys方法加上@Cacheable(“category”)注解

/**
     * 查询一级分类。
     * 父ID是0, 或者  层级是1
     */
    @Cacheable("category")
    @Override
    public List<CategoryEntity> getLevel1Categorys() {
    
    
        System.out.println("调用了 getLevel1Categorys  查询了数据库........【一级分类】");
        return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
    }

测试结果

指定一个名字,放入哪个分区@Cacheable({“category”})
1)当前方法的结果需要缓存,如果缓存中有,方法不被调用
2)默认缓存数据的key: category::SimpleKey []
3)默认使用jdk序列化机制,将序列化后的数据存到redis
4)默认过期时间-1,永不过期

image-20211114163649602

3.2.6.4.3 @Cacheable细节设置
  1. 指定key
/**
     * 查询一级分类。
     * 父ID是0, 或者  层级是1
     */
    @Cacheable(value = "category",key = "#root.method.name")
    @Override
    public List<CategoryEntity> getLevel1Categorys() {
    
    
        System.out.println("调用了 getLevel1Categorys  查询了数据库........【一级分类】");
        return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
    }
  1. 指定过期时间
spring:
  cache:
    type: redis
    redis:
      time-to-live: 3600000

重启测试

ttl设置为我们自定义值

缓存的key值指定为方法名

image-20211114165133360

3.2.6.4.4 自定义缓存设置

1)指定key,可接收SpEL表达式 @Cacheable(value = {“category”}, key = “‘level1Categorys’”)
SpEL表达式可以参照官网 Avaliable Caching SpEL Evaluation Context
使用方法名用key:key = “#root.method.name”
2)指定时间 cache.redis.time-to-live=3600s
3)将数据保存为json格式:
4)前缀CACHE_,如果未指定,则使用缓存名字作为前缀:category

  1. 新增缓存配置类MyCacheConfig
package site.zhourui.gulimall.product.config;

import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @author zr
 * @date 2021/11/14 16:57
 */
@EnableConfigurationProperties(CacheProperties.class)
@EnableCaching
@Configuration
public class MyCacheConfig {
    
    

//    @Autowired
//    CacheProperties cacheProperties;

    /**
     * 需要将配置文件中的配置设置上
     * 1、使配置类生效
     * 1)开启配置类与属性绑定功能EnableConfigurationProperties
     *
     * @ConfigurationProperties(prefix = "spring.cache")  public class CacheProperties
     * 2)注入就可以使用了
     * @Autowired CacheProperties cacheProperties;
     * 3)直接在方法参数上加入属性参数redisCacheConfiguration(CacheProperties redisProperties)
     * 自动从IOC容器中找
     * <p>
     * 2、给config设置上
     */
    @Bean
    RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
    
    
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        //指定缓存序列化方式为json
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
        // 配置文件生效:RedisCacheConfiguration
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        //设置配置文件中的各项配置,如过期时间,如果此处以下的代码没有配置,配置文件中的配置不会生效
        if (redisProperties.getTimeToLive() != null) {
    
    
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        if (redisProperties.getKeyPrefix() != null) {
    
    
            config = config.prefixKeysWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
    
    
            config = config.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
    
    
            config = config.disableKeyPrefix();
        }
        return config;
    }
}

  1. 自定义配置
spring:
  cache:
    type: redis
    redis:
      time-to-live: 3600000
      #设置key的前缀,一般情况下不要自定统一前缀,方便分区处理
#      key-prefix: _CACHE
      #key是否使用前缀
      use-key-prefix: true
      #是否允许空值 # 防止缓存穿透,可缓存null值
      cache-null-values: true
  1. 测试

image-20211114171543412

3.2.6.4.5 自定义序列化原理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7kpaohud-1636947687908)(http://zr.zhourui.site/img/Snipaste_2020-09-10_19-40-20 (2)].png)

3.2.6.4.6 @CacheEvict使用
/**
     * 级联更新所有关联的数据
     * @param category
     */
    @Transactional
    @CacheEvict(value = {
    
    "category"},key ="'getLevel1Categorys'")//删除指定key值的缓存
    @Override
    public void updateCascade(CategoryEntity category) {
    
    
        this.updateById(category);
        categoryBrandRelationService.updateCategory(category.getCatId(),category.getName());
    }

升级用法:

删除带category前缀的所有缓存allEntries = true

/**
     * 级联更新所有关联的数据
     * @param category
     */
    @Transactional
    @CacheEvict(value = {
    
    "category"},allEntries = true)   //调用该方法(updateCascade)会删除缓存category下的所有cache
    @Override
    public void updateCascade(CategoryEntity category) {
    
    
        this.updateById(category);
        categoryBrandRelationService.updateCategory(category.getCatId(),category.getName());
    }

测试

随意修改一个类目名,修改成功后发现缓存也被删除了

3.2.6.4.7 @Caching 的使用

作用:在数据修改时需要对多个缓存进行操作时使用

    @Transactional
//    @CacheEvict(value = {"category"},key ="'getLevel1Categorys'")//删除指定key值的缓存
    @Caching(evict = {
    
    
            @CacheEvict(value = {
    
    "category"},key ="'getLevel1Categorys'"),
            @CacheEvict(value = {
    
    "category"},key ="'getCatalogJson'")
    })
    @Override
    public void updateCascade(CategoryEntity category) {
    
    
        this.updateById(category);
        categoryBrandRelationService.updateCategory(category.getCatId(),category.getName());
    }

3.2.6.4.7 失效模式or双写模式

@Cacheable:标注方法上:当前方法的结果存入缓存,如果缓存中有,方法不调用
@CacheEvict:触发将数据从缓存删除的操作【删除缓存】【可实现失效模式】
@CachePut:不影响方法执行更新缓存【更新缓存】【可实现双写模式】
@Caching:组合以上多个操作【实现双写+失效模式】
@CacheConfig:在类级别共享缓存的相同配置

3.2.6.4.8 SpringCache的不足

1、读模式:
缓存穿透:查询一个DB不存在的数据。解决:缓存空数据;ache-null-values=true【布隆过滤器】
缓存击穿:大量并发进来同时查询一个正好过期的数据。解决:加锁; 默认未加锁【sync = true】本地锁

 @Cacheable(value = "category",key = "#root.method.name",sync = true)

​ 缓存雪崩:大量的key同时过期。解决:加上过期时间。: spring.cache.redis.time-to-live= 360000s
2、写模式:(缓存与数据库一致)(没有解决)
​ 1)、读写加锁。
​ 2)、引入canal,感知mysql的更新去更新缓存
​ 3)、读多写多,直接去查询数据库就行

总结:
​ 常规数据(读多写少,即时性,一致性要求不高的数据)﹔完全可以使用Spring-Cache,写模式(只要缓存的数据有过期时间就可以)
​ 特殊数据:特殊设计

猜你喜欢

转载自blog.csdn.net/qq_31745863/article/details/121331632