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

前言

​ Redis作为目前使用较为广泛的缓存,可以有效提高应用程序的性能。但是,缓存也会带来一些挑战,例如缓存预热、缓存雪崩、缓存击穿和缓存穿透等问题。在本篇博客中,我们将深入探讨这些问题,并介绍如何去解决。

先介绍一下查询请求的正常执行流程

如果Redis中存在数据,则直接返回给用户。否则,去数据库中查找数据,找到后写入Redis缓存并将查询结果返回给用户,没找到就报错。

注:这里我写的是先写缓存在返回数据给用户(4和5)。个人理解应该根据不同的应用场景业务需求进行决定。

一般来说,如果缓存中没有找到数据,就需要从数据库中查询。如果数据库中有数据,那么需要考虑将数据写入缓存还是直接返回给用户。

如果该数据被频繁地请求,那么将数据写入缓存可以提高访问速度和响应时间,减轻数据库的负载。因此,这种情况下建议先将数据写入缓存,然后再将数据返回给用户。如果该数据并不会被频繁地请求,那么将数据写入缓存的效果可能不如预期,因为缓存中的数据可能很快就会被淘汰。此时,建议先将数据返回给用户,然后再将数据写入缓存。

当然,具体的处理方式还需要考虑其他因素,如数据的大小、缓存的容量、缓存的淘汰策略等。因此,在实际应用中,需要根据具体情况进行权衡和选择。

1. 缓存预热


1.1 定义

缓存预热是指在应用程序启动或重启后,通过提前加载热点数据到Redis缓存中,避免大量用户请求查询数据库,以提高缓存的命中率

热点数据是指最常被访问的数据,它们可以是用户数据、系统配置数据、产品信息、广告等。

缓存预热其实是一种优化方案,可以避免冷启动、减少应用程序在高峰期的压力。你想,当一个应用程序启动时,缓存中没有数据,第一次请求就需要从数据库中获取数据来响应用户的请求。如果这些数据被缓提前存在Redis中,就可以大大加快应用程序的响应速度。

所以可以在应用程序启动之前通过预加载一些经常被访问的数据到Redis中。这样,当第一个请求到达时,数据将已经存在于缓存中,响应时间将大大缩短。

1.2 应用场景

​ 最常见的就是各大电商平台的双十一活动了,双十一期间会面临大量的用户访问和订单请求,为了应对这些高并发情况就会采用缓存预热的方式提升系统性能和用户体验。例如,提前将商品信息、店铺信息等热点数据加载到缓存中,这些数据是在低峰期从数据库中获取的,然后放入Redis缓存中,从而降低了高峰期对数据库的访问。

1.3 实现方案

实现缓存预热需要根据具体业务场景和缓存技术选择合适的方案,以下是一些常用的实现方式:

  • 预热脚本

​ 预热脚本是指通过编写程序或脚本来预先加载热点数据到缓存中。可以在应用程序启动时或者定时任务中运行预热脚本,以便于提高缓存的命中率和应用程序的性能。

例:

public class CachePreheatScript {
    private Cache cache; // 缓存对象
    private List<String> keys; // 需要预热的key列表

    public CachePreheatScript(Cache cache, List<String> keys) {
        this.cache = cache;
        this.keys = keys;
    }

    public void preheat() {
        for (String key : keys) {
            Object value = loadDataFromDatabase(key); // 从数据库中加载数据
            cache.put(key, value); // 将数据放入缓存中
        }
    }

    private Object loadDataFromDatabase(String key) {
        // 从数据库中加载数据的逻辑
        return value;
    }
}

注:这里统一使用了Cache作为缓存变量,就没有特指Redis缓存,具体根据应用场景使用

  • 定时任务

​ 定时任务是指通过设置定时任务来定期预热缓存,可以根据业务场景和数据特点选择合适的时间间隔和预热数据量,以保证缓存预热的效果和系统性能。

例:

@Component
public class CachePreheatTask {
    private Cache cache; // 缓存对象
    private List<String> keys; // 需要预热的key列表

    @Scheduled(fixedDelay = 10000) // 每隔10秒钟执行一次
    public void preheat() {
        for (String key : keys) {
            Object value = loadDataFromDatabase(key); // 从数据库中加载数据
            cache.put(key, value); // 将数据放入缓存中
        }
    }

    private Object loadDataFromDatabase(String key) {
        // 从数据库中加载数据的逻辑
        return value;
    }
}
  • 延迟队列

​ 延迟队列是指通过将需要预热的数据放入队列中,再通过消费者将数据异步加载到缓存中。可以通过设置队列大小、消费者数量和消费速度等参数来控制预热数据的加载速度和负载。

Redis实现延迟队列的示例代码如下:

public class CachePreheatQueue {
    private Jedis jedis; // Redis缓存
    private String queueKey; // 队列名称

    public CachePreheatQueue(Jedis jedis, String queueKey) {
        this.jedis = jedis;
        this.queueKey = queueKey;
    }

    public void addTask(String key) {
        long timestamp = System.currentTimeMillis() + 1000 * 60; // 1分钟后执行预热任务
        jedis.zadd(queueKey, timestamp, key); // 将任务放入延迟队列中
    }

    public void consumeTask() {
        while (true) {
            Set<String> tasks = jedis.zrangeByScore(queueKey, 0, System.currentTimeMillis(), 0, 1); // 获取需要执行的任务
            if (tasks.isEmpty()) {
                try {
                    Thread.sleep(1000); // 休眠1秒钟
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } else {
                String key = tasks.iterator().next();
                Object value = loadDataFromDatabase(key); // 从数据库中加载数据
                cache.put(key, value); // 将数据放入缓存中
                jedis.zrem(queueKey, key); // 从延迟队列中删除已执行的任务
            }
        }
    }

    private Object loadDataFromDatabase(String key) {
        // 从数据库中加载数据的逻辑
        return value;
    }
}
1.4 缓存预热效果评估

评估缓存预热的效果需要通过一些指标来衡量,以下是一些常用的评估指标:

  • 命中率

命中率是指缓存中已经存在的数据在被请求时能够被命中的比例,通常用命中次数除以总的请求次数来计算。如果缓存预热能够提高命中率,就说明预热效果比较好。

  • 响应时间

响应时间是指用户请求数据后,从请求发出到数据返回的总时间,通常用平均响应时间来表示。如果缓存预热能够降低响应时间,就说明预热效果比较好。

  • 缓存大小

缓存大小是指缓存中存储的数据量,通常用缓存使用率来表示。如果缓存预热成功,缓存中存储的数据量应该比预热前有所增加。

  • 缓存命中时间

缓存命中时间是指缓存中已经存在的数据被命中时的响应时间,通常用平均缓存命中时间来表示。如果缓存预热能够降低缓存命中时间,就说明预热效果比较好。

  • 缓存更新时间

缓存更新时间是指缓存中的数据被更新后,更新到缓存中的时间,通常用平均缓存更新时间来表示。如果缓存预热能够缩短缓存更新时间,就说明预热效果比较好。

总之,评估缓存预热效果需要综合考虑多个指标,根据具体情况选择合适的指标进行衡量。同时,需要定期进行评估和优化,以保证缓存预热的效果持续稳定地提升系统性能和用户体验。

2. 缓存雪崩


2.1 定义

缓存雪崩是指在缓存中大量的键值同时失效或者被清除(缓存服务宕机),导致请求直接落到数据库上,从而导致数据库的压力骤增,甚至可能导致数据库宕机。

如图,缓存挂掉了(或者缓存中大量数据不存在),导致请求直接打到数据库上,一旦出现高并发,就容易导致数据库宕机。

在这里插入图片描述

那么为什么会出现这个问题呢?

缓存雪崩可以由多种原因引起,下面是一些可能导致缓存雪崩的原因:

  1. 缓存数据过期时间集中:在某个时间点,大量的缓存数据同时过期,导致在这个时间点上对数据库的请求量骤增,从而导致数据库出现负载压力过大或崩溃的情况。
  2. 缓存服务器宕机:如果缓存服务器宕机,缓存数据将不可用,请求将直接发送到数据库,从而导致数据库的压力骤增,系统性能下降。
  3. 缓存数据访问热度不均衡:如果某些缓存数据被频繁访问,而其他缓存数据很少被访问,就会导致缓存服务器的负载不均衡,从而导致缓存雪崩。

缓存数据的分布不均衡可能导致缓存雪崩,这是因为当缓存数据的访问热度不均衡时,一些数据会被频繁访问,而其他数据则很少被访问。当大量请求同时访问热点数据时,会导致缓存服务器的压力骤增,从而导致缓存服务器无法及时响应请求(缓存挂掉了),最终导致缓存雪崩。

为了避免这种情况的发生,可以采用以下措施:

  1. 将缓存数据分散到多个缓存服务器上,避免数据的访问热度集中在某个服务器上。
  2. 采用缓存数据的分布式存储方式,将缓存数据按照一定的规则分布到多个缓存服务器上,从而避免数据的访问热度集中在某个服务器上。
  3. 根据缓存数据的访问频率和访问规律来动态调整缓存数据的存储位置,使得访问热度较高的数据能够被存储在多个缓存服务器上,从而避免缓存雪崩的发生。
2.2 真实案例

缓存雪崩是一个常见的问题,以下是一些缓存雪崩的真实案例:

  1. 2016年11月,中国铁路12306网站在购票高峰期因为缓存雪崩导致网站崩溃。当时,铁路12306网站使用的是Memcached缓存系统,由于缓存数据没有设置有效期,导致缓存数据在同一时间失效,最终导致了缓存雪崩。
  2. 2015年5月,美国亚马逊网站由于缓存雪崩导致网站出现故障,影响了全球数百万用户的购物体验。亚马逊网站使用的是Elasticache缓存系统,由于缓存数据没有设置有效期,导致在同一时间大量缓存数据同时失效,最终导致了缓存雪崩。
  3. 2014年1月,中国移动的短信平台因为缓存雪崩导致短信服务中断。当时,中国移动使用的是Memcached缓存系统,由于缓存数据没有设置有效期,导致缓存数据在同一时间失效,最终导致了缓存雪崩。

注:上面这些案例是New Bing给的,不知道是否真实,百度都搜不到了,反正在大公司出这种问题基本整个团队跟着倒霉吧。

2.3 解决方案

为了避免这种情况,可以采取以下几种方法:

  1. 缓存数据的过期时间随机化

​ 如果缓存中的数据过期时间是固定的,那么当所有数据同时失效时,就会导致缓存雪崩。因此,我们可以将缓存数据的过期时间随机化,比如在原有过期时间的基础上增加一个随机的时间。这样可以避免所有数据同时失效。

// 设置缓存数据的过期时间,增加一个随机的时间
int expireTime = 3600 + new Random().nextInt(1800);
cache.put(key, value, expireTime);
  1. 引入多级缓存

​ 多级缓存是指在应用程序中使用多个不同的缓存层级,比如将热点数据放在本地缓存中,将其他数据放在分布式缓存中。这样可以避免所有数据同时失效,同时也可以减轻缓存层的负担,提高系统性能。

// 创建本地缓存和分布式缓存
Cache localCache = new LocalCache();
Cache distributedCache = new DistributedCache();

// 查询数据
Object value = localCache.get(key);
if (value != null) {
    return value;
} else {
    value = distributedCache.get(key);
    if (value != null) {
        // 将数据存入本地缓存
        localCache.put(key, value);
        return value;
    } else {
        // 查询数据库或其他后端资源,并将数据存入本地缓存和分布式缓存
        value = queryFromDatabase();
        localCache.put(key, value);
        distributedCache.put(key, value);
        return value;
    }
}

多级缓存实质上是提高缓存的可用性,还有一种方法就是做集群部署,通过集群来提升缓存的可用性,可以利用Redis的分布式集群实现缓存的高可用。

补充一下分布式缓存本地缓存,其主要区别在于缓存的存储位置和范围。

本地缓存是指缓存在应用程序进程内的缓存,数据存储在内存中只能被当前进程访问。本地缓存通常使用的是内存缓存(如ConcurrentHashMap等),可以快速地读写数据,适用于存储短期的、较小的数据。本地缓存的优点是访问速度快,缺点是缓存容量受限,不能共享数据,不能保证数据一致性

分布式缓存是指缓存在多个节点上的缓存,数据存储在多个节点的内存中,可以被多个应用程序进程共享。分布式缓存通常使用的是键值存储系统(如Redis、Memcached等),可以支持大容量的数据存储,提供高可用性、高性能、高并发的缓存服务,适用于存储长期的、较大的数据。分布式缓存的优点是支持大容量的数据存储,可以共享数据,保证数据一致性,缺点是访问速度相对较慢,需要考虑数据分片、一致性等问题。

除了存储位置和范围不同之外,分布式缓存和本地缓存还有以下区别:

  1. 分布式缓存可以跨越多个服务器节点,可以支持横向扩展,而本地缓存只能在单个进程中使用。
  2. 分布式缓存可以提供高可用性和容错性,因为数据会被复制到多个节点上,而本地缓存只能在当前进程中使用,无法提供容错性。
  3. 分布式缓存可以提供更多的缓存策略和功能,如数据过期、数据淘汰、分布式锁、事务等,而本地缓存的功能相对简单。

在实际应用中,通常会根据数据的大小、访问频率、一致性要求等因素来选择使用本地缓存还是分布式缓存,或者同时使用两者来达到最优的缓存效果。

  1. 使用缓存预热

​ 缓存预热是指在系统启动时,将一些热点数据提前加载到缓存中,这样可以避免在系统运行过程中,缓存中的数据全部失效,导致大量请求直接访问数据库。缓存预热可以通过定时任务或者在系统启动时执行。

上面讲过就不再重复了!

  1. 使用分布式锁

​ 当缓存中的数据过期时,多个线程可能会同时去查询数据库,这会导致数据库瞬间被大量请求压垮,从而导致系统瘫痪。为了避免这种情况,我们可以使用分布式锁,保证只有一个线程能够去查询数据库,其他线程则等待。

// 获取分布式锁
if (redisLock.tryLock(key, timeout)) {
    try {
        // 查询缓存
        Object value = cache.get(key);
        if (value != null) {
            return value;
        }
        // 查询数据库
        value = queryFromDatabase();
        // 将查询结果存入缓存
        cache.put(key, value);
        return value;
    } finally {
        // 释放分布式锁
        redisLock.unlock(key);
    }
} else {
    // 获取锁失败,等待一段时间后重试
    Thread.sleep(100);
    return getData(key);
}
  1. 熔断降级

​ 熔断降级是一种常见的解决缓存雪崩问题的方法。熔断降级通过在缓存雪崩发生前,提前设置好备选方案,当缓存雪崩发生时,可以快速切换到备选方案,避免系统崩溃。比如,当流量到达一定的阈值时,就直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上。至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。

  • 服务熔断:当缓存服务器宕机或超时响应时,为了防止整个系统出现雪崩,暂时停止业务服务访问缓存系统。
  • 服务降级:当出现大量缓存失效,而且处在高并发高负荷的情况下,在业务系统内部暂时舍弃对一些非核心的接口和数据的请求,而直接返回一个提前准备好的 fallback(退路)错误处理信息。

3. 缓存击穿


3.1 定义

缓存击穿指由于某个或某些热点数据的缓存在某一时刻过期或者被清空,大量请求同时访问该数据,导致请求都落到了数据库上,从而导致数据库压力瞬间激增,甚至崩溃。

乍一看,这不就是缓存雪崩嘛,确实比较神似,不过还是有一些区别的,缓存雪崩是由于缓存整体失效,导致大量请求都访问数据库,而缓存击穿则是由于某个热点数据失效,导致大量请求都访问数据库。

3.2 分析

某一个热点key失效了,所以只能去访问数据库,导致数据库压力剧增

在这里插入图片描述

3.3 解决方案
  1. 设置永不过期的缓存

​ 将热点数据设置为永不过期,这样可以避免缓存失效导致的缓存击穿问题。但是这种方式会导致缓存中的数据不是最新的,需要根据具体业务场景选择是否使用。

cache.put(key, value, NEVER_EXPIRE);//NEVER_EXPIRE 表示缓存数据永不过期。
  1. 互斥锁

​ 在缓存中没有该热点数据时,加锁查询数据库,只有第一个查询的线程能够查询数据库并更新缓存,其他线程等待锁的释放。这种方式虽然可以避免缓存击穿问题,但是会影响系统的并发性能,需要谨慎使用。

public Object getData(String key) {
    Object result = cache.get(key);
    if (result == null) {
        synchronized (key.intern()) {
            result = cache.get(key);
            if (result == null) {
                result = queryFromDatabase(key);
                cache.put(key, result);
            }
        }
    }
    return result;
}

注:分布式系统需采用分布式锁

4. 缓存穿透


4.1 定义

缓存穿透是指请求的键值在缓存和数据库中都不存在,导致请求始终打到数据库上,就好像缓存不存在。这种情况通常是由于恶意攻击、参数错误、系统故障等原因引起的。

缓存穿透问题在一定程度上与缓存命中率有关。如果缓存设计不合理,缓存的命中率非常低,那么,数据访问的绝大部分压力都会集中在后端数据库层面。

4.2 分析

​ 多个用户请求查询不存在的key值,导致缓存大面积未命中,大量的请求打到数据库上面,最终引发缓存穿透问题。

在这里插入图片描述

4.3 解决方案
  1. 使用布隆过滤器

​ 布隆过滤器的作用是某个 key 不存在,那么就一定不存在,某个 key 存在,那么很大可能是存在(存在一定的误判率)。于是我们可以在缓存之前再加一层布隆过滤器,在查询的时候先去布隆过滤器查询 key 是否存在,如果不存在就直接返回,避免了查询数据库的操作。

public Object getData(String key) {
    if (!bloomFilter.mightContain(key)) {
        return null;
    }
    Object result = cache.get(key);
    if (result == null) {
        synchronized (key.intern()) {
            result = cache.get(key);
            if (result == null) {
                result = queryFromDatabase(key);
                if (result != null) {
                    cache.put(key, result);
                    bloomFilter.put(key);
                } else {
                    bloomFilter.put(key);
                    //NULL_OBJECT表示空对象,TIMEOUT 表示缓存时间。
                    cache.put(key, NULL_OBJECT, TIMEOUT);
                }
            }
        }
    } else if (result == NULL_OBJECT) {
        result = null;
    }
    return result;
}
  1. 设置缓存空值

在缓存中放置一个空对象,表示该 key 对应的数据不存在,这样可以避免缓存穿透问题。但是需要注意空对象的缓存时间,不能太长,否则会占用缓存空间。

public Object getData(String key) {
    Object result = cache.get(key);
    if (result == null) {
        result = queryFromDatabase(key);
        if (result != null) {
            cache.put(key, result);
        } else {
            cache.put(key, NULL_OBJECT, TIMEOUT);
        }
    } else if (result == NULL_OBJECT) {
        result = null;
    }
    return result;
}

其中,NULL_OBJECT 表示空对象,TIMEOUT 表示缓存时间。

  1. 接口层限流

​ 对访问频率较高的接口进行限流,限制每个 IP 访问次数,避免大量的请求直接落到数据库上,从而避免缓存穿透问题。

public void getData(String key) {
    //rateLimiter 是限流器实例
    if (!rateLimiter.tryAcquire()) {
        throw new RuntimeException("Too many requests");
    }
    Object result = cache.get(key);
    if (result == null) {
        synchronized (key.intern()) {
            result = cache.get(key);
            if (result == null) {
                result = queryFromDatabase(key);
                if (result != null) {
                    cache.put(key, result);
                } else {
                    //NULL_OBJECT表示空对象,TIMEOUT表示缓存时间。
                    cache.put(key, NULL_OBJECT, TIMEOUT);
                }
            }
        }
    } else if (result == NULL_OBJECT) {
        result = null;
    }
    return result;
}

5. 总结


缓存雪崩、缓存击穿、缓存穿透是生产和面试中常见的问题,在请求量小时影响不大,但高并发场景下这些问题可能会造成服务器宕机,甚至在重启服务器之后依然会扛不住压力继续宕机,只有提前做好分析,综合考虑业务场景、数据特点和系统架构等方面,采用多种手段进行优化和调整,才能够尽可能的减小生产服务器损失。

​ 最后再总结一句:上面一大堆最重要的一点就是:如何避免大量请求同一时间直接打到数据库上。只要把握住这一点就可以用各种方案去解决问题。

参考文章


Redis 缓存雪崩、缓存穿透、缓存击穿、缓存预热 - 掘金 (juejin.cn)

十分钟彻底掌握缓存击穿、缓存穿透、缓存雪崩 - 三分恶 - 博客园 (cnblogs.com)

什么是缓存雪崩、缓存击穿、缓存穿透? - 知乎 (zhihu.com)

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值