面试切入点
缓存预热
什么是预热?
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*/@Testpublic 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
{@Resourceprivate 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
{//产品IDprivate 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";@Autowiredprivate 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网络抖动导致timeoutlog.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网络抖动导致timeoutlog.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";@Autowiredprivate 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;}@PostConstructpublic 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
@PostConstructpublic 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网络抖动导致timeoutlog.error("jhs exception:{}",e);e.printStackTrace();// ....再次查询mysql}return list;}
实现效果
接口调用
A失效了
B兜底
总结
视频链接
Redis缓存之预热、击穿、穿透、雪崩