本文是对 Redis 知识的补充,在了解了如何搭建多种类型的 Redis 集群,并清楚了 Redis 集群搭建的过程的原理和注意事项之后,就要开始了解在使用 Redis 时可能出现的突发问题和对应的解决方案。
引言:虽然 Redis 是单线程的,但它可以通过使用非阻塞 I/O 去高效地处理大量并发连接。具有事件驱动机制,可以基于事件循环机制,达到快速响应各种请求。另外,在 Redis 6.0 引入了多线程 I/O,可以利用多核 CPU 提高网络 I/O 的效率,是市面上高效的 NoSQL 缓存组件。尤其是在分布式系统中,Redis 缓存经常被用来减轻数据库的负载,提高系统的响应速度。
在具有高并发的项目环境或者秒杀环境等,前台会产生超量的请求到缓存或者数据库或者项目突然出现攻击,攻击者对某一个缓存键发起大量请求,或者缓存中不存在的数据直接命中数据库等情况。这些情况必定会导致系统性能下降,甚至完全崩溃。下面就常见的三个问题作解释。
文章目录
- 一、什么是缓存雪崩?
- 二、怎么解决缓存雪崩
- 1、缓存预热(主要)
- 2、过期时间错峰(主要)
- 3、加锁控制对某个缓存的读线程数量
- 4、设置二级缓存
- 5、设置合理的 Redis 集群
- 6、降级限流
- 三、什么是缓存穿透?
- 四、怎么解决缓存穿透
- 1、缓存空值(主要)
- 2、布隆过滤器(主要)
- 3、增强数据的合法性校验(主要)
- 4、使用分布式锁
- 5、限流和熔断
- 6、设置热点数据永不过期
- 7、二级缓存
- 五、什么是缓存击穿?
- 六、怎么解决缓存击穿
- 1、设置热点数据过期时间
- 2、使用布隆过滤器
- 3、使用互斥锁(分布式锁)
- 4、使用逻辑过期
- 5、本地缓存和Redis缓存(二级缓存)
- 七、总结
一、什么是缓存雪崩?
缓存雪崩是指大量缓存数据在同一时间段失效,导致所有请求都涌向数据库,引发数据库崩溃,或者 Redis 宕机导致缓存系统失效。
这个问题的触发是具有条件性的,这个请况的出现原因在于高并发访问时,Redis 创建缓存时每一秒接收到的数据量大,同时这批数据又设置了相同的过期时间,导致的大量缓存数据在同一时间段失效。
所造成的危害就是数据库瞬间承载大量压力,可能直接导致数据库崩溃,系统性能急剧下降,用户体验变差。
二、怎么解决缓存雪崩
那如何避免或者解决这种情况呢?上述情况通常可以分为以下几种,我们要做多种措施,保证不会出现缓存雪崩的情况。
1、缓存预热(主要)
Redis 启动时是没有缓存任何数据的,在后续的时间就可能面临大量热点数据的请求,这时没有相应缓存,会全部命中数据库,造成数据库性能出现问题。这个的解决办法一般是 缓存预热
。
缓存预热就是系统上线时提前将相关的缓存数据直接加载到缓存系统,而不是等到用户请求的时候才将查询数据缓存,这样用户可直接查询事先被预热的数据。这样部署项目一般都在不常访问的时间段,提前缓存热点数据,在后续的时间就不会面临大量访问数据库的情况。
2、过期时间错峰(主要)
缓存预热并不能根本解决问题,在 新热点数据
出现时还是会出现缓存雪崩,例如某些黑料的出现等,这时就需要探究缓存大批失效的根本能问题。
热点数据同时失效的根本原因在于创建缓存时设置的过期时间是固定的,这种方式就会造成一批生成、一批失效。同一批创建的缓存可以 在固定的过期时间加上一个时间的随机值
,按照项目要求设置随机值范围,就可以避免同时失效。
3、加锁控制对某个缓存的读线程数量
在缓存失效后,通过加锁或者队列的方式,来控制读数据库和写缓存的线程数量,例如对于某个 Key ,只允许一个线程查询数据和写缓存,其他线程等待。
4、设置二级缓存
设置二级缓存或者双缓存策略,A1为原始缓存,A2为拷贝缓存,A1失效后可以访问A2,A1缓存失效的时间可以设置短期,A2缓存可以设置为长期。其中A2可以是热点缓存,将A1中的热点缓存记录并放入A2,应对突发情况。
5、设置合理的 Redis 集群
使用高可用架构,根据自己系统的业务能力,合理设计Redis集群,例如:
- 小型系统就是用一主一从的集群架构。
- 对实时性要求不高并且有一定并发问题的系统就使用一主多从的集群架构。
- 对于实时性要求高,存在高并发的系统使用多主多从的集群架构。
不同的集群架构会合理使用主从复制功能和哨兵模式来避免单点故障和因某个主节点崩溃而导致的系统问题。
6、降级限流
对于出现缓存雪崩后的补救措施可以使用服务降级和请求限流等机制进行补救。服务降级的最终目的就是保证核心服务可用,即使是有损的。服务降级应当提前确定好哪些服务是可降级的,不同情况下不同的服务的优先级也不同。
具体做法可以拒接服务,延迟服务和随即提供服务,或者说禁用某些功能或者禁用某些功能模块。
三、什么是缓存穿透?
缓存穿透是指客户端查询一个缓存中不存在的键,由于缓存中无数据,无法命中,所以每次查询都会直接访问数据库,并且数据库中也没有数据,这样就导致缓存一直无法生效,从而导致数据库负载增加,产生压力过大的情况。
用户第一次请求,缓存没数据就会触发写缓存操作,第二次请求就会命中缓存。这种问题造成的危害是数据库负载增加,可能引发性能问题,恶意请求可能频繁访问数据库,导致资源浪费。例如如发起为 id
为 -1
的数据或 id
为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
四、怎么解决缓存穿透
1、缓存空值(主要)
顾名思义,就是收到一个查询后,发现缓存和数据库都没有要查询的数据,这时就将查询的数据以 null
值缓存到 Redis 中。
具体来说,当客户端访问数据时,先请求 Redis,但 Redis 中没有数据,这时会访问数据库,但是数据库中也没有数据。因此这个数据穿透了缓存,直击数据库,并且这种状态下该数据还无法生成缓存。
如果大量的请求同时访问这种不存在的数据,那么这些请求就都会访问到数据库,由于数据库能够承载的并发不如 Redis ,就会出现数据库性能出现问题。
解决方案对于数据库中不存在的数据如果被查询到了,就把这个数据存入到 Redis 缓存中去,下次用户过来访问这个不存在的数据,就能在 Redis 缓存中找到。
这种方式也会产生问题,面临大量的恶意请求,会导致缓存逐渐增加,进而影响服务性能,另外,如果该数据被写入到数据库中,缓存还是null值,会造成数据的短期不一致。
2、布隆过滤器(主要)
布隆过滤器是一种基于哈希算法的数据结构,用于快速判断一个元素是否存在于一个集合中。在请求到达缓存之前,先通过布隆过滤器判断该请求是否合法。如果布隆过滤器判断该请求不存在,则直接返回,避免查询数据库。
布隆过滤器的大致原理:布隆过滤器中存放二进制位,数据库的数据通过哈希算法计算其哈希值并存放到布隆过滤器中,后面判断该数据是否存在的时候,就是查找该数据的哈希值是 0 还是 1 。这是一种概率上的统计,能够保证该数据被判断不存在的时候就一定是不存在,被判断存在的时候不一定存在,存在一定的穿透风险。
优点是内存占用较少,适合处理大规模数据,Redis 缓存中也不会存储多余的键。但是实现相对复杂,并且存在一定的误判率,但可以通过调整布隆过滤器的参数来降低误判率。
3、增强数据的合法性校验(主要)
在请求到达缓存和数据库之前,增加对请求参数的合法性校验。例如,对于用户ID的请求,可以校验ID是否符合预期的格式或范围。如果参数不合法,则直接返回错误,避免不必要的查询。
4、使用分布式锁
在缓存穿透的场景下,当多个请求同时发现缓存中没有数据时,可以使用分布式锁(如 Redis 的 SETNX
命令)来确保只有一个请求去数据库查询数据并更新缓存,其他请求等待锁释放后直接从缓存获取数据。
5、限流和熔断
对于高频请求,可以采用限流策略,限制单位时间内对某个接口的访问次数。当请求量超过阈值时,直接返回错误或排队等待。此外,还可以结合熔断机制,在缓存穿透导致数据库压力过大时,暂时停止对数据库的访问。
6、设置热点数据永不过期
对于一些热点数据(如热门商品信息),可以设置其缓存永不过期,或者通过后台线程定期更新缓存。这样可以避免因缓存过期而导致的缓存穿透问题。
7、二级缓存
也可以采用缓存雪崩中的二级缓存解决办法,A1为原始缓存,A2为拷贝缓存,A1失效后可以访问A2,A1缓存失效的时间可以设置短期,A2缓存可以设置为长期。其中A2可以是热点缓存,将A1中的热点缓存记录并放入A2,应对突发情况。
不同的解决方案适用于不同的场景,可以根据实际需求选择合适的方法。例如,对于小规模应用,缓存空对象或增强数据校验可能足够;而对于大规模高并发系统,布隆过滤器或分布式锁可能是更好的选择。
五、什么是缓存击穿?
缓存击穿,是指一个 Key 在不停地支撑着高并发,高并发集中对这一个点进行访问,当这个 Key 在失效的瞬间,持续的高并发就穿破缓存,直接请求数据库。缓存击穿和缓存雪崩的区别在于缓存击穿是针对某一个 Key 缓存而盲,缓存雪崩则是针对很多 Key。
六、怎么解决缓存击穿
首先要知道缓存击穿出现的场景,才好使用对应的解决方案,缓存击穿是指一个 Key 在不停地支撑着高并发,高并发集中对这一个点进行访问,当这个 Key 在失效的瞬间,持续的高并发就穿破缓存,直接请求数据库。
对一般的网站而言,很难有一个 Key 能达到缓存击穿的级别,一般是热门网站的秒杀或爆款商品,才有可能发生这种情况。当发生缓存击穿时,在这个 Key 没有被重新加载到缓存之前,或者过期时间超过抢购时段时是一种很好的避免发生缓存击穿的方法,这时这种可以不需要考虑数据一致性的问题的话。
1、设置热点数据过期时间
首先在秒杀环境中,设置 Key 的过期时间超过秒杀的时间是最好的一种办法,或者对于热点数据,可以设置其缓存永不过期,通过后台线程定期更新缓存。这就不会出现缓存击穿的情况,但是可能会出现数据不一致问题。
// 设置热点数据永不过期
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(data));
// 后台线程定期更新缓存
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
Object newData = queryDatabase(key);
setCache(key, newData);
}, 0, 10, TimeUnit.MINUTES);
2、使用布隆过滤器
布隆过滤器是用于快速判断一个元素是否存在于一个集合中。首先初始化布隆过滤器,将所有可能的Key加入布隆过滤器,在请求到达缓存之前,先通过布隆过滤器判断该请求是否合法。如果布隆过滤器判断该请求不存在,则直接返回,避免查询数据库。在缓存穿透的解决方案中也推荐过这种方法。
// 初始化布隆过滤器
BloomFilter<String> bloomFilter = new BloomFilter<>(100000, 0.01);
bloomFilter.add("hotKey1");
bloomFilter.add("hotKey2");
// 查询缓存
if (!bloomFilter.contains(key)) {
return null; // Key不存在,直接返回
}
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
3、使用互斥锁(分布式锁)
在查询缓存时,如果发现缓存失效,尝试获取分布式锁。通过分布式锁(如Redis的SETNX命令)确保只有一个线程去查询数据库并更新缓存,其他线程等待锁释放后直接从缓存获取数据。如果获取锁成功,则查询数据库并更新缓存。如果获取锁失败,则等待锁释放后再次查询缓存。
String lockKey = "lock:" + key;
boolean isLock = tryLock(lockKey);
if (isLock) {
// 查询数据库并更新缓存
Object data = queryDatabase(key);
setCache(key, data);
} else {
// 等待锁释放后再次查询缓存
Thread.sleep(50);
return queryCache(key);
}
4、使用逻辑过期
逻辑过期是指在缓存中存储数据时,将数据和过期时间一起存储。当查询缓存时,先检查数据是否过期,如果未过期,直接返回数据,如果已过期,启动独立线程去更新缓存,同时返回旧数据。这样做的优点在于即使缓存已过期,也可以先返回旧数据。
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
// 查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
return redisData.getData();
} else {
// 启动独立线程更新缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
Object newData = queryDatabase(key);
setCacheWithLogicalExpire(key, newData, expireTime);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
return redisData.getData(); // 返回旧数据
}
}
5、本地缓存和Redis缓存(二级缓存)
采用多级缓存架构,在本地缓存中存储热点数据,先查询本地缓存,如果本地缓存失效,则查询Redis缓存。
// 查询本地缓存
Object data = localCache.get(key);
if (data == null) {
// 查询Redis缓存
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
data = JSONUtil.toBean(json, type);
localCache.put(key, data); // 更新本地缓存
}
}
return data;
不同的解决方案适用于不同的场景,可以根据实际需求选择合适的方法。
- 互斥锁适用于缓存失效时需要保护数据库的场景。
- 逻辑过期适用于需要快速返回数据且对数据实时性要求不高的场景。
- 热点数据永不过期适用于热点数据频繁访问的场景。
- 布隆过滤器适用于数据量大且需要快速判断Key是否存在的场景。
- 多级缓存适用于需要进一步减轻数据库压力的场景。
七、总结
看到最后的读者也明白了,缓存雪崩、缓存穿透和缓存击穿出现的场景各有不同,但是解决办法都是存在一定的共同点,相似的解决方案基本都是辅助解决问题,而对于不同场景独特的解决方案才是解决该问题的核心。在各种解决方案中,大家一定要注意数据的实时性和一致性,在保障了这两个前提下才能更好的解决问题,实现高可用的系统。