缓存穿透、缓存击穿、缓存雪崩

前言:

在设计缓存系统时,就不得不考虑所谓:缓存穿透、缓存击穿、缓存雪崩,这三大问题。

缓存设计一般遵循如下流程图:

一、缓存穿透:

缓存穿透是指查询一个一定不存在的数据(某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;      
          }
 }
View Code

 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();  
    }  
} 
View Code 

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;  
}
View Code
原文地址:https://www.cnblogs.com/vpersie2008/p/12253429.html