缓存雪崩
定义
缓存雪崩是指在短时间内,有大量缓存同时过期,导致此时大批量请求均未命中缓存,从而直接查询数据库并对数据库造成了巨大的压力,严重情况下可能会导致数据库宕机,这种情况称作缓存雪崩。
解决方案
方案一:随机化过期时间
在介绍Cache Aside Pattern的时候我们对缓存统一设置了60s的过期时间,并对热点key的缓存在服务发布的时候提前初始化好了。
这样做的话,过了60s之后,热点key的缓存会统一时间过期,那大部分流量会进入加载DB数据进缓存的逻辑。所以,我们可以尝试对缓存过期的时间设置一个范围随机数,比如60s ~ 120s,这样有一部分缓存晚一些过期,分摊一些进入DB加载数据的压力。
cacheService.set(key, data, 60 + new Random().nextInt(60));
随机时间范围的选择根据实际业务来决定,一般过期时间设置的比较分散的情况下就能较大程度减少缓存雪崩发生的概率。
方案二:缓存数据永不过期
既然缓存雪崩的问题是缓存统一过期导致的,那缓存数据在更新的时候可以不设置过期时间,然后更新DB的操作不能删除Cache,改为更新Cache。整个更新操作需要对热点key加锁,否则会出现之前提到过的数据不一致问题。
有小伙伴会问,如果有时候更新DB成功了,但更新Cache失败了,从而导致后面的查询不是最新数据的情况该怎么办呢?
这里可以设计一个定时任务,比如每小时把所有数据查询DB数据刷一下缓存,保证之前更新失败造成的影响不会很大。
但这样的方式并不适用与所有业务,如果缓存的数据过多,会很占用缓存资源。可以适当做一些挑选,设置相对请求量更大的key永不过期即可。
缓存击穿
定义
缓存击穿是指对于某一个热点缓存,如果缓存过期的同时刚好有大量的并发请求,由于没有做并发控制,他们很可能同时绕过if(data == null)
判断条件,进入加载DB数据进缓存的逻辑,从而给DB造成巨大压力甚至宕机,这样的情况称为缓存击穿。
解决方案
缓存击穿实际上可以理解为一种特殊的缓存雪崩,所以给出的解决方案可以是在缓存雪崩的基础上再做进一步的优化。
方案一:加锁排队
随机化过期时间基本上能大幅度降低不同缓存key同时过期的概率,在其方案的基础上,对某一个key读取缓存不存在之后加载DB的逻辑做并发控制,也就能较好地解决缓存雪崩和缓存击穿问题了。
并发控制的具体逻辑,这里举个例子说明一下。
假设当前对某一个key的并发请求数为1000,那势必会有某一个请求先获取到锁,然后去DB加载数据会更新到Cache。而剩余的999个请求会被锁拦住,但这里不会直接报错,而是设置一个等待时间,比如2s。
当请求1更新Cache之后,释放锁。剩余999个请求依次排队获取锁,当请求2获取到锁进入加载逻辑之后,由于请求1已经更新了Cache,所以其实这里可以再次查询一次Cache,查询到就直接返回了,无需再加载一次数据。
但2s的等待时间内,可能只能处理199个请求,那现在还剩下800个请求,会进入获取锁超时失败的流程。实际上请求1已经更新了Cache了,所以获取锁失败的流程就是再查询一次Cache,有则返回,否则报错系统繁忙。
结合代码理解一下:
public class DataCacheManager {
public Data query(Long id) {
String key = "keyPrefix:" + id;
//查询缓存
Data data = cacheService.get(key);
if(data == null) {
try {
cacheService.lock(key, 2);
//先查Cache
Data data = cacheService.get(key);
if(data != null) {
return data;
}
//查询DB
data = dataDao.get(id);
//更新缓存
cacheService.set(key, data);
} catch (tryLockTimeoutException e) {
//超时后再查一次缓存,存在则返回,否则抛出异常
data = dataDao.get(id);
if (data != null) {
return data;
}
throw e;
} finally {
cacheService.unLock(key);
}
}
return data;
}
}
方案二:热点数据永不过期
同样的,缓存击穿问题也是由于Cache过期导致的,只要将热点数据的Cache设置为永不过期即可避免。
缓存穿透
定义
缓存击穿是指缓存和数据库中都没有的数据,可用户还是源源不断的发起大量请求,导致每次请求都会到数据库,从而压垮数据库。
通常情况下按照正常的页面操作走是不会出现这个情况的,比如从电商首页或者列表页点进去的商品是一定存在的。但可能有人伪造http请求,并改了goodsId为0之后发起大量的请求,目的就是搞垮你的系统~ 所以需要提前预防悲剧的发生。
解决方案
方案一:参数校验
客户端发过来的请求,对参数进行校验,对于不满足检查条件的情况下直接拦截。比如商品id肯定不会小于0,避免请求打到下游。
很显然,该方案不能完全杜绝穿透的发生,如果传了一个正常的数字,比如goodsId = 666666,正好又没有该商品,就拦不住了。
方案二:缓存空值
既然是因为请求了DB没有的数据,那把这样的数据也存储的Cache中去就好了,可以存个"null"之类的值,保证不和正常结构一致就行,后面查询出来识别是"null"之后直接返回null即可。
不过,最好设置一个合理的过期时间,因为key对应数据不一定一直为null。
方案三:布隆过滤器(Bloom Filter)
关于布隆过滤器,后面会详细介绍。其原理不难,它是一种数据结构,利用极小的内存,可以判断大量的数据“一定不存在或者可能存在”。
对于缓存穿透,我们可以将查询的数据条件都哈希到一个足够大的布隆过滤器中,请求会先被布隆过滤器拦截,一定不存在的数据就直接拦截返回了,从而避免下一步对DB的压力,可能存在的数据可以走到DB实际查询,但概率不大所以影响很小。
兜底方案:DB限流&降级
缓存雪崩、击穿、穿透三个问题说到底就是将数据库打卦了,所以核心问题在于如何保证数据库不会被打卦。
从数据库层面来考虑,它控制不了你到底来多少请求,肯定是不会完全相信你做的那些优化保证的,万一你写的代码有bug呢对吧~最保险的方式就是给自己设上防护,这个防护就是限流。
假设你设置了1s能通过1000个请求的限流,如果此时来了2000个请求,前1000个正常执行,后1000个触发了限流,可以走你配置的降级逻辑,比如返回一些配置的默认值,或者给个友好的报错提示等。这里的限流最好是有针对性的,比如某个热点表的读请求拥有单独的限流配置,保证不影响到其他正常的请求被限流。
总结
不知道你有没有发现,有一个方案能同时解决缓存雪崩和缓存穿透的问题,那就是使数据永不过期。但它只适用于缓存key不会很多的场景,不过实际上我们大部分同学所做的业务的缓存key并不会太多,甚至一些较大的电商项目,也有很多的场景是适用的。
比如商品的缓存,一般最多也就几亿到几十亿,Redis一个单实例最多能存2的32次方的key,最少也能存2.5亿的key,所以集群场景下通常是能接受缓存key很多的,只是单个key不宜很大。
甚至,如果在缓存永不过期的基础上,以缓存的数据为主要数据源(Cache中查询不到不去查询DB了),还能解决穿透的问题。话说我在工作中就专门封装了这样一个框架,已经接入商品,活动等系统了,后续写完了分享出来~
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。