缓存穿透,缓存击穿,缓存雪崩的原理及解决方案

前言

设计一个缓存系统,不得不要考虑的问题就是:缓存穿透、缓存击穿与失效时的雪崩效应java

缓存穿透

缓存穿透是指查询一个必定不存在的数据,因为缓存是不命中时被动写的,而且出于容错考虑,若是从存储层查不到数据则不写入缓存,这将致使这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击咱们的应用,这就是漏洞。举例:如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户极可能是攻击者,攻击会致使数据库压力过大。

解决方式:redis

  • 布隆过滤器
    将全部可能存在的数据哈希到一个足够大的bitmap中,一个必定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
  • 空结果进行缓存
    简单粗暴的方法(咱们采用的就是这种),若是一个查询返回的数据为空(不论是数 据不存在,仍是系统故障),咱们仍然把这个空结果进行缓存,但它的过时时间会很短,最长不超过五分钟

缓存雪崩

缓存雪崩是指在咱们设置缓存时采用了相同的过时时间,致使缓存在某一时刻同时失效,请求所有转发到DB,DB瞬时压力太重雪崩。

解决方式:
(1)加锁或者队列的方式保证缓存的单线 程(进程)写,从而避免失效时大量的并发请求落到底层存储系统上
(2)将缓存失效时间分散开,好比咱们能够在原有的失效时间基础上增长一个随机值,好比1-5分钟随机,这样每个缓存的过时时间的重复率就会下降,就很难引起集体失效的事件数据库

缓存击穿

对于一些设置了过时时间的key,若是这些key可能会在某些时间点被超高并发地访问,是一种很是“热点”的数据。这个时候,须要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是不少key。

缓存在某个时间点过时的时候,刚好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过时通常都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
解决方式:
(1)使用互斥锁(mutex key)
这种解决方案思路比较简单,就是只让一个线程构建缓存,其余线程等待构建缓存的线程执行完,从新从缓存获取数据就能够了(以下图)

若是是单机,能够用synchronized或者lock来处理,若是是分布式环境能够用分布式锁就能够了(分布式锁,能够用memcache的add, redis的setnx, zookeeper的添加节点操做)。后端

下面是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();  
    }  
}

若是换成redis,就是:安全

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);  
        }  
    }  
}

(2)"提早"使用互斥锁(mutex key):
在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已通过期时候,立刻延长timeout1并从新设置到cache。而后再从数据库加载数据并设置到cache中。伪代码以下:并发

v = memcache.get(key);  
if (v == null) {  
    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();  
    }  
} else {  
    if (v.timeout <= now()) {  
        if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {  
            // extend the timeout for other threads  
            v.timeout += 3 * 60 * 1000;  
            memcache.set(key, v, KEY_TIMEOUT * 2);  
            // load the latest value from db  
            v = db.get(key);  
            v.timeout = KEY_TIMEOUT;  
            memcache.set(key, value, KEY_TIMEOUT * 2);  
            memcache.delete(key_mutex);  
        } else {  
            sleep(50);  
            retry();  
        }  
    }  
}

(3)"永远不过时":
这里的“永远不过时”包含两层意思:
(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;  
}

(4)资源保护:
netflix的hystrix,能够作资源的隔离保护主线程池,若是把这个应用到缓存的构建也何尝不可。
分布式

四种方案对比:
​ 做为一个并发量较大的互联网应用,咱们的目标有3个:
​ 1. 加快用户访问速度,提升用户体验。
​ 2. 下降后端负载,保证系统平稳。
​ 3. 保证数据“尽量”及时更新(要不要彻底一致,取决于业务,而不是技术。)
​ 四种方法以下比较,仍是那就话:没有最好,只有最合适。高并发

解决方案 优势 缺点
简单分布式锁(Tim yang) 1. 思路简单2. 保证一致性 1. 代码复杂度增大2. 存在死锁的风险3. 存在线程池阻塞的风险
加另一个过时时间(Tim yang) 1. 保证一致性 同上
不过时(本文) 1. 异步构建缓存,不会阻塞线程池 1. 不保证一致性。2. 代码复杂度增大(每一个value都要维护一个timekey)。3. 占用必定的内存空间(每一个value都要维护一个timekey)。
资源隔离组件hystrix(本文) 1. hystrix技术成熟,有效保证后端。2. hystrix监控强大。 1. 部分访问存在降级策略。

总结以下:

1.  热点key + 过时时间 + 复杂的构建缓存过程 => mutex key问题
    2.  构建缓存一个线程作就能够了。
    3.  四种解决方案:没有最佳只有最合适。

总结

针对业务系统,永远都是具体状况具体分析,没有最好,只有最合适。 最后,对于缓存系统常见的缓存满了和数据丢失问题,须要根据具体业务分析,一般咱们采用LRU策略处理溢出,Redis的RDB和AOF持久化策略来保证必定状况下的数据安全。