文章目录
- 一、缓存雪崩(缓存失效)
- 概念:
- 解决思路:
- 二、缓存穿透
- 概念:
- 解决思路:
- 三、缓存击穿
- 概念:
- 解决思路:
- 四、缓存并发
- 概念:
- 解决思路:
一、缓存雪崩(缓存失效)
概念:
未加载到缓存中,或者缓存同一时间大面积的失效,从而导致所有请求都去查数据库,导致数据库CPU和内存负载过高,甚至宕机。
解决思路:
1.缓存的高可用性:
缓存层设计成高可用,防止缓存大面积故障。即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,例如 Redis Sentinel 和 Redis Cluster 都实现了高可用。
参考方法:
⑴在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
⑵在即将发生大并发访问前手动触发更新缓存。
⑶不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。
⑷做二级缓存,或者双缓存策略。
2.缓存降级:
可以考虑利用ehcache等本地缓存,但主要还是对源服务访问进行限流、资源隔离(熔断)、降级等。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
降级的最终目的是保证核心服务可用,即使是有损的。
在进行降级之前要对系统进行梳理,比如:哪些业务是核心业务(必须保证),哪些业务可以容许暂时不提供服务(比如利用静态页面替换)等。同时可以配合服务器核心指标,来设置整体预案,比如:
⑴一般:某些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级。
⑵警告:某些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警。
⑶错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值之上,此时可以根据情况自动降级或者人工降级。
⑷严重错误:比如因为特殊原因数据错误了,或者其他严重错误,此时需要紧急人工降级。
3.Redis备份和缓存预热:
⑴Redis数据备份和恢复。
⑵缓存预热。
缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。
这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存。用户直接查询的是事先被预热的缓存数据。
实现方法:
①直接写个缓存刷新页面,上线前手工访问。
②在项目启动的时候自动进行加载。
4.提前演练:
最后,建议还是在项目上线前,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题,提前发现问题。
二、缓存穿透
概念:
不存在的数据。
在缓存redis没有命中,则需要从数据库查询。这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。
解决思路:
1.设置默认返回值:
如果查询数据库也为空,直接设置一个默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库。设置一个过期时间或者当有值的时候将缓存中的值替换掉即可。
2.查询前过滤:
可以给key设置一些格式规则,然后查询之前先过滤掉不符合规则的Key。
将数据库中所有的查询条件,放到布隆过滤器中。当一个查询请求来临的时候,先经过布隆过滤器进行检查,如果请求存在这个条件中,那么继续执行,如果不在,直接丢弃。
⑴布隆过滤器的容量size设置要比数据库总条件数稍微大一些。
⑵对于误判率的设置,根据实际项目,以及硬件设施来具体决定。不能设置为0。误判率设置的越小,硬件要求相应越高。
三、缓存击穿
概念:
一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到数据库,造成瞬时数据库请求量大、压力骤增。
和缓存雪崩的区别在于这里针对某一个key缓存,缓存雪崩则是针对很多key。
解决思路:
1.使用互斥锁(mutex key):(最常用)
在缓存失效的时候(判断拿出来的值为空),不是立即去加载数据库,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再加载数据库并回设缓存;否则,就重试整个get缓存的方法。
SETNX 是【SET if Not eXists】的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。
实现伪代码:
public String get(String key) {
String value = redis.get(key);
// 说明缓存值过期
if (value == null) {
// 设置3min的超时,防止del操作失败的时候,再次访问无法加载数据库
// 代表设置成功
if (redis.setnx(key_mutex, value_mutex, 3 * 60)) {
db_value = db.get(key);
redis.set(key, db_value, expire_secs);
redis.del(key_mutex);
} else {
// 到此代表同一时间其他线程已经加载数据库并回设到缓存了,此时重试获取缓存值即可
sleep(50);
// 重试
get(key);
}
} else {
return value;
}
}
2.永不过期:
这里的“永不过期”包含两层意思:
⑴从redis上看,不设置过期时间,就保证了,不会出现热点key过期问题,也就是“物理”不过期。
⑵把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期。
实现伪代码:
String get(String key) {
V v = redis.get(key);
String value = v.getValue();
long timeout = v.getTimeout();
if (timeout <= System.currentTimeMillis()) {
// 异步更新后台异常执行
threadPool.execute(new Runnable() {
public void run() {
if (redis.setnx(key_mutex, value_mutex)) {
// 设置3min的超时,防止del操作失败的时候,再次访问无法加载数据库
db_value = db.get(key);
redis.set(key, db_value, expire_secs);
redis.delete(key_mutex);
}
}
});
}
return value;
}
注意:
此处只是提供思想,具体代码实现还需要考虑时间同步、加锁解锁角色统一等问题。
由于key可能随着时间的变化而变化,针对固定的数据进行特殊缓存是不能起到治本作用的,结合LRU算法能够较好的解决这个问题。此外还要LRU-K、Two Queues和Mutil Queues等等可对比拓展。
四、缓存并发
概念:
这里的并发指的是多个redis的client同时set key引起的并发问题。
其实redis自身就是单线程操作,多个client并发操作,按照先到先执行的原则,先到的先执行,其余的阻塞,并不存在竞争关系。但是利用jedis等客户端对Redis进行并发访问时会出现问题。
解决思路:
1.分布式锁+时间戳:
通过加锁把并行读写改成串行读写,避免资源竞争。通过时间戳保证操作的顺序执行。
2.消息队列:
在并发量过大的情况下,可以通过消息中间件进行处理,把并行读写串行化。