文章目录
- 小故事
- 一、为什么要使用缓存?
- 二、什么是缓存穿透?怎么解决?
- 2.1解决方案
- 2.2代码实现
- 三、什么是缓存击穿?怎么解决?
- 3.1解决方案
- 3.2代码实现
- 四、什么是缓存雪崩?怎么解决?
- 4.1解决方案
- 五、Redis缓存工具类【可解决缓存穿透,缓存击穿和缓存雪崩】
🌈你好呀!我是 山顶风景独好
💝欢迎来到我的博客,很高兴能够在这里和您见面!
💝希望您在这里可以感受到一份轻松愉快的氛围!
💝不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。
🚀 欢迎一起踏上探险之旅,挖掘无限可能,共同成长!
小故事
- 在一个繁华的电商世界里,有一家名叫“快购”的在线商城。为了提升用户体验和响应速度,快购商城采用了Redis作为缓存层来缓存热门商品信息、用户数据等关键数据。然而,随着用户量的增长和交易量的激增,Redis缓存层开始面临一些挑战,其中就包括缓存穿透、缓存击穿和缓存雪崩这三个问题。
缓存穿透- 有一天,快购商城的客服团队接到了一位顾客的投诉,说他在搜索某个不存在的商品编号时,系统响应特别慢。技术团队的小明迅速定位到了问题所在:这个不存在的商品编号在Redis缓存中不存在,而每次查询时都会直接访问数据库,导致数据库压力骤增。这就是所谓的“缓存穿透”。
- 小明决定采取一些措施来解决这个问题。他首先引入了布隆过滤器,在查询Redis之前先判断该商品编号是否可能存在于Redis中。如果不存在,则直接返回结果,避免了对数据库的无效查询。
缓存击穿- 不久后,快购商城的某个热门商品即将进行限时促销活动。然而,在这个关键时刻,该商品的缓存却突然失效了。由于这个商品被大量用户频繁访问,导致大量请求直接冲击到了数据库,系统性能急剧下降。这就是“缓存击穿”现象。
- 为了避免这种情况再次发生,小明设计了一个缓存重建策略。当某个商品的缓存失效时,他会先使用互斥锁来确保只有一个线程能够访问数据库并重建缓存。其他线程则会等待缓存重建完成后再从Redis中获取数据。这样,就避免了大量请求同时冲击数据库的问题。
缓存雪崩- 随着双十一购物节的临近,快购商城的流量达到了历史峰值。然而,就在这时,Redis中的大量缓存突然同时失效了。由于这些缓存失效的key对应的数据都是热门商品和用户数据等关键信息,导致大量请求直接冲击到了数据库,系统几乎陷入了瘫痪状态。这就是“缓存雪崩”现象。
- 为了应对这个问题,小明采取了一系列措施。首先,他重新评估了缓存的过期时间设置,确保不会出现大量缓存同时失效的情况。其次,他引入了Redis集群来分担单个Redis节点的压力,提高了系统的可用性和容错能力。最后,他还为数据库层添加了限流和降级策略,确保在Redis缓存失效时,数据库层也能够保持稳定运行。
- 经过一系列的努力和改进,快购商城成功应对了缓存穿透、缓存击穿和缓存雪崩这三个挑战。在双十一购物节期间,系统保持了稳定高效的运行状态,为用户提供了流畅的购物体验。小明和他的技术团队也因此获得了公司的表彰和用户的赞誉。
一、为什么要使用缓存?
提高性能:
- 缓存存储了数据的副本,当应用需要读取数据时,它首先会尝试从缓存中获取,而不是直接访问数据库或后端服务。由于缓存通常位于内存中,其读取速度远远快于磁盘或网络,因此可以显著提高应用程序的响应速度。
减轻数据库压力:
- 在高并发的场景下,如果所有请求都直接访问数据库,将会导致数据库负载急剧上升,甚至可能引发数据库崩溃。通过使用缓存,可以将大部分读请求转移到缓存层处理,从而显著减轻数据库的压力。
增强系统的可扩展性:
- 缓存层可以作为数据库和应用程序之间的缓冲层,通过扩展缓存集群的规模,可以轻松应对不断增长的用户请求和数据量。同时,由于缓存层与应用程序之间的耦合度较低,可以方便地进行水平扩展和升级。
优化用户体验:
- 缓存能够减少用户等待时间,提升页面的加载速度和响应速度,从而优化用户体验。对于需要快速响应的互联网应用来说,这一点至关重要。
降低网络开销:
- 在分布式系统中,应用程序可能需要从远程服务或数据库获取数据。如果频繁进行网络请求,将会产生大量的网络开销。通过使用缓存,可以减少不必要的网络请求,降低网络带宽的使用和传输延迟。
实现数据预加载和预热:
- 通过将预期会被频繁访问的数据预先加载到缓存中,可以避免在实际访问时产生延迟。这种预加载和预热策略可以进一步提高系统的响应速度和性能。
支持高并发和实时性要求:
- 对于一些需要支持高并发和实时性要求的场景(如在线交易、社交应用等),缓存可以提供快速、低延迟的数据访问能力,确保系统能够满足用户的需求。
二、什么是缓存穿透?怎么解决?
缓存穿透(Cache Penetration)的概念是指在查询一个不存在的数据时,由于缓存中不存在这个数据,导致查询请求直接到达了数据库层,而数据库层也没有这个数据,因此无法将结果写入缓存。这样一来,每次针对这个不存在的数据的查询都会直接请求到数据库层,造成数据库的压力增大,甚至可能引发数据库宕机。
2.1解决方案
- 数据预热:在系统启动或数据更新时,将热点数据预先加载到缓存中。
- 布隆过滤器:在查询缓存之前,先使用布隆过滤器(Bloom Filter)来判断这个数据是否可能存在于缓存中。如果布隆过滤器判断该数据不存在,则直接返回,不再查询缓存和数据库。
- 空值缓存:当查询到一个不存在的数据时,可以将这个空值或特定标识(如“NULL”或“NOT_FOUND”)缓存起来,并设置一个较短的过期时间。这样,在后续的查询中,就可以直接从缓存中返回空值或特定标识,避免了对数据库的无效查询。
- 接口层校验:在接口层对请求的参数进行校验,如果参数不符合业务规则(如ID小于0),则直接返回错误,避免无效请求到达数据库层。
- 监控和报警:对缓存穿透的情况进行监控,当发现大量无效请求时,及时报警并采取相应的措施。
2.2代码实现
空值缓存: 如果数据库的查询结果为空,我们仍然将这个结果进行一个缓存,减轻数据库的压力时间最长不超过五分钟
//缓存空值解决缓存穿透问题
public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
//从redis查询缓存数据
String json = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if (StrUtil.isNotBlank(json)) {
//存在,直接返回
return JSONUtil.toBean(json, type);
}
//命中空值
if (json != null) {
//返回错误信息
return null;
}
//不存在,根据id查询数据库
R r = dbFallback.apply(id);
//不存在,返回错误数据
if (r == null) {
//将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", 2L, TimeUnit.MINUTES);
//返回错误信息
return null;
}
//存在,写入redis
this.set(key, r, time, unit);
//返回数据
return r;
}
布隆过滤器:在查询缓存之前,先使用布隆过滤器(Bloom Filter)来判断这个数据是否可能存在于缓存中。如果布隆过滤器判断该数据不存在,则直接返回,不再查询缓存和数据库。
pom.xml添加依赖<!-- 添加Guava的BloomFilter依赖 --> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>30.1-jre</version> </dependency>
添加配置application.yml
//布隆过滤器配置 bloom-filter: expected-insertions: 1000000 # 期望插入的元素数量 fpp: 0.01 # 误判率
配置类中创建bean
@Configuration
public class BloomFilterConfig {
@Value("${bloom-filter.expected-insertions}")
private int expectedInsertions;
@Value("${bloom-filter.fpp}")
private double falsePositiveProbability;
@Bean
public BloomFilter<String> bloomFilter() {
return BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), expectedInsertions, falsePositiveProbability);
}
}
三、什么是缓存击穿?怎么解决?
缓存击穿(Cache Breakdown)的概念是:当某个热点数据(即被频繁访问的数据)在缓存中过期失效时,由于这个数据被大量用户或系统并发访问,导致大量请求直接穿透缓存层,直接访问数据库层,从而造成数据库压力骤增的现象。
3.1解决方案
- 热点数据永久缓存:如果热点数据基本不会发生变化,可以考虑将其设置为永不过期,从而避免缓存击穿的发生。
- 分布式锁:在缓存失效时,使用分布式锁(如基于Redis、ZooKeeper的锁)确保只有一个线程能够访问数据库并重新构建缓存,其他线程则等待锁释放后从缓存中获取数据。这样可以避免大量请求同时冲击数据库。
- 定时重建缓存:对于更新频率较低但访问量大的数据,可以设置定时任务在缓存失效前主动重新构建缓存,确保缓存中始终有可用的数据。
- 缓存降级:在缓存击穿发生时,可以考虑暂时将部分请求降级处理,如返回默认数据或提示用户稍后再试,以减轻数据库压力。
3.2代码实现
逻辑过期解决缓存击穿:
数据类:@Data public class RedisData { private LocalDateTime expireTime; private Object data; }
// 定义一个固定大小的线程池,用于异步重建缓存
private static final ExecutorService rebuild_executor = Executors.newFixedThreadPool(10);
/**
* 使用逻辑过期时间的查询方法
*
* @param keyPrefix Redis key的前缀
* @param id 数据的唯一标识
* @param type 返回数据的类型
* @param dbFallback 数据库查询的回调方法
* @param time 缓存的过期时间长度
* @param unit 缓存的过期时间单位
* @param <R> 返回值的泛型类型
* @param <ID> ID的泛型类型
* @return 查询到的数据
*/
public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
// 构建Redis的key
String key = keyPrefix + id;
// 从Redis中获取数据
String json = stringRedisTemplate.opsForValue().get(key);
// 这里有逻辑错误,如果json为空,应该进入下面的逻辑,但当前判断是如果json不为空则直接返回null,这是不对的
// 应改为 if (StrUtil.isBlank(json)) { ... }
if (StrUtil.isNotBlank(json)) {
return null; // 这里应该返回反序列化的对象,但json为空时的处理应该在这里之前
}
// 如果json为空,则下面的代码不会执行(由于上面的逻辑错误),但假设json不为空
// 尝试将json转换为RedisData对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// 从RedisData对象中提取数据并转换为指定类型R
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
// 获取数据的逻辑过期时间
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 数据未过期,直接返回
return r;
}
// 数据已过期,开始缓存重建
// 构建锁的key
String lockKey = "lock_key" + id;
// 尝试获取锁
boolean isLock = tryLock(lockKey);
if (isLock) {
// 成功获取锁,提交线程到线程池进行缓存重建
rebuild_executor.submit(() -> {
try {
// 调用数据库查询的回调方法,获取最新数据
R apply = dbFallback.apply(id);
// 将数据写入Redis,并设置逻辑过期时间
this.setWithLogicalExpire(key, apply, time, unit);
} catch (Exception e) {
throw new RuntimeException(); // 这里最好捕获具体的异常并处理,而不是直接抛出RuntimeException
} finally {
// 释放锁
unLock(lockKey);
}
});
}
// 注意:由于缓存重建是异步的,这里可能返回过期或null的数据,直到新的数据被加载到缓存中
return r; // 这里返回的可能是过期数据,因为重建是异步的
}
/**
* 尝试获取锁
*
* @param key 锁的key
* @return 是否成功获取锁
*/
private boolean tryLock(String key) {
// 使用setIfAbsent方法尝试在Redis中设置key的值,如果key不存在则设置成功并返回true,否则返回false
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag); // 判断是否成功获取锁
}
/**
* 释放锁
*
* @param key 锁的key
*/
private void unLock(String key) {
// 直接删除Redis中的key来释放锁,但这种方式存在一些问题,比如锁被误删或锁过期后删除导致的竞态条件
// 在实际应用中,通常会使用更复杂的锁机制,如Redis的RedLock算法
stringRedisTemplate.delete(key);
}
四、什么是缓存雪崩?怎么解决?
- 缓存雪崩是指当大量缓存同时失效或过期后,引起系统性能急剧下降的情况。
- 具体来说,当缓存中的数据大批量到达过期时间,而此时的查询数据量又非常巨大时,系统需要再次访问数据库以重新生成缓存。
- 由于这个处理步骤耗时较长,可能会达到上百毫秒甚至更长时间,对于高并发的系统来说,这意味着在缓存重建期间,大量的请求都会直接访问数据库,对数据库造成巨大的压力和不必要的性能损耗。
- 这种对数据库的访问压力又会进一步拖慢整个系统,严重时甚至可能导致数据库宕机,进而形成一系列连锁反应,造成整个系统崩溃。
缓存雪崩的原因主要包括:- 缓存大面积的同时失效:这可能是由于为缓存设置了相近的有效期,导致大量缓存在同一时间失效。
- 对热点数据的持续高并发访问:在缓存失效后,大量的请求会同时访问数据库以获取数据,进一步加剧了系统的压力。
4.1解决方案
均匀设置过期时间:
- 为避免大量缓存数据在同一时间过期,可以在设置缓存过期时间时加上一个随机数,确保数据不会在同一时间失效。
- 例如,如果一个缓存键原本应该设置1小时的过期时间,可以改为在1小时±一个较小的时间范围内随机设置过期时间。
使用互斥锁:
- 当业务线程发现访问的数据不在Redis中时,可以使用互斥锁(如Redis的SETNX命令)来确保同一时间只有一个线程去数据库查询并更新缓存。
- 未能获取互斥锁的线程可以选择等待锁释放后重新读取缓存,或者返回空值或默认值。
后台更新缓存:
- 缓存的更新工作交由后台线程定时或根据业务需求触发,而不是由业务线程在访问时发现缓存失效后直接更新。
- 这样可以避免大量请求同时去数据库查询数据并更新缓存,减轻数据库压力。
缓存预热:
- 在系统启动或低峰时段,预先将热点数据加载到Redis缓存中,确保在高峰时段用户访问时数据已经在缓存中。
使用Redis主从复制和哨兵机制:
- 通过主从复制,将数据复制到多个从服务器,确保在主服务器出现问题时,从服务器可以继续提供服务。
- 哨兵机制负责监控Redis主从服务器的健康状态,一旦主服务器出现问题,哨兵将自动切换到备份服务器作为新的主服务器,保障数据的持续可用。
增加缓存容量:
- 根据业务需求,可以通过增加Redis节点数量或增大单个节点的容量来提高缓存容量,从而避免缓存雪崩。
优化数据库连接:
- 确保数据库连接池配置合理,避免在缓存失效时大量请求同时创建数据库连接导致数据库性能下降。
使用限流和降级策略:
- 在系统层面使用限流策略,如令牌桶、漏桶算法等,限制对Redis和数据库的访问频率。
- 当Redis缓存失效时,可以采用降级策略,如返回默认数据或提示用户稍后重试,避免大量请求直接访问数据库。
五、Redis缓存工具类【可解决缓存穿透,缓存击穿和缓存雪崩】
Redis中缓存的逻辑过期数据类
package com.org.utils;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
Redis缓存工具类
package com.org.utils;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/**
* 缓存客户端工具类,用于操作Redis缓存,支持普通缓存设置与逻辑过期缓存处理。
*/
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 设置普通缓存。
* @param key 缓存键
* @param value 缓存值
* @param time 过期时间
* @param unit 时间单位
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 设置逻辑过期缓存。
* 使用RedisData包装实际数据和过期时间。
* @param key 缓存键
* @param value 缓存值
* @param time 过期时间
* @param unit 时间单位
*/
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 查询缓存并处理缓存穿透问题。
* 如果数据库中也不存在,则将空值写入Redis一段时间。
* @param keyPrefix 缓存键前缀
* @param id 数据唯一标识
* @param type 结果对象类型
* @param dbFallback 数据库查询回调函数
* @param time 缓存过期时间
* @param unit 时间单位
* @param <R> 结果类型
* @param <ID> ID类型
* @return 查询结果
*/
public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
// 命中空值表示之前查询过且数据库中也无此数据
if (json != null) {
return null;
}
R r = dbFallback.apply(id);
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", 2L, TimeUnit.MINUTES); // 写入空值避免缓存穿透
return null;
}
set(key, r, time, unit);
return r;
}
private static final ExecutorService rebuild_executor = Executors.newFixedThreadPool(10);
/**
* 查询逻辑过期缓存,如果过期则异步重建缓存。
* 使用分布式锁防止重建缓存的并发问题。
* @param keyPrefix 缓存键前缀
* @param id 数据唯一标识
* @param type 结果对象类型
* @param dbFallback 数据库查询回调函数
* @param time 缓存过期时间
* @param unit 时间单位
* @param <R> 结果类型
* @param <ID> ID类型
* @return 查询结果
*/
public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback ,Long time, TimeUnit unit) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) {
return null; // 缓存中没有该key,直接返回null
}
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
if (!expireTime.isAfter(LocalDateTime.now())) {
// 缓存已过期,异步重建
String lockKey = "lock_key" + id;
if (tryLock(lockKey)) {
rebuild_executor.submit(() -> {
try {
R apply = dbFallback.apply(id);
setWithLogicalExpire(key, apply, time, unit);
} catch (Exception e) {
throw new RuntimeException("缓存重建失败", e);
} finally {
unLock(lockKey);
}
});
}
} else {
// 未过期,直接返回
return r;
}
return r; // 即使过期,在等待重建期间仍返回旧值(直至重建完成)
}
/**
* 尝试获取分布式锁。
* @param key 锁的键
* @return 是否成功获取锁
*/
private boolean tryLock(String key) {
return BooleanUtil.isTrue(stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS));
}
/**
* 释放分布式锁。
* @param key 锁的键
*/
private void unLock(String key){
stringRedisTemplate.delete(key);
}
}