一、面试题
1.缓存预热、雪崩、穿透和击穿分别是什么?你遇到过几种情况?
2. 缓存预热你是怎么做的?
3. 如何避免或者减少缓存雪崩?
4. 击穿和穿透有什么区别?他们是一个意思还是截然不同?
5. 穿透和击穿你有什么解决方案?如何避免?
6. 假如出现了缓存不一致,你有什么修补方案?
二、详解
1. 缓存预热
简介
Redis缓存预热是指在系统上线后,将相关的缓存数据直接加载到缓存系统,以避免在用户请求的时候,先查询数据库,然后再将数据回写到缓存。这样可以极大减少对数据库的压力,提高系统的响应速度和性能。缓存预热主要应用于秒杀场景,例如618购物节。
实现方案
- 数据入库后,程序员可以手动将这些数据从mysql中同步到redis中
- 通过中间件来完成,例如使用阿里巴巴的canal实现双写一致性,或者使用java的@PostConstruct注解
2. 缓存雪崩
- 简介
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
给不同的Key的TTL添加随机值,比如将缓存失效时间分散开,可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
① 利用Redis集群提高服务的可用性,比如哨兵模式、集群模式;
② 给缓存业务添加降级限流策略,比如可以在ngxin或spring cloud gateway中处理;(注:降级可做为系统的保底策略,适用于穿透、击穿、雪崩)
③ 给业务添加多级缓存,比如使用Guava或Caffeine作为一级缓存,redis作为二级缓存等;
- 解决方案
- redis中的key设置为永不过期或过期时间错开
- redis缓存集群实现高可用
- 多缓存结合预防雪崩:ehcache本地缓存+redis缓存
- 服务降级:服务降级是在面对系统负载过高、资源不足或外部依赖故障等异常情况下,通过临时屏蔽某些功能或改变服务行为,以保证核心功能的可用性和性能稳定性的一种策略。服务降级的目的是在极端或异常情况下提供有限但可靠的服务,而不是完全失败或导致系统崩溃。(Hystrix或者阿里sentinel限流或者降级)
- 人民币玩家:阿里云-云数据库redis版
3. 缓存穿透
请求去查询一条数据,先查redis无,后查mysql无,都查询不到数据。但是请求每次都打到mysql中了,导致后台数据库压力暴增,这种现象我们称为缓存穿透,redis在这种情况下就成了摆设。简单来说,就是一个数据既不在redis中也不再mysql中,数据库存在被多次暴击的风险。
对于缓存穿透的问题,最害怕的是恶意攻击。它的解决方案有两个:
- 空对象缓存或者缺省值
这种解决方案叫做回写增强,如果发生了缓存穿透,我们可以针对查询的数据,在redis中存一个和业务部门商量后的缺省值。mysql查不到数据的话,也让redis存入刚刚查不到的key并保护起来。但是这种方法架不住黑客的恶意攻击,它只能解决key相同的情况,如果每次key不同,还是会发生缓存穿透且redis中的垃圾key后越来越多。
- Google布隆过滤器Guava解决缓存穿透
4. Google布隆过滤器Guava
Guava中的布隆过滤器是比较权威的,实际项目中用的比较多,下面结合案例学习一下Guava的使用。
案例一
- 案例架构
- SpringBoot环境搭建:
Guava依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>
Pom.xml
spring:
redis:
host: 127.0.0.1
port: 6379
#password:
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
redis配置类
@Configuration
public class RedisConfig {
//这里先不配置,测试一下待会的错误
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(lettuceConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
- 使用:
@SpringBootTest
public class RedisTest {
@Autowired
private RedisTemplate redisTemplate;
@Test
public void testGUavaWithBloomFilter(){
//1. 创建guava布隆过滤器,过滤数据类型是int,然后样本数据是100
BloomFilter<Integer> integerBloomFilter = BloomFilter.create(Funnels.integerFunnel(), 100);
//2 . 判断指定的元素是否存在
System.out.println(integerBloomFilter.mightContain(1));
System.out.println(integerBloomFilter.mightContain(2));
//3. 将元素添加到布隆过滤器
integerBloomFilter.put(1);
integerBloomFilter.put(2);
//4 . 再次判断指定的元素是否存在
System.out.println(integerBloomFilter.mightContain(1));
System.out.println(integerBloomFilter.mightContain(2));
}
}
案例二
@SpringBootTest
public class RedisTest {
@Autowired
private RedisTemplate redisTemplate;
//1. 定义一个常量
public static final int _1W = 10000;
//2. 定义Guava的初始容量
public static final int SIZE = 100 * _1W;
//3. 误判率,它越小误判的数量也就越少(思考:误判率是否是无限小?)
public static double fpp = 0.03;
//4. 创建布隆过滤器
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), SIZE, fpp);
@Test
public void testGUavaWithBloomFilter() {
//1. 先让bloomfilter加入100万白名单数据
for (int i = 0; i < SIZE; i++) {
bloomFilter.put(i);
}
//2. 故意取10w个不在合法范围内的数据,进行误判率的演示
ArrayList<Integer> list = new ArrayList<>(10 * _1W);
//3. 验证
for (int i = SIZE + 1; i <= SIZE + (10 * _1W); i++) {
if(bloomFilter.mightContain(i)){
System.out.println(i+"被误判了");
list.add(i);
}
}
System.out.println("误判总数量:"+list.size());
}
}
发现误判数量和误判率fpp紧密联系,是不是我们把误判率设置的越低就误判数量就会越少?
为什么会出现上面问题,我们简单分析一下Guava的源码:
进入create方法
public static <T> BloomFilter<T> create(Funnel<? super T> funnel, int expectedInsertions, double fpp) {
return create(funnel, (long)expectedInsertions, fpp);
}
调用了重载方法create
static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions, double fpp, Strategy strategy) {
//参数合法性校验
Preconditions.checkNotNull(funnel);
Preconditions.checkArgument(expectedInsertions >= 0L, "Expected insertions (%s) must be >= 0", expectedInsertions);
Preconditions.checkArgument(fpp > 0.0, "False positive probability (%s) must be > 0.0", fpp);
Preconditions.checkArgument(fpp < 1.0, "False positive probability (%s) must be < 1.0", fpp);
Preconditions.checkNotNull(strategy);
if (expectedInsertions == 0L) {
expectedInsertions = 1L;
}
//布隆过滤器的底层是bit数组,这里就定义了bit数组的长度,是通过数据量和误判率求出来的
long numBits = optimalNumOfBits(expectedInsertions, fpp);
//定义hash函数的数量
int numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits);
try {
return new BloomFilter(new BloomFilterStrategies.LockFreeBitArray(numBits), numHashFunctions, funnel, strategy);
} catch (IllegalArgumentException var10) {
throw new IllegalArgumentException((new StringBuilder(57)).append("Could not create BloomFilter of ").append(numBits).append(" bits").toString(), var10);
}
}
上面说的numBits就是我们bit数组的长度,由于我们设置的误判率为0.03,数据量为100万,就可以算出numBits(即实际的容量并不是100万)
然后根据numBits算出需要的hash函数是五个。所以结论是,Guava底层使用的bit数组长度和hash函数的数量都是和我们设置的误判率紧密相关的。
Guava是用java实现的,它实现了布隆过滤器和Redis的解耦
5. 缓存击穿
缓存击穿是指当某个热点数据(被大量并发访问)在Redis缓存中过期时,所有对该数据的访问都会直接导致数据库承受巨大的压力,从而可能造成数据库崩溃。(简单来说就是热点key失效,暴打mysql)
所以一般的技术部门需要知道热点key是哪些,做到心里有数防止击穿
缓存击穿的解决方案:
- 差异失效时间,对于频繁访问的热点key,干脆就不设置过期时间(解决key有ttl失效导致缓存击穿的问题)
- 互斥更新,采用双检加锁策略(解决更新数据时,删除redis缓存,但mysql数据库还没更新完,这段时间发生缓存击穿问题)
6. 缓存击穿案例—天猫聚划算
- 场景
在商场促销的过程中,每个特价商品的促销是有时间规定的,假设现在某个商品在聚划算上面要促销三天,然后三天结束后的一个瞬间我们需要把这个商品替换成新的商品,而在替换的这一个瞬间,redis需要清掉当前商品的数据,若此时有不怀好意者取海量访问该商品,由于redis已经没有该商品的数据了,就会发生缓存击穿,那么如何解决这个问题?
步骤 | 说明 |
---|---|
1 | 高并发场景,Mysql不适合 |
2 | 先把mysql里面参加活动的数据抽进redis,一般采用定时任务扫描来决定上线活动还是下线活动 |
3 | 支持分页功能,一页20条记录 |
- 环境搭建
创建实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {
//产品id
private long id;
//产品名词
private String name;
//产品价格
private Integer price;
//产品详情
private String detail;
}
创建业务类
采用定时起将参与聚划算活动的特价商品新增入redis中
@Service
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;
//模拟数据库取数据
private List<Product> getProductsFromMysql(){
List<Product> list=new ArrayList<>();
for(int i=0;i<=20;i++){
Random random=new Random();
int id= random.nextInt(10000);
Product product=new Product((long)id,"product"+i,i,"detail");
list.add(product);
}
return list;
}
@PostConstruct
public void intiJHS(){
System.out.println("启动定时器天猫聚划算模拟功能开始");
//1. 用多线程模拟定时任务,后台任务定时将mysql里面的参加活动的商品刷新到redis中
new Thread(()-> {
while(true){
//模拟mysql查询数据
List<Product> products = this.getProductsFromMysql();
//采用redis list数据结构的lpush命令来实现存储
redisTemplate.delete(JHS_KEY);
//加入最新的数据给redis
redisTemplate.opsForList().leftPush(JHS_KEY,products);
//暂停1分钟,间隔1分钟执行一次
try{
TimeUnit.MINUTES.sleep(1);
}catch (Exception e){
e.printStackTrace();
}
}
},"thread1").start();
}
}
实现Controller
@RestController
public class JHSProductController {
@Autowired
private RedisTemplate redisTemplate;
public List<Product> find(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,start,end);
if(CollectionUtils.isEmpty(list)){
//TODO mysql查询数据
}
System.out.println("参加或的的商家:"+list);
}catch (Exception e){
//出异常了,redis宕机或者redis网络抖动导致timeout
e.printStackTrace();
}finally {
}
return list;
}
}
启动微服务
可以发现数据全部插入了
- BUG和隐患说明
热点key失效导致可怕的缓存击穿:
两个黄色代码不是原子的,如果删除redis缓存后,第二条语句还没有结束,这个期间如果有请求就会把请求打到mysql
- 解决方案
互斥更新、随机退避和差异失效时间
- 所谓的互斥更新就是前面介绍的双检加锁策略
- 所谓差异失效时间就是双缓存策略
- 修改代码(双缓存)
@PostConstruct
public void initJHSAB(){
System.out.println("启动AB定时器计划任务天猫聚划算功能模拟.......");
//1. 用多线程模拟定时任务,后台任务定时将mysql里面的参加活动的商品刷新到redis中
new Thread(()-> {
while(true){
//模拟mysql查询数据
List<Product> products = this.getProductsFromMysql();
//先更新B缓存且让B缓存过期时间超过A缓存,如果A失效了还有B
redisTemplate.delete(JHS_KEY_B); //先删除B
redisTemplate.opsForList().leftPushAll(JHS_KEY_B,products);
redisTemplate.expire(JHS_KEY_B,86410L,TimeUnit.SECONDS);
//再更新A缓存
redisTemplate.delete(JHS_KEY_A);
redisTemplate.opsForList().leftPushAll(JHS_KEY_A,products);
redisTemplate.expire(JHS_KEY_A,86400L,TimeUnit.SECONDS);
//暂停1分钟,间隔1分钟执行一次
try{
TimeUnit.MINUTES.sleep(1);
}catch (Exception e){
e.printStackTrace();
}
}
},"thread1").start();
}
@RestController
public class JHSProductController {
@Autowired
private RedisTemplate redisTemplate;
public List<Product> find(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)){
System.out.println("A缓存失效");
list=redisTemplate.opsForList().range(JHS_KEY_B,start,end);
if(CollectionUtils.isEmpty(list)){
//TODO mysql查询数据
}
}
System.out.println("参加或的的商家:"+list);
}catch (Exception e){
//出异常了,redis宕机或者redis网络抖动导致timeout
e.printStackTrace();
}finally {
}
return list;
}
}