前言
设计一个缓存系统,必须要考虑的问题有:缓存穿透、缓存击穿,与失效时的雪崩效应。
分析与解决方案
缓存穿透是什么?
缓存穿透,指查询一个一定不存在的数据。由于缓存是不命中时被动写的,出于容错考虑。如果从存储层查不到数据,则不写入缓存。这将导致每次请求这个不存在的数据,都要到存储层去查询,失去了缓存的意义。
在流量大时,DB 可能就挂掉了。甚至,有人可能利用不存在的 key 频繁攻击应用。这就是漏洞。
明明没有发生火灾,却报了火警,就会造成资源被浪费。其他地方发生火灾时,导致无法去现场灭火。
缓存穿透解决方案
方式一:采用布隆过滤器
将所有可能存在的数据哈希到一个足够大的 bitmap ,一个一定不存在的数据会被该 bitmap 拦截掉。避免了对底层存储系统的查询压力。
方式二:缓存一个返回的空结果
如果一个查询返回的数据为空,无论是数据不存在,还是系统故障,仍然把空结果缓存。设置的过期时间会很短,最长不超过 5 分钟。
缓存雪崩是什么
设置缓存时,采用了相同的过期时间,导致缓存在某一时刻同时失效。全部请求转发到 DB,DB 瞬间压力过重雪崩。
缓存雪崩解决方案
方案一:加锁或队列
大多数设计者考虑用加锁或队列的方式,保证缓存的单线程(进程)写,从而避免缓存同时失效时,大量的高并发请求落到底层存储系统上。(缓存中所有的文章都失效了,也可直接写入缓存,再队列写入 DB 即可。??)
方案二:将缓存失效时间分散开
可以在原有的失效时间基础上,增加一个随机值,比如 1-5 分钟随机。以此,每一个缓存的过期时间的重复率就会降低,就难以引发集体失效的事件。
(什么实际应用场景中,会有缓存集体失效的情况呢?)
缓存击穿
对于一些设置了过期时间的 key,如果这些key可能会在某些时间点被【超高并发地访问】,是一种非常“热点”的数据。
这时,要考虑缓存被”击穿”的问题,与缓存雪崩的区别在于——这里针对某一key缓存,前者则是很多key。
缓存在某一时间点过期,而该时间点对这个key有大量的并发请求过来。这些请求发现缓存过期,一般都会从后端 DB 加载数据,并回设置到缓存。这时,大并发的请求可能会瞬间把后端 DB 压垮。
缓存击穿解决方案
1、使用互斥锁(mutex key)
缓存失效时,判断拿出来的值为空,不是立即去 load db。
而是,先使用缓存工具的某些带成功操作返回值的操作,如Redis 的 SETNX 或 Memcach 的 ADD ,set 一个 mutext key。当操作返回成功时,再进行 load db 的操作,并回设缓存。
否则,就重试真个 get 缓存的方法。
// 2.6.1
String get(String key) {
String value = redis.get(key);
if (value == null) {
// 如果设置互斥锁的成功,则调用 db
if (redis.setnx(key_mutext, "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 {
Thread.sleep(50);
get(key);
}
}
}
// 最新版本代码
`<br />
public String get(key) {
<br />
String value = redis.get(key);
<br />
if (value == null) { // 代表缓存值过期
<br />
// 设置 3 min 的超时,防止 del 操作失败,下次缓存过期一直不能 load db
<br />
if (redis.setnx(key_mutext, 1, 3 * 60) == 1) { // 代表设置成功
<br />
value = db.get(key);
<br />
redis.set(key, value, expire_secs);
<br />
redis.del(key_mutex);
<br />
} else { // 代表其他线程已 load db 并回设到缓存了,重试获取缓存值即可。
<br />
sleep(50);
<br />
get(key); // 重试
<br />
}
<br />
}
<br />
}
`
2、“提前”使用互斥锁(mutex key):
在 value 内部设置 1 个超时值(timeout1),timeout1 比实际的 memcache timeout(timeout2) 小。
当从 cache 读取到 timeout1,发现它已经过期,马上延长 timeout1 并重新设置到 cache。然后再从数据库加载数据,并设置到 cache 中。
伪代码如下:
value = memcache.get(key);
`<br />
if (value == null) {
<br />
if (memcache.add(key_mutex, 3 60 1000) == true) {
<br />
value = db.get(key);
<br />
memcache.set(key, value);
<br />
memcache.delete(key_mutex);
<br />
} else {
<br />
sleep(50);
<br />
retry();
<br />
}
<br />
} else {
<br />
if (value.timeout <= now()) {
<br />
if (memcache.add(key_mutex, 3 60 1000) === true) {
<br />
// extend the timeout for other threads
<br />
v.timeout += 3 60 1000;
<br />
memcache.set(key, v, KEY_TIMEOUT 2);
<br />
<br />
// load the latest value from db
<br />
value = db.get(key);
<br />
value.timeout = KEY_TIMEOUT;
<br />
memcache.set(key, value, KEY_TIMEOUT 2);
<br />
memcache.delete(key_mutex);
<br />
} else {
<br />
sleep(50);
<br />
retry();
<br />
}
<br />
}
<br />
}`
3、“永远不过期”
“永不过期”包含两层意思:
(1)从 redis 上,确实没设置过期时间。这保证了,不会出现热点 key 过期问题,也就是“物理”不过期。
(2)从功能上看,不过期,那就成了静态的了?
把过期时间存在 key 对应的 value 里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是”逻辑”过期。
(2)这种方法对于性能非常友好。不足的就是构建缓存时,其余线程(非构建缓存的线程)可能访问的是老数据。
String get(final String key) {
`<br />
v = redis.get(key);
<br />
String value = v.getValue();
<br />
long timeout = v.getTimeout();
<br />
if (v.timeout <= System.currentTimeMillis()) {
<br />
threadPool.execute(new Runnable() {
<br />
public void run() {
<br />
String keyMutex = “mutex:” + key;
<br />
if (redis.setnx(keyMute, “1”)) {
<br />
// 3 min timeout to avoid mutex holder crash
<br />
redis.expire(keyMutex, 3* 60);
<br />
String dbValue = db.get(key);
<br />
redis.set(key, dbValue);
<br />
redis.delete(keyMutex);
<br />
}
<br />
}
<br />
});
<br />
}
<br />
return value;
<br />
}`
4、资源保护
采用 netflix 的 hystrix,可以做资源的隔离保护主线程池。
对于缓存系统常见的缓存满了和数据丢失问题,需要根据具体业务分析,通常采用 LRU 策略处理溢出。
Redis 的 RDB 和 AOF 持久化策略,保证一定情况下的数据安全。