文章目录
一、缓存穿透(数据查不到)
1.概念
当用户想要查询一个数据时,发现redis内存数据库没有,也就是缓存没有命中,于是向持久层数据库查询。发现也没有,于是本次查询失败。当用户很多时,缓存都没有命中,于是都去请求了持久层数据库。缓存本就是为了减轻数据库压力,这样redis形同虚设,这给持久层数据库造成很大的压力,这时候就相当于出现了缓存穿透。
2.解决方案
(1)解决方案一:缓存空对象
当存储层没有查询到后,即使返回的空对象也将其缓存起来,同时会设置一个过期时间(这里缓存不存在key的时候一定要设置过期时间,不然当数据库已经新增了这一条记录的时候,会导致缓存和数据库不一致的情况),之后再访问这个数据将会从缓存中获取,若我为空,就不会再去访问持久层数据库,保护了后端数据源,防止了攻击用户反复用同一个id暴力攻击。
但有两种问题:
- 如果空值被缓存起来,这就意味着缓存需要更多的空间存储更多的键
- 即使对空值设置了过期时间,还是会在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响。
(2)解决方案二:布隆过滤器
布隆过滤器是一种数据结构,对所有可能查询的参数以hash形式存储,同时引入一种节省空间的数据结构,位图(bitmap),他是一个有序的数组,只有两个值,0 和 1。0代表不存在,1代表存在。在控制层先进行校验,不符合则丢弃,从而避免了对底层存储系统的查询压力。
事实上布隆过滤器并不是百分百过滤的,由于哈希冲突与位图特性,会出现误判情况,即将不存在的通过计算,误判为存在,这种情况被称为假阳性(False Positive Probability,FPP),但其出现的机率很小,默认为0.03,所以布隆过滤器不是万能的,但是它能帮我们抵挡掉大部分不存在的数据已经很不错了,已经减轻数据库很多压力了。
从容器的角度来说:
如果布隆过滤器判断元素在集合中存在,不一定存在
如果布隆过滤器判断不存在,一定不存在
从元素的角度来说:
如果元素实际存在,布隆过滤器一定判断存在
如果元素实际不存在,布隆过滤器可能判断存在
二、缓存击穿(访问量太大,缓存正好过期)
1.概述
这里需要注意和缓存击穿的区别,是指一个key非常热点,在不停扛着高并发,高并发集中对这一个点进行访问,当这个key在过期的瞬间,持续的高并发就会穿破缓存,直接请求数据库,就像在一个屏障上凿了一个洞。
当某个key在过期的瞬间,就会有大量的请求并发访问,这类数据一般是热点数据,由于缓存过期,会同时访问数据库来查询最新数据,并且回写缓存,会导致数据库瞬间压力增大
2.解决方案
(1)解决方案一:永不过期
顾名思义,将key设置为永不过期,这样就不会出现缓存过期的情况。但是随着永久的key越来越多,可能对服务器的压力也会不断增大。
优点:不会出现缓存击穿
缺点:key逐渐增多,如果不常使用,会造成服务器资源浪费,增大服务器压力。
(2)解决方案二:加互斥锁
业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去查询持久层数据库,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX,也就是只有不存在的时候才设置,可以利用它来实现锁的效果)去set一个mutex key,当操作返回成功时,再进行查询持久层数据库的操作并回设缓存。否则,就重试整个get缓存的方法。
用浅显易懂的语言来说,就是如果有多个线程同时去访问缓存中同一个key,但是这个key并不存在,所以使用setnx先设置一个key,设置成功的那个线程然后就去持久层数据库拿数据,而其他线程使用setnx再设置这个key时,其已经存在,就会设置失败而被拦截,让他过一会再重新get这个key。等到那个线程从持久层数据库拿来值,并把key-value放入缓存中后,其他线程就能直接获取到缓存中正确的key。
代码如下:
public String get(key) {
String value = redis.get(key);
if (value == null) { // 缓存已过期
//对其设置3分钟过期时间防止delete失败导致下次过期无法使用
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {// 设置成功
value = db.get(key);
redis.set(key,value,expire_secs);
redis.del(key_mutex);
} else {// 其他线程已load db并回设缓存,重试获取缓存即可
sleep(50);
get(key); //重试
}
}else { // 缓存未过期
return value;
}
}
}
优点:思路简单,保持一致性
缺点:代码更加复杂,存在死锁风险,存在线程池阻塞风险
(3)解决方案三:异步重建缓存——“提前“互斥锁
我们也可以把过期时间存在key对应的value里,其值比真实过期时间早一些,如果发现要过期了 通过一个后台的异步线程对缓存进行重新构建。 其与永不过期类似,但若该key一直没有访问,就会正常过期,不会占用内存,如果高并发访问,也能一直维持不过期。
代码如下:
String get(final String key) {
V v = redis.get(key);
String value = v.getValue();
long timeout = v.getTimeout();
if (v.timeout <= System.currentTimeMillis()) {
// 异步更新后台异常执行
threadPool.execute(new Runnable() {
public void run() {
String keyMutex = "mutex:" + key;
if (redis.setnx(keyMutex, 1,3 * 60)) {
// 对其设置3分钟过期时间防止delete失败导致下次过期无法使用
String dbValue = db.get(key);
redis.set(key, dbValue);
redis.delete(keyMutex);
}
}
});
}
return value;
}
优点:异步构建缓存,不会阻塞线程池
缺点:不保证一致性,非构建缓存线程可能访问老数据,代码复杂度增大且占用一定的内存空间(每个value都要维护一个timekey)
三、缓存雪崩(缓存集体过期)
1.概述
缓存雪崩,是指在某一个时间段,缓存集中过期失效,导致请求全部转发到数据库,使数据库瞬间压力过大而导致雪崩。但这并不是最致命的,最致命的是断电断网或Redis服务器宕机。这对服务器造成的压力是不可预知的,很有可能瞬间就把数据库压垮,当然这是突发情况,只能通过高可用来解决,服务器宕机后,立即让其他服务器接替,而不影响正常业务的开展。
2.解决方案
(1)解决方案一:高可用
既然redis有可能挂掉,那就增设几台redis,在多个机房部署redis,保证redis的高可用,使用主从+哨兵模式,这样一台挂掉之后其他的还可以继续工作,其实就是搭建的集群,保证缓存层的高可用。
(2)解决方案二:限流降级
限流的思想是,缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
(3)解决方案三:数据预热
数据加热的含义就是在正式部署之前,我先把可能的数据先访问一遍,这样部分可能大量访问的数据就会加载在缓存中。在即将发生大并发访问前手动触发加载缓存不同的key。
(4)解决方案四:优化缓存过期时间
比如可以将key过期时间设置不同的周期,再加上一些随机因子。这样尽可能分散缓存过期时间,防止大量key在同一时间过期。也可以将热门的key缓存时间长一些,冷门的key缓存时间短一些,也能节省缓存服务的资源。
(5)解决方案五:过期时间处理
-
设置key永不过期
-
使用互斥锁重建缓存
-
使用异步重建缓存
PS:详细方法见上面缓存击穿对应处理方法
参考文献:
热点key问题:https://www.iteye.com/blog/carlosfu-2269687
熔断、限流、降级:https://zhuanlan.zhihu.com/p/61363959
布隆过滤器详解:https://blog.csdn.net/wuzhiwei549/article/details/