Redis缓存异常(击穿/雪崩)及解决方案
Redis是一个完全开源的、遵守BSD协议的、高性能的key-value数据结构存储系统,它支持数据的持久化,可以将内存中的数据保存在磁盘中,而且不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储,功能十分强大,Redis还支持数据的备份,即master-slave模式的数据备份,从而提高可用性。当然最重要的还是读写速度快,作为我们平常开发中最常用的缓存方案被广泛应用。但在实际应用过程中,它会存在缓存雪崩、缓存击穿和缓存穿透等异常情况,如果忽视这些情况可能会带来灾难性的后果,下面主要对这些缓存异常和常见处理方案进行相应分析与总结。
缓存异常有四种类型
- 缓存和数据库的数据不一致
- 缓存雪崩
- 缓存击穿
- 缓存穿透
一、缓存击穿
1.1 概念
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
1.2 解决方案
@Service
public class UserInfoServiceImpl implements UserInfoService {
@Resource
private UserMapper userMapper;
@Resource
private RedisTemplate<Long, String> redisTemplate;
@Override
public UserInfo findById(Long id) {
//查询缓存
String userInfoStr = redisTemplate.opsForValue().get(id);
//如果缓存中不存在,查询数据库
//1
if (isEmpty(userInfoStr)) {
UserInfo userInfo = userMapper.findById(id);
//数据库中不存在
if (userInfo == null) {
return null;
}
userInfoStr = JSON.toJSONString(userInfo);
//2
//放入缓存
redisTemplate.opsForValue().set(id, userInfoStr);
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
private boolean isEmpty(String string) {
return !StringUtils.hasText(string);
}
}
流程如下:
在//1
到//2
之间耗时1.5
秒,那就代表着在这1.5
秒时间内所有的查询都会走查询数据库。这也就是我们所说的缓存中的缓存击穿
。
其实,你们项目如果并发量不是很高,也不用怕,并且我见过很多项目也就差不多是这么写的,也没那么多事,毕竟只是第一次的时候可能会发生缓存击穿。
但,我们也不要抱着一个侥幸的心态去写代码,既然是多线程导致的,估计很多人会想到锁,下面我们使用锁来解决。
1.2.1 方例1:锁
既然使用到锁,那么我们第一时间应该关心的是锁的粒度。
如果我们放在方法findById
上,那就是所有查询都会有锁的竞争,这里我相信大家都知道我们为什么不放在方法上。
public UserInfo findById(Long id) {
//查询缓存
String userInfoStr = redisTemplate.opsForValue().get(id);
if (isEmpty(userInfoStr)) {
//只有不存的情况存在锁
synchronized (UserInfoServiceImpl.class) {
UserInfo userInfo = userMapper.findById(id);
//数据库中不存在
if (userInfo == null) {
return null;
}
userInfoStr = JSON.toJSONString(userInfo);
//放入缓存
redisTemplate.opsForValue().set(id, userInfoStr);
}
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
看似解决问题了,其实问题还是没得到解决,还是会缓存击穿,因为排队获取到锁后,还是会执行同步块代码,也就是还会查询数据库,完全没有解决缓存击穿。
1.2.2 方案2:双重检查锁
由此,我们引入双重检查锁,我们在上的版本中进行稍微改变,在同步模块中再次校验缓存中是否存在。
public UserInfo findById(Long id) {
//查询缓存
String userInfoStr = redisTemplate.opsForValue().get(id);
//第一次校验缓存是否存在
if (isEmpty(userInfoStr)) {
//上锁
synchronized (UserInfoServiceImpl.class) {
//再次查询缓存,目的是判断是否前面的线程已经set过了
userInfoStr = redisTemplate.opsForValue().get(id);
//第二次校验缓存是否存在
if (isEmpty(userInfoStr)) {
UserInfo userInfo = userMapper.findById(id);
//数据库中不存在
if (userInfo == null){
return null;
}
userInfoStr = JSON.toJSONString(userInfo);
//放入缓存
redisTemplate.opsForValue().set(id, userInfoStr);
}
}
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
1.2.3 方案3:互斥锁
代码实现和synchronized
锁实现大致相同
//获取Redisson对象实例 省略...
public UserInfo findById(Long id) {
String userInfoStr = redisTemplate.opsForValue().get(id);
//判断缓存是否存在,是否为空对象
if (isEmpty(userInfoStr)) {
RLock lock = Redisson.getLock(id);
try {
//尝试获取锁
//waitTimeout尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
//leaseTime锁的持有时间,超过这个时间锁会自动失效
//leaseTime值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完
if (lock.tryLock((long) waitTimeout, (long) leaseTime, TimeUnit.SECONDS)) {
userInfoStr = redisTemplate.opsForValue().get(id);
if (isEmpty(userInfoStr)) {
UserInfo userInfo = userMapper.findById(id);
if (userInfo == null) {
//构建一个空对象
userInfo = new UserInfo();
}
userInfoStr = JSON.toJSONString(userInfo);
redisTemplate.opsForValue().set(id, userInfoStr);
}
}
} finally {
lock.unlock();
}
}
UserInfo userInfo = JSON.parseObject(userInfoStr, UserInfo.class);
//空对象处理
if (userInfo.getId() == null) {
return null;
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
这样,看起来我们就解决了缓存击穿问题,大家觉得解决了吗?
1.3 小结
在访问key
之前,采用SETNX(set if not exists)
来设置另一个短期key
来锁住当前key
的访问,访问结束再删除该短期key
。
二、缓存穿透
2.1 概念
缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
2.2 解决方案
回顾上面的案例,在正常的情况下是没问题,但是一旦有人恶意攻击呢?比如说:入参id=-1
,在数据库里并没有这个id
,怎么办呢?
第一步、缓存中不存在
第二步、查询数据库
第三步、由于数据库中不存在,直接返回了,并没有操作缓存
第四步、再次执行第一步.....死循环了吧
2.2.1 方案1:设置空对象
就是当缓存中和数据库中都不存在的情况下,以id
为key
,空对象为value
。
set(id, 空对象);
回到上面的四步,就变成了。
比如说:入参id=-1
,在数据库里并没有这个id
,怎么办呢?
第一步、缓存中不存在
第二步、查询数据库
第三步、由于数据库中不存在,以id
为key
,空对象为value
放入缓存中
第四步、执行第一步,此时,缓存就存在了,只是这时候只是一个空对象。
代码实现部分:
public UserInfo findById(Long id) {
String userInfoStr = redisTemplate.opsForValue().get(id);
//判断缓存是否存在,是否为空对象
if (isEmpty(userInfoStr)) {
synchronized (UserInfoServiceImpl.class) {
userInfoStr = redisTemplate.opsForValue().get(id);
if (isEmpty(userInfoStr)) {
UserInfo userInfo = userMapper.findById(id);
if (userInfo == null) {
//构建一个空对象
userInfo = new UserInfo();
}
userInfoStr = JSON.toJSONString(userInfo);
redisTemplate.opsForValue().set(id, userInfoStr);
}
}
}
UserInfo userInfo = JSON.parseObject(userInfoStr, UserInfo.class);
//空对象处理
if (userInfo.getId() == null) {
return null;
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
2.2.2 方案2:布隆过滤器
具体请参考布隆过滤器
private static Long size = 1000000000L;
private static BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(), size);
@Override
public UserInfo findById(Long id) {
String userInfoStr = redisTemplate.opsForValue().get(id);
if (isEmpty(userInfoStr)) {
//校验是否在布隆过滤器中
if (bloomFilter.mightContain(id)) {
return null;
}
synchronized (UserInfoServiceImpl.class) {
userInfoStr = redisTemplate.opsForValue().get(id);
if (isEmpty(userInfoStr) ) {
if (bloomFilter.mightContain(id)) {
return null;
}
UserInfo userInfo = userMapper.findById(id);
if (userInfo == null) {
//放入布隆过滤器中
bloomFilter.put(id);
return null;
}
userInfoStr = JSON.toJSONString(userInfo);
redisTemplate.opsForValue().set(id, userInfoStr);
}
}
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
2.3 小结
- 接口层增加校验,如用户鉴权校验,
id
做基础校验,id<=0
的直接拦截 - 采用布隆过滤器,使用一个足够大的
bitmap
,用于存储可能访问的key
,不存在的key
直接被过滤; - 访问
key
未在DB
查询到值,也将空值写进缓存,但可以设置较短过期时间。
三、缓存雪崩
3.1 概念
缓存雪崩是指缓存同一时间大面积的失效,所有的请求都会落到数据库上,造成数据库瞬时承受大量请求而崩掉。一般多发生在项目初期缓存未加载或缓存过期时间相同
3.2 解决方案
- 缓存数据设置过期时间时加一个随机值时间,防止同一时间大量数据过期现象发生。
- 一般并发量不是特别多的时候,使用最多的解决方案是加锁排队。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
- 设置热点数据永远不过期。
- 给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。
四、其他
除了上述三种常见的Redis缓存异常问题之外,还经常听到的有缓存预热和缓存降级两个名词,与其说是异常问题,不如说是两种的优化处理方法。
4.1 缓存预热
缓存预热就是系统上线前后,将相关的缓存数据直接加载到缓存系统中去,而不依赖用户。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题。用户直接查询事先被预热的缓存数据,这样可以避免那么系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。
根据数据不同量级,可以有以下几种做法:
- 数据量不大:项目启动的时候自动进行加载。
- 数据量较大:后台定时刷新缓存。
- 数据量极大:只针对热点数据进行预加载缓存操作。
4.2 缓存降级
当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
缓存降级是指当缓存失效或缓存服务出现问题时,为了防止缓存服务故障,导致数据库跟着一起发生雪崩问题,所以也不去访问数据库,但因为一些原因,仍然想要保证服务还是基本可用的,虽然肯定会是有损服务。因此,对于不重要的缓存数据,我们可以采取服务降级策略。
一般做法有以下两种:
- 直接访问内存部分的数据缓存。
- 直接返回系统设置的默认值。
缓存降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。
在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:
- 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
- 警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
- 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
- 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
服务降级的最终目的是保证核心服务可用,但是有些服务是无法降级的(如加入购物车、结算)。
4.3 热点数据和冷数据
热点数据,缓存才有价值
对于冷数据而言,大部分数据可能还没有再次访问到就已经被挤出内存,不仅占用内存,而且价值不大。频繁修改的数据,看情况考虑使用缓存
对于热点数据,比如我们的某IM产品,生日祝福模块,当天的寿星列表,缓存以后可能读取数十万次。再举个例子,某导航产品,我们将导航信息,缓存以后可能读取数百万次。
数据更新前至少读取两次,缓存才有意义。这个是最基本的策略,如果缓存还没有起作用就失效了,那就没有太大价值了。
那存不存在,修改频率很高,但是又不得不考虑缓存的场景呢?有!比如,这个读取接口对数据库的压力很大,但是又是热点数据,这个时候就需要考虑通过缓存手段,减少数据库的压力,比如我们的某助手产品的,点赞数,收藏数,分享数等是非常典型的热点数据,但是又不断变化,此时就需要将数据同步保存到Redis缓存,减少数据库压力。
4.4 缓存热点key
缓存中的一个Key(比如一个促销商品),在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
解决方案
对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询
五、总结
缓存击穿:使用双重检查锁的方式来解决,看到双重检查锁,大家肯定第一印象就会想到单例模式,这里也算是给大家复习一把双重检查锁的使用。
缓存穿透:将空对象放入缓存中、布隆过滤器、使用锁的时候注意锁的力度,建议换成分布式锁(Redis
或者Zookeeper
实现),多个节点时可用Redisson提供的分布式锁!
缓存雪崩:数据预热、分散缓存失效时间、数据永不过期
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器