一、SpringBoot和Redis/MySQL的数据交互
说明: 在 SpringBoot2.x 之后,原来使用的jedis 被替换为了 lettuce
SpringBoot/Spring和Redis之间的交互简称为Spring-data-redis,有两种方式提供选择:
jedis :采用的直连,多个线程操作的话,是不安全的,如果想要避免不安全的,使用jedis pool连接池。
lettuce :采用netty,示例可以再多个线程中进行共享,不存在线程不安全的情况!可以减少线程数据了。
首先加入Redis相关依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
要想和Redis进行交互,必须要创建能和Redis进行交互的对象,并且要容器加载时,就要创建好该对象并注入,所以写在工厂类RedisConfig中:
@Configuration
public class RedisConfig{
@Bean
public RedisTemplate<Object, Object> jsonRedisTemplate(
RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setStringSerializer(new StringRedisSerializer());
//配置json类型的序列化工具
template.setDefaultSerializer(new Jackson2JsonRedisSerializer(Object.class));
template.setConnectionFactory(redisConnectionFactory);
return template;
}
}
RedisTemplate 是 Spring 提供的一个工具类,简化了与 Redis 交互的操作。方法参数 (RedisConnectionFactory redisConnectionFactory)是 Redis 连接工厂,用于创建连接到 Redis 服务器的连接。
setStringSerializer(new StringRedisSerializer())是设置键的序列化方式为字符串序列化。这样 Redis 中存储的键值对的键会被序列化为字符串。
template.setDefaultSerializer(new Jackson2JsonRedisSerializer(Object.class));:设置 RedisTemplate 的默认序列化器为 Jackson2JsonRedisSerializer,该序列化器可以将 Java 对象序列化为 JSON 格式存储在 Redis 中,或者将 JSON 反序列化为 Java 对象。
setConnectionFactory是设置 Redis 连接工厂,用于创建实际的 Redis 连接。这样一个RedisTemplate对象就被实例化好了,可以用来和Redis进行交互。
controller层:
@RestController
public class StudentController {
@Autowired(required = false)
StudentService userService;
@GetMapping("/findById/{id}")
public Student findById(@PathVariable("id") Integer id) {
Student stu = userService.findById(id);
return stu;
}
@GetMapping("/delete/{id}")
public Integer delete(@PathVariable("id") Integer id) {
userService.deleteStudentById(id);
return id;
}
}
这里是通过示例化一个业务层的对象来调用业务层方法,其中@RestController是一个组合注解,相当于 @Controller 和 @ResponseBody。不会跳转到视图上而是直接作为 HTTP 响应的主体内容返回给客户端。
@GetMapping是将方法映射到 HTTP GET 请求上获取数据返回给客户端,根据id返回对应的Student对象,@PathVariable("id")将 URL 中的 {id} 部分绑定到方法参数 id 上。
实体类层代码:
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("student")
public class Student{
@TableId(value = "stuid" ,type = IdType.AUTO)
private Integer stuid;
@TableField(value = "stuname")
private String stuname;
@TableField(value = "stuhobby")
private String stuhobby;
}
@TableName是用于指定该实体类对应的数据库表名。在这里,Student 类映射到数据库中的 student 表。剩下的TableId和TableField是对应的属性和字段的名称对应映射。
service业务层代码:
@Service
public class StudentService {
@Autowired(required = false)
StudentMapper mapper;
@Autowired
public RedisUtil redisUtil;
/**
* 获取用户策略:先从缓存中获取用户,没有则取数据表中数据,再将数据写入缓存
*/
public Student findById(Integer id){
String key = "student:id"+id;
//1.1判断key在redis中是否存在
boolean hasKey = redisUtil.hasKey(key);
if (hasKey){
//1.2存在缓存中则直接获取
Object stu = redisUtil.get(key);
//ObjectMapper 是 Jackson 库的核心类,
// 负责将 Java 对象与 JSON 格式之间进行转换。
//创建 ObjectMapper 的实例 change,用于后续的对象转换。
ObjectMapper change = new ObjectMapper();
//convertValue方法 允许将一个对象转换为指定类型的另一个对象。
//在这里,它将 stu 对象转换为 Student 类的实例。
Student student = change.convertValue(stu,Student.class);
System.out.println("==========从缓存中获得数据=========");
System.out.println(student.getStuname());
System.out.println("==============================");
return student;
}else {
//1.3不存在缓存,先从数据库中获取,再保存至redis,最后返回用户
Student student = mapper.selectById(id);
System.out.println("==========从数据表中获得数据=========");
System.out.println(student.getStuname());
System.out.println("==============================");
if (student!=null){
redisUtil.set(key,student);
}
return student;
}
}
/**
* 删除用户策略:删除数据表中数据,然后删除缓存
*
*/
public void deleteStudentById(Integer id){
//1.删除数据库中的数据
int result = mapper.deleteById(id);
//2.判断数据库中是否删除成功
String key = "student:id"+id;
//2.1 不为0代表删除成功
if(result!=0){
//3.判断redis中是否存在
boolean hasKey = redisUtil.hasKey(key);
//4.存在则删除,不存在就直接跳转
if (hasKey){
redisUtil.del(key);
System.out.println("删除了缓存中的key:" + key);
}
}
}
}
findById方法执行解析:
首先是创建mapper实现类对象,因为mapper层实现了BaseMapper接口,这里是MyBatis-plus提供的接口,里面封装了许多方法来和数据库交互,来减少我们的代码量。
创建RedisUtil工具类类对象,用来和Redis进行交互的一些封装好的方法的工具类,比如对 Redis 的常用操作,比如获取、设置、删除缓存数据。
首先创建存储学生信息的键名,格式为 student:id:1 这样。然后通过hasKey()方法检查刚才创建好的键名是否存在,如果存在则直接从缓存中获取,如果不存在则直接从数据库获取然后存储在Redis中。
存在的话通过创建好的工具类对象中的get方法来提取学生信息并打印。
不存在的话通过mapper层调用BaseMapper提供的方法从数据库中拿到数据在放写入redis这中去。
delete方法执行解析:
首先是先删除数据库中的信息,看是否删除删除成功,如果成功再去判断Redis中是否存在这条数据,如果存在则删除,不存在则跳转。
测试类代码:
@SpringBootApplication
@MapperScan("com.zad.springboot_redis02.mapper")
public class SpringbootRedis02Application {
public static void main(String[] args) {
SpringApplication.run(SpringbootRedis02Application.class, args);
}
}
@MapperScan是让 MyBatis 找到指定包中的 Mapper 接口,并将它们注册为 Spring 容器中的 Bean,这样你可以在服务类中通过依赖注入使用这些 Mapper 接口。
application.properties配置层代码:
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/schoolp?serverTimezone=GMT%2B8
#开启日志管理,可以查看sql语句
#logging.level.com.zad.springboot_redis02.mapper=debug
#debug=true
#配置要连接redis的地址
spring.redis.host=localhost
spring.redis.port=6379
前几步骤就是连接数据库数据源配置,com.zad.springboot_redis02.mapper是日志级别为 debug,这样可以看到详细的日志输出,包括执行的 SQL 语句等。
后面是Redis的配置,host配置主机地址,port配置的是服务器的端口号。
演示示例:
数据库表如下:
启动主入口方法,输入地址请求:
返回客户端json字符串形式:
再去RedisDesktopManager中查看Redis是否插入:
显示已经插入到Redis中表示成功。
二、Redis的持久化
Redis 是内存数据库,如果不将内存中的数据库状态保存到磁盘,那么一旦服务器进程退出,服务器中的数据库状态也会消失。所以 Redis 提供了持久化功能。
1、持久化过程保存什么
- 将当前数据状态进行保存,快照形式,存储数据结果,存储格式简单,关注点在数据 (RDB)
- 将数据的操作过程进行保存,日志形式,存储操作过程,关注点在数据的操作过程**(AOF)**
2、RDB方式
概念:在指定的时间间隔内将内存中的数据集快照写入磁盘 ,也就是行话讲的Snapshot快照,它恢复时是将快照文件直接读到内存里,快照计数据。
(1)RDB手动
save指令:
- 命令:save
- 作用:手动执行一次保存操作
- save指令相关配置:dbfilename dump.rdb
- 说明:设置本地数据库文件名,默认值为 dump.rdb
- 经验:通常设置为 dump-端口号.rdb
dir:
- 说明:设置存储.rdb文件的路径
- 经验:通常设置成存储空间较大的目录中,目录名称data
rdbcompression yes:
- 说明:设置存储至本地数据库时是否压缩数据,默认为 yes,采用 LZF算法 压缩
- 经验:通常默认为开启状态,如果设置为no,可以节省 CPU 运行时间,但会使存储的文件变大(巨大)
rdbchecksum yes:
- 说明:设置是否进行CRC64算法RDB文件格式校验, 该校验过程在写文件和读文件过程均进行
- 经验:通常默认为开启状态,如果设置为no,可以节约读写性过程约10%时间消耗,但是存储一定的数据损坏风险
bgsave指令:
- 命令 :bgsave
- 作用 :手动启动后台保存操作,但不是立即执行
- bgsave指令工作原理:
- 注意:bgsave命令是针对save阻塞问题做的优化。Redis内部所有涉及到RDB操作都采用bgsave的方式,save命令可以放弃使用。
Fork:
Fork的作用是 复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等) 数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程。
(2)RDB自动
配置:save second changes
作用:满足限定时间范围内key的变化数量达到指定数量即进行持久化
参数:
- second:监控时间范围
- changes:监控key的变化量
位置:在conf文件中进行配置
注意:
- save配置要根据实际业务情况进行设置,频度过高或过低都会出现性能问题,结果可能是灾难性的
- save配置中对于second与changes设置通常具有互补对应关系,尽量不要设置成包含性关系
- save配置启动后执行的是bgsave操作
(3)RDB的优点
- RDB是一个紧凑压缩的二进制文件,存储效率较高
- RDB内部存储的是redis在某个时间点的数据快照,非常适合用于数据备份,全量复制等场景
- RDB恢复数据的速度要比AOF快很多
- RDB节省磁盘空间
(4)RDB缺点
- Fork的时候,内存中的数据被克隆了一份,大致2倍的膨胀性需要考虑
- 虽然Redis在fork时使用了写时拷贝技术,但是如果数据庞大时还是比较消耗性能
- RDB方式无论是执行指令还是利用配置,无法做到实时持久化,具有较大的可能性丢失数据
- Redis的众多版本中未进行RDB文件格式的版本统一,有可能出现各版本服务之间数据格式无法兼容现象
3、AOF方式
概念:AOF(append only file)持久化:以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中命令达到恢复数据的目的;与RDB相比可以简单描述为改记录数据为记录数据产生的过程AOF的主要作用是解决了数据持久化的实时性,目前已经是Redis持久化的主流方式。
(1)AOF执行过程
- 客户端的请求写命令会被append追加到AOF缓冲区内;
- AOF缓冲区根据AOF持久化策略[always,everysec,no]将操作sync同步到磁盘的AOF文件中;
- AOF文件大小超过重写策略或手动重写时,会对AOF文件rewrite重写,压缩AOF文件容量;
- Redis服务重启时,会重新load加载AOF文件中的写操作达到数据恢复的目的;
(2)AOF写数据三种策略(appendfsync)
- always(每次):每次写入操作均同步到AOF文件中,数据零误差,性能较低
- everysec(每秒):每秒将缓冲区中的指令同步到AOF文件中,数据准确性较高,性能较高在系统突然宕机的情况下丢失1秒内的数据
- no(系统控制):由操作系统控制每次同步到AOF文件的周期,整体过程不可控
(3) AOF相关配置
- 配置:appendonly yes|no
- 作用:是否开启AOF持久化功能,默认为不开启状态
- 配置 :appendfsync always|everysec|no
- 作用:AOF写数据策略
- 配置:appendfilename filename
- 作用:AOF持久化文件名,默认文件名未appendonly.aof,建议配置为appendonly-端口号.aof
- 配置:dir
- 作用:AOF持久化文件保存路径,与RDB持久化文件保持一致即可
(4)AOF重写
在AOF中可能会遇到的问题:
这里我们就可以使用AOF重写了。
AOF重写 :随着命令不断写入AOF,文件会越来越大,为了解决这个问题,Redis引入了AOF重写机制压缩文件体积。AOF文件重写是将Redis进程内的数据转化为写命令同步到新AOF文件的过程。简单说就是将对同一个数据的若干个条命令执行结果转化成最终结果数据对应的指令进行记录,日志记指令。
AOF重写作用:
- 降低磁盘占用量,提高磁盘利用率
- 提高持久化效率,降低持久化写时间,提高IO性能
- 降低数据恢复用时,提高数据恢复效率
AOF重写规则:
- 进程内已超时的数据不再写入文件
- 忽略无效指令,重写时使用进程内数据直接生成,这样新的AOF文件只保留最终数据的写入命令 如del key1、 hdel key2、srem key3、set key4 111、set key4 222等
- 对同一数据的多条写命令合并为一条命令
- 如lpush list1 a、lpush list1 b、 lpush list1 c 可以转化为:lpush list1 a b c。
- 为防止数据量过大造成客户端缓冲区溢出,对list、set、hash、zset等类型,每条指令最多写入64个元 素
- AOF和RDB同时开启,系统默认取AOF的数据(数据不会存在丢失)。
AOF重写方式:
- 手动重写 bgrewriteaof
- 自动重写
- 触发机制,何时重写
Redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的一倍且文件大于64M时触发;重写虽然可以节约大量磁盘空间,减少恢复时间。但是每次重写还是有一定的负担的,因此设定Redis要满足一定条件才会进行重写:
- auto-aof-rewrite-min-size 设置重写的基准值,最小文件64MB。达到这个值开始重写。
- auto-aof-rewrite-percentage 设置重写的基准值,文件达到100%时开始重写(文件是原来重写后文件的2倍时触发)
总结:
- 官方推荐两个都启用,如果对数据不敏感,可以选单独用RDB,不建议单独用 AOF,因为可能会出现 Bug。
- 如果只是做纯内存缓存,可以都不用。
三、Redis 删除策略
1、过期数据
Redis是一种内存级数据库,所有数据均存放在内存中,内存中的数据可以通过TTL指令获取其状态。
- XX:具有时效性的数据
- -1:永久有效的数据
- -2:已经过期的数据或被删除的数据或未定义的数据
过期数据不是真的被删除了。
2、数据删除策略
数据删除策略的目标:在内存占用与CPU占用之间寻找一种平衡,顾此失彼都会造成整体redis性能的下降,甚至引发服务器宕 机或内存泄露。
- 定时删除
- 惰性删除
- 定期删除
(1)定时删除
- 创建一个定时器,当key设置有过期时间,且过期时间到达时,由定时器任务立即执行对键的删除操作
- 优点:节约内存,到时就删除,快速释放掉不必要的内存占用
- 缺点:CPU压力很大,无论CPU此时负载量多高,均占用CPU,会影响redis服务器响应时间和指 令吞吐量
- 总结:用处理器性能换取存储空间(拿时间换空间)
(2)惰性删除
数据到达过期时间,不做处理。等下次访问该数据时:
- 如果未过期,返回数据
- 发现已过期,删除,返回不存在
优点:节约CPU性能,发现必须删除的时候才删除
缺点:内存压力很大,出现长期占用内存的数据
总结:用存储空间换取处理器性能(拿空间换时间)
(3)定期删除
两种方案都走极端,有没有折中方案?
- Redis启动服务器初始化时,读取配置server.hz的值,默认为10
- 每秒钟执行server.hz次serverCron()中的方法---databasesCron()---activeExpireCycle()
- activeExpireCycle()对每个expires[*]逐一进行检测,每次执行250ms/server.hz
- 对某个expires[*]检测时,随机挑选W个key检测:
- 如果key超时,删除key
- 如果一轮中删除的key的数量>W * 25%,循环该过程
- *如果一轮中删除的key的数量≤W * 25%,检查下一个expires[*],0-15循环
- W取值=ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP属性值
- 参数current_db用于记录activeExpireCycle() 进入哪个expires[*] 执行
- 如果activeExpireCycle()执行时间到期,下次从current_db继续向下执行
**定期删除:**周期性轮询redis库中的时效性数据,采用随机抽取的策略,利用过期数据占比的方式控制删除频度
- 优点1:CPU性能占用设置有峰值,检测频度可自定义设置
- 优点2:内存压力不是很大,长期占用内存的冷数据会被持续清理
- 总结:周期性抽查存储空间 (随机抽查,重点抽查)
删除策略比对:
- 定时删除 节约内存,无占用 不分时段占用CPU资源,频度高 拿时间换空间。
- 惰性删除 内存占用严重 延时执行,CPU利用率高 拿空间换时间。
- 定期删除 内存定期随机清理 每秒花费固定的CPU资源维护内存 随机抽查,重点抽查。
4、逐出算法
当新数据进入redis时,如果内存不足怎么办?
- Redis使用内存存储数据,在执行每一个命令前,会调用freeMemoryIfNeeded() 检测内存是否充 足。如果内存不满足新加入数据的最低存储要求,redis要临时删除一些数据为当前指令清理存储 空间。清理数据的策略称为逐出算法。
- 注意:逐出数据的过程不是100%能够清理出足够的可使用的内存空间,如果不成功则反复执行。 当对所有数据尝试完毕后,如果不能达到内存清理的要求,将出现错误信息。
抛出异常:(error) OOM command not allowed when used memory >'maxmemory'
影响数据逐出的相关配置:
maxmemory最大可使用内存 占用物理内存的比例,默认值为0,表示不限制,生产环境中根据需求设定,通常设置在50%以上。
maxmemory-samples每次选取待删除数据的个数 选取数据时并不会全库扫描,导致严重的性能消耗,降低读写性能。因此采用随机获取数据的方式 作为待检测删除数据
maxmemory-policy删除策略:
检测易失数据(可能会过期的数据集server.db[i].expires ):
① volatile-lru:挑选最近最少使用的数据淘汰
② volatile-lfu:挑选最近使用次数最少的数据淘汰
③ volatile-ttl:挑选将要过期的数据淘汰
④ volatile-random:任意选择数据淘汰
检测全库数据(所有数据集server.db[i].dict ):
⑤ allkeys-lru:挑选最近最少使用的数据淘汰
⑥ allkeys-lfu:挑选最近使用次数最少的数据淘汰
⑦ allkeys-random:任意选择数据淘汰
放弃数据驱逐:
⑧ no-enviction(驱逐):禁止驱逐数据(redis4.0中默认策略),会引发错误OOM(Out Of Memory)达到最大内存后的,对被挑选出来的数据进行删除的策略
四、企业级解决方案
1、缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
2、缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:互斥锁、逻辑过期。
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的, 此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的 时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到 数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行 数据库代码,对数据库访问压力过大。
解决方案一、使用锁来解决:
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压 力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用 tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人 去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休 眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
解决方案二、逻辑过期方案:
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我 们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们 内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续 通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1 去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据 的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访 问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把 重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
进行对比:
互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其 他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性 能肯定受到影响
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是 在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦。
3、缓存穿透
缓存穿透:缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这 些请求都会打到数据库。
常见的解决方案有两种:
①缓存空对象:
- 优点:实现简单,维护方便。
- 缺点:额外的内存消耗、可能造成短期的不一致。
②布隆过滤:
- 优点:内存占用较少,没有多余key
- 缺点: 实现复杂,存在误判可能
缓存空对象思路分析:当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据, 此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据 库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会 访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis 中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了。
布隆过滤:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问 redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,假设布隆过滤器判断这个数据不存在,则直接返回。
这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突。