我们在处理缓存时,经常会遇到缓存满了、缓存数据的持久化、缓存穿透、缓存雪崩和缓存击穿。前两种用redis比较好解决:
缓存满了:我们可以使用redis(3.0以后)中的驱逐策略。
-
noeviction:不删除策略。达到最大缓存限制时,如果需要更多的内存,直接返回错误信息。大多数写命令都会导致占用更多的内存(有极少数会例外, 如 DEL )。
-
allkeys-lru:所有的key通用;有限删除出最近最少使用(less recently used,LRU)的key。
-
volatile-lru:只限于设置了expire的部分,优先删除最近最少使用的(less recently used,LRU)key。
-
allkeys-random:所有key通用,随机删除一部分key。
-
volatile-random:只限于设置了expire的部分,随机删除一部分key。
- volatile-ttl:只限于设置了expire的部分;优先删除剩余时间(time to live,TTL)短的key。
缓存持久化:我们可以通过设置redis的RDB和AOF来实现缓存的落地。
接下来就要说本篇文章的重点了:缓存穿透、缓存雪崩、缓存击穿了
缓存穿透
首先我们知道缓存的作用是提高查询速度,查询请求来了先从缓存中查,如果没有的话,再去底层DB中查找,再添加到cache中。
什么是缓存穿透?
当你查询一个一定不存在的数据(cache和DB中都不存在)时,流程肯定是先去cache中查找,但是肯定查不到,然后去DB中查找,肯定也查不到,然后返回,但是DB中没有数据是不会缓存到cache中的。如果只是这么查询一次(低频率)看起来很正常,没有问题。但是如果有些人(黑客)恶意的用一定不存在的数据,高频率的查询,那么最终的结果是这些查询肯定都是走的DB查询,这样不仅我们的cache失去了存在的意义,还有可能把我们的DB挂掉,这就是一种隐藏的BUG。
解决缓存穿透
- 第一种办法:简单粗暴,如果查询一定不存在的数据时,最后查询DB返回一个null值时,我们把这个null值也缓存到redis中,但是设置的超时时间短一些,这样当频繁查询这个不存在的数据时,我们就可以直接从cache中获得null值,而不用查询DB了。
- 第二种办法:使用com.google.guava的布隆过滤器。将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层DB的查询压力。
缓存雪崩
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
解决缓存雪崩
解决方案可以跟解决缓存击穿的方案通用。我个人感觉缓存击穿就是一种特殊的缓存雪崩
缓存击穿
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。
缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮
解决缓存击穿
在说第一种方法之前我们先熟悉一下redis中的setnx命令的用法。因为我们第一种方法会用到这个命令。
SET key value
将key的值设置为value,当且仅当key不存在的时候。
若给定的key已经存在,setnx不会做任何操作。
SETNX 是【SET if Not eXists】(如果不存在,则SET)的缩写。
可用版本:>= 1.0.0
时间复杂度: O(1)
返回值: 设置成功,返回 1。设置失败,返回 0
效果如下
redis> EXISTS job # job 不存在
(integer) 0
redis> SETNX job "programmer" # job 设置成功
(integer) 1
redis> SETNX job "code-farmer" # 尝试覆盖 job ,失败(integer) 0
redis> GET job # 没有被覆盖"programmer"
- 第一种方法:使用互斥锁
业界比较常用的做法。该方法是比较普遍的做法,即,在根据key获得的value值为空时,先锁上,再从数据库加载,加载完毕,释放锁。若其他线程发现获取锁失败,则睡眠50ms后重试。
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.set(key, value);
redis.delete(key_mutex);
} else {
//其他线程休息50毫秒后重试
Thread.sleep(50);
get(key);
}
}
}
这种做法的优点:
- 思路简单
- 保证数据一致性
这种做法的缺点
- 代码复杂度增大
- 存在死锁的风险
- 第二种方法:“永不过期”
这里的“永远不过期”包含两层意思: (1) 从redis上看,确实没有设置过期时间,这就保证了,不会出现key同时集中过期和热点key过期问题(击穿问题和雪崩问题),也就是“物理”不过期。 (2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存放在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期 从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,出现数据不一致现象。
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;
}
这种方法的优点
- 性价最佳,用户无需等待
这种方法的缺点
无法保证缓存一致性