计算机笔记--【Redis高级】

提示:本文章的内容来源于自己所学的一些知识以及网络

文章目录

  • 一、分布式缓存
    • 1.Redis持久化
      • 1.1.RDB持久化(存数据)
        • 1.1.1.执行时机
        • 1.1.2.RDB原理
        • 1.1.3.小结
      • 1.2.AOF持久化(存命令)
        • 1.2.1.AOF原理
        • 1.2.2.AOF配置
        • 1.2.3.AOF文件重写
      • 1.3.RDB与AOF对比
    • 2.Redis主从
      • 2.1.搭建主从架构
      • 2.2.主从数据同步原理
        • 2.2.1.全量同步
        • 2.2.2.增量同步
        • 2.2.3.repl_backlog原理(环形数组)
      • 2.3.主从同步优化(尽量避免全量同步)
      • 2.4.小结
    • 3.Redis哨兵
      • 3.1.哨兵原理
        • 3.1.1.集群结构和作用
        • 3.1.2.集群监控原理
        • 3.1.3.集群故障恢复原理
        • 3.1.4.小结
      • 3.2.搭建哨兵集群
      • 3.3.RedisTemplate
        • 3.3.1.导入Demo工程
        • 3.3.2.引入依赖
        • 3.3.3.配置Redis地址
        • 3.3.4.配置读写分离
    • 4.Redis分片集群
      • 4.1.搭建分片集群
      • 4.2.散列插槽
        • 4.2.1.插槽原理
        • 4.2.1.小结
      • 4.3.集群伸缩
        • 4.3.1.需求分析
        • 4.3.2.创建新的redis实例
        • 4.3.3.添加新节点到redis
        • 4.3.4.转移插槽
      • 4.4.故障转移
        • 4.4.1.自动故障转移
        • 4.4.2.手动故障转移
      • 4.5.RedisTemplate访问分片集群
  • 二、多级缓存
    • 1.什么是多级缓存
    • 2.JVM进程缓存
      • 2.1.导入案例
      • 2.2.初识Caffeine
      • 2.3.实现JVM进程缓存
        • 2.3.1.需求
        • 2.3.2.实现
    • 3.Lua语法入门
      • 3.1.初识Lua
      • 3.1.HelloWorld
      • 3.2.变量和循环
        • 3.2.1.Lua的数据类型
        • 3.2.2.声明变量
        • 3.2.3.循环
      • 3.3.条件控制、函数
        • 3.3.1.函数
        • 3.3.2.条件控制
        • 3.3.3.案例
    • 4.实现多级缓存
      • 4.1.安装OpenResty
      • 4.2.OpenResty快速入门
      • 4.2.1.反向代理流程
        • 4.2.2.OpenResty监听请求
        • 4.2.3.编写item.lua
      • 4.3.请求参数处理
        • 4.3.1.获取参数的API
        • 4.3.2.获取参数并返回
      • 4.4.查询Tomcat
        • 4.4.1.发送http请求的API
        • 4.4.2.封装http工具
        • 4.4.3.CJSON工具类
        • 4.4.4.实现Tomcat查询
        • 4.4.5.基于ID负载均衡
          • 1)原理
          • 2)实现
          • 3)测试
      • 4.5.Redis缓存预热
      • 4.6.查询Redis缓存
        • 4.6.1.封装Redis工具
        • 4.6.2.实现Redis查询
      • 4.7.Nginx本地缓存
        • 4.7.1.本地缓存API
        • 4.7.2.实现本地缓存查询
    • 5.缓存同步
      • 5.1.数据同步策略
      • 5.2.安装Canal
        • 5.2.1.认识Canal
        • 5.2.2.安装Canal
      • 5.3.监听Canal
        • 5.3.1.引入依赖:
        • 5.3.2.编写配置:
        • 5.3.3.修改Item实体类
        • 5.3.4.编写监听器
    • 6.总结
  • 三、Redis最佳实践
    • 1.Redis键值设计
      • 1.1.优雅的key结构
      • 1.2.拒绝BigKey
      • 1.3.恰当的数据类型
    • 2.批处理优化
      • 2.1.Pipeline
      • 2.2.集群下的批处理
    • 3.服务端优化
      • 3.1.持久化配置
      • 3.2.慢查询
      • 3.3.命令及安全配置
      • 3.4.内存配置
    • 4.集群最佳实践
  • 四、Redis原理分析
    • 1.数据结构
      • 1.1.动态字符串SDS
      • 1.2.IntSet
      • 1.3.Dict
      • 1.4.ZipList
      • 1.5.QuickList
      • 1.6.SkipList
      • 1.7.RedisObject
      • 1.8五种数据结构
    • 2.网络模型
      • 2.1.用户空间和内核空间
      • 2.2.阻塞IO
      • 2.3.非阻塞IO
      • 2.4.IO多路复用
      • 2.5.信号驱动IO
      • 2.6.异步IO
      • 2.7.Redis网络模型
    • 3.通信协议
      • 3.1.RESP协议
      • 3.2.模拟Redis客户端
    • 4.内存策略
      • 4.1.过期策略
      • 4.2.淘汰策略

一、分布式缓存

基于Redis集群解决单机Redis存在的问题

单机的Redis存在四大问题:
计算机笔记--【Redis高级】_第1张图片

1.Redis持久化

Redis有两种持久化方案:

  • RDB持久化
  • AOF持久化

1.1.RDB持久化(存数据)

RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。快照文件称为RDB文件,默认是保存在当前运行目录。

1.1.1.执行时机

RDB持久化在四种情况下会执行:

  • 执行save命令
  • 执行bgsave命令
  • Redis停机时
  • 触发RDB条件时

1)save命令(不推荐,耗时)

执行下面的命令,可以立即执行一次RDB:
计算机笔记--【Redis高级】_第2张图片
save命令会导致主进程执行RDB(写入数据到磁盘操作比较耗时),这个过程中其它所有命令都会被阻塞。只有在数据迁移时可能用到。

2)bgsave命令(推荐使用,异步持久化

下面的命令可以异步执行RDB:

在这里插入图片描述
这个命令执行后会开启独立进程完成RDB,主进程可以持续处理用户请求,不受影响。

3)停机时(主动停机,非宕机)

Redis停机时会执行一次save命令,实现RDB持久化。

4)触发RDB条件

Redis内部有触发RDB的机制(隔一段时间就触发RDB),可以在redis.conf文件中找到,格式如下:

# 900秒内,如果至少有1个key被修改,则执行bgsave , 如果是save "" 则表示禁用RDB
save 900 1  
save 300 10  
save 60 10000 

RDB的其它配置也可以在redis.conf文件中设置:

# 是否压缩 ,建议不开启,压缩也会消耗cpu,磁盘的话不值钱
rdbcompression yes

# RDB文件名称
dbfilename dump.rdb  

# 文件保存的路径目录
dir ./ 
1.1.2.RDB原理

bgsave开始时会fork主进程得到子进程(主进程阻塞),子进程共享主进程的内存数据,页表是映射关系,进程操作的都是虚拟内存,通过映射关系来操作实际的内存。完成fork后读取内存数据并写入 RDB 文件。

fork采用的是copy-on-write技术(写的时候做一份拷贝,解决持久化脏读问题):

  • 当主进程执行读操作时,访问共享内存;
  • 当主进程执行写操作时,则会拷贝一份数据,执行写操作。
    计算机笔记--【Redis高级】_第3张图片
1.1.3.小结

RDB方式bgsave的基本流程?

  • fork主进程得到一个子进程,拷贝页表,共享内存空间,做持久化速度快(阻塞主线程
  • 子进程读取内存数据并写入新的RDB文件(异步写入,非阻塞
  • 用新RDB文件替换旧的RDB文件

RDB会在什么时候执行?save 60 1000代表什么含义?

  • 默认是服务停止时(save命令
  • 代表60秒内至少执行1000次修改则触发RDB(bgsave命令

RDB的缺点?

  • RDB执行间隔时间长,两次RDB之间写入数据有丢失的风险
  • fork子进程、压缩、写出RDB文件都比较耗时

1.2.AOF持久化(存命令)

1.2.1.AOF原理

AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。
计算机笔记--【Redis高级】_第4张图片

1.2.2.AOF配置

AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF:

# 是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"

AOF的命令记录的频率也可以通过redis.conf文件来配:

# 表示每执行一次写命令,立即记录到AOF文件,写内存同时写磁盘,性能差,可靠性强
appendfsync always 
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案(性能高、牺牲了可靠性,可能会丢失一秒钟内的数据)
appendfsync everysec 
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘(随机事件写,频率低,性能低)
appendfsync no

三种策略对比:
计算机笔记--【Redis高级】_第5张图片

1.2.3.AOF文件重写

因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果
在这里插入图片描述
如图,AOF原本有三个命令,但是set num 123 和 set num 666都是对num的操作,第二次会覆盖第一次的值,因此第一个命令记录下来没有意义。

所以重写命令后,AOF文件内容就是:mset name jack num 666

Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:

# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写 
auto-aof-rewrite-min-size 64mb 

1.3.RDB与AOF对比

RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用。
计算机笔记--【Redis高级】_第6张图片

2.Redis主从

2.1.搭建主从架构

单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离(但是要保证数据的一致性,数据同步)
计算机笔记--【Redis高级】_第7张图片
具体搭建流程参考《Redis集群搭建.md》:

2.2.主从数据同步原理

2.2.1.全量同步

主从第一次建立连接时,会执行全量同步 RDB、repl_baklog,将master节点的所有数据都拷贝给slave节点,流程:(RDB文件同步后,比较慢,耗性能,主机可能还是写一部分数据,导致数据不一致,通过repl_baklog来发送命令,缓冲区的,来保证主从数据一致性
计算机笔记--【Redis高级】_第8张图片
这里有一个问题,master如何得知salve是第一次来连接呢?

有几个概念,可以作为判断依据:

  • Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid
  • offset偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。

因此slave做数据同步,必须向master声明自己的replication id 和offset,master才可以判断到底需要同步哪些数据。

因为slave原本也是一个master,有自己的replid和offset,当第一次变成slave,与master建立连接时,发送的replid和offset是自己的replid和offset。

master判断发现slave发送来的replid与自己的不一致,说明这是一个全新的slave,就知道要做全量同步了。

master会将自己的replid和offset都发送给这个slave,slave保存这些信息。以后slave的replid就与master一致了。

因此,master判断一个节点是否是第一次同步的依据,就是看replid是否一致

如图:
计算机笔记--【Redis高级】_第9张图片
演示:



全量同步完整流程描述:

  • slave节点请求增量同步
  • master节点判断replid,发现不一致,拒绝增量同步
  • master将完整内存数据生成RDB,发送RDB到slave
  • slave清空本地数据,加载master的RDB
  • master将RDB期间的命令记录在repl_baklog(新数据),并持续将log中的命令发送给slave
  • slave执行接收到的命令,保持与master之间的同步
2.2.2.增量同步

全量同步需要先做RDB,然后将RDB文件通过网络传输个slave,成本太高了。因此除了第一次做全量同步,其它大多数时候slave与master都是做增量同步(比如说slave重启后同步,数据存在落后)。

什么是增量同步?就是只更新slave与master存在差异的部分数据。如图:
计算机笔记--【Redis高级】_第10张图片
那么master怎么知道slave与自己的数据差异在哪里呢?

2.2.3.repl_backlog原理(环形数组)

master怎么知道slave与自己的数据差异在哪里呢?

这就要说到全量同步时的repl_baklog文件了。

这个文件是一个固定大小的数组,只不过数组是环形,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖

repl_baklog中会记录Redis处理过的命令日志及offset,包括master当前的offset,和slave已经拷贝到的offset:
计算机笔记--【Redis高级】_第11张图片
slave与master的offset之间的差异,就是salve需要增量拷贝的数据了。

随着不断有数据写入,master的offset逐渐变大,slave也不断的拷贝,追赶master的offset
计算机笔记--【Redis高级】_第12张图片
直到数组被填满:
计算机笔记--【Redis高级】_第13张图片
此时,如果有新的数据写入,就会覆盖数组中的旧数据。不过,旧的数据只要是绿色的,说明是已经被同步到slave的数据,即便被覆盖了也没什么影响。因为未同步的仅仅是红色部分

但是,如果slave出现网络阻塞,导致master的offset远远超过了slave的offset
计算机笔记--【Redis高级】_第14张图片
如果master继续写入新数据,其offset就会覆盖旧的数据,直到将slave现在的offset也覆盖:
计算机笔记--【Redis高级】_第15张图片
棕色框中的红色部分,就是尚未同步,但是却已经被覆盖的数据。此时如果slave恢复,需要同步,却发现自己的offset都没有了,无法完成增量同步了。只能做全量同步。
在这里插入图片描述

2.3.主从同步优化(尽量避免全量同步)

主从同步可以保证主从数据的一致性,非常重要。

可以从以下几个方面来优化Redis主从就集群:

  • 在master中配置repl-diskless-sync yes启用无磁盘复制(写RDB文件时,不写到磁盘的IO流,直接写到网络的IO中,发给slave,减少一次磁盘IO操作,适用于磁盘比较慢,网络带宽比较快),避免全量同步时的磁盘IO。
  • Redis单节点上的内存占用不要太大,较少数据量,减少RDB导致的过多磁盘IO
  • 适当提高repl_baklog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
  • 限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,减少master压力

主从从架构图:
计算机笔记--【Redis高级】_第16张图片

2.4.小结

简述全量同步和增量同步区别?

  • 全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发送给slave。
  • 增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave

什么时候执行全量同步?

  • slave节点第一次连接master节点时
  • slave节点断开时间太久,repl_baklog中的offset已经被覆盖时

什么时候执行增量同步?

  • slave节点断开又恢复,并且在repl_baklog中能找到offset时

那么slave节点宕机恢复后可以找master节点同步数据,那master节点宕机之后怎么办?

  • 如果有RDB文件,直接恢复,但是此时是不能进行写操作。这就涉及到Redis的哨兵操作了(健康监测),发现master宕机之后,立马选一个新的slave作为master。

3.Redis哨兵

Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复

3.1.哨兵原理

3.1.1.集群结构和作用

哨兵的结构如图:
计算机笔记--【Redis高级】_第17张图片
哨兵的作用如下:

  • 监控:Sentinel 会不断检查您的master和slave是否按预期工作
  • 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
  • 通知:Sentinel充当Redis客户端(redisTenplate)的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端(比如说新上位的主机地址等)
3.1.2.集群监控原理

Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:

•主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线(也可能是因为网络阻塞等原因造成的,但是我主观认为你挂掉了)。

•客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半(投票机制)。
计算机笔记--【Redis高级】_第18张图片

3.1.3.集群故障恢复原理

一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:

  • 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点(断开时间越长则丢失的数据越多)
  • 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
  • 如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
  • 最后是判断slave节点的运行id大小,越小优先级越高(随机选择一个)。

当选出一个新的master后,该如何实现切换呢?

流程如下:

  • sentinel给备选的slave1节点发送slaveof no one命令,让该节点成为master
  • sentinel给所有其它slave发送slaveof 192.168.150.101 7002 命令(广播),让这些slave成为新master的从节点,开始从新的master上同步数据。
  • 最后,sentinel将故障节点标记为slave(修改其配置文件,该问slave),当故障节点恢复后会自动成为新的master的slave节点
    在这里插入图片描述
3.1.4.小结

Sentinel的三个作用是什么?

  • 监控
  • 故障转移
  • 通知

Sentinel如何判断一个redis实例是否健康?

  • 每隔1秒发送一次ping命令,如果超过一定时间没有相向则认为是主观下线
  • 如果大多数sentinel都认为实例主观下线,则判定服务下线

故障转移步骤有哪些?

  • 首先选定一个slave作为新的master,执行slaveof no one
  • 然后让所有节点都执行slaveof 新master
  • 修改故障节点配置,添加slaveof 新master

3.2.搭建哨兵集群

具体搭建流程参考《Redis集群搭建.md》

3.3.RedisTemplate

在Sentinel集群监管下的Redis主从集群,其节点会因为自动故障转移而发生变化,Redis的客户端必须感知这种变化,及时更新连接信息。Spring的RedisTemplate底层利用lettuce实现了节点的感知和自动切换。

下面,我们通过一个测试来实现RedisTemplate集成哨兵机制。

3.3.1.导入Demo工程

首先,我们引入课前资料提供的Demo工程:
计算机笔记--【Redis高级】_第19张图片

3.3.2.引入依赖

在项目的pom文件中引入依赖:

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redisartifactId>
dependency>
3.3.3.配置Redis地址

然后在配置文件application.yml中指定redis的sentinel相关信息:

spring:
  redis:
    sentinel:
      master: mymaster    # 指定sentinel集群的名称
      nodes:              # 指定redis-sentinel集群地址
        - 192.168.150.101:27001
        - 192.168.150.101:27002
        - 192.168.150.101:27003
3.3.4.配置读写分离

在项目的启动类中,添加一个新的bean:

@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
    return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}

这个bean中配置的就是读写策略,包括四种:

  • MASTER:从主节点读取
  • MASTER_PREFERRED:优先从master节点读取,master不可用才读取replica
  • REPLICA:从slave(replica)节点读取
  • REPLICA _PREFERRED:优先从slave(replica)节点读取,所有的slave都不可用才读取master

4.Redis分片集群

4.1.搭建分片集群

主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:

  • 海量数据存储问题(单个Redis中的数据不能太多,占内存,RDB时间久)

  • 高并发写的问题

使用分片集群可以解决上述问题,如图:
计算机笔记--【Redis高级】_第20张图片
分片集群特征:

  • 集群中有多个master,每个master保存不同数据

  • 每个master都可以有多个slave节点

  • master之间通过ping监测彼此健康状态

  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点(各个master之间会有路由)

具体搭建流程参考《Redis集群搭建.md》

4.2.散列插槽

4.2.1.插槽原理

Redis会把每一个master节点映射到0~16383共16384个插槽(hash slot)上,查看集群信息时就能看到:
计算机笔记--【Redis高级】_第21张图片
数据key不是与节点绑定,而是与插槽绑定,哈希插槽(与MySQL中的一致性哈希分片类似)。redis会根据key的有效部分计算插槽值,分两种情况:

  • key中包含"{}",且“{}”中至少包含1个字符,“{}”中的部分是有效部分
  • key中不包含“{}”,整个key都是有效部分

例如:key是num,那么就根据num计算,如果是{itcast}num,则根据itcast计算。计算方式是利用CRC16算法得到一个hash值,然后对16384取余,得到的结果就是slot值。
计算机笔记--【Redis高级】_第22张图片
如图,在7001这个节点执行 set a 1 时,对a做hash运算,对16384取余,得到的结果是15495,因此要存储到7003节点。

到了7003后,执行get num时,对num做hash运算,对16384取余,得到的结果是2765,因此需要切换到7001节点

4.2.1.小结

Redis如何判断某个key应该在哪个实例?

  • 将16384个插槽分配到不同的实例
  • 根据key的有效部分计算哈希值,对16384取余
  • 余数作为插槽,寻找插槽所在实例即可,也就是数据不和节点绑定,而是和插槽绑定

如何将同一类数据固定的保存在同一个Redis实例?

  • 这一类数据使用相同的有效部分,例如key都以{typeId}为前缀

4.3.集群伸缩

集群伸缩指的是Redis能够任意的增加或者移除节点redis-cli --cluster 提供了很多操作集群的命令,可以通过下面方式查看:
计算机笔记--【Redis高级】_第23张图片
比如,添加节点的命令:
计算机笔记--【Redis高级】_第24张图片

4.3.1.需求分析

需求:向集群中添加一个新的master节点,并向其中存储 num = 10

  • 启动一个新的redis实例,端口为7004
  • 添加7004到之前的集群,并作为一个master节点
  • 给7004节点分配插槽,使得num这个key可以存储到7004实例

这里需要两个新的功能:

  • 添加一个节点到集群中
  • 将部分插槽分配到新插槽(难点
4.3.2.创建新的redis实例

创建一个文件夹:

mkdir 7004

拷贝配置文件:

cp redis.conf /7004

修改配置文件:

sed /s/6379/7004/g 7004/redis.conf

启动

redis-server 7004/redis.conf
4.3.3.添加新节点到redis

添加节点的语法如下:
计算机笔记--【Redis高级】_第25张图片
执行命令:

redis-cli --cluster add-node  192.168.150.101:7004 192.168.150.101:7001

通过命令查看集群状态:

redis-cli -p 7001 cluster nodes

如图,7004加入了集群,并且默认是一个master节点:
计算机笔记--【Redis高级】_第26张图片
但是,可以看到7004节点的插槽数量为0,因此没有任何数据可以存储到7004上

# 启动集群中的Redis
redis-cli -c -p 7001
4.3.4.转移插槽

我们要将num存储到7004节点,因此需要先看看num的插槽是多少:
在这里插入图片描述
如上图所示,num的插槽为2765.

我们可以将0~3000的插槽从7001转移到7004,命令格式如下:
计算机笔记--【Redis高级】_第27张图片
具体命令如下:

建立连接:
在这里插入图片描述
得到下面的反馈:
计算机笔记--【Redis高级】_第28张图片
询问要移动多少个插槽,我们计划是3000个:
计算机笔记--【Redis高级】_第29张图片
新的问题来了:

那个node来接收这些插槽?

显然是7004,那么7004节点的id是多少呢?
计算机笔记--【Redis高级】_第30张图片
复制这个id,然后拷贝到刚才的控制台后:
计算机笔记--【Redis高级】_第31张图片
这里询问,你的插槽是从哪里移动过来的?
计算机笔记--【Redis高级】_第32张图片
这里我们要从7001获取,因此填写7001的id:

填完后,点击done,这样插槽转移就准备好了:
计算机笔记--【Redis高级】_第33张图片

  • all:代表全部,也就是三个节点各转移一部分
  • 具体的id:目标节点的id
  • done:没有了

确认要转移吗?输入yes:
在这里插入图片描述
然后,通过命令查看结果:

可以看到:
计算机笔记--【Redis高级】_第34张图片
目的达成。

4.4.故障转移

集群初识状态是这样的:
在这里插入图片描述
其中7001、7002、7003都是master,我们计划让7002宕机。

4.4.1.自动故障转移

当集群中有一个master宕机会发生什么呢?

直接停止一个redis实例,例如7002:

redis-cli -p 7002 shutdown

1)首先是该实例与其它实例失去连接

2)然后是疑似宕机:
在这里插入图片描述
3)最后是确定下线,自动提升一个slave为新的master:
在这里插入图片描述
4)当7002再次启动,就会变为一个slave节点了:
在这里插入图片描述

4.4.2.手动故障转移

手动故障转移指的是,某个master老旧,需要更换,首先用一个slave来同步该master的数据,同步完成之后,slave上位,master成为slave,角色互换。

利用cluster failover命令可以手动让集群中的某个master宕机,切换到执行cluster failover命令的这个slave节点,实现无感知的数据迁移。其流程如下:
计算机笔记--【Redis高级】_第35张图片
这种failover命令可以指定三种模式:

  • 缺省:默认的流程,如图1~6歩
  • force:省略了对offset的一致性校验
  • takeover:直接执行第5歩,忽略数据一致性、忽略master状态和其它master的意见

案例需求:在7002这个slave节点执行手动故障转移,重新夺回master地位

步骤如下:

1)利用redis-cli连接7002这个节点

2)执行cluster failover命令

如图:
在这里插入图片描述
效果:
在这里插入图片描述

4.5.RedisTemplate访问分片集群

RedisTemplate底层同样基于lettuce实现了分片集群的支持,而使用的步骤与哨兵模式基本一致:

1)引入redis的starter依赖

2)配置分片集群地址

3)配置读写分离

与哨兵模式相比,其中只有分片集群的配置方式略有差异,如下:

spring:
  redis:
    cluster:
      nodes:
        - 192.168.150.101:7001
        - 192.168.150.101:7002
        - 192.168.150.101:7003
        - 192.168.150.101:8001
        - 192.168.150.101:8002
        - 192.168.150.101:8003

二、多级缓存

缓存的作用是减轻数据库的压力,缩短服务响应的时间,从而提高服务的并发能力

1.什么是多级缓存

传统的缓存策略一般是请求到达Tomcat(并发能力不高)后,先查询Redis,如果未命中则查询数据库,如图:
计算机笔记--【Redis高级】_第36张图片
存在下的问题:

•请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈

•Redis缓存失效时,会对数据库产生冲击

多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能:

  • 浏览器访问静态资源时,优先读取浏览器本地缓存

  • 访问非静态资源(ajax查询数据)时,访问服务端

  • 请求到达Nginx后,优先读取Nginx本地缓存

  • 如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat)

  • 如果Redis查询未命中,则查询Tomcat

  • 请求进入Tomcat后,优先查询JVM进程缓存

  • 如果JVM进程缓存未命中,则查询数据库
    计算机笔记--【Redis高级】_第37张图片
    在多级缓存架构中,Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器,而是一个编写业务的Web服务器了(用作本地缓存、并发能力高)

    因此这样的业务Nginx服务也需要搭建集群来提高并发,再有专门的nginx服务来做反向代理,如图:
    计算机笔记--【Redis高级】_第38张图片
    另外,我们的Tomcat服务将来也会部署为集群模式:
    计算机笔记--【Redis高级】_第39张图片
    可见,多级缓存的关键有两个:

  • 一个是在nginx中编写业务,实现nginx本地缓存、Redis、Tomcat的查询

  • 另一个就是在Tomcat中实现JVM进程缓存

其中Nginx编程则会用到OpenResty框架结合Lua这样的语言。

这也是今天课程的难点和重点。

2.JVM进程缓存

了演示多级缓存的案例,我们先准备一个商品查询的业务。

2.1.导入案例

参考课前资料的:《案例导入说明.md》

2.2.初识Caffeine

缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。我们把缓存分为两类:

  • 分布式缓存,例如Redis:
    • 优点:存储容量更大、可靠性更好、可以在集群间共享
    • 缺点:访问缓存有网络开销
    • 场景:缓存数据量较大、可靠性要求较高、需要在集群间共享
  • 进程本地缓存,例如HashMap、GuavaCache:
    • 优点:读取本地内存,没有网络开销,速度更快
    • 缺点:存储容量有限、可靠性较低、无法共享
    • 场景:性能要求较高,缓存数据量较小

我们今天会利用Caffeine框架来实现JVM进程缓存。

Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前Spring内部的缓存使用的就是Caffeine。GitHub地址:https://github.com/ben-manes/caffeine

Caffeine的性能非常好,下图是官方给出的性能对比:
计算机笔记--【Redis高级】_第40张图片
可以看到Caffeine的性能遥遥领先!

缓存使用的基本API:

@Test
void testBasicOps() {
    // 构建cache对象
    Cache<String, String> cache = Caffeine.newBuilder().build();

    // 存数据
    cache.put("gf", "迪丽热巴");

    // 取数据
    String gf = cache.getIfPresent("gf");
    System.out.println("gf = " + gf);

    // 取数据,包含两个参数:
    // 参数一:缓存的key
    // 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是查询数据库的逻辑
    // 优先根据key查询JVM缓存,如果未命中,则执行参数二的Lambda表达式
    String defaultGF = cache.get("defaultGF", key -> {
        // 根据key去数据库查询数据
        return "柳岩";
    });
    System.out.println("defaultGF = " + defaultGF);
}

Caffeine既然是缓存的一种,肯定需要有缓存的清除策略,不然的话内存总会有耗尽的时候。

Caffeine提供了三种缓存驱逐策略:

  • 基于容量:设置缓存的数量上限

    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(1) // 设置缓存大小上限为 1
        .build();
    
  • 基于时间:设置缓存的有效时间

    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
        // 设置缓存有效期为 10 秒,从最后一次写入开始计时 
        .expireAfterWrite(Duration.ofSeconds(10)) 
        .build();
    
  • 基于引用:设置缓存为软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用。

注意:在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。

2.3.实现JVM进程缓存

2.3.1.需求

利用Caffeine实现下列需求:

  • 给根据id查询商品的业务添加缓存,缓存未命中时查询数据库
  • 给根据id查询商品库存的业务添加缓存,缓存未命中时查询数据库
  • 缓存初始大小为100
  • 缓存上限为10000
2.3.2.实现

首先,我们需要定义两个Caffeine的缓存对象,分别保存商品、库存的缓存数据。

在item-service的com.heima.item.config包下定义CaffeineConfig类:

package com.heima.item.config;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.heima.item.pojo.Item;
import com.heima.item.pojo.ItemStock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class CaffeineConfig {

    @Bean
    public Cache<Long, Item> itemCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }

    @Bean
    public Cache<Long, ItemStock> stockCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }
}

然后,修改item-service中的com.heima.item.web包下的ItemController类,添加缓存逻辑:

@RestController
@RequestMapping("item")
public class ItemController {

    @Autowired
    private IItemService itemService;
    @Autowired
    private IItemStockService stockService;

    @Autowired
    private Cache<Long, Item> itemCache;
    @Autowired
    private Cache<Long, ItemStock> stockCache;
    
    // ...其它略
    
    @GetMapping("/{id}")
    public Item findById(@PathVariable("id") Long id) {
        return itemCache.get(id, key -> itemService.query()
                .ne("status", 3).eq("id", key)
                .one()
        );
    }

    @GetMapping("/stock/{id}")
    public ItemStock findStockById(@PathVariable("id") Long id) {
        return stockCache.get(id, key -> stockService.getById(key));
    }
}

3.Lua语法入门

Nginx编程需要用到Lua语言,因此我们必须先入门Lua的基本语法。

3.1.初识Lua

Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。官网:https://www.lua.org/
计算机笔记--【Redis高级】_第41张图片
Lua经常嵌入到C语言开发的程序中,例如游戏开发、游戏插件等。

Nginx本身也是C语言开发,因此也允许基于Lua做拓展。

3.1.HelloWorld

CentOS7默认已经安装了Lua语言环境,所以可以直接运行Lua代码。

1)在Linux虚拟机的任意目录下,新建一个hello.lua文件
在这里插入图片描述
2)添加下面的内容

print("Hello World!")  

3)运行
在这里插入图片描述

3.2.变量和循环

学习任何语言必然离不开变量,而变量的声明必须先知道数据的类型。

3.2.1.Lua的数据类型

Lua中支持的常见数据类型包括:
计算机笔记--【Redis高级】_第42张图片
另外,Lua提供了type()函数来判断一个变量的数据类型:
计算机笔记--【Redis高级】_第43张图片

3.2.2.声明变量

Lua声明变量的时候无需指定数据类型,而是用local来声明变量为局部变量:

-- 声明字符串,可以用单引号或双引号,
local str = 'hello'
-- 字符串拼接可以使用 ..
local str2 = 'hello' .. 'world'
-- 声明数字
local num = 21
-- 声明布尔类型
local flag = true

Lua中的table类型既可以作为数组,又可以作为Java中的map来使用。数组就是特殊的table,key是数组角标而已:

-- 声明数组 ,key为角标的 table
local arr = {'java', 'python', 'lua'}
-- 声明table,类似java的map
local map =  {name='Jack', age=21}

Lua中的数组角标是从1开始,访问的时候与Java中类似:

-- 访问数组,lua数组的角标从1开始
print(arr[1])

Lua中的table可以用key来访问:

-- 访问table
print(map['name'])
print(map.name)
3.2.3.循环

对于table,我们可以利用for循环来遍历。不过数组和普通table遍历略有差异。

遍历数组:

-- 声明数组 key为索引的 table
local arr = {'java', 'python', 'lua'}
-- 遍历数组
for index,value in ipairs(arr) do
    print(index, value) 
end

遍历普通table

-- 声明map,也就是table
local map = {name='Jack', age=21}
-- 遍历table
for key,value in pairs(map) do
   print(key, value) 
end

3.3.条件控制、函数

Lua中的条件控制和函数声明与Java类似。

3.3.1.函数

定义函数的语法:

functon 函数名( argument1, argument2..., argumentn)
    -- 函数体
    return 返回值
end

例如,定义一个函数,用来打印数组:

function printArr(arr)
    for index, value in ipairs(arr) do
        print(value)
    end
end
3.3.2.条件控制

类似Java的条件控制,例如if、else语法:

if(布尔表达式)
then
   --[ 布尔表达式为 true 时执行该语句块 --]
else
   --[ 布尔表达式为 false 时执行该语句块 --]
end

与java不同,布尔表达式中的逻辑运算是基于英文单词:
计算机笔记--【Redis高级】_第44张图片

3.3.3.案例

需求:自定义一个函数,可以打印table,当参数为nil时,打印错误信息

function printArr(arr)
    if not arr then
        print('数组不能为空!')
    end
    for index, value in ipairs(arr) do
        print(value)
    end
end

4.实现多级缓存

多级缓存的实现离不开Nginx编程,而Nginx编程又离不开OpenResty。

4.1.安装OpenResty

OpenResty® 是一个基于 Nginx的高性能 Web 平台,用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。具备下列特点:

  • 具备Nginx的完整功能
  • 基于Lua语言进行扩展,集成了大量精良的 Lua 库、第三方模块
  • 允许使用Lua自定义业务逻辑自定义库

官方网站: https://openresty.org/cn/
计算机笔记--【Redis高级】_第45张图片
安装Lua可以参考资料提供的《安装OpenResty.md》

4.2.OpenResty快速入门

我们希望达到的多级缓存架构如图:
计算机笔记--【Redis高级】_第46张图片
其中:

  • windows上的nginx用来做反向代理服务,将前端的查询商品的ajax请求代理到OpenResty集群

  • OpenResty集群用来编写多级缓存业务

4.2.1.反向代理流程

现在,商品详情页使用的是假的商品数据。不过在浏览器中,可以看到页面有发起ajax请求查询真实商品数据。

这个请求如下:
计算机笔记--【Redis高级】_第47张图片
请求地址是localhost,端口是80,就被windows上安装的Nginx服务给接收到了。然后代理给了OpenResty集群:
计算机笔记--【Redis高级】_第48张图片
我们需要在OpenResty中编写业务,查询商品数据并返回到浏览器。

但是这次,我们先在OpenResty接收请求,返回假的商品数据。

4.2.2.OpenResty监听请求

OpenResty的很多功能都依赖于其目录下的Lua库,需要在nginx.conf中指定依赖库的目录,并导入依赖:
1)添加对OpenResty的Lua模块的加载

修改/usr/local/openresty/nginx/conf/nginx.conf文件,在其中的http里面,添加下面代码:

#lua 模块
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
#c模块     
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";  

2)监听/api/item路径

修改/usr/local/openresty/nginx/conf/nginx.conf文件,在nginx.conf的server里面,添加对/api/item这个路径的监听:

location  /api/item {
    # 默认的响应类型
    default_type application/json;
    # 响应结果由lua/item.lua文件来决定
    content_by_lua_file lua/item.lua;
}

这个监听,就类似于SpringMVC中的@GetMapping("/api/item")做路径映射。

content_by_lua_file lua/item.lua则相当于调用item.lua这个文件,执行其中的业务,把结果返回给用户。相当于java中调用service。

4.2.3.编写item.lua

1)在/usr/loca/openresty/nginx目录创建文件夹:lua
计算机笔记--【Redis高级】_第49张图片
2)在/usr/loca/openresty/nginx/lua文件夹下,新建文件:item.lua
计算机笔记--【Redis高级】_第50张图片
3)编写item.lua,返回假数据

item.lua中,利用ngx.say()函数返回数据到Response中

ngx.say('{"id":10001,"name":"SALSA AIR","title":"RIMOWA 21寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4","price":17900,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t6934/364/1195375010/84676/e9f2c55f/597ece38N0ddcbc77.jpg!q70.jpg.webp","category":"拉杆箱","brand":"RIMOWA","spec":"","status":1,"createTime":"2019-04-30T16:00:00.000+00:00","updateTime":"2019-04-30T16:00:00.000+00:00","stock":2999,"sold":31290}')

4)重新加载配置

nginx -s reload

刷新商品页面:http://localhost/item.html?id=1001,即可看到效果:
计算机笔记--【Redis高级】_第51张图片

4.3.请求参数处理

上一节中,我们在OpenResty接收前端请求,但是返回的是假数据。

要返回真实数据,必须根据前端传递来的商品id,查询商品信息才可以。

那么如何获取前端传递的商品参数呢?

4.3.1.获取参数的API

OpenResty中提供了一些API用来获取不同类型的前端请求参数:
计算机笔记--【Redis高级】_第52张图片
更改配置文件:
计算机笔记--【Redis高级】_第53张图片
更改Lua脚本文件:
计算机笔记--【Redis高级】_第54张图片

4.3.2.获取参数并返回

在前端发起的ajax请求如图:
计算机笔记--【Redis高级】_第55张图片
可以看到商品id是以路径占位符方式传递的,因此可以利用正则表达式匹配的方式来获取ID

1)获取商品id
修改/usr/loca/openresty/nginx/nginx.conf文件中监听/api/item的代码,利用正则表达式获取ID:

location ~ /api/item/(\d+) {
    # 默认的响应类型
    default_type application/json;
    # 响应结果由lua/item.lua文件来决定
    content_by_lua_file lua/item.lua;
}

2)拼接ID并返回

修改/usr/loca/openresty/nginx/lua/item.lua文件,获取id并拼接到结果中返回:

-- 获取商品id
local id = ngx.var[1]
-- 拼接并返回
ngx.say('{"id":' .. id .. ',"name":"SALSA AIR","title":"RIMOWA 21寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4","price":17900,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t6934/364/1195375010/84676/e9f2c55f/597ece38N0ddcbc77.jpg!q70.jpg.webp","category":"拉杆箱","brand":"RIMOWA","spec":"","status":1,"createTime":"2019-04-30T16:00:00.000+00:00","updateTime":"2019-04-30T16:00:00.000+00:00","stock":2999,"sold":31290}')

3)重新加载并测试

运行命令以重新加载OpenResty配置:

nginx -s reload

刷新页面可以看到结果中已经带上了ID:
计算机笔记--【Redis高级】_第56张图片

4.4.查询Tomcat

拿到商品ID后,本应去缓存中查询商品信息,不过目前我们还未建立nginx、redis缓存。
计算机笔记--【Redis高级】_第57张图片

因此,这里我们先根据商品id去tomcat查询商品信息。我们实现如图部分:
计算机笔记--【Redis高级】_第58张图片
需要注意的是,我们的OpenResty是在虚拟机,Tomcat是在Windows电脑上。两者IP一定不要搞错了(只要将虚拟机上的IP最后一位数字改为1就是Win上的IP地址)。
计算机笔记--【Redis高级】_第59张图片

4.4.1.发送http请求的API

计算机笔记--【Redis高级】_第60张图片
nginx提供了内部API用以发送http请求:

local resp = ngx.location.capture("/path",{
    method = ngx.HTTP_GET,   -- 请求方式
    args = {a=1,b=2},  -- get方式传参数
})

返回的响应内容包括:

  • resp.status:响应状态码
  • resp.header:响应头,是一个table
  • resp.body:响应体,就是响应数据

注意:这里的path是路径,并不包含IP和端口。这个请求会被nginx内部的server监听并处理(我监听我自己)。

但是我们希望这个请求发送到Tomcat服务器,所以还需要编写一个server来对这个路径做反向代理:

 location /path {
     # 这里是windows电脑的ip和Java服务端口,需要确保windows防火墙处于关闭状态
     proxy_pass http://192.168.150.1:8081; 
 }

原理如图:
计算机笔记--【Redis高级】_第61张图片
更改配置文件:
计算机笔记--【Redis高级】_第62张图片

4.4.2.封装http工具

下面,我们封装一个发送Http请求的工具,基于ngx.location.capture来实现查询tomcat。

1)添加反向代理,到windows的Java服务

因为item-service中的接口都是/item开头,所以我们监听/item路径,代理到windows上的tomcat服务。

修改 /usr/local/openresty/nginx/conf/nginx.conf文件,添加一个location:

location /item {
    proxy_pass http://192.168.150.1:8081;
}

以后,只要我们调用ngx.location.capture("/item"),就一定能发送请求到windows的tomcat服务。

2)封装工具类

之前我们说过,OpenResty启动时会加载以下两个目录中的工具文件:
计算机笔记--【Redis高级】_第63张图片
所以,自定义的http工具也需要放到这个目录下。

/usr/local/openresty/lualib目录下,新建一个common.lua文件:

vi /usr/local/openresty/lualib/common.lua

内容如下:

-- 封装函数,发送http请求,并解析响应
local function read_http(path, params)
    local resp = ngx.location.capture(path,{
        method = ngx.HTTP_GET,
        args = params,
    })
    if not resp then
        -- 记录错误信息,返回404
        ngx.log(ngx.ERR, "http请求查询失败, path: ", path , ", args: ", args)
        ngx.exit(404)
    end
    return resp.body
end
-- 将方法导出
local _M = {  
    read_http = read_http
}  
return _M

这个工具将read_http函数封装到_M这个table类型的变量中,并且返回,这类似于导出。

使用的时候,可以利用require('common')来导入该函数库,这里的common是函数库的文件名。

3)实现商品查询

最后,我们修改/usr/local/openresty/lua/item.lua文件,利用刚刚封装的函数库实现对tomcat的查询:

-- 引入自定义common工具模块(在lualib目录下,直接导入不加路径就行),返回值是common中返回的 _M
local common = require("common")
-- 从 common中获取read_http这个函数
local read_http = common.read_http
-- 获取路径参数
local id = ngx.var[1]
-- 根据id查询商品
local itemJSON = read_http("/item/".. id, nil)
-- 根据id查询商品库存
local itemStockJSON = read_http("/item/stock/".. id, nil)
-- JSON需要转换为lua的table对象,再进行操作
-- 用CJSON工具
-- 重新加载nginx,nginx -s reload
-- 返回数据打印
ngx.say(itemJSON )

这里查询到的结果是json字符串,并且包含商品、库存两个json字符串,页面最终需要的是把两个json拼接为一个json:
计算机笔记--【Redis高级】_第64张图片
这就需要我们先把JSON变为lua的table,完成数据整合后,再转为JSON。

4.4.3.CJSON工具类

OpenResty提供了一个cjson的模块用来处理JSON的序列化和反序列化。

官方地址: https://github.com/openresty/lua-cjson/

1)引入cjson模块:

local cjson = require "cjson"

2)序列化:

local obj = {
    name = 'jack',
    age = 21
}
-- 把 table 序列化为 json
local json = cjson.encode(obj)

3)反序列化:

local json = '{"name": "jack", "age": 21}'
-- 反序列化 json为 table
local obj = cjson.decode(json);
print(obj.name)
4.4.4.实现Tomcat查询

下面,我们修改之前的item.lua中的业务,添加json处理功能:

-- 导入common函数库
local common = require('common')
local read_http = common.read_http
-- 导入cjson库
local cjson = require('cjson')

-- 获取路径参数
local id = ngx.var[1]
-- 根据id查询商品
local itemJSON = read_http("/item/".. id, nil)
-- 根据id查询商品库存
local itemStockJSON = read_http("/item/stock/".. id, nil)

-- JSON转化为lua的table
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)

-- 组合数据
item.stock = stock.stock
item.sold = stock.sold

-- 把item序列化为json 返回结果
ngx.say(cjson.encode(item))
4.4.5.基于ID负载均衡

刚才的代码中,我们的tomcat是单机部署。而实际开发中,tomcat一定是集群模式:
计算机笔记--【Redis高级】_第65张图片
因此,OpenResty需要对tomcat集群做负载均衡。

而默认的负载均衡规则是轮询模式,当我们查询/item/10001时:

  • 第一次会访问8081端口的tomcat服务,在该服务内部就形成了JVM进程缓存
  • 第二次会访问8082端口的tomcat服务,该服务内部没有JVM缓存(因为JVM缓存无法共享),会查询数据库

你看,因为轮询的原因,第一次查询8081形成的JVM缓存并未生效,直到下一次再次访问到8081时才可以生效,缓存命中率太低了。

怎么办?

如果能让同一个商品,每次查询时都访问同一个tomcat服务,那么JVM缓存就一定能生效了。

也就是说,我们需要根据商品id做负载均衡,而不是轮询。

1)原理

nginx提供了基于请求路径做负载均衡的算法:

nginx根据请求路径做hash运算,把得到的数值对tomcat服务的数量取余,余数是几,就访问第几个服务,实现负载均衡。

例如:

  • 我们的请求路径是 /item/10001
  • tomcat总数为2台(8081、8082)
  • 对请求路径/item/1001做hash运算求余的结果为1
  • 则访问第一个tomcat服务,也就是8081

只要id不变,每次hash运算结果也不会变,那就可以保证同一个商品,一直访问同一个tomcat服务,确保JVM缓存生效。
计算机笔记--【Redis高级】_第66张图片

2)实现

修改/usr/local/openresty/nginx/conf/nginx.conf文件,实现基于ID做负载均衡。

首先,定义tomcat集群,并设置基于路径做负载均衡:

# tomcat集群配置
upstream tomcat-cluster {
	# 对请求的路径做哈希运算
    hash $request_uri;
    # tomcat集群的地址
    server 192.168.150.1:8081;
    server 192.168.150.1:8082;
}

然后,修改对tomcat服务的反向代理,目标指向tomcat集群:

location /item {
    proxy_pass http://tomcat-cluster;
}

重新加载OpenResty

nginx -s reload
3)测试

启动两台tomcat服务:
计算机笔记--【Redis高级】_第67张图片
同时启动:
计算机笔记--【Redis高级】_第68张图片
清空日志后,再次访问页面,可以看到不同id的商品,访问到了不同的tomcat服务:
计算机笔记--【Redis高级】_第69张图片
计算机笔记--【Redis高级】_第70张图片

4.5.Redis缓存预热

计算机笔记--【Redis高级】_第71张图片
Redis缓存会面临冷启动问题:

冷启动:服务刚刚启动时,Redis中并没有缓存,如果所有商品数据都在第一次查询时添加缓存,可能会给数据库带来较大压力

缓存预热:在实际开发中,我们可以利用大数据统计用户访问的热点数据,在项目启动时将这些热点数据提前查询并保存到Redis中

我们数据量较少,并且没有数据统计相关功能,目前可以在启动时将所有数据都放入缓存中。

1)利用Docker安装Redis

docker run --name redis -p 6379:6379 -d redis redis-server --appendonly yes

2)在item-service服务中引入Redis依赖

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redisartifactId>
dependency>

3)配置Redis地址

spring:
  redis:
    host: 192.168.150.101

4)编写初始化类

缓存预热需要在项目启动时完成,并且必须是拿到RedisTemplate之后。

这里我们利用InitializingBean接口来实现,因为InitializingBean可以在对象被Spring创建并且成员变量全部注入后执行,用作缓存预热最好不过。

package com.heima.item.config;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.heima.item.pojo.Item;
import com.heima.item.pojo.ItemStock;
import com.heima.item.service.IItemService;
import com.heima.item.service.IItemStockService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class RedisHandler implements InitializingBean {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private IItemService itemService;
    @Autowired
    private IItemStockService stockService;
	// JSON序列化工具
    private static final ObjectMapper MAPPER = new ObjectMapper();
	
	// 此方法在自动注入完成之后调用
    @Override
    public void afterPropertiesSet() throws Exception {
        // 初始化缓存
        // 1.查询商品信息
        List<Item> itemList = itemService.list();
        // 2.放入缓存
        for (Item item : itemList) {
            // 2.1.item序列化为JSON
            String json = MAPPER.writeValueAsString(item);
            // 2.2.存入redis
            redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
        }

        // 3.查询商品库存信息
        List<ItemStock> stockList = stockService.list();
        // 4.放入缓存
        for (ItemStock stock : stockList) {
            // 2.1.item序列化为JSON
            String json = MAPPER.writeValueAsString(stock);
            // 2.2.存入redis
            redisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), json);
        }
    }
}

4.6.查询Redis缓存

现在,Redis缓存已经准备就绪,我们可以再OpenResty中实现查询Redis的逻辑了。如下图红框所示:
计算机笔记--【Redis高级】_第72张图片
当请求进入OpenResty之后:

  • 优先查询Redis缓存
  • 如果Redis缓存未命中,再查询Tomcat
4.6.1.封装Redis工具

OpenResty提供了操作Redis的模块,我们只要引入该模块就能直接使用。但是为了方便,我们将Redis操作封装到之前的common.lua工具库中。

修改/usr/local/openresty/lualib/common.lua文件:

1)引入Redis模块,并初始化Redis对象

-- 导入redis
-- 目录名.文件名
local redis = require('resty.redis')
-- 初始化redis,创建对象
local red = redis:new()
-- 建立联接的超时时间,发送请求的超时时间,响应结果的超时时间,单位毫秒
red:set_timeouts(1000, 1000, 1000)

2)封装函数,用来释放Redis连接,其实是放入连接池

-- 关闭redis连接的工具方法,其实是放入连接池
local function close_redis(red)
    local pool_max_idle_time = 10000 -- 连接的空闲时间,单位是毫秒
    local pool_size = 100 --连接池大小
    local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)
    if not ok then
        ngx.log(ngx.ERR, "放入redis连接池失败: ", err)
    end
end

3)封装函数,根据key查询Redis数据

-- 查询redis的方法 ip和port是redis地址,key是查询的key
local function read_redis(ip, port, key)
    -- 获取一个连接
    local ok, err = red:connect(ip, port)
    if not ok then
        ngx.log(ngx.ERR, "连接redis失败 : ", err)
        return nil
    end
    -- 查询redis
    local resp, err = red:get(key)
    -- 查询失败处理
    if not resp then
        ngx.log(ngx.ERR, "查询Redis失败: ", err, ", key = " , key)
    end
    --得到的数据为空处理
    if resp == ngx.null then
        resp = nil
        ngx.log(ngx.ERR, "查询Redis数据为空, key = ", key)
    end
    close_redis(red)
    return resp
end

4)导出

-- 将方法导出
local _M = {  
    read_http = read_http,
    read_redis = read_redis
}  
return _M

完整的common.lua:

-- 导入redis
local redis = require('resty.redis')
-- 初始化redis
local red = redis:new()
red:set_timeouts(1000, 1000, 1000)

-- 关闭redis连接的工具方法,其实是放入连接池
local function close_redis(red)
    local pool_max_idle_time = 10000 -- 连接的空闲时间,单位是毫秒
    local pool_size = 100 --连接池大小
    local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)
    if not ok then
        ngx.log(ngx.ERR, "放入redis连接池失败: ", err)
    end
end

-- 查询redis的方法 ip和port是redis地址,key是查询的key
local function read_redis(ip, port, key)
    -- 获取一个连接
    local ok, err = red:connect(ip, port)
    if not ok then
        ngx.log(ngx.ERR, "连接redis失败 : ", err)
        return nil
    end
    -- 查询redis
    local resp, err = red:get(key)
    -- 查询失败处理
    if not resp then
        ngx.log(ngx.ERR, "查询Redis失败: ", err, ", key = " , key)
    end
    --得到的数据为空处理
    if resp == ngx.null then
        resp = nil
        ngx.log(ngx.ERR, "查询Redis数据为空, key = ", key)
    end
    close_redis(red)
    return resp
end

-- 封装函数,发送http请求,并解析响应
local function read_http(path, params)
    local resp = ngx.location.capture(path,{
        method = ngx.HTTP_GET,
        args = params,
    })
    if not resp then
        -- 记录错误信息,返回404
        ngx.log(ngx.ERR, "http查询失败, path: ", path , ", args: ", args)
        ngx.exit(404)
    end
    return resp.body
end
-- 将方法导出
local _M = {  
    read_http = read_http,
    read_redis = read_redis
}  
return _M
4.6.2.实现Redis查询

接下来,我们就可以去修改item.lua文件,实现对Redis的查询了。

查询逻辑是:

  • 根据id查询Redis
  • 如果查询失败则继续查询Tomcat
  • 将查询结果返回

1)修改/usr/local/openresty/lua/item.lua文件,添加一个查询函数:

-- 导入common函数库
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 封装查询函数
function read_data(key, path, params)
    -- 查询本地缓存
    local val = read_redis("127.0.0.1", 6379, key)
    -- 判断查询结果
    if not val then
        ngx.log(ngx.ERR, "redis查询失败,尝试查询http, key: ", key)
        -- redis查询失败,去查询http
        val = read_http(path, params)
    end
    -- 返回数据
    return val
end

2)而后修改商品查询、库存查询的业务:
计算机笔记--【Redis高级】_第73张图片
3)完整的item.lua代码:

-- 导入common函数库
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 导入cjson库
local cjson = require('cjson')

-- 封装查询函数
function read_data(key, path, params)
    -- 查询本地缓存
    local val = read_redis("127.0.0.1", 6379, key)
    -- 判断查询结果
    if not val then
        ngx.log(ngx.ERR, "redis查询失败,尝试查询http, key: ", key)
        -- redis查询失败,去查询http
        val = read_http(path, params)
    end
    -- 返回数据
    return val
end

-- 获取路径参数
local id = ngx.var[1]

-- 查询商品信息
local itemJSON = read_data("item:id:" .. id,  "/item/" .. id, nil)
-- 查询库存信息
local stockJSON = read_data("item:stock:id:" .. id, "/item/stock/" .. id, nil)

-- JSON转化为lua的table
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
-- 组合数据
item.stock = stock.stock
item.sold = stock.sold

-- 把item序列化为json 返回结果
ngx.say(cjson.encode(item))

4.7.Nginx本地缓存

现在,整个多级缓存中只差最后一环,也就是nginx的本地缓存了。如图:
计算机笔记--【Redis高级】_第74张图片

4.7.1.本地缓存API

OpenResty为Nginx提供了shard dict的功能,可以在nginx的多个worker线程之间共享数据,实现缓存功能。

1)开启共享字典,在nginx.conf的http里面添加配置:

 # 共享字典,也就是本地缓存,名称叫做:item_cache,大小150m
 lua_shared_dict item_cache 150m; 

2)操作共享字典:

-- 获取本地缓存对象
local item_cache = ngx.shared.item_cache
-- 存储, 指定key、value、过期时间,单位s,默认为0代表永不过期
item_cache:set('key', 'value', 1000)
-- 读取
local val = item_cache:get('key')
4.7.2.实现本地缓存查询

计算机笔记--【Redis高级】_第75张图片

1)修改/usr/local/openresty/lua/item.lua文件,修改read_data查询函数,添加本地缓存逻辑:

-- 导入共享词典,本地缓存
local item_cache = ngx.shared.item_cache

-- 封装查询函数
function read_data(key, expire, path, params)
    -- 查询本地缓存
    local val = item_cache:get(key)
    if not val then
        ngx.log(ngx.ERR, "本地缓存查询失败,尝试查询Redis, key: ", key)
        -- 查询redis
        val = read_redis("127.0.0.1", 6379, key)
        -- 判断查询结果
        if not val then
            ngx.log(ngx.ERR, "redis查询失败,尝试查询http, key: ", key)
            -- redis查询失败,去查询http
            val = read_http(path, params)
        end
    end
    -- 查询成功,把数据写入本地缓存
    item_cache:set(key, val, expire)
    -- 返回数据
    return val
end

2)修改item.lua中查询商品和库存的业务,实现最新的read_data函数:
计算机笔记--【Redis高级】_第76张图片
其实就是多了缓存时间参数,过期后nginx缓存会自动删除,下次访问即可更新缓存。

这里给商品基本信息设置超时时间为30分钟,库存为1分钟。

因为库存更新频率较高,如果缓存时间过长,可能与数据库差异较大。

3)完整的item.lua文件:

-- 导入common函数库
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 导入cjson库
local cjson = require('cjson')
-- 导入共享词典,本地缓存
local item_cache = ngx.shared.item_cache

-- 封装查询函数
function read_data(key, expire, path, params)
    -- 查询本地缓存
    local val = item_cache:get(key)
    if not val then
        ngx.log(ngx.ERR, "本地缓存查询失败,尝试查询Redis, key: ", key)
        -- 查询redis
        val = read_redis("127.0.0.1", 6379, key)
        -- 判断查询结果
        if not val then
            ngx.log(ngx.ERR, "redis查询失败,尝试查询http, key: ", key)
            -- redis查询失败,去查询http
            val = read_http(path, params)
        end
    end
    -- 查询成功,把数据写入本地缓存
    item_cache:set(key, val, expire)
    -- 返回数据
    return val
end

-- 获取路径参数
local id = ngx.var[1]

-- 查询商品信息
local itemJSON = read_data("item:id:" .. id, 1800,  "/item/" .. id, nil)
-- 查询库存信息
local stockJSON = read_data("item:stock:id:" .. id, 60, "/item/stock/" .. id, nil)

-- JSON转化为lua的table
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
-- 组合数据
item.stock = stock.stock
item.sold = stock.sold

-- 把item序列化为json 返回结果
ngx.say(cjson.encode(item))

5.缓存同步

大多数情况下,浏览器查询到的都是缓存数据,如果缓存数据与数据库数据存在较大差异,可能会产生比较严重的后果。

所以我们必须保证数据库数据、缓存数据的一致性,这就是缓存与数据库的同步。

5.1.数据同步策略

缓存数据同步的常见方式有三种:

设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新(被动策略,时效性差)

  • 优势:简单、方便
  • 缺点:时效性差,缓存过期之前可能不一致
  • 场景:更新频率较低,时效性要求低的业务

同步双写:在修改数据库的同时,直接修改缓存(形成一个事务)

  • 优势:时效性强,缓存与数据库强一致
  • 缺点:有代码侵入,耦合度高;
  • 场景:对一致性、时效性要求较高的缓存数据

**异步通知:**修改数据库时发送事件通知相关服务监听到通知后修改缓存数据

  • 优势:低耦合,可以同时通知多个缓存服务
  • 缺点:时效性一般,可能存在中间不一致状态
  • 场景:时效性要求一般,有多个服务需要同步

而异步实现又可以基于MQ或者Canal来实现:

1)基于MQ的异步通知:
计算机笔记--【Redis高级】_第77张图片
解读:

  • 商品服务完成对数据的修改后,只需要发送一条消息到MQ中。
  • 缓存服务监听MQ消息,然后完成对缓存的更新

依然有少量的代码侵入。

2)基于Canal的通知
计算机笔记--【Redis高级】_第78张图片
解读:

  • 商品服务完成商品修改后,业务直接结束,没有任何代码侵入
  • Canal监听MySQL变化,当发现变化后,立即通知缓存服务
  • 缓存服务接收到canal通知,更新缓存

代码零侵入

5.2.安装Canal

5.2.1.认识Canal

Canal [kə’næl],译意为水道/管道/沟渠,canal是阿里巴巴旗下的一款开源项目,基于Java开发。基于数据库增量日志解析,提供增量数据订阅&消费。GitHub的地址:https://github.com/alibaba/canal

Canal是基于mysql的主从同步来实现的,MySQL主从同步的原理如下:
计算机笔记--【Redis高级】_第79张图片

  • 1)MySQL master 将数据变更写入二进制日志( binary log),其中记录的数据叫做binary log events
  • 2)MySQL slave 将 master 的 binary log events拷贝到它的中继日志(relay log)
  • 3)MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据

而Canal就是把自己伪装成MySQL的一个slave节点,从而监听master的binary log变化。再把得到的变化信息通知给Canal的客户端,进而完成对其它数据库的同步。
计算机笔记--【Redis高级】_第80张图片

5.2.2.安装Canal

安装和配置Canal参考资料文档

5.3.监听Canal

Canal提供了各种语言的客户端,当Canal监听到binlog变化时,会通知Canal的客户端。
计算机笔记--【Redis高级】_第81张图片
我们可以利用Canal提供的Java客户端,监听Canal通知消息。当收到变化的消息时,完成对缓存的更新。

不过这里我们会使用GitHub上的第三方开源的canal-starter客户端。地址:https://github.com/NormanGyllenhaal/canal-client

与SpringBoot完美整合,自动装配,比官方客户端要简单好用很多。

5.3.1.引入依赖:
<dependency>
    <groupId>top.javatoolgroupId>
    <artifactId>canal-spring-boot-starterartifactId>
    <version>1.2.1-RELEASEversion>
dependency>
5.3.2.编写配置:
canal:
  destination: heima # canal的集群名字,要与安装canal时设置的名称一致
  server: 192.168.150.101:11111 # canal服务地址
5.3.3.修改Item实体类

计算机笔记--【Redis高级】_第82张图片

通过@Id、@Column、等注解完成Item与数据库表字段的映射:

package com.heima.item.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Transient;

import javax.persistence.Column;
import java.util.Date;

@Data
@TableName("tb_item")
public class Item {
    @TableId(type = IdType.AUTO)
    @Id
    private Long id;//商品id
    @Column(name = "name")
    private String name;//商品名称
    private String title;//商品标题
    private Long price;//价格(分)
    private String image;//商品图片
    private String category;//分类名称
    private String brand;//品牌名称
    private String spec;//规格
    private Integer status;//商品状态 1-正常,2-下架
    private Date createTime;//创建时间
    private Date updateTime;//更新时间
    @TableField(exist = false)
    @Transient
    private Integer stock;
    @TableField(exist = false)
    @Transient
    private Integer sold;
}
5.3.4.编写监听器

计算机笔记--【Redis高级】_第83张图片
通过实现EntryHandler接口编写监听器,监听Canal消息。注意两点:

  • 实现类通过@CanalTable("tb_item")指定监听的表信息
  • EntryHandler的泛型是与表对应的实体类
package com.heima.item.canal;

import com.github.benmanes.caffeine.cache.Cache;
import com.heima.item.config.RedisHandler;
import com.heima.item.pojo.Item;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import top.javatool.canal.client.annotation.CanalTable;
import top.javatool.canal.client.handler.EntryHandler;

@CanalTable("tb_item")
@Component
public class ItemHandler implements EntryHandler<Item> {

    @Autowired
    private RedisHandler redisHandler;
    @Autowired
    private Cache<Long, Item> itemCache;

    @Override
    public void insert(Item item) {
        // 写数据到JVM进程缓存
        itemCache.put(item.getId(), item);
        // 写数据到redis缓存
        redisHandler.saveItem(item);
    }

    @Override
    public void update(Item before, Item after) {
        // 写数据到JVM进程缓存
        itemCache.put(after.getId(), after);
        // 写数据到redis缓存
        redisHandler.saveItem(after);
    }

    @Override
    public void delete(Item item) {
        // 删除数据到JVM进程缓存
        itemCache.invalidate(item.getId());
        // 删除数据到redis缓存
        redisHandler.deleteItemById(item.getId());
    }
}

在这里对Redis的操作都封装到了RedisHandler这个对象中,是我们之前做缓存预热时编写的一个类,内容如下:

package com.heima.item.config;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.heima.item.pojo.Item;
import com.heima.item.pojo.ItemStock;
import com.heima.item.service.IItemService;
import com.heima.item.service.IItemStockService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class RedisHandler implements InitializingBean {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private IItemService itemService;
    @Autowired
    private IItemStockService stockService;

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    public void afterPropertiesSet() throws Exception {
        // 初始化缓存
        // 1.查询商品信息
        List<Item> itemList = itemService.list();
        // 2.放入缓存
        for (Item item : itemList) {
            // 2.1.item序列化为JSON
            String json = MAPPER.writeValueAsString(item);
            // 2.2.存入redis
            redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
        }

        // 3.查询商品库存信息
        List<ItemStock> stockList = stockService.list();
        // 4.放入缓存
        for (ItemStock stock : stockList) {
            // 2.1.item序列化为JSON
            String json = MAPPER.writeValueAsString(stock);
            // 2.2.存入redis
            redisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), json);
        }
    }

    public void saveItem(Item item) {
        try {
            String json = MAPPER.writeValueAsString(item);
            redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    public void deleteItemById(Long id) {
        redisTemplate.delete("item:id:" + id);
    }
}

6.总结

客户端304状态码,表示请求的静态资源命中浏览器客户端缓存,直接返回。
计算机笔记--【Redis高级】_第84张图片
计算机笔记--【Redis高级】_第85张图片

三、Redis最佳实践

1.Redis键值设计

1.1.优雅的key结构

计算机笔记--【Redis高级】_第86张图片
演示:

1.2.拒绝BigKey

计算机笔记--【Redis高级】_第87张图片
通常可以采用MEMORY USAGE key 来查看一个key的所占字节数,但通常比较消耗CPU性能,采用的命令有所改变,如下:

计算机笔记--【Redis高级】_第88张图片
计算机笔记--【Redis高级】_第89张图片
演示:




找出BigKey的测试代码(自定义Scan):

package com.heima.test;

import com.heima.jedis.util.JedisConnectionFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.ScanResult;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class JedisTest {
    private Jedis jedis;

    @BeforeEach
    void setUp() {
        // 1.建立连接
        // jedis = new Jedis("192.168.150.101", 6379);
        jedis = JedisConnectionFactory.getJedis();
        // 2.设置密码
        jedis.auth("123321");
        // 3.选择库
        jedis.select(0);
    }

    @Test
    void testString() {
        // 存入数据
        String result = jedis.set("name", "虎哥");
        System.out.println("result = " + result);
        // 获取数据
        String name = jedis.get("name");
        System.out.println("name = " + name);
    }

    @Test
    void testHash() {
        // 插入hash数据
        jedis.hset("user:1", "name", "Jack");
        jedis.hset("user:1", "age", "21");

        // 获取
        Map<String, String> map = jedis.hgetAll("user:1");
        System.out.println(map);
    }


    final static int STR_MAX_LEN = 10 * 1024;
    final static int HASH_MAX_LEN = 500;

    @Test
    void testScan() {
        int maxLen = 0;
        long len = 0;

        String cursor = "0";
        do {
            // 扫描并获取一部分key
            ScanResult<String> result = jedis.scan(cursor);
            // 记录cursor
            cursor = result.getCursor();
            List<String> list = result.getResult();
            if (list == null || list.isEmpty()) {
                break;
            }
            // 遍历
            for (String key : list) {
                // 判断key的类型
                String type = jedis.type(key);
                switch (type) {
                    case "string":
                        len = jedis.strlen(key);
                        maxLen = STR_MAX_LEN;
                        break;
                    case "hash":
                        len = jedis.hlen(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "list":
                        len = jedis.llen(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "set":
                        len = jedis.scard(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "zset":
                        len = jedis.zcard(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    default:
                        break;
                }
                if (len >= maxLen) {
                    System.out.printf("Found big key : %s, type: %s, length or size: %d %n", key, type, len);
                }
            }
        } while (!cursor.equals("0"));
    }
    
    @AfterEach
    void tearDown() {
        if (jedis != null) {
            jedis.close();
        }
    }
}


Redis-Rdb-Tools网站(离线分析)
网络监控(购买阿里云的监控服务):
计算机笔记--【Redis高级】_第90张图片
计算机笔记--【Redis高级】_第91张图片
演示:

1.3.恰当的数据类型

计算机笔记--【Redis高级】_第92张图片
计算机笔记--【Redis高级】_第93张图片
演示(调整entry的大小):
计算机笔记--【Redis高级】_第94张图片
计算机笔记--【Redis高级】_第95张图片
方案三:将一个大的Hash拆分成很多个晓得Hash。
计算机笔记--【Redis高级】_第96张图片
测试:

package com.heima.test;

import com.heima.jedis.util.JedisConnectionFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.ScanResult;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class JedisTest {
    private Jedis jedis;

    @BeforeEach
    void setUp() {
        // 1.建立连接
        // jedis = new Jedis("192.168.150.101", 6379);
        jedis = JedisConnectionFactory.getJedis();
        // 2.设置密码
        jedis.auth("123321");
        // 3.选择库
        jedis.select(0);
    }

    @Test
    void testString() {
        // 存入数据
        String result = jedis.set("name", "虎哥");
        System.out.println("result = " + result);
        // 获取数据
        String name = jedis.get("name");
        System.out.println("name = " + name);
    }

    @Test
    void testHash() {
        // 插入hash数据
        jedis.hset("user:1", "name", "Jack");
        jedis.hset("user:1", "age", "21");

        // 获取
        Map<String, String> map = jedis.hgetAll("user:1");
        System.out.println(map);
    }
    
    @Test
    void testSetBigKey() {
        Map<String, String> map = new HashMap<>();
        for (int i = 1; i <= 650; i++) {
            map.put("hello_" + i, "world!");
        }
        jedis.hmset("m2", map);
    }

    @Test
    void testBigHash() {
        Map<String, String> map = new HashMap<>();
        for (int i = 1; i <= 100000; i++) {
            map.put("key_" + i, "value_" + i);
        }
        jedis.hmset("test:big:hash", map);
    }

    @Test
    void testBigString() {
        for (int i = 1; i <= 100000; i++) {
            jedis.set("test:str:key_" + i, "value_" + i);
        }
    }

    @Test
    void testSmallHash() {
        int hashSize = 100;
        Map<String, String> map = new HashMap<>(hashSize);
        for (int i = 1; i <= 100000; i++) {
            int k = (i - 1) / hashSize;
            int v = i % hashSize;
            map.put("key_" + v, "value_" + v);
            if (v == 0) {
                jedis.hmset("test:small:hash_" + k, map);
            }
        }
    }

    @AfterEach
    void tearDown() {
        if (jedis != null) {
            jedis.close();
        }
    }
}

计算机笔记--【Redis高级】_第97张图片

2.批处理优化

2.1.Pipeline

Redis执行命令是微秒级的,而网络传输是毫秒级的。
计算机笔记--【Redis高级】_第98张图片
演示(网络传输的耗时):

计算机笔记--【Redis高级】_第99张图片
测试:

@Test
void testFor() {
    for (int i = 1; i <= 100000; i++) {
        jedis.set("test:key_" + i, "value_" + i);
    }
}

计算机笔记--【Redis高级】_第100张图片
N条命令批量执行:一次传输的太多可能会将带宽占满,导致网络堵塞
计算机笔记--【Redis高级】_第101张图片
演示:
计算机笔记--【Redis高级】_第102张图片
测试:

@Test
void testMxx() {
    String[] arr = new String[2000];
    int j;
    long b = System.currentTimeMillis();
    for (int i = 1; i <= 100000; i++) {
        j = (i % 1000) << 1;
        arr[j] = "test:key_" + i;
        arr[j + 1] = "value_" + i;
        if (j == 0) {
            jedis.mset(arr);
        }
    }
    long e = System.currentTimeMillis();
    System.out.println("time: " + (e - b));
}

计算机笔记--【Redis高级】_第103张图片
计算机笔记--【Redis高级】_第104张图片
计算机笔记--【Redis高级】_第105张图片
测试:

@Test
void testPipeline() {
    // 创建管道
    Pipeline pipeline = jedis.pipelined();
    long b = System.currentTimeMillis();
    for (int i = 1; i <= 100000; i++) {
        // 放入命令到管道
        pipeline.set("test:key_" + i, "value_" + i);
        if (i % 1000 == 0) {
            // 每放入1000条命令,批量执行
            pipeline.sync();
        }
    }
    long e = System.currentTimeMillis();
    System.out.println("time: " + (e - b));
}

计算机笔记--【Redis高级】_第106张图片
计算机笔记--【Redis高级】_第107张图片

2.2.集群下的批处理

  • 推荐使用并行slot
    计算机笔记--【Redis高级】_第108张图片
    批处理失败演示:
    在这里插入图片描述
    hash_tag批处理演示:
    在这里插入图片描述
    手写串行slot代码测试:

    package com.heima.test;
    
    import com.heima.jedis.util.ClusterSlotHashUtil;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import redis.clients.jedis.HostAndPort;
    import redis.clients.jedis.JedisCluster;
    import redis.clients.jedis.JedisPoolConfig;
    
    import java.util.HashMap;
    import java.util.HashSet;
    import java.util.List;
    import java.util.Map;
    import java.util.stream.Collectors;
    
    public class JedisClusterTest {
    
        private JedisCluster jedisCluster;
    
        @BeforeEach
        void setUp() {
            // 配置连接池
            JedisPoolConfig poolConfig = new JedisPoolConfig();
            poolConfig.setMaxTotal(8);
            poolConfig.setMaxIdle(8);
            poolConfig.setMinIdle(0);
            poolConfig.setMaxWaitMillis(1000);
            HashSet<HostAndPort> nodes = new HashSet<>();
            nodes.add(new HostAndPort("192.168.150.101", 7001));
            nodes.add(new HostAndPort("192.168.150.101", 7002));
            nodes.add(new HostAndPort("192.168.150.101", 7003));
            nodes.add(new HostAndPort("192.168.150.101", 8001));
            nodes.add(new HostAndPort("192.168.150.101", 8002));
            nodes.add(new HostAndPort("192.168.150.101", 8003));
            jedisCluster = new JedisCluster(nodes, poolConfig);
        }
    
        @Test
        void testMSet() {
            jedisCluster.mset("name", "Jack", "age", "21", "sex", "male");
    
        }
    
    	// 串行slot方案
        @Test
        void testMSet2() {
            Map<String, String> map = new HashMap<>(3);
            map.put("name", "Jack");
            map.put("age", "21");
            map.put("sex", "Male");
    
            Map<Integer, List<Map.Entry<String, String>>> result = map.entrySet()
                    .stream()
                    .collect(Collectors.groupingBy(
                            entry -> ClusterSlotHashUtil.calculateSlot(entry.getKey()))
                    );
            for (List<Map.Entry<String, String>> list : result.values()) {
                String[] arr = new String[list.size() * 2];
                int j = 0;
                for (int i = 0; i < list.size(); i++) {
                    j = i<<2;
                    Map.Entry<String, String> e = list.get(0);
                    arr[j] = e.getKey();
                    arr[j + 1] = e.getValue();
                }
                jedisCluster.mset(arr);
            }
        }
    
        @AfterEach
        void tearDown() {
            if (jedisCluster != null) {
                jedisCluster.close();
            }
        }
    }
    

Spring提供的代码测试(封装好的并行slot处理):

package com.heima;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.heima.redis.pojo.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@SpringBootTest
class RedisStringTests {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void testString() {
        // 写入一条String数据
        stringRedisTemplate.opsForValue().set("verify:phone:13600527634", "124143");
        // 获取string数据
        Object name = stringRedisTemplate.opsForValue().get("name");
        System.out.println("name = " + name);
    }

    private static final ObjectMapper mapper = new ObjectMapper();

    @Test
    void testSaveUser() throws JsonProcessingException {
        // 创建对象
        User user = new User("虎哥", 21);
        // 手动序列化
        String json = mapper.writeValueAsString(user);
        // 写入数据
        stringRedisTemplate.opsForValue().set("user:200", json);

        // 获取数据
        String jsonUser = stringRedisTemplate.opsForValue().get("user:200");
        // 手动反序列化
        User user1 = mapper.readValue(jsonUser, User.class);
        System.out.println("user1 = " + user1);
    }

    @Test
    void testHash() {
        stringRedisTemplate.opsForHash().put("user:400", "name", "虎哥");
        stringRedisTemplate.opsForHash().put("user:400", "age", "21");

        Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries("user:400");
        System.out.println("entries = " + entries);
    }


    @Test
    void testNormal() {
        long begin = System.currentTimeMillis();
        for (int i = 1; i <= 100000; i++) {
            stringRedisTemplate.opsForValue().set("key_" + i, "value_" + i);
        }
        long end = System.currentTimeMillis();
        System.out.println("耗时 = " + (end - begin));
    }

    @Test
    void testMset() {
        long begin = System.currentTimeMillis();
        Map<String, String> map = new HashMap<>(1000);
        for (int i = 1; i <= 100000; i++) {
            map.put("key_" + i, "value_" + i);
            if (map.size() >= 1000) {
                stringRedisTemplate.opsForValue().multiSet(map);
                map.clear();
            }
        }
        long end = System.currentTimeMillis();
        System.out.println("mset耗时 = " + (end - begin));
    }

    @Test
    void testPipeline() {
        long begin = System.currentTimeMillis();
        for (int i = 0; i <= 100; i++) {
            int c = i * 1000;
            stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
                for (int j = 1; j <= 1000; j++) {
                    int d = c + j;
                    connection.set(
                            ("key_" + d).getBytes(StandardCharsets.UTF_8),
                            ("value_" + d).getBytes(StandardCharsets.UTF_8)
                    );
                }
                return null;
            });
        }
        long end = System.currentTimeMillis();
        System.out.println("Pipeline耗时 = " + (end - begin));
    }

    @Test
    void testBigHash() {
        Map<String, String> map = new HashMap<>(1000);
        for (int i = 1; i <= 1000000; i++) {
            map.put("key_" + i, "value_" + i);
            if (map.size() >= 1000) {
                stringRedisTemplate.opsForHash().putAll("hk", map);
                map.clear();
            }
        }
    }

    @Test
    void testSmallHash() {
        Map<String, String> map = new HashMap<>(1000);
        for (int i = 0; i < 1000000; i++) {
            map.put("key_" + (i % 500), "value_" + i);
            if (map.size() >= 500) {
                stringRedisTemplate.opsForHash().putAll("k" + (i / 500), map);
                map.clear();
            }
        }
    }
	// 集群下的批处理方案
    @Test
    void testMSetInCluster() {
        Map<String, String> map = new HashMap<>(3);
        map.put("name", "Rose");
        map.put("age", "21");
        map.put("sex", "Female");
        stringRedisTemplate.opsForValue().multiSet(map);


        List<String> strings = stringRedisTemplate.opsForValue().multiGet(Arrays.asList("name", "age", "sex"));
        strings.forEach(System.out::println);

    }
}

3.服务端优化

3.1.持久化配置

计算机笔记--【Redis高级】_第109张图片
控制rewrite的阈值:

配置no-appendfsync-on-rewrite = yes,禁止在rewrite期间做aof,避免因AOF引起的阻塞,原因如下:
计算机笔记--【Redis高级】_第110张图片

3.2.慢查询

计算机笔记--【Redis高级】_第111张图片
计算机笔记--【Redis高级】_第112张图片
计算机笔记--【Redis高级】_第113张图片

3.3.命令及安全配置

计算机笔记--【Redis高级】_第114张图片
漏洞重现方式。
计算机笔记--【Redis高级】_第115张图片


计算机笔记--【Redis高级】_第116张图片

3.4.内存配置

计算机笔记--【Redis高级】_第117张图片
计算机笔记--【Redis高级】_第118张图片
计算机笔记--【Redis高级】_第119张图片

计算机笔记--【Redis高级】_第120张图片
计算机笔记--【Redis高级】_第121张图片
计算机笔记--【Redis高级】_第122张图片
计算机笔记--【Redis高级】_第123张图片

4.集群最佳实践

计算机笔记--【Redis高级】_第124张图片
计算机笔记--【Redis高级】_第125张图片

计算机笔记--【Redis高级】_第126张图片
计算机笔记--【Redis高级】_第127张图片

四、Redis原理分析

1.数据结构

1.1.动态字符串SDS

计算机笔记--【Redis高级】_第128张图片
计算机笔记--【Redis高级】_第129张图片
二进制安全,指的是读取字符串的时候,以len的大小来读,不是以’\0’为结束标识符
在这里插入图片描述

1.2.IntSet

计算机笔记--【Redis高级】_第130张图片
计算机笔记--【Redis高级】_第131张图片计算机笔记--【Redis高级】_第132张图片
在这里插入图片描述
计算机笔记--【Redis高级】_第133张图片
计算机笔记--【Redis高级】_第134张图片
计算机笔记--【Redis高级】_第135张图片
计算机笔记--【Redis高级】_第136张图片
计算机笔记--【Redis高级】_第137张图片

1.3.Dict

计算机笔记--【Redis高级】_第138张图片
计算机笔记--【Redis高级】_第139张图片
计算机笔记--【Redis高级】_第140张图片
计算机笔记--【Redis高级】_第141张图片
计算机笔记--【Redis高级】_第142张图片
计算机笔记--【Redis高级】_第143张图片
计算机笔记--【Redis高级】_第144张图片
计算机笔记--【Redis高级】_第145张图片
计算机笔记--【Redis高级】_第146张图片
计算机笔记--【Redis高级】_第147张图片
计算机笔记--【Redis高级】_第148张图片
计算机笔记--【Redis高级】_第149张图片
计算机笔记--【Redis高级】_第150张图片
计算机笔记--【Redis高级】_第151张图片
计算机笔记--【Redis高级】_第152张图片
计算机笔记--【Redis高级】_第153张图片
计算机笔记--【Redis高级】_第154张图片

1.4.ZipList

压缩列表的是为了解决指针占用内存过多的问题。
计算机笔记--【Redis高级】_第155张图片
计算机笔记--【Redis高级】_第156张图片
计算机笔记--【Redis高级】_第157张图片
计算机笔记--【Redis高级】_第158张图片
计算机笔记--【Redis高级】_第159张图片
计算机笔记--【Redis高级】_第160张图片
计算机笔记--【Redis高级】_第161张图片
计算机笔记--【Redis高级】_第162张图片
计算机笔记--【Redis高级】_第163张图片
计算机笔记--【Redis高级】_第164张图片
计算机笔记--【Redis高级】_第165张图片

1.5.QuickList

在ZipList中申请的内存是一大块连续的空间,不好申请,存在内存碎片化。在增删的时候,重新申请,涉及到内核态的切换,存在耗时长。
计算机笔记--【Redis高级】_第166张图片
计算机笔记--【Redis高级】_第167张图片
计算机笔记--【Redis高级】_第168张图片
计算机笔记--【Redis高级】_第169张图片
计算机笔记--【Redis高级】_第170张图片
计算机笔记--【Redis高级】_第171张图片

1.6.SkipList

不从首尾查询,而是从中间开始查询,怎么解决?中间查询的性能太低。
计算机笔记--【Redis高级】_第172张图片
计算机笔记--【Redis高级】_第173张图片
计算机笔记--【Redis高级】_第174张图片
计算机笔记--【Redis高级】_第175张图片
计算机笔记--【Redis高级】_第176张图片
计算机笔记--【Redis高级】_第177张图片

1.7.RedisObject

将6种底层的数据结构封装成RedisObject占用16字节(对象头)。
计算机笔记--【Redis高级】_第178张图片
计算机笔记--【Redis高级】_第179张图片
计算机笔记--【Redis高级】_第180张图片

1.8五种数据结构

计算机笔记--【Redis高级】_第181张图片
计算机笔记--【Redis高级】_第182张图片
在这里插入图片描述
计算机笔记--【Redis高级】_第183张图片
计算机笔记--【Redis高级】_第184张图片
计算机笔记--【Redis高级】_第185张图片
计算机笔记--【Redis高级】_第186张图片
计算机笔记--【Redis高级】_第187张图片
计算机笔记--【Redis高级】_第188张图片
计算机笔记--【Redis高级】_第189张图片
计算机笔记--【Redis高级】_第190张图片
计算机笔记--【Redis高级】_第191张图片
计算机笔记--【Redis高级】_第192张图片
计算机笔记--【Redis高级】_第193张图片
计算机笔记--【Redis高级】_第194张图片
计算机笔记--【Redis高级】_第195张图片
计算机笔记--【Redis高级】_第196张图片
计算机笔记--【Redis高级】_第197张图片
计算机笔记--【Redis高级】_第198张图片
计算机笔记--【Redis高级】_第199张图片
计算机笔记--【Redis高级】_第200张图片
计算机笔记--【Redis高级】_第201张图片
计算机笔记--【Redis高级】_第202张图片
计算机笔记--【Redis高级】_第203张图片
计算机笔记--【Redis高级】_第204张图片
计算机笔记--【Redis高级】_第205张图片
计算机笔记--【Redis高级】_第206张图片
计算机笔记--【Redis高级】_第207张图片
计算机笔记--【Redis高级】_第208张图片
计算机笔记--【Redis高级】_第209张图片
计算机笔记--【Redis高级】_第210张图片

2.网络模型

2.1.用户空间和内核空间

计算机笔记--【Redis高级】_第211张图片
计算机笔记--【Redis高级】_第212张图片
计算机笔记--【Redis高级】_第213张图片
计算机笔记--【Redis高级】_第214张图片
计算机笔记--【Redis高级】_第215张图片
计算机笔记--【Redis高级】_第216张图片
计算机笔记--【Redis高级】_第217张图片
计算机笔记--【Redis高级】_第218张图片
计算机笔记--【Redis高级】_第219张图片

2.2.阻塞IO

计算机笔记--【Redis高级】_第220张图片
计算机笔记--【Redis高级】_第221张图片

2.3.非阻塞IO

计算机笔记--【Redis高级】_第222张图片

2.4.IO多路复用


计算机笔记--【Redis高级】_第223张图片
计算机笔记--【Redis高级】_第224张图片
计算机笔记--【Redis高级】_第225张图片
计算机笔记--【Redis高级】_第226张图片
计算机笔记--【Redis高级】_第227张图片
计算机笔记--【Redis高级】_第228张图片
计算机笔记--【Redis高级】_第229张图片
计算机笔记--【Redis高级】_第230张图片
计算机笔记--【Redis高级】_第231张图片
计算机笔记--【Redis高级】_第232张图片
计算机笔记--【Redis高级】_第233张图片
计算机笔记--【Redis高级】_第234张图片
计算机笔记--【Redis高级】_第235张图片
计算机笔记--【Redis高级】_第236张图片
计算机笔记--【Redis高级】_第237张图片
计算机笔记--【Redis高级】_第238张图片
计算机笔记--【Redis高级】_第239张图片
计算机笔记--【Redis高级】_第240张图片
计算机笔记--【Redis高级】_第241张图片
计算机笔记--【Redis高级】_第242张图片
先断开list_head中的链表,如果是ET模式,则会清空该链表,存在没读完的情况,获不到剩余的数据(需要手动添加到链表)。
计算机笔记--【Redis高级】_第243张图片
计算机笔记--【Redis高级】_第244张图片

2.5.信号驱动IO

计算机笔记--【Redis高级】_第245张图片

2.6.异步IO

计算机笔记--【Redis高级】_第246张图片
计算机笔记--【Redis高级】_第247张图片

2.7.Redis网络模型

计算机笔记--【Redis高级】_第248张图片
计算机笔记--【Redis高级】_第249张图片
计算机笔记--【Redis高级】_第250张图片
计算机笔记--【Redis高级】_第251张图片
计算机笔记--【Redis高级】_第252张图片
计算机笔记--【Redis高级】_第253张图片
计算机笔记--【Redis高级】_第254张图片
计算机笔记--【Redis高级】_第255张图片
计算机笔记--【Redis高级】_第256张图片
计算机笔记--【Redis高级】_第257张图片
计算机笔记--【Redis高级】_第258张图片
计算机笔记--【Redis高级】_第259张图片
计算机笔记--【Redis高级】_第260张图片

3.通信协议

3.1.RESP协议

计算机笔记--【Redis高级】_第261张图片
计算机笔记--【Redis高级】_第262张图片
计算机笔记--【Redis高级】_第263张图片
计算机笔记--【Redis高级】_第264张图片

3.2.模拟Redis客户端

TODO

4.内存策略

计算机笔记--【Redis高级】_第265张图片

4.1.过期策略

计算机笔记--【Redis高级】_第266张图片
计算机笔记--【Redis高级】_第267张图片
计算机笔记--【Redis高级】_第268张图片
计算机笔记--【Redis高级】_第269张图片
计算机笔记--【Redis高级】_第270张图片
计算机笔记--【Redis高级】_第271张图片
计算机笔记--【Redis高级】_第272张图片
计算机笔记--【Redis高级】_第273张图片
计算机笔记--【Redis高级】_第274张图片
计算机笔记--【Redis高级】_第275张图片

4.2.淘汰策略

计算机笔记--【Redis高级】_第276张图片
计算机笔记--【Redis高级】_第277张图片
计算机笔记--【Redis高级】_第278张图片
计算机笔记--【Redis高级】_第279张图片

你可能感兴趣的:(redis,缓存,数据库)