前言

设计一个缓存系统,必须要考虑的问题有:缓存穿透、缓存击穿,与失效时的雪崩效应。

分析与解决方案

缓存穿透是什么?

缓存穿透,指查询一个一定不存在的数据。由于缓存是不命中时被动写的,出于容错考虑。如果从存储层查不到数据,则不写入缓存。这将导致每次请求这个不存在的数据,都要到存储层去查询,失去了缓存的意义。

在流量大时,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 持久化策略,保证一定情况下的数据安全。