缓存穿透、缓存击穿、缓存雪崩
前言:
在设计缓存系统时,就不得不考虑所谓:缓存穿透、缓存击穿、缓存雪崩,这三大问题。
缓存设计一般遵循如下流程图:
一、缓存穿透:
缓存穿透是指查询一个一定不存在的数据(某Key对应的缓存和DB数据都不存在),由于缓存是不命中需要从数据库中查询,查询不到则不会写缓存,此时若缓存和DB 都查询不到,那么这将导致每次请求数据都要到数据库去查询,造成缓存穿透。
这种情况失去了缓存的意义,在流量大时,DB很可能就挂掉了,要是有人利用不存在的Key频繁攻击我们,这就是我们的漏洞。
解决方案:
1.布隆过滤:
(1)可以对所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃。例如我们查找CountryCode+Currency 对应的货币符号,我们可以把这个Key的所有可能值都以hash形式存储,不符合这个hash的数据则不能进行查询缓存,直接就返回了。
(2)所谓布隆过滤,即将所有可能存在参数数据哈希到一个足够大的bitmap中,一个一定不存在的查询参数被这个bitmap拦截掉,从而避免了对底层系统的查询压力。这样做的好处是优先从控制层进行过滤,不符合条件的Key被拒绝掉,减轻查询压力。
2.缓存空对象
缓存空对象,将null变成一个值,具体做法是不管缓存能否查询到数据,将该查询参数对应的数据以空的形式存储,但切记设置过期时间,一般不会超过5分钟。
缓存空对象带来的问题:
第一、每个不存在的Key对应的数据都以空值存储,那么首先它将消耗更多的内存空间,一旦遇到攻击,那么后果很严重,所以这种办法一般都会设置一个较短的过期时间,过期后数据自动剔除。
第二、缓存层和业务层存在一段时间二者数据不一致问题,可能对业务会有影响。例如过期时间设置为5分钟,那么在这5分钟内一旦DB中有新数据添加,而此时缓存中还缓存的空值,那么二者存在数据不一致性问题。
解决该问题,可以利用消息系统或其他方式清除缓存中的空数据。
二、缓存雪崩:
如果缓存在一段时间内同时失效,例如我们在设置缓存时,采用了相同的过期时间,导致在某一时刻所有缓存同时失效,请求全部到DB上,DB瞬时压力过重导致雪崩。
解决方案:
首先强调的是缓存雪崩对底层系统的冲击非常可怕。但很遗憾的是目前并没有完美的解决方案。
1.大多数设计者考虑“加锁”或者“队列”方式保证缓存的单线程(进程)写,从而避免大量并发请求落到底层存储系统上。比如某个Key只允许一个线程查询和写缓存,其他线程等待。
2.有一个简单处理方案,就是将缓存失效时间分散开,比如我们在原有失效时间上增加一个随机值,如1~5分钟随机,尽量让缓存不要同时失效,从而尽量避免缓存雪崩。
三、缓存击穿
对于一些设置了过期时间的Key,当这些Key在被某些时间点大量高并发访问时,这个时候就需要考虑缓存被“击穿”的问题,这个问题和雪崩区别在于只针对某个Key的缓存,而缓存雪崩是针对多个Key的缓存。
简单来说,就是当某个时间点某个Key被高并发访问,此时恰好缓存过期,那么所有请求都落到DB上了,这是瞬时的大并发就有可能导致将DB压垮,这种现象就叫缓存击穿。
解决方案:
1.使用互斥锁(mutex key)
业界常用的方法,就是加互斥锁,简单来说就是缓存失效的时候,不要直接load DB,而是加一个锁,简单处理就是如下伪代码方式,锁没释放前,第二个线程过来需要等待才能去DB中load数据。
上述代码说明:
(1)缓存中有数据,直接走上述13行代码直接返回结果。
(2)缓存中没有数据,第一个进入的线程,获取锁并从数据库中取数据,没释放锁之前,其他并行线程进入的线程会等待100ms,再重新去缓存中取数据,这样就起到了防止都去DB中重复取数据,重复往缓存中更新数据的情况。
(3)上图这种是简化处理,理论上如果能根据key值加锁就更好了,就是线程A 从数据库取Key1的数据并不妨碍线程B取Key2的数据,上述代码明显做不到这一点。
互斥锁业界常用方法(Redis和Memcache):
业界最常用的方法,就是加互斥锁,简单来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load DB,而是先使用缓存公共的某些带成功返回值得操作(比如Redis的SETNX或者Memcache的ADD),去Set一个Mutex key,当操作返回成功时,再进行Load DB 的操作。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; } }
memcache代码:
if (memcache.get(key) == null) { // 3 min timeout to avoid mutex holder crash if (memcache.add(key_mutex, 3 * 60 * 1000) == true) { value = db.get(key); memcache.set(key, value); memcache.delete(key_mutex); } else { sleep(50); retry(); } }
2.设置缓存“永远不过期”
这里包含两层意思:
(1)从Redis上看,确实没有设置过期时间,这就保证了不会出现热点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; }