Redis缓存之预热、击穿、穿透、雪崩
面试切入点
缓存预热
什么是预热?
mysql假如新增100条记录,一般默认以mysql为准作为底单数据,如何同步到redis(布隆过滤器),这100条合法数据??
为什么需要预热?
mysql有100条新记录,redis无
1.比较懒,什么都不做,只对mysql做了数据新增,利用redis的回写机制,让他逐步实现100条新增记录的同步。最好提前比如晚上部署发布版本的时候,由自己人提前做一次,让redis同步了,不要把这个问题留给客户。
2.通过中间件或者程序自行完成@PostConstruct。
缓存雪崩
发生
- redis主机挂了,Redis全盘崩溃,偏硬件运维
- redis中有大量key同时过期,大面积失效,偏软件开发
预防+解决
-
redis中key设置为永不过期或者过期时间错开
-
redis缓存集群实现高可用(主从+哨兵、Redis Cluster、开启Redis持久化机制aof或者rdb,尽快恢复缓存数据)
-
多缓存结合预防雪崩(ehcache本地缓存+redis缓存)
-
服务降级(Hystrix或者阿里sentinel限流或降级)
-
人民币玩家 (阿里云-云数据库Redis版)
缓存穿透
是什么?
请求去查询一条记录,先看redis无,后查mysql无,都查询不到该条记录,但是请求每次都会打到数据库上面去,导致后台数据库的压力暴增,这种现象我们称为缓存穿透,这个redis变成了摆设。
简单来说,本来无一物,两库都没有。既不在Redis缓存库,也不在mysql,数据库存在被多次暴击风险。
解决
方案1:空对象缓存或者缺省值
-
一般情况OK
-
黑客攻击
黑客会对你的系统进行攻击,拿一个不存在的id去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而宕掉。
key相同打你系统
第一次打到mysql,空对象缓存后第二次就返回defaultNull缺省值,避免mysql被攻击,不用再到数据库中去走一圈了
key不同打你系统
由于存在空对象缓存和缓存回写(看自己业务不限死),redis中的无关紧要的key也会越写越多(记得设置redis过期时间)
方案2:Google布隆过滤器Guava解决缓存穿透
Guava 中布隆过滤器的实现算是比较权威的,所以实际项目中我们可以直接使用Guava布隆过滤器
案例:白名单过滤器
架构
误判问题,但是概率小可以接受,不能从布隆过滤器删除 。全部合法的key都需要放入Guava版布隆过滤器+redis里面,不然数据返回就是null
引入pom
<!--guava Google 开源的 Guava 中自带的布隆过滤器-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>
入门使用
/**
* 测试布隆过滤器 demo
*/
@Test
public void testGuavaBloomFilter(){
//1.创建guava版本的布隆过滤器
BloomFilter<Integer> integerBloomFilter = BloomFilter.create(Funnels.integerFunnel(), 100);
//2.判断指定的元素是否存在
System.out.println(integerBloomFilter.mightContain(1));
System.out.println(integerBloomFilter.mightContain(2));
System.out.println();
//3.往过滤器加入元素
integerBloomFilter.put(1);
integerBloomFilter.put(2);
System.out.println(integerBloomFilter.mightContain(1));
System.out.println(integerBloomFilter.mightContain(2));
}
运行效果
取样本数据100W,查查不在这个样本范围内的10W数据是否存在??
Controller层
import com.atguigu.redis7.service.GuavaBloomFilterService;
import com.atguigu.redis7.service.GuavaFilterService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PathVariable;
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 GuavaFilterService guavaBloomFilterService;
@ApiOperation("guava布隆过滤器插入100万样本数据并额外10W测试是否存在")
@RequestMapping(value = "/guavafilter",method = RequestMethod.GET)
public void guavaBloomFilter()
{
guavaBloomFilterService.guavaBloomFilter();
}
}
service层
package com.atguigu.redis7.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 GuavaFilterService
{
//1 定义一个常量
public static final int _1W = 10000;
//2 定义我们guava布隆过滤器,初始容量
public static final int SIZE = 100 * _1W;
//3 误判率,它越小误判的个数也就越少(思考,是否可以是无限小??没有误判岂不是更好)
public static double fpp = 0.03;//0.01 0.000000000000001
//4 创建guava布隆过滤器
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), SIZE,fpp);
public void guavaBloomFilter()
{
//加入100W白名单的数据
for (int i = 1; i <=SIZE ; i++) {
bloomFilter.put(i);
}
//取10W个不在合法范围内的数据进行布隆过滤器的误判验证
ArrayList<Integer> list = new ArrayList<>(10 * _1W);
//验证
for (int i = SIZE+1; i <=SIZE+10*_1W ; i++) {
if(bloomFilter.mightContain(i)){
log.info("被误判了:{}",i);
list.add(i);
}
}
log.info("误判总数量:{}",list.size());
}
}
实际效果
误判结果:3033
误判率的大小与坑位以及hash函数的关系:
0.03的误判率,hash函数是5,但是0.01时变成7,同时坑位数目变多。
默认误判率为0.03
布隆过滤器说明
实现黑名单机制
缓存击穿
是什么?
大量的请求同时查询一个key时,此时的这个key正好失效了,就会导致大量的请求都打到数据库上面去了。
简单说就是热点key突然失效了,暴打Mysql
备注:穿透和击穿,截然不同
危害
- 会造成某一时刻数据库的请求量过大,压力剧增
- 一般技术部门**需要知道热点key是那些个?**做到心里有数防止击穿
解决
热点key失效 - 时间到了自然清除但还被访问到
- delete掉的key,刚巧又被访问
解决方案 - 方案1:差异失效时间,对于访问频繁的热点key,干脆就不设置过期时间
- 方案2:互斥更新,采用双重校验加锁的策略
案例:天猫聚划算功能实现+防止缓存击穿
问题:热点key突然失效了,导致缓存击穿
技术方案实现:
分析过程:
redis数据类型选型
业务类
package com.atguigu.redis7.entities;
import io.swagger.annotations.ApiModel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(value = "聚划算活动producet信息")
public class Product
{
//产品ID
private Long id;
//产品名称
private String name;
//产品价格
private Integer price;
//产品详情
private String detail;
}
controller层
package com.atguigu.redis7.controller;
import com.atguigu.redis7.entities.Product;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
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 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";
@Autowired
private RedisTemplate redisTemplate;
/**
* 分页查询:在高并发的情况下,只能走redis查询,走db的话必定会把db打垮
* @param page
* @param size
* @return
*/
@RequestMapping(value = "/pruduct/find",method = RequestMethod.GET)
@ApiOperation("聚划算案例,每次1页每页5条显示")
public List<Product> find(int page, int size) {
List<Product> list=null;
long start = (page - 1) * size;
long end = start + size - 1;
try
{
// 采用redis list结构里面的lrang命令来实现加载和分页查询
list = redisTemplate.opsForList().range(JHS_KEY,start,end);
if(CollectionUtils.isEmpty(list))
{
//TODO 走mysql查询
}
log.info("参加活动的商家:{}",list);
}catch (Exception e){
// 出异常了,一般redis宕机了或者redis网络抖动导致timeout
log.error("jhs exception:{}",e);
e.printStackTrace();
// ....再次查询mysql
}
return list;
}
@RequestMapping(value = "/pruduct/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
{
list = redisTemplate.opsForList().range(JHS_KEY_A,start,end);
if(CollectionUtils.isEmpty(list))
{
log.info("---A缓存已经过期失效或活动结束了,记得人工修改,B缓存继续顶着");
list = redisTemplate.opsForList().range(JHS_KEY_B,start,end);
if(CollectionUtils.isEmpty(list))
{
//TODO 走mysql查询
}
}
}catch (Exception e){
// 出异常了,一般redis宕机了或者redis网络抖动导致timeout
log.error("jhs exception:{}",e);
e.printStackTrace();
// ....再次查询mysql
}
return list;
}
}
采用定时器将参与聚划算的特价商品写入redis中
@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";
@Autowired
private RedisTemplate redisTemplate;
/**
* 偷个懒不加mybatis了,模拟从数据库读取20件特价商品,用于加载到聚划算的页面中
* @return
*/
private List<Product> getProductsFromMysql() {
List<Product> list=new ArrayList<>();
for (int i = 1; i <=20; i++) {
Random rand = new Random();
int id= rand.nextInt(10000);
Product obj=new Product((long) id,"product"+i,i,"detail");
list.add(obj);
}
return list;
}
@PostConstruct
public void initJHS()
{
log.info("启动定时器天猫聚划算功能模拟开始......,O(∩_∩)O哈哈~");
//1 用线程模拟定时任务,后台任务定时将mysql里面的参加活动的商品刷新到redis里
new Thread(() -> {
while (true)
{
//2 模拟从mysql查出数据,用于加载到redis并给聚划算页面显示
List<Product> list = this.getProductsFromMysql();
//3 采用redis list数据结构的lpush命令来实现存储
redisTemplate.delete(JHS_KEY);
//4 加入最新的数据给redis参加活动
redisTemplate.opsForList().leftPushAll(JHS_KEY,list);
//5 暂停1分钟线程,间隔一分钟执行一次,模拟聚划算一天执行的参加活动的品牌
try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
}
},"t1").start();
}
}
目前的效果
redis中的数据
请求接口
响应数据
基本功能完成,请思考一下在高并发的情境下有什么经典问题??
Bug和隐患说明
- 热点key突然失效导致可怕的缓存击穿
delete命令执行的一瞬间有空隙,其他请求线程继续找Redis为null,打到了mysql,暴击。
最终目的:2条命令的原子性还是其次,主要是防止热key突然失效暴击mysql,打爆系统
互斥更新与双重校验登场
差异失效时间登场
代码实现
两块缓存,先更新缓存B,再更新缓存A
@PostConstruct
public void initJHSAB(){
log.info("启动AB定时器计划任务天猫聚划算功能模拟.........."+DateUtil.now());
//1 用线程模拟定时任务,后台任务定时将mysql里面的参加活动的商品刷新到redis里
new Thread(() -> {
while (true)
{
//2 模拟从mysql查出数据,用于加载到redis并给聚划算页面显示
List<Product> list = this.getProductsFromMysql();
//3 先更新B缓存且让B缓存过期时间超过A缓存,如果A突然失效了还有B兜底,防止击穿
redisTemplate.delete(JHS_KEY_B);
redisTemplate.opsForList().leftPushAll(JHS_KEY_B,list);
redisTemplate.expire(JHS_KEY_B,86410L,TimeUnit.SECONDS);
//4 再更新A缓存
redisTemplate.delete(JHS_KEY_A);
redisTemplate.opsForList().leftPushAll(JHS_KEY_A,list);
redisTemplate.expire(JHS_KEY_A,86400L,TimeUnit.SECONDS);
//5 暂停1分钟线程,间隔一分钟执行一次,模拟聚划算一天执行的参加活动的品牌
try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
}
},"t1").start();
}
查询逻辑:先查询A,再查询B,缓存都没有,才去查询数据库
@RequestMapping(value = "/pruduct/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
{
list = redisTemplate.opsForList().range(JHS_KEY_A,start,end);
if(CollectionUtils.isEmpty(list))
{
log.info("---A缓存已经过期失效或活动结束了,记得人工修改,B缓存继续顶着");
list = redisTemplate.opsForList().range(JHS_KEY_B,start,end);
if(CollectionUtils.isEmpty(list))
{
//TODO 走mysql查询
}
}
}catch (Exception e){
// 出异常了,一般redis宕机了或者redis网络抖动导致timeout
log.error("jhs exception:{}",e);
e.printStackTrace();
// ....再次查询mysql
}
return list;
}
实现效果
接口调用
A失效了
B兜底
总结
视频链接
Redis缓存之预热、击穿、穿透、雪崩