1、背景
一般企业都会用到mysql等关系型数据库,当访问量不大的时候还可以支撑;当并发量高的时候,比如商品抢购或者主页访问瞬间较大的时候,请求直接到达db,可能会导致系统性能急剧下降以致瘫痪。db是面向磁盘的,磁盘IO是比较重的操作,性能较低。为了克服上述的问题,通常需要在客户端和db之间引入一层缓存NoSql技术,这是一种基于内存的数据库,并提供一定的持久化功能,比如redis。但是引入缓存之后,又有可能出现缓存穿透等问题。
2、概念
缓存穿透:key对应的数据在数据源并不存在,每次针对此key的请求从缓存获取不到,请求都会到数据源,从而可能压垮数据源。比如用一个不存在的用户id获取用户信息,不论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库。
缓存击穿:key对应的数据存在,但在redis中过期,此时若有大量并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
缓存雪崩:当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力。
3、解决方案
1、缓存穿透
查询一个一定不存在的数据,缓存不命中,最终达到db,查询不到数据又不写入缓存,导致每次都要查db,失去缓存的意义。
方案:
- 布隆过滤器,维护一个足够大的bitmap,不存在的key一定会被拦截到,降低对底层db的查询压力
- 简单粗暴的方法,如果从db重查询为空,则缓存一个默认的值代表空值,过期时间设置较短些,最长不超过5min
粗暴方式的伪代码:
//伪代码
public object getValue() {
int cacheTime = 30;
String cacheKey = "key";
String cacheValue = RedisHelper.Get(cacheKey);
if (cacheValue != null) {
return cacheValue;
}
cacheValue = RedisHelper.Get(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
//数据库查询不到,为空
cacheValue = getValueFromDb();
if (cacheValue == null) {
//如果发现为空,设置个默认值,也缓存起来
cacheValue = string.Empty;
}
RedisHelper.Add(cacheKey, cacheValue, cacheTime);
return cacheValue;
}
}
2、缓存击穿
热点key(某些时间点被超高并发访问)失效,请求直达db。
方案:
- 设置永不过期,另起调度任务定时更新key的值
- 业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。
互斥锁伪代码:
public String get(key) {
String value = redis.get(key);
if (value == null) { //代表缓存值过期
//设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db
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,缓存击穿针对一个key。
方案:
- 加锁排队:减轻数据库的压力,并没有提高系统吞吐量。假设在高并发下,缓存重建期间key是锁着的,这是过来1000个请求999个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法!
- 设置过期标志更新缓存
- 为key设置不同的随机的缓存失效时间
设置过期标志更新缓存伪代码
public object get() {
int cacheTime = 30;
String cacheKey = "product_list";
//缓存标记
String cacheSign = cacheKey + "_sign";
String sign = RedisHelper.Get(cacheSign);
//获取缓存值
String cacheValue = RedisHelper.Get(cacheKey);
if (sign != null) {
return cacheValue; //未过期,直接返回
} else {
RedisHelper.Add(cacheSign, "1", cacheTime);
ThreadPool.QueueUserWorkItem((arg) -> {
//这里一般是 sql查询数据
cacheValue = GetProductListFromDB();
//日期设缓存时间的2倍,用于脏读
RedisHelper.Add(cacheKey, cacheValue, cacheTime * 2);
});
return cacheValue;
}
}
解释说明:
- 缓存标记:记录缓存数据是否过期,如果过期会触发通知另外的线程在后台去更新实际key的缓存;
- 缓存数据:它的过期时间比缓存标记的时间延长1倍,例:标记缓存时间30分钟,数据缓存设置为60分钟。这样,当缓存标记key过期后,实际缓存还能把旧数据返回给调用端,直到另外的线程在后台更新完成后,才会返回新缓存。
4、总结
针对业务系统,永远都是具体情况具体分析;一切不以业务为基础的框架设计都是耍流氓,所以还是要具体问题具体分析,找出一个最合适的方案。
参考:
https://www.cnblogs.com/xichji/p/11286443.html