Redis作为目前使用较为广泛的缓存,可以有效提高应用程序的性能。但是,缓存也会带来一些挑战,例如缓存预热、缓存雪崩、缓存击穿和缓存穿透等问题。在本篇博客中,我们将深入探讨这些问题,并介绍如何去解决。
先介绍一下查询请求的正常执行流程:
如果Redis中存在数据,则直接返回给用户。否则,去数据库中查找数据,找到后写入Redis缓存并将查询结果返回给用户,没找到就报错。
注:这里我写的是先写缓存在返回数据给用户(4和5)。个人理解应该根据不同的应用场景和业务需求进行决定。
一般来说,如果缓存中没有找到数据,就需要从数据库中查询。如果数据库中有数据,那么需要考虑将数据写入缓存还是直接返回给用户。
如果该数据被频繁地请求,那么将数据写入缓存可以提高访问速度和响应时间,减轻数据库的负载。因此,这种情况下建议先将数据写入缓存,然后再将数据返回给用户。如果该数据并不会被频繁地请求,那么将数据写入缓存的效果可能不如预期,因为缓存中的数据可能很快就会被淘汰。此时,建议先将数据返回给用户,然后再将数据写入缓存。
当然,具体的处理方式还需要考虑其他因素,如数据的大小、缓存的容量、缓存的淘汰策略等。因此,在实际应用中,需要根据具体情况进行权衡和选择。
缓存预热是指在应用程序启动或重启后,通过提前加载热点数据到Redis缓存中,避免大量用户请求查询数据库,以提高缓存的命中率。
热点数据是指最常被访问的数据,它们可以是用户数据、系统配置数据、产品信息、广告等。
缓存预热其实是一种优化方案,可以避免冷启动、减少应用程序在高峰期的压力。你想,当一个应用程序启动时,缓存中没有数据,第一次请求就需要从数据库中获取数据来响应用户的请求。如果这些数据被缓提前存在Redis
中,就可以大大加快应用程序的响应速度。
所以可以在应用程序启动之前通过预加载一些经常被访问的数据到Redis中。这样,当第一个请求到达时,数据将已经存在于缓存中,响应时间将大大缩短。
最常见的就是各大电商平台的双十一活动了,双十一期间会面临大量的用户访问和订单请求,为了应对这些高并发情况就会采用缓存预热的方式提升系统性能和用户体验。例如,提前将商品信息、店铺信息等热点数据加载到缓存中,这些数据是在低峰期从数据库中获取的,然后放入Redis缓存中,从而降低了高峰期对数据库的访问。
实现缓存预热需要根据具体业务场景和缓存技术选择合适的方案,以下是一些常用的实现方式:
预热脚本是指通过编写程序或脚本来预先加载热点数据到缓存中。可以在应用程序启动时或者定时任务中运行预热脚本,以便于提高缓存的命中率和应用程序的性能。
例:
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;
}
}
评估缓存预热的效果需要通过一些指标来衡量,以下是一些常用的评估指标:
命中率是指缓存中已经存在的数据在被请求时能够被命中的比例,通常用命中次数除以总的请求次数来计算。如果缓存预热能够提高命中率,就说明预热效果比较好。
响应时间是指用户请求数据后,从请求发出到数据返回的总时间,通常用平均响应时间来表示。如果缓存预热能够降低响应时间,就说明预热效果比较好。
缓存大小是指缓存中存储的数据量,通常用缓存使用率来表示。如果缓存预热成功,缓存中存储的数据量应该比预热前有所增加。
缓存命中时间是指缓存中已经存在的数据被命中时的响应时间,通常用平均缓存命中时间来表示。如果缓存预热能够降低缓存命中时间,就说明预热效果比较好。
缓存更新时间是指缓存中的数据被更新后,更新到缓存中的时间,通常用平均缓存更新时间来表示。如果缓存预热能够缩短缓存更新时间,就说明预热效果比较好。
总之,评估缓存预热效果需要综合考虑多个指标,根据具体情况选择合适的指标进行衡量。同时,需要定期进行评估和优化,以保证缓存预热的效果持续稳定地提升系统性能和用户体验。
缓存雪崩是指在缓存中大量的键值同时失效或者被清除(缓存服务宕机),导致请求直接落到数据库上,从而导致数据库的压力骤增,甚至可能导致数据库宕机。
如图,缓存挂掉了(或者缓存中大量数据不存在),导致请求直接打到数据库上,一旦出现高并发,就容易导致数据库宕机。
那么为什么会出现这个问题呢?
缓存雪崩可以由多种原因引起,下面是一些可能导致缓存雪崩的原因:
缓存数据的分布不均衡可能导致缓存雪崩,这是因为当缓存数据的访问热度不均衡时,一些数据会被频繁访问,而其他数据则很少被访问。当大量请求同时访问热点数据时,会导致缓存服务器的压力骤增,从而导致缓存服务器无法及时响应请求(缓存挂掉了),最终导致缓存雪崩。
为了避免这种情况的发生,可以采用以下措施:
缓存雪崩是一个常见的问题,以下是一些缓存雪崩的真实案例:
注:上面这些案例是New Bing给的,不知道是否真实,百度都搜不到了,反正在大公司出这种问题基本整个团队跟着倒霉吧。
为了避免这种情况,可以采取以下几种方法:
- 缓存数据的过期时间随机化
如果缓存中的数据过期时间是固定的,那么当所有数据同时失效时,就会导致缓存雪崩。因此,我们可以将缓存数据的过期时间随机化,比如在原有过期时间的基础上增加一个随机的时间。这样可以避免所有数据同时失效。
// 设置缓存数据的过期时间,增加一个随机的时间
int expireTime = 3600 + new Random().nextInt(1800);
cache.put(key, value, expireTime);
- 引入多级缓存
多级缓存是指在应用程序中使用多个不同的缓存层级,比如将热点数据放在本地缓存中,将其他数据放在分布式缓存中。这样可以避免所有数据同时失效,同时也可以减轻缓存层的负担,提高系统性能。
// 创建本地缓存和分布式缓存
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等),可以支持大容量的数据存储,提供高可用性、高性能、高并发的缓存服务,适用于存储长期的、较大的数据。分布式缓存的优点是支持大容量的数据存储,可以共享数据,保证数据一致性,缺点是访问速度相对较慢,需要考虑数据分片、一致性等问题。
除了存储位置和范围不同之外,分布式缓存和本地缓存还有以下区别:
在实际应用中,通常会根据数据的大小、访问频率、一致性要求等因素来选择使用本地缓存还是分布式缓存,或者同时使用两者来达到最优的缓存效果。
- 使用缓存预热
缓存预热是指在系统启动时,将一些热点数据提前加载到缓存中,这样可以避免在系统运行过程中,缓存中的数据全部失效,导致大量请求直接访问数据库。缓存预热可以通过定时任务或者在系统启动时执行。
上面讲过就不再重复了!
- 使用分布式锁
当缓存中的数据过期时,多个线程可能会同时去查询数据库,这会导致数据库瞬间被大量请求压垮,从而导致系统瘫痪。为了避免这种情况,我们可以使用分布式锁,保证只有一个线程能够去查询数据库,其他线程则等待。
// 获取分布式锁
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);
}
- 熔断降级
熔断降级是一种常见的解决缓存雪崩问题的方法。熔断降级通过在缓存雪崩发生前,提前设置好备选方案,当缓存雪崩发生时,可以快速切换到备选方案,避免系统崩溃。比如,当流量到达一定的阈值时,就直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上。至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。
缓存击穿指由于某个或某些热点数据的缓存在某一时刻过期或者被清空,大量请求同时访问该数据,导致请求都落到了数据库上,从而导致数据库压力瞬间激增,甚至崩溃。
乍一看,这不就是缓存雪崩嘛,确实比较神似,不过还是有一些区别的,缓存雪崩是由于缓存整体失效,导致大量请求都访问数据库,而缓存击穿则是由于某个热点数据失效,导致大量请求都访问数据库。
某一个热点key失效了,所以只能去访问数据库,导致数据库压力剧增
- 设置永不过期的缓存
将热点数据设置为永不过期,这样可以避免缓存失效导致的缓存击穿问题。但是这种方式会导致缓存中的数据不是最新的,需要根据具体业务场景选择是否使用。
cache.put(key, value, NEVER_EXPIRE);//NEVER_EXPIRE 表示缓存数据永不过期。
- 互斥锁
在缓存中没有该热点数据时,加锁查询数据库,只有第一个查询的线程能够查询数据库并更新缓存,其他线程等待锁的释放。这种方式虽然可以避免缓存击穿问题,但是会影响系统的并发性能,需要谨慎使用。
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;
}
注:分布式系统需采用分布式锁
缓存穿透是指请求的键值在缓存和数据库中都不存在,导致请求始终打到数据库上,就好像缓存不存在。这种情况通常是由于恶意攻击、参数错误、系统故障等原因引起的。
缓存穿透问题在一定程度上与缓存命中率有关。如果缓存设计不合理,缓存的命中率非常低,那么,数据访问的绝大部分压力都会集中在后端数据库层面。
多个用户请求查询不存在的key值,导致缓存大面积未命中,大量的请求打到数据库上面,最终引发缓存穿透问题。
- 使用布隆过滤器
布隆过滤器的作用是某个 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;
}
- 设置缓存空值
在缓存中放置一个空对象,表示该 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
表示缓存时间。
- 接口层限流
对访问频率较高的接口进行限流,限制每个 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;
}
缓存雪崩、缓存击穿、缓存穿透是生产和面试中常见的问题,在请求量小时影响不大,但高并发场景下这些问题可能会造成服务器宕机,甚至在重启服务器之后依然会扛不住压力继续宕机,只有提前做好分析,综合考虑业务场景、数据特点和系统架构等方面,采用多种手段进行优化和调整,才能够尽可能的减小生产服务器损失。
最后再总结一句:上面一大堆最重要的一点就是:如何避免大量请求同一时间直接打到数据库上。只要把握住这一点就可以用各种方案去解决问题。
Redis 缓存雪崩、缓存穿透、缓存击穿、缓存预热 - 掘金 (juejin.cn)
十分钟彻底掌握缓存击穿、缓存穿透、缓存雪崩 - 三分恶 - 博客园 (cnblogs.com)
什么是缓存雪崩、缓存击穿、缓存穿透? - 知乎 (zhihu.com)