缓存击穿
什么时候Redis中没有要查询的数据呢?答案是过期和新增:过期:在Redis会有一个key值,每个key值都有一个ttl,也就是生命周期,一旦过期了就只能存在数据库里了;新增:插入,更新的数据还未来得及同步到Redis中。 如果线程查询一个数据库中不存在的值,此时从数据库中就返回一个空值。当一个线程不断执行同一条查询语句查询这个Redis和数据库都不存在的数据时,比如说执行上万次同一条查询语句,那么每次都穿过Redis,这样Redis就没有意义了。那么为了解决这个问题,就可以在Redis中设置空值,同一条查询语句对应的key(数据库中没有的值)都对应着一个空值,当查询其他查询语句也查询数据库中不存在的值时,都要对每个key设置一个空值,随着越来越多这样的key,Redis服务器就会承受不了这么大的压力,例如下面的例子就引出了Redis缓存穿透:
缓存击穿: 一个并发访问量比较大的key在某个时间过期,导致所有的请求直接打在DB上。
描述:某一个热点 key,在缓存过期的一瞬间,同时有大量的请求打进来,由于此时缓存过期了,所以请求最终都会走到数据库,造成瞬时数据库请求量大、压力骤增,甚至可能打垮数据库。
缓存击穿如何解决
加锁更新
查询缓存,发现缓存中不存在,加锁,让其它线程等待,只让一个线程去更新缓存。 该方法是比较普遍的做法,即,在根据key获得的value值为空时,先锁上,再从数据库加载,加载完毕,释放锁。若其他线程发现获取锁失败,则睡眠50ms后重试。 在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。
关于互斥锁的选择,看到的大部分文章都是选择 Redis 分布式锁,因为这个可以保证只有一个请求会走到数据库,这是一种思路。
但是其实仔细想想的话,这边其实没有必要保证只有一个请求走到数据库,只要保证走到数据库的请求能大大降低即可,所以还有另一个思路是 JVM 锁。
JVM 锁保证了在单台服务器上只有一个请求走到数据库,通常来说已经足够保证数据库的压力大大降低,同时在性能上比分布式锁更好。
需要注意的是,无论是使用“分布式锁”,还是“JVM 锁”,加锁时要按 key 维度去加锁。
我看网上很多文章都是使用一个“固定的 key”加锁,这样会导致不同的 key 之间也会互相阻塞,造成性能严重损耗。
单机伪代码
static Lock reenLock = new ReentrantLock();
//最后使用互斥锁的方式来实现.
public List<String> getData() throws InterruptedException {
List<String> result = new ArrayList<String>();
// 从缓存读取数据
result = getDataFromCache();
if (result.isEmpty()) {
if (reenLock.tryLock()) {
try {
System.out.println("我拿到锁,从DB获取数据库后写入缓存");
// 从数据库查询数据
result = getDataFromDB();
// 将查询到的数据写入缓存
setDataToCache(result);
} finally {
reenLock.unlock();// 释放锁
}
} else {//我没有拿到锁
result = getDataFromCache();// 先查一下缓存
if (result.isEmpty()) {
System.out.println("我没拿到锁,缓存也没数据,等一下");
Thread.sleep(100);//
return getData();// 递归调用重试
}
}
}
return result;
}
集群环境的redis的代码如下所示:
public Object getData(String key) throws InterruptedException {
Object value = redis.get(key);
// 缓存值过期
if (value == null) {
// lockRedis:专门用于加锁的redis;
// "empty":加锁的值随便设置都可以
if (lockRedis.set(key, "empty", "PX", lockExpire, "NX")) {
try {
// 查询数据库,并写到缓存,让其他线程可以直接走缓存
value = getDataFromDb(key);
redis.set(key, value, "PX", expire);
} catch (Exception e) {
// 异常处理
} finally {
// 释放锁
lockRedis.delete(key);
}
} else {
// sleep50ms后,进行重试
Thread.sleep(50);
return getData(key);
}
}
return value;
}
优点
思路简单
保证一致性
缺点
代码复杂度增大
存在死锁的风险
异步构建缓存
在这种方案下,构建缓存采取异步策略,会从线程池中取线程来异步构建缓存,从而不会让所有的请求直接怼到数据库上。该方案redis自己维护一个timeout,当timeout小于System.currentTimeMillis()时,则进行缓存更新,否则直接返回value值。
集群环境的redis代码如下所示:
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 min timeout to avoid mutex holder crash
redis.expire(keyMutex, 3 * 60);
String dbValue = db.get(key);
redis.set(key, dbValue);
redis.delete(keyMutex);
}
}
});
}
return value;
}
优点
性价最佳,用户无需等待
缺点
无法保证缓存一致性
热点数据不过期
直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。
这种方式适用于比较极端的场景,例如流量特别特别大的场景,使用时需要考虑业务能接受数据不一致的时间,还有就是异常情况的处理,不要到时候缓存刷新不上,一直是脏数据,那就凉了。
缓存穿透
什么是缓存穿透
描述:访问一个缓存和数据库都不存在的 key,此时会直接打到数据库上,并且查不到数据,没法写缓存,所以下一次同样会打到数据库上。
此时,缓存起不到作用,请求每次都会走到数据库,流量大时数据库可能会被打挂。此时缓存就好像被“穿透”了一样,起不到任何作用。
与上面讲到的缓存击穿的区别: 缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
缓存穿透可能有两种原因:
自身业务代码问题 恶意攻击,爬虫造成空命中
解决方案
缓存空值/默认值
一种方式是在数据库不命中之后,把一个空对象或者默认值保存到缓存,之后再访问这个数据,就会从缓存中获取,这样就保护了数据库。
缓存空值有两大问题:
空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的
方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。
例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致。
这时候可以利用消息队列或者其它异步方式清理缓存中的空对象。
布隆过滤器
除了缓存空对象,我们还可以在存储和缓存之前,加一个布隆过滤器,做一层过滤。
布隆过滤器里会保存数据是否存在,如果判断数据不不能再,就不会访问存储。
布隆过滤器的巨大用处就是,能够迅速判断一个元素是否在一个集合中。因此他有如下三个使用场景:
网页爬虫对URL的去重,避免爬取相同的URL地址
反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱(同理,垃圾短信)
缓存击穿,将已存在的缓存放到布隆过滤器中,当黑客访问不存在的缓存时迅速返回避免缓存及DB挂掉。
OK,接下来我们来谈谈布隆过滤器的原理
其内部维护一个全为0的bit数组,需要说明的是,布隆过滤器有一个误判率的概念,误判率越低,则数组越长,所占空间越大。误判率越高则数组越小,所占的空间越小。
private static BloomFilter bloomFilter =BloomFilter.create(Funnels.integerFunnel(), size); String get(String key) {
String value = redis.get(key);
if (value == null) {
if(!bloomfilter.mightContain(key)){
return null;
}else{
value = db.get(key);
redis.set(key, value);
}
}
return value;
}
接口校验
在正常业务流程中可能会存在少量访问不存在 key 的情况,但是一般不会出现大量的情况,所以这种场景最大的可能性是遭受了非法攻击。可以在最外层先做一层校验:用户鉴权、数据合法性校验等,例如商品查询中,商品的ID是正整数,则可以直接对非正整数直接过滤等等。
缓存雪崩
简单说:由于缓存不可用,导致大量请求访问后端服务,可能 mysql 扛不住高并发而打死, 像滚雪球一样,影响越来越大,最后导致整个网站崩溃不可用
分布式系统都存在这样一个问题,由于网络的不稳定性,决定了任何一个服务的可用性都不是 100% 的。当网络不稳定的时候,作为服务的提供者,自身可能会被拖死,导致服务调用者阻塞,最终可能引发雪崩连锁效应。
当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力,造成数据库后端故障,从而引起应用服务器雪崩。
至于为什么会像滚雪球一样?整个与整个系统的架构有关;
redis 集群彻底崩溃:不可用
缓存服务在请求 redis 时,会有大量的线程阻塞,占用资源
超时请求失败之后,会去 mysql 查询原始数据,mysql 抗不住,被打死
源头服务由于 mysql 被打死,对源服务的请求也被阻塞,占用资源
缓存服务大量的资源全部耗费在访问 redis 和 源服务上;最后自己被拖死,无法提供服务
nginx 无法访问缓存服务,只能基于本地缓存提供服务,当缓存过期后,就耗费在访问缓存服务上
最后整个网站崩溃,页面加载不出来任何数据
雪崩效应产生的几种场景
1、缓存服务器挂了
2、高峰期缓存大面积失效
3、热点缓存失效,对热点数据持续高并发
考虑的比较完善的一套方案,分为事前、事中、事后三个层次去思考再怎么来应对缓存雪崩的场景
事前解决方案
单缓存
1.避免缓存设置相近的有效期;为有效期增加随机值;统一规划有效期,失效时间均匀分布。 2.使用互斥锁:jvm锁机制;分布式锁机制,防止热点数据持续高并发访问
限流模式
限流模式则可以称为预防模式。限流模式主要是提前对各个类型的请求设置最高的QPS阈值,若高于设置的阈值则对该请求直接返回,不再调用后续资源。这种模式不能解决服务依赖的问题,只能解决系统整体资源分配问题,因为没有被限流的请求依然有可能造成雪崩效应。
超时机制设计 (1)超时分两种,一种是请求的等待超时,一种是请求运行超时。
(2)等待超时:在任务入队列时设置任务入队列时间,并判断队头的任务入队列时间是否大于超时时间,超过则丢弃任务。
(3)运行超时:直接可使用线程池提供的get方法。
双缓存方案
主缓存:有效期按照经验值设置,主要读取的缓存,主缓存失效后从数据库加载最新值。
备份缓存:有效期长,获取锁失败时读取的缓存,主缓存更新时需要同步更新备份缓存。
其实就是缓存降级策略。
发生缓存雪崩之前,事情之前,怎么去避免 redis 彻底挂掉
redis本身的高可用性、复制、主从架构,操作主节点,读写,数据同步到从节点,一旦主节点挂掉,从节点跟上
双机房部署,一套 redis cluster,部分机器在一个机房,另一部分机器在另外一个机房
还有一种部署方式,两套 redis cluster,两套 redis cluster 之间做一个数据的同步,redis 集群是可以搭建成树状的结构的
一旦说单个机房出了故障,至少说另外一个机房还能有些 redis 实例提供服务
事中解决方案
redis cluster 已经彻底崩溃了,已经开始大量的访问无法访问到 redis 了
ehcache 本地缓存 所做的多级缓存架构的作用上了 ,ehcache 的缓存应对零散的 redis 中数据被清除掉的现象,另外一个主要是预防 redis 彻底崩溃
多台机器上部署的缓存服务实例的内存中,还有一套 ehcache 的缓存,还能支撑一阵
对 redis 访问的资源隔离 对 redis 访问使用 hystrix 进行隔离,防止自己资源大量阻塞在访问 redis 上
对源服务访问的限流以及资源隔离 同上,防止自己资源大量阻塞在访问源服务上,同时 hystrix 在资源隔离时也做到了限流
事后解决方案
redis 数据可以恢复,之前讲解过各种备份机制,redis 数据备份和恢复,redis 重新启动起来
redis 数据彻底丢失了或者数据过旧,快速缓存预热,redis 重新启动起来
由于事中做了限流与隔离,缓存服务不会被打死,通过熔断策略 和 half-open 策略, 可以自动可以恢复对 redis 的访问,发现 redis 可以访问了,就自动恢复了
熔断降级
服务熔断:当缓存服务器宕机或超时响应时,为了防止整个系统出现雪崩,暂时停止业务服务访问缓存系统。 服务降级:当出现大量缓存失效,而且处在高并发高负荷的情况下,在业务系统内部暂时舍弃对一些非核心的接口和数据的请求,而直接返回一个提前准备好的 fallback(退路)错误处理信息。
|