1 缓存穿透
缓存穿透是指查询一个数据库中根本不存在的数据,因此缓存层和存储层都不会命中, 通常出于容错的考虑, 如果从存储层查不到数据则不写入缓存层。
缓存穿透将导致不存在的数据每次请求都要到DB存储层去查询, 失去了缓存保护后端存储的意义。
不管使用哪种缓存系统都有可能遇到缓存穿透的问题,少量的缓存穿透对系统也没有损害,但是如果你的系统遭遇攻击,存在大量的缓存穿透的话,那么可能就是一个麻烦了,如果大量的缓存穿透超过了后端服务器的承受能力,那么就有可能造成服务崩溃,这是不可接受的。
造成缓存穿透的基本原因有两个:
- 第一, 自身业务代码或者数据出现问题。
- 第二, 一些恶意攻击、 爬虫等造成大量空命中。
基于存在这种大量缓存穿透的可能性,所以我们就需要从根源上解决缓存穿透的问题,解决缓存穿透,目前一般有两种方案:缓存空值和使用布隆过滤器:
1.1 缓存空值
如果我们系统是遇到攻击的话,那么很有可能查询的值是伪造的,必然大概率不存在我们的系统中,这样无论查询多少次,在缓存中一直不存在,这样缓存穿透就一直存在。
在这种情况下,我们可以在缓存系统中缓存一个空值,防止穿透一直存在,但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。下面是一段伪代码:
Object nullValue = new Object();
try {
Object valueFromDB = getFromDB(uid); //从数据库中查询数据
if (valueFromDB == null) {
cache.set(uid, nullValue, 10); //如果从数据库中查询到空值,就把空值写入缓存,设置较短的超时时间
} else {
cache.set(uid, valueFromDB, 1000);
}
} catch(Exception e) {
cache.set(uid, nullValue, 10);
}
虽然这种方法可以解决缓存穿透的问题,但是这种方式也存在弊端,因为在缓存系统中存了大量的空值,浪费缓存的存储空间,如果缓存空间被占满了,还会还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。
适用场景:数据命中不高,数据频繁变化实时性高(一些乱转业务)
维护成本:代码比较简单,但是有两个问题:
- 第一是空值做了缓存,意味着缓存系统中存了更多的key-value,也就是需要更多空间(有人说空值没多少,但是架不住多啊),解决方法是我们可以设置一个较短的过期时间
。
- 第二是数据会有一段时间窗口的不一致,假如,Cache设置了5分钟过期,此时Storage确实有了这个数据的值,那此段时间就会出现数据不一致,解决方法是我们可以利用消息或者其他方式,清除掉Cache中的数据.
1.2 使用布隆过滤器
布隆过滤器用于检索一个元素是在集合中的状态可以得到下面两种情况:1 一定不在集合中;2 可能存在集合中。
利用布隆过滤器的这个特点可以解决缓存穿透的问题,在服务启动的时候先把数据的查询条件,例如数据的 ID 映射到布隆过滤器上,当然如果新增数据时,除了写入到数据库中之外,也需要将数据的ID存入到布隆过滤器中。
我们在查询某条数据时,先判断这个查询的 ID 是否存在布隆过滤器中,如果不存在就直接返回空值,而不需要继续查询数据库和缓存,存在布隆过滤器中才继续查询数据库和缓存,这样就解决缓存穿透的问题。
适用场景:数据命中不高,数据相对固定实时性低(通常是数据集较大)
维护成本:代码维护复杂, 缓存空间占用少,单页存在缺点:
-
存在误判。布隆过滤器可以100%确定一个元素不在集合之中,但不能100%确定一个元素在集合之中。当k个位都为1时,也有可能是其它的元素将这些bit置为1的。
少量的误判率影响不大,只要能把决大部分无效的请求打回即可。
-
删除困难。一个放入容器的元素映射到位图的k个位置上是1,删除的时候不能简单的直接全部置为0,可能会影响其他元素的判断。
布隆过滤器不能删除数据,如果要删除得重新初始化数据。
-
需要提前初始化。使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放。
1.3 总结
缓存空值和使用布隆过滤器都可以在一定程度上解决缓存穿透的问题,各自有各自的优势,具体如何使用根据特定的场景舍取。
可参见《详解布隆过滤器BloomFilter的原理,使用场景和注意事项》
2 缓存雪崩
缓存雪崩是指,在某一个时间段,缓存集中过期失效.
雪崩是指我们一般在设置缓存的时候都会设置一个缓存过期时间,如果一些缓存过期时间在同一刻,也就是缓存同时过期了。这个时候有大量请求过来,发现缓存中不存在,就直接到数据库中获取,就像雪崩一样同时打到数据库上面,这样就会导致数据库负载高,很有可能就会崩溃。
产生雪崩的原因之一,比如在写本文的时候,马上就要到双十二零点,很快就会迎来一波抢购,这波商品时间比较集中的放入了缓存,假设缓存一个小时。那么到了凌晨一点钟的时候,这批商品的缓存就都过期了。而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。
2.1 解决方案
解决方案一:
发现缓存不存在,在读取数据库时,再保存到redis缓存中,采用队列的方式或加锁
的方式,进行排队访问数据库,这样就能够避免同时打到数据库上。
解决方案二:
这个方案就比较简单,雪崩是因为过期时间在同一刻导致的,那我们就可以在设置过期时间分散开
。如在原有的过期时间的基础上加上一个 随机数,比如:1~10分钟,这样就不会放生在同一刻缓存失效了。
3. 缓存击穿
缓存击穿是指一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到DB,造成瞬时DB请求量大、压力骤增。
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存失效,前者则是很多key,这里是指仅一个key
。
缓存击穿概念主要还是和过期有关,只不过是针对一个key,而不是雪崩那样多个key。针对同一个key访问量巨大,称之为热点问题,这里的热点问题侧重过期,请求打到数据库层面,导致数据库吃不消,和平常的“热点缓存问题”不同,平常的“热点缓存问题”是侧重缓存未过期,请求未打到数据库,但是超过了redis单个节点的访问容量上限,导致redis吃不消。可参见《经典面试问题》
缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
3.1 解决方案
3.1.1 互斥锁
导致问题的原因是同一时间查,同一时间写缓存,导致并发下缓存也没用,所以考虑使用单线程等方法将写缓存保证只有一个去查了写,其他的使用缓存。
业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。
SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。在redis2.6.1之前版本未实现setnx的过期时间,所以 这里给出两种版本代码参考:
2.6.1前单机版本锁 ,需要手动设置过期时间:
//2.6.1前单机版本锁
String get(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(key_mutex, "1")) {
// 3 min timeout to avoid mutex holder crash
redis.expire(key_mutex, 3 * 60)
value = db.get(key);
// 回写Redis
redis.set(key, value);
// 删除key_mutex
redis.delete(key_mutex);
} else {
//其他线程休息50毫秒后重试
Thread.sleep(50);
get(key);
}
}
}
最新版本代码:
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); //重试,重新进入get方法
}
} else {
return value;
}
}
3.1.1热点数据不过期。
直接将热点数据的缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。
这种方式适用于比较极端的场景,例如流量特别特别大的场景,使用时需要考虑业务能接受数据不一致的时间,还有就是异常情况的处理,不要到时候缓存刷新不上,一直是脏数据,那就凉了。