带你搞明白什么是缓存穿透、缓存击穿、缓存雪崩

import org.springframework.web.bind.annotation.RequestParam;

import org.springframework.web.bind.annotation.RestController;

@RestController

public class OrderController {

private OrderService orderService;

public OrderController(OrderService orderService){

this.orderService = orderService;

}

@RequestMapping(value = “/detail”,method = RequestMethod.GET)

public OrderBo getDetail(@RequestParam(“id”) Long id){

return orderService.getDetail(id);

}

}

OrderService.java

package com.ymy.service;

import com.ymy.bo.OrderBo;

import com.ymy.mapper.OrderMapper;

import lombok.extern.slf4j.Slf4j;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.stereotype.Service;

@Service

@Slf4j

public class OrderService {

private RedisTemplate redisTemplate;

private OrderMapper orderMapper;

public OrderService(RedisTemplate redisTemplate,OrderMapper orderMapper){

this.redisTemplate = redisTemplate;

this.orderMapper = orderMapper;

}

/**

  • 通过id查询订单详情

  • @param id

  • @return

*/

public OrderBo getDetail(Long id) {

//缓存中查询词词订单

OrderBo orderBo = (OrderBo) redisTemplate.opsForValue().get(“order:” + id);

if(orderBo != null ){

log.info(“缓存中查询到了信息,直接返回:{}”,orderBo);

return orderBo;

}

log.info(“前往数据库查询”);

orderBo = orderMapper.getDetail(id);

if(orderBo != null ){

//将数据保存到数据库,有效时间一小时

redisTemplate.opsForValue().set(“order:” + id,orderBo,3600,TimeUnit.SECONDS);

log.info(“数据已经存入缓存”);

}

return orderBo;

}

}

OrderMapper.java

package com.ymy.mapper;

import com.ymy.bo.OrderBo;

import org.apache.ibatis.annotations.Mapper;

import org.apache.ibatis.annotations.Select;

@Mapper

public interface OrderMapper {

/**

  • 通过订单id查询订单信息

  • @param id

  • @return

*/

@Select(" select id,order_code as orderCode,order_price as orderPrice,peoduct_name as peoductName,create_time as createTime from orders where id = #{id} ")

OrderBo getDetail(Long id);

}

RedisConfig.java

package com.ymy.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;

import com.fasterxml.jackson.annotation.PropertyAccessor;

import com.fasterxml.jackson.databind.ObjectMapper;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.data.redis.connection.RedisConnectionFactory;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;

import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration

public class RedisConfig {

@Bean

public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {

RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();

redisTemplate.setConnectionFactory(redisConnectionFactory);

// 使用Jackson2JsonRedisSerialize 替换默认序列化

Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

ObjectMapper objectMapper = new ObjectMapper();

objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);

objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);

jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

// 设置value的序列化规则和 key的序列化规则

redisTemplate.setKeySerializer(new StringRedisSerializer());

redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);

redisTemplate.setHashKeySerializer(new StringRedisSerializer());

redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

redisTemplate.afterPropertiesSet();

return redisTemplate;

}

}

上面的代码实现的功能很简单,通过订单id查询订单详情,不过查询的循序是先查缓存,如果缓存没有数据,在查询数据库,大致流程图如下:

在这里插入图片描述

这个过程很简单,看上去没有什么问题,如果你仔细观察的话就会发现一个致命的问题,就是刚刚说的缓存穿透问题,我们来做个实验。

我在数据库提前添加了一条数据,信息如下:

在这里插入图片描述

正常情况:查询id等于1的订单信息。

第一次:

在这里插入图片描述

2020-04-19 15:55:35.564 INFO 20188 — [nio-9900-exec-1] com.ymy.service.OrderService : 前往数据库查询

2020-04-19 15:55:35.675 INFO 20188 — [nio-9900-exec-1] com.ymy.service.OrderService : 数据已经存入缓存

由于是第一次查询,所以缓存中不会存在数据,请求直接到达了数据库,并且获取到了id为1的数据,并且将数据添加到了缓存。

在这里插入图片描述

第二次查询id等于1的数据

2020-04-19 15:57:47.879 INFO 20188 — [nio-9900-exec-5] com.ymy.service.OrderService : 缓存中查询到了信息,直接返回:OrderBo(id=1, orderCode=202004191416, orderPrice=3299.00, peoductName=iphone se2, createTime=2020-04-19 14:17:07)

我们发现他直接命中了缓存,直接返回,这是正常情况,那如果非正常情况呢?比如查询的订单id=-1呢?这个时候会发生什么事情?

http://localhost:9900/detail?id=-1

在这里插入图片描述

看到没有,请求全都进入数据库了,这种情况是肯定不被允许的,如果你的程序中存在这种情况,一定要赶紧修改,否则有可能会让一些心怀不轨的人直接将数据库的服务搞宕机,那这种问题如何解决呢?

解决方案


将空数据存入缓存

什么意思呢?简单点来说,不管数据库中有没有查询到数据,都往缓存中添加一条数据,这样下次请求的时候就会直接在缓存中返回,这种方式比较简单粗暴,我们一起看看如何实现。

代码改造:

OrderService.java

public OrderBo getDetail(Long id) {

//缓存中查询词词订单

Object obj = redisTemplate.opsForValue().get(“order:” + id);

if(obj != null ){

String data = obj.toString();

log.info(“缓存中查询到了信息,直接返回:{}”,data);

return “”.equals(data) ? null : (OrderBo) obj;

}

log.info(“前往数据库查询”);

OrderBo orderBo = orderMapper.getDetail(id);

if(orderBo != null ){

//将数据保存到数据库,有效时间一小时

redisTemplate.opsForValue().set(“order:” + id,orderBo,3600,TimeUnit.SECONDS);

log.info(“数据已经存入缓存”);

}else {

redisTemplate.opsForValue().set(“order:” + id,“”,300,TimeUnit.SECONDS);

log.info(“数据库中不存在此数据,但是为了防止缓存穿透,存入一条空数据到缓存中”);

}

return orderBo;

}

往缓存中添加数据的时候一定要注意值的问题,请看我这里,我添加的是一个空字符串,并不是null,是因为我判断的条件是缓存中!=null就直接返回,如果你往缓存中添加一条null的数据,这个时候就会和你的判断起冲突,又会进入到数据库了,所以这点需要特别注意,我们来看测试:

第一次请求:http://localhost:9900/detail?id=-1

2020-04-19 16:23:21.520 INFO 16596 — [nio-9900-exec-6] com.ymy.service.OrderService : 前往数据库查询

2020-04-19 16:23:21.577 INFO 16596 — [nio-9900-exec-6] com.ymy.service.OrderService : 数据库中不存在此数据,但是为了防止缓存穿透,存入一条空数据到缓存中

第二次请求:http://localhost:9900/detail?id=-1

2020-04-19 16:24:25.855 INFO 16596 — [nio-9900-exec-9] com.ymy.service.OrderService : 缓存中查询到了信息,直接返回:

这个时候请求命中了缓存,就不会前往数据库中了,但是这个需要注意一点:空值的过期时间不能设置的太长,什么意思呢?设想一下,我们现在数据库中只有id=1的数据,我们查询id=2也会往缓存中插入一条数据,但是这个时候数据库中新增了一条订单id=2,用户下次查询的时候看到你存储在缓存中中的数据,接直接回了空,但是数据库中明明已经添加了这条数据,这就是为什么过期时间不要设置太久的原因,当然了,我们也需要分情况考虑,比如查询id<=0的,我们都可以考虑永久存入缓存或者设置很长的过期时间,推荐设置很长的过期时间,为什么呢?因为订单id不存在会<=0,但是对于>=0,我们可以将过期时间设置为30秒等等,这个看业务需求即可。

布隆过滤器

布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。

这个算法实现起来比上面第一种稍微复杂一点,这里就不具体说明了,如果感兴趣的话可以百度自行了解一下,不是很难。

缓存击穿

===================================================================

什么是缓存击穿


缓存击穿是指热点key在某个时间点过期的时候,而恰好在这个时间点对这个Key有大量的并发请求过来,从而大量的请求打到db(数据库)。

这个也不难理解,和缓存穿透有点像但是性质又不相同,都是缓存中没有数据,请求命中数据库,缓存穿透指的是数据库中不存在的数据,缓存击穿则是指缓存失效的问题。,这种情况不太好模拟,我们可以直接将缓存中数据清空,替代缓存数据过期。

代码还是上面的代码,不做任何修改,不过我们不再使用postman测试,而是采用jemter,首先我们删除缓存中的数据,模拟key已经过期,我们查询id=1的订单详细信息,但需要注意的是,我并不是发一个请求,而是100个同时请求,会发生什么呢?

线程数:

在这里插入图片描述

Http请求信息

在这里插入图片描述

聚合报告

在这里插入图片描述

我们发现100个并发请求全部成功,异常率为0,接下来就是重点了,控制台会打印什么呢?

在这里插入图片描述

这就是缓存击穿,是不是很恐怖,虽然命中数据库的次数不是很多,那是因为我们的并发请求不是很大,像双十一这种并发,如果存在这种问题,数据库可能撑不过3秒就炸了。

解决方案


自动更新

什么是自动更新呢?这个有点类似与jwt的自动刷新token机制,jwt的自动刷新token实现原理大致为:请求的时候判断一下token的剩余有效时间,如果有效时间小于设定的时间,那么jwt将生成一个新的token,然后再将次token重新设置过期时间,并将新的token返回给前端使用,这个也可以参考一下,redis是支持查询某个key剩余有效时间,所以这里我们只需要设定一个时间差,比如3分钟,请求的时候查询的有效时间如果小于3分钟,那么刷新这个key的有效时间,刷新这个操作可以使用异步实现(提高性能)。

可能你想到了,这种方式存在缺陷,没错,如果再快失效的3分钟内没有请求,那么缓存中的key将不会被刷新,还是会存在缓存击穿的问题,所以这种方式不是特别推荐。

定时刷新

定时刷新有两种方案

第一种:定时任务

查询快要过期的key,更新内容,并刷新有效时间,这种比较消耗服务器性能,也不是特别推荐。

第二种:延迟队列

如果大家了解它的话可能一下就知道我说的是什么意思了,将数据存入缓存的那一刻同时发送一个延迟队列(安指定时间消费),时间小于缓存中key的过期时间,到了指定时间,消费者刷新key的有效时间再发送一个延迟队列,以此循环,这种方式还是不错的,但是实现方式相对于第一种来说就要复杂一点了,他需要依靠消息中间件来完成,如果消息中间件某个时间宕机,那就gg了,虽然这种方式虽然比较推荐,但是成本偏高,因为为了防止消息中间件宕机,我们有可能需要对消息中间件做集群处理。

程序加锁

我个人推荐使用这个,为什么呢?因为它不需要额外的服务器开销,也不需要额外的资源消耗,他仅仅只是让线程串行而已,但是这个时候你可能就会有疑问了,加锁不是会严重影响程序的效率吗?为什么你还推荐这种方式呢?

其实并不是所有的锁都会很大的降低程序的性能,这里我们当然不能使用synchronized,原因很简单,他的效率比较慢,不太适合这种情况,我要介绍的这种锁名字为:读写锁。

什么是读写锁?请参考我的另外一篇博客:【并发编程】java并发编程之ReentrantReadWriteLock读写锁

好了,我们一起来改造一下之前的代码

OrderService.java

package com.ymy.service;

import com.ymy.bo.OrderBo;

import com.ymy.mapper.OrderMapper;

import lombok.extern.slf4j.Slf4j;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

import java.util.concurrent.atomic.AtomicInteger;

import java.util.concurrent.locks.Lock;

import java.util.concurrent.locks.ReadWriteLock;

import java.util.concurrent.locks.ReentrantReadWriteLock;

@Service

@Slf4j

public class OrderService {

private RedisTemplate redisTemplate;

private OrderMapper orderMapper;

private static final AtomicInteger count = new AtomicInteger(0);

private static final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();

public OrderService(RedisTemplate redisTemplate, OrderMapper orderMapper) {

this.redisTemplate = redisTemplate;

this.orderMapper = orderMapper;

}

/**

  • 通过id查询订单详情

  • @param id

  • @return

*/

public OrderBo getDetail(Long id) {

int num = count.incrementAndGet();

//获取读锁

Lock readLock = readWriteLock.readLock();

try {

readLock.lock();

//缓存中查询订单信息

log.info(“前往缓存中查询信息,第一次,这是第:{}次请求”,num);

Object obj = redisTemplate.opsForValue().get(“order:” + id);

if (obj != null) {

String data = obj.toString();

log.info(“缓存中查询到了信息,直接返回:{}”, data);

return “”.equals(data) ? null : (OrderBo) obj;

}

log.info(“没有在缓存中获取到数据,即将前往数据库获取,这是第:{}次请求”,num);

} finally {

//释放读锁

readLock.unlock();

}

//获取写锁

Lock writeLock = readWriteLock.writeLock();

try{

writeLock.lock();

//缓存中查询订单信息

log.info(“第二次前往缓存中查询信息,这是第:{}次请求”,num);

Object obj = redisTemplate.opsForValue().get(“order:” + id);

if (obj != null) {

String data = obj.toString();

log.info(“缓存中查询到了信息,直接返回:{}”, data);

return “”.equals(data) ? null : (OrderBo) obj;

}

log.info(“前往数据库查询,这是第:{}次请求”,num);

OrderBo orderBo = orderMapper.getDetail(id);

log.info(“数据库返回的数据:{},这是第:{}次请求”,orderBo,num);

if (orderBo != null) {

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后

由于篇幅限制,小编在此截出几张知识讲解的图解

P8级大佬整理在Github上45K+star手册,吃透消化,面试跳槽不心慌

P8级大佬整理在Github上45K+star手册,吃透消化,面试跳槽不心慌

P8级大佬整理在Github上45K+star手册,吃透消化,面试跳槽不心慌

P8级大佬整理在Github上45K+star手册,吃透消化,面试跳槽不心慌

P8级大佬整理在Github上45K+star手册,吃透消化,面试跳槽不心慌

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
1713663798003)]

[外链图片转存中…(img-W160gBlN-1713663798003)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后

由于篇幅限制,小编在此截出几张知识讲解的图解

[外链图片转存中…(img-0J4hhQFf-1713663798003)]

[外链图片转存中…(img-DShyh03i-1713663798003)]

[外链图片转存中…(img-gKhx0dZC-1713663798004)]

[外链图片转存中…(img-LePuoZM5-1713663798004)]

[外链图片转存中…(img-ccsay3mb-1713663798004)]

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

  • 24
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值