Redis缓存预热、 缓存雪崩、 缓存击穿和缓存穿透
一、缓存预热
1.1.缓存预热是什么?
缓存预热指的是系统上线后,提前将相关的缓存数据直接加载到缓存系统例如redis中。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据即可。
1.2.缓存预热如何解决?
使用 @PostConstruct 初始化白名单数据。
二、缓存雪崩
2.1.缓存雪崩是什么?
缓存雪崩指的是redis 主机宕机, Redis全盘崩溃,或者redis 中有大量key同时过期大面积失效,导致redis中查不到数据,最终请求到数据库中,对数据库服务器造成压力,导致数据库直接挂了的情况。
2.2.发生缓存雪崩的情况
对于缓存雪崩主要存在一下两种情况:
- redis 主机宕机, Redis全盘崩溃,这时候偏硬件运维;
- redis 中有大量key 同时过期大面积失效,偏软件开发;
2.3.如何预防和解决缓存雪崩
预防和解决缓存雪崩主要从一下几点方式解决:
- redis 中 key 设置为永不过期 or 过期时间错开
- redis 缓存集群实现高可用(主从加哨兵、Redis 集群、开启Redis 持久化机制 aof / rdb尽快恢复缓存集群)
- 多缓存结合预防雪崩(ehcache 本地缓存 + redis缓存)
- 服务降级(Hystrix 或者 sentinel 限流 & 降级)
三、缓存穿透
3.1.缓存穿透指的是什么?
请求去查询一条数据,先查询redis,redis中不存在,在去查询mysql,如果mysql里面也是不存在该数据,redis和MySQL都查询不到该条记录,但是这样请求每次都会打到数据库上面去,导致后台数据库压力暴增,这时候redis并没有起到对于MySQL数据库的保护作用,这种现象就称之为缓存穿透。
3.2 如何解决缓存穿透呢?
方式一:空对象缓存或者设置初始值回写增强
如果发生了缓存穿透,我们可以针对要查询的数据,在Redis里存一个和业务部门商量后确定的缺省值(比如,零、负数、defaultNull等)。比如:
键uid:userxxx,值defaultNull作为案例的key和value
先去redis查键uid:userxxx没有,再去mysql查没有获得 ,这就发生了一次穿透现象。但是可以增强回写机制,MySQL也查不到的数据的时候,让Redis存入刚刚查不到的key并以达到保护MySQL的效果。方式如下:
- 第一次来查询uid:userxxx,redis和mysql都没有数据,这时候返回null给调用者,但是增强回写后,将刚刚查询的key赋值一个defaultNull,写入到redis,第二次来查uid:userxxx,此时redis就有值了。
- 后续可以直接从Redis中读取default缺省值返回给业务应用程序,避免了把大量请求发送给mysql处理,导致mysql宕机的风险。
但是,上述访问并不是无懈可击的,只能解决key相同的情况,如果有大量不同的key涌入后,redis会缓存大量的空对象缓存,久而久之会导致redis内存占满,出现问题,所以要设置redis中key的过期日期时间
架构图如下:

实现代码如下:修改之前CustomerService中findCustomerById方法,添加上述对应回写增强的代码即可
/** * 读取操作,通过双检加锁机制完成 * @param customerId * @return */ public Customer findCustomerById(Integer customerId){ Customer customer = null; //缓存redis的key名称 String key = CACHE_KEY_CUSTOMER + customerId; //1.先去redis查询 customer = (Customer) redisTemplate.opsForValue().get(key); //2.如果redis存在数据,直接返回,如果不存在,就去MySQL中查询 if(customer == null){ // 3.对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql synchronized (CustomerService.class){ //3.1第二次查询redis 加锁 customer = (Customer) redisTemplate.opsForValue().get(key); //4.在去查询MySQL customer = customerMapper.selectByPrimaryKey(customerId); //5.MySQL存在数据,redis不存在,先从MySQL查询出来的内容写入到redis中 if(customer != null){ //6.把mysql查询到的数据会写到到redis, 保持双写一致性 7天过期 redisTemplate.opsForValue().set(key, customer, 7L, TimeUnit.DAYS); }else { // defaultNull 规定为redis查询为空、MySQL查询也没有,缓存一个defaultNull标识为空,以防缓存穿透 redisTemplate.opsForValue().set(key,"defaultNull",7L, TimeUnit.DAYS); } } } return customer; }
方式二:Google布隆过滤器Guava解决缓存穿透
布隆过滤器作白名单使用,白名单里面有数据的请求才让通过,没有直接返回拒绝处理。但是存在误判,由于误判率很小,只有1%的请求打到MySQL,可以接受,需要注意全部合法的key都需要放入到Guava版布隆过滤器和redis中,不然数据就是返回null

Google过滤器Guava的参考网址如下:
https://github.com/google/guava/blob/master/guava/src/com/google/common/hash/BloomFilter.java
- 还是使用的上一节布隆过滤器的模块,在pom中加入Guava过滤器的依赖:
<!--guava Google 开源的 Guava 中自带的布隆过滤器-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>
- 在service包中创建 GuavaBloomFilterService,内容如下:
package com.redis.demo.service; import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.ArrayList; @Service @Slf4j public class GuavaBloomFilterService { //1.定义常量 public static final int _1w = 10000; //2.定位Guava布隆过滤器,设置初始容量 public static final int size = 100*_1w; //3.误判率,误判率越小,误判的个数也是越少,但是需要的资源空间也是越大 // 这个数越小所用的hash函数越多,bitmap占用的位越多,默认的就是0.03,5个hash函数,而设置为0.01,则需要7个函数 public static double fpp = 0.03; //4.创建Guava布隆过滤器 public static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),size,fpp); public void GuavaBloomFilter(){ //1.先让bloomfilter加入100w白名单数据 for(int i=0;i<=size;i++){ bloomFilter.put(i); } //2.故意取10w个不合法范围内的数据,来进行误判率的演示,这里的list用于保存误判的数据信息 ArrayList<Integer> list = new ArrayList<>(10 * _1w); //3.验证,i从1000001取值 for(int i=size+1;i<size+(10 * _1w);i++){ //判断是否存在,这里取的大于100w的值,还存在布隆过滤器,则表示是误判的 if(bloomFilter.mightContain(i)){ log.info("被误判了:{}",i); list.add(i); } } log.info("误判的总数量::{}",list.size()); } }
- 在controller包下创建 GuavaBloomFilterController 代码如下:
package com.redis.demo.controller; import com.redis.demo.service.GuavaBloomFilterService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; @Api(tags = "Google的Guava布隆过滤器") @RestController @Slf4j public class GuavaBloomFilterController { @Resource private GuavaBloomFilterService guavaBloomFilterService; @ApiOperation("Guava布隆过滤器插入100万样本数据,额外10w(110w)测试是否存在") @RequestMapping(value = "/guavaFilter",method = RequestMethod.GET) public void guavaBloomFilter(){ guavaBloomFilterService.GuavaBloomFilter(); } }
- 测试
这里保证redis在启动状态下,然后启董项目模块,访问swagger,http://localhost:7070/swagger-ui.html#,执行接口:

查看控制台:

现在共有10w条数据,最终的误判数是3033,然后误判率是3.033%,约等于3%;
四、缓存击穿
4.1 缓存击穿是什么?
缓存击穿就是大量请求同时查询一个key时,此时这个key刚好失效了,就会导致大量的请求到数据库上面去,也就是热点key突然都失效了,MySQL承受高并发量,进而导致数据库宕机
4.2 缓存击穿如何解决?
缓存击穿的解决方式有一下几种:
- 差异失效时间,对于访问频繁的热点key,直接就不设置过期时间
- 互斥更新,采用双检加锁(多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。)
4.2.缓存击穿案例演示
以聚划算功能为案例,演示缓存击穿,问题是热点key5突然失效导致了缓存击穿,在通过redis实现聚划算商品推荐之后,每次推荐一组商品,到期后需要更换另一组商品。这时候最危险的就是,到期的商品key删除了,而新的商品key并没有加载到redis中,就会导致缓存击穿。解决思路如下:
- 功能实现肯定是需要使用 Redis,而不是MySQL实现
- 先需要将MySQL里面参加活动的数据抽取进Redis,一般采用定时器扫描来决定上线活动还是下线取消。
- 支持分页功能,一页20条记录
- 在redis中List数据类型的Lpush即可实现
上述案例的实现还是在之前的功能模块实现:
在entities中创建实体类:Product类
package com.redis.demo.entities; import io.swagger.annotations.ApiModel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @ApiModel(value = "聚划算活动product信息") @AllArgsConstructor @NoArgsConstructor @Data public class Product { // 产品id private Long id; // 产品名称 private String name; // 产品价格 private Integer price; // 产品详情 private String detail; }
在service中创建JHSTaskService类(采用定时器将参加活动的商品加入redis)
package com.redis.demo.service; import com.redis.demo.entities.Product; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.concurrent.TimeUnit; @Service @Slf4j public class JHSTaskService { public static final String JHS_KEY = "jhs"; public static final String JHS_KEY_A = "jhs:a"; public static final String JHS_KEY_B = "jhs:b"; @Resource private RedisTemplate redisTemplate; private List<Product> getProductFromMySql(){ ArrayList<Product> list = new ArrayList<>(); for(int i=0;i<=20;i++){ Random random = new Random(); int id = random.nextInt(1000); Product product = new Product((long) id, "product" + i, i, "detail"); list.add(product); } return list; } /** * 单缓存 */ @PostConstruct public void initJHS(){ log.info("启动定时器 天猫聚划算模拟开始 ==============="); //1.使用线程模拟定时任务,后台任务定时将MySQL里面的特价商品刷新到redis new Thread(()->{ while (true){ //2.模拟从MySQL中查询到数据,加到redis中并返回给前端 List<Product> productFromMySql = this.getProductFromMySql(); //3.采用redis List数据结构的lpush命令来实现存储 redisTemplate.delete(JHS_KEY); //删除已有的聚划算数据 //4.加入最新的数据到redis中 redisTemplate.opsForList().leftPushAll(JHS_KEY,productFromMySql); //5.暂停60s,间隔1分钟(60s)执行一次,模拟聚划算一天执行的参加活动的品牌 try { TimeUnit.SECONDS.sleep(60); } catch (InterruptedException e) { e.printStackTrace(); } } },"t1").start(); } @PostConstruct // 测试双缓存 public void initJHSAB(){ log.info("启动AB的定时器 天猫聚划算模拟开始 ==============="); // 1.用线程模拟定时任务,后台任务定时将mysql里面的特价商品刷新的redis new Thread(()->{ //2.模拟从MySQL查到数据,加到redis并返回 List<Product> productFromMySql = this.getProductFromMySql(); //3.先更新缓存B,B的缓存过期时间超过了缓存A的时间,如果A突然失效,还有B在兜底,能有效防止击穿 redisTemplate.delete(JHS_KEY_B); redisTemplate.opsForList().leftPushAll(JHS_KEY_B,productFromMySql); //设置缓存B的过期时间为1天多10s,这个是因为缓存A的过期是就为1天(1天是86400L),给10s中的时间差 redisTemplate.expire(JHS_KEY_A, 86410L, TimeUnit.SECONDS); // 4.再更新A缓存 redisTemplate.delete(JHS_KEY_A); redisTemplate.opsForList().leftPushAll(JHS_KEY_A,productFromMySql); //设置缓存A的过期时间为1天(1天是86400L) redisTemplate.expire(JHS_KEY_A, 86400L, TimeUnit.SECONDS); // 5.暂停1分钟,间隔1分钟执行一次,模拟聚划算一天执行的参加活动的品牌 try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } },"t1").start(); } }
在controller中创建JHSProductController类
package com.redis.demo.controller; import cn.hutool.core.collection.CollectionUtil; import com.redis.demo.entities.Product; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.util.CollectionUtils; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; import java.util.List; @RestController @Slf4j @Api(tags = "聚划算商品列表接口") public class JHSProductController { public static final String JHS_KEY = "jhs"; public static final String JHS_KEY_A = "jhs:a"; public static final String JHS_KEY_B = "jhs:b"; @Resource private RedisTemplate redisTemplate; @RequestMapping(value = "/product/find", method = RequestMethod.GET) @ApiOperation("聚划算案例,每次1页每页5条显示") public List<Product> find(int page, int size) { List<Product> list=null; //设置读取读取的条数(如果是要读取第5页,那么应该知道前4页占了多少条数据) int start = (page - 1) * size; //这里计算开始读取减去1,是因为索引从0开始的 int end = start + size - 1; try { //采用redis中List结构里面的range命令来实现加载和分页 list = redisTemplate.opsForList().range(JHS_KEY, start, end); //CollectionUtils.isEmpty()作用:判断参数null或者其size0 if(CollectionUtil.isEmpty(list)){ //如果这时候为空就要查询MySQL } log.info("参加活动的商家: {}", list); }catch (Exception e){ //出现了异常吗,一般redis宕机了或者redis由于网络抖动导致的timeout log.error("jhs exception{}", e); e.printStackTrace(); // 后续可以加入重试机制 再次查询mysql } return list; } @RequestMapping(value = "/product/findAB", method = RequestMethod.GET) @ApiOperation("AB双缓存架构,防止热点key突然消失") public List<Product> findAB(int page, int size){ List<Product> list = null; long start = (page - 1) * size; long end = start + size - 1; try { //采用redis List结构里面的range命令来实现加载和分页 list = redisTemplate.opsForList().range(JHS_KEY_A, start, end); //判断是否为空 if(CollectionUtil.isEmpty(list)){ log.info("-----A缓存已经过期或活动结束了,记得人工修补,B缓存继续顶着"); // A没有缓存来找B list = redisTemplate.opsForList().range(JHS_KEY_B, start, end); if (CollectionUtils.isEmpty(list)){ //如果还是没有则到mysql查询 } } log.info("参加活动的商家: {}", list); }catch (Exception e){ //出现了异常吗,一般redis宕机了或者redis由于网络抖动导致的timeout log.error("jhs exception{}", e); e.printStackTrace(); // 后续可以加入重试机制 再次查询mysql } return list; } }
测试
先启动redis在启动项目,查看redis中jhs:a和jsh:b数据是否加载完成

到期之后会换成另一组数据,模拟聚划算,活动时间到期了,换成下一批商品


浙公网安备 33010602011771号