本文将讲述缓存预热、缓存雪崩、缓存穿透、缓存击穿对应的概念和应对措施,并使用代码演示部分应对措施,如Google布隆过滤器、分页显示+定时更新的应用场景下的技术方案。
目录
概念
- 缓存预热:在redis中提前存储数据以备使用,比如之前文章中提到的使用布隆过滤器时,可以在初始化阶段将已存在的对象提前放入redis。
- 缓存雪崩:分为两个软、硬件两个方面
- 硬件:redis服务器崩溃,导致大量sql请求进入MySQL。
- 软件:大量Key同时过期、失效,导致大量sql请求进入MySQL。
- 缓存穿透:不存在记录的请求到达服务器,请求可以透过redis直接到达MySQL。
- 缓存击穿:热点Key失效,导致大量sql请求进入MySQL。
应对措施
缓存预热
方案
在Redis中进行缓存预热的常见方案:
- 静态预热: 在应用程序启动时或定期加载不经常更改的数据。
- 在应用程序启动时,执行一个任务或脚本来加载静态数据到缓存中,如SpringBoot中使用@PostConstruct初始化白名单。
- 热门数据预热: 针对经常访问的数据,提前将其加载到缓存中,以减少缓存未命中。
- 根据访问日志或分析工具的输出,确定哪些数据最常被访问,然后编写脚本或任务来预热这些数据。
- 定时预热: 周期性地刷新数据的缓存,以确保缓存中的数据是最新的。
- 使用定时任务或调度工具,每天/每小时/每分钟等定期触发任务,来刷新特定数据的缓存,如SpringBoot中使用@Scheduled定时预热缓存。
代码
使用@PostConstruct初始化白名单:
@PostConstruct
public void initializeBloomFilter() {
// 创建或获取布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 10000, 0.1);
// 将白名单数据添加到布隆过滤器
bloomFilter.put("user1");
bloomFilter.put("user2");
bloomFilter.put("user3");
// 将布隆过滤器保存到Redis中(如果需要跨应用共享)
redisTemplate.opsForValue().set("bloom_filter", bloomFilter);
}
使用@Scheduled定时预热缓存:
@Scheduled(cron = "0 0 * * *") // 每小时触发一次
public void preloadWeatherData() {
// 在预定的时间间隔内获取最新的天气数据并加载到缓存中
WeatherData data = weatherDataFetcher.fetchLatestWeatherData();
cache.put("latest_weather_data", data);
}
缓存雪崩
预防方案
硬件:使用Redis集群或哨兵,开启Redis持久化方案,保证崩溃后可尽快恢复集群;或使用云Redis服务,选择阿里云、腾讯云这类平台,保证服务器不会崩溃。
软件:将key设置为永不过期或将过期时间错开;使用多机缓存策略,ehcache本地缓存和redis缓存相结合,将本地缓存和Redis缓存中的数据的过期时间分散开,减少了同时失效的可能性。
发生后解决方案
常使用Hystrix或者阿里sentinel限流&降级策略,立即降低请求速率以减轻对后端资源的压力,并切换到降级模式,提供备用数据或服务给用户;然后尝试尽快恢复缓存服务,并考虑增加后端资源的容量。
缓存穿透
请求流程图:
方案
- 空对象缓存:对MySQL查询为空的key,在redis中存入缺省值,可解决key相同的缓存穿透。(注:空对象的过期时间一般较短,以保证最终一致性)
- 布隆过滤器:过滤大量不存在请求,可解决key不同的缓存穿透。
代码
空对象缓存:
// 从缓存中获取数据
public String getDataFromCache(String key) {
String cachedValue = jedis.get(key);
// 如果缓存中有值,直接返回
if (cachedValue != null) {
return cachedValue;
}
// 如果缓存中没有值,设置一个空标记,并设置较短的过期时间
// 防止大量的请求同时查询数据库
String emptyValue = "NULL"; // 这个可以是特殊的空值标记
jedis.setex(key, 60, emptyValue); // 设置缓存过期时间为60秒
return emptyValue;
}
// 模拟从数据库中获取数据的方法
public String fetchDataFromDatabase(String key) {
// ...
}
public static void main(String[] args) {
RedisCachePrevention cache = new RedisCachePrevention();
String key = "exampleKey";
String cachedData = cache.getDataFromCache(key);
if ("NULL".equals(cachedData)) {
// 缓存中是空标记,说明之前数据库中没有找到数据,返回默认值
System.out.println("Data not found in cache.");
} elseif (cachedData == null) {
//缓存中是空,说明还没有访问数据库,查询数据库
String databaseData = cache.fetchDataFromDatabase(key);
if (databaseData != null) {
// 数据库中找到有效数据,将其放入缓存
cache.jedis.setex(key, 3600, databaseData); // 设置较长的缓存过期时间
}
System.out.println("Data retrieved from database: " + databaseData);
} else {
// 缓存中有数据,直接使用
System.out.println("Data retrieved from cache: " + cachedData);
}
}
布隆过滤器:
使用Google的Guava中的布隆过滤器。
导入依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>
过滤器服务代码:
@Service
public class GuavaBloomFilterService {
public static final int size = 1000000;//预计保存1000000条记录
public static final double fpp = 0.01;//期望假阳性率为1%
// 构建布隆过滤器
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, fpp);
public void addToBloom(Integer integer) {
bloomFilter.put(integer);
}
public boolean checkInBloom(Integer integer) {
return bloomFilter.mightContain(integer);
}
@PostConstruct
public void init(){
//...
}
}
即可实现过滤器功能。
缓存击穿
方案
- 互斥更新:即双检查加锁机制,避免多线程对同一个key进行访问进而造成高并发访问MySQL导致崩溃。
- 随机退避:为每个并发请求生成一个随机的等待时间,然后在等待时间之后再尝试从后端存储系统获取数据,使并发请求在时间上分散,而不是同时触发后端请求。
- 差异失效时间:对热点Key直接不设置过期时间;对于必须过期更新的对象,可以开辟多块缓存,如A主B副,先更新B,再更新A,在A更新期间发生的查询进入B,而不是进入MySQL。
代码
互斥更新:(之前文章中实现过,直接拷贝)
public User getUser(int id) {
User user = redis.get(id);
if (user == null) { // 第一次检查,避免不必要的加锁
synchronized (this) {
user = redis.get(id); // 双检查,检查缓存中是否有数据
if (user == null) {
user = dao.get(id); // 从数据库获取数据
redis.set(id, user); // 将数据写入缓存
}
}
}
return user;
}
随机退避:
// 模拟后端数据源的获取操作
private String fetchDataFromBackend(String key) {
// 这里可以实现从后端数据源获取数据的逻辑
// 这里简单地返回一个字符串作为示例
return "DataFromBackendForKey-" + key;
}
// 模拟缓存的获取操作
private String getDataFromCache(String key) {
// 这里模拟从缓存中获取数据的逻辑
// 假设缓存失效,返回null表示缓存未命中
return null;
}
public String getDataWithRetryAndRandomBackoff(String key) {
// 尝试获取数据的最大次数
int maxRetries = 3;
// 初始退避时间(毫秒)
long initialBackoffMillis = 100;
// 最大退避时间(毫秒)
long maxBackoffMillis = 1000;
// 退避时间的随机因子范围
int randomFactorRange = 500;
for (int retry = 0; retry < maxRetries; retry++) {
String cachedData = getDataFromCache(key);
if (cachedData != null) {
return cachedData; // 缓存命中,返回数据
}
// 数据从缓存获取失败,进行随机退避
long backoffMillis = initialBackoffMillis + new Random().nextInt(randomFactorRange);
backoffMillis = Math.min(backoffMillis, maxBackoffMillis);
try {
TimeUnit.MILLISECONDS.sleep(backoffMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 缓存未命中,模拟从后端数据源获取数据
String backendData = fetchDataFromBackend(key);
if (backendData != null) {
// 数据从后端获取成功,将数据放入缓存,这里可以添加缓存的逻辑
return backendData;
}
}
// 所有尝试都失败,返回默认值或抛出异常
return "DefaultDataOrHandleError";
}
差异失效时间:(需求:每天晚上刷新页面内存,分页显示每日促销商品)
@Scheduled(cron = "0 0 0 * *") // 每天触发一次
public void initJHSAB(){
log.info("启动AB定时器计划任务页面内容更新功能模拟.........."+DateUtil.now());
//模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
List<Product> list=this.getProductsFromMysql();
//先更新B缓存
this.redisTemplate.delete(JHS_KEY_B);
this.redisTemplate.opsForList().leftPushAll(JHS_KEY_B,list);
this.redisTemplate.expire(JHS_KEY_B,20L,TimeUnit.DAYS);
//再更新A缓存
this.redisTemplate.delete(JHS_KEY_A);//当热点Key—A进行删除时,请求会进入B缓存,而不是MySQL
this.redisTemplate.opsForList().leftPushAll(JHS_KEY_A,list);
this.redisTemplate.expire(JHS_KEY_A,15L,TimeUnit.DAYS);
log.info("runJhs定时刷新双缓存AB两层..............");
}
//用户查询商品
@RequestMapping(value = "/pruduct/findab",method = RequestMethod.GET)
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数据结构的lrange命令实现分页查询
list = this.redisTemplate.opsForList().range(JHS_KEY_A, start, end);
if (CollectionUtils.isEmpty(list)) {
log.info("=========A缓存已经失效了,记得人工修补,B缓存自动延续5天");
//用户先查询缓存A(上面的代码),如果缓存A查询不到(例如,更新缓存的时候删除了),再查询缓存B
this.redisTemplate.opsForList().range(JHS_KEY_B, start, end);
//TODO 走DB查询
}
log.info("查询结果:{}", list);
} catch (Exception ex) {
//这里的异常,一般是redis瘫痪 ,或 redis网络timeout
log.error("exception:", ex);
//TODO 走DB查询
}
return list;
}