Redis:缓存预热、缓存雪崩、缓存穿透、缓存击穿

本文将讲述缓存预热、缓存雪崩、缓存穿透、缓存击穿对应的概念和应对措施,并使用代码演示部分应对措施,如Google布隆过滤器、分页显示+定时更新的应用场景下的技术方案。

目录

概念

应对措施

缓存预热

方案

代码

缓存雪崩

预防方案

发生后解决方案

缓存穿透

方案

代码

缓存击穿

方案

代码

总结


概念

  • 缓存预热:在redis中提前存储数据以备使用,比如之前文章中提到的使用布隆过滤器时,可以在初始化阶段将已存在的对象提前放入redis。
  • 缓存雪崩:分为两个软、硬件两个方面
    • 硬件:redis服务器崩溃,导致大量sql请求进入MySQL。
    • 软件:大量Key同时过期、失效,导致大量sql请求进入MySQL。
  • 缓存穿透:不存在记录的请求到达服务器,请求可以透过redis直接到达MySQL。
  • 缓存击穿:热点Key失效,导致大量sql请求进入MySQL。

应对措施

缓存预热

方案

在Redis中进行缓存预热的常见方案:

  1. 静态预热: 在应用程序启动时或定期加载不经常更改的数据。
    1. 在应用程序启动时,执行一个任务或脚本来加载静态数据到缓存中,如SpringBoot中使用@PostConstruct初始化白名单。
  2. 热门数据预热: 针对经常访问的数据,提前将其加载到缓存中,以减少缓存未命中。
    1. 根据访问日志或分析工具的输出,确定哪些数据最常被访问,然后编写脚本或任务来预热这些数据。
  3. 定时预热: 周期性地刷新数据的缓存,以确保缓存中的数据是最新的。
    1. 使用定时任务或调度工具,每天/每小时/每分钟等定期触发任务,来刷新特定数据的缓存,如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限流&降级策略,立即降低请求速率以减轻对后端资源的压力,并切换到降级模式,提供备用数据或服务给用户;然后尝试尽快恢复缓存服务,并考虑增加后端资源的容量。

缓存穿透

请求流程图:

方案
  1. 空对象缓存:对MySQL查询为空的key,在redis中存入缺省值,可解决key相同的缓存穿透。(注:空对象的过期时间一般较短,以保证最终一致性)
  2. 布隆过滤器:过滤大量不存在请求,可解决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;
}

总结

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值