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

缓存的收益和成本

1、缓存带来的回报
  1. 高速读写
  2. 降低后端负载(业务端使用redis降低后端mysql负载)
2、缓存带来的代价
  1. 数据的不一致性
    缓存层和数据层有时间窗口不一致,和更新策略有关
  2. 代码维护成本,增加代码的复杂度
  3. 堆内缓存(缓存占用的内存是从jvn里面分配的)可能带来内存溢出的风险影响用户进程

堆内缓存和远程服务器缓存redis的选择

  • 堆内缓存一般性能更好,远程缓存需要套接字传输
  • 用户级别缓存尽量采用远程缓存
  • 大数据量尽量采用远程缓存,服务节点化原则

缓存处理流程

使用缓存通常的操作是,请求先访问缓存数据,如果缓存中不存在的话,就会回源到数据库中然后将数据写入到缓存中;如果存在的话就直接返回数据。

在这里插入图片描述

缓存穿透

现象: 每次请求直接穿透缓存层,直接回源到数据库中,给数据库带来了巨大访问压力,甚至宕机。(宕机:操作系统无法从一个严重系统错误中恢复过来,或系统硬件层面出问题,以致系统长时间无响应,而不得不重新启动计算机的现象)

原因: 访问数据会先访问缓存,如果数据不存在缓存中才会查询数据库,但是如果查询数据库也查询不出来数据,也是说当前访问数据永远不会写入缓存中。这样就导致了,访问一定不存在的数据,就相当于缓存层形同虚设,每次请求都会到db层,造成数据库负担过大。

产生原因: 自身业务代码或者数据出现问题;一些恶意攻击、爬虫等造成大量空命中

解决方案核心 ==》缓存穿透强调是获取本不存在的缓存数据,请求必然会越过缓存层直接到达到存储层,很明显这是利用业务规则的漏洞对系统发起攻击,解决方案的核心原则是 过滤这些非法业务请求 ,与是否是热点数据、缓存失效时间等因素没有关系。

解决方案:

  • 1、 采用 bloom filter 保存缓存过的key,在访问请求到来时可以过滤掉不存在的key,防止这些请求到db层;

布隆过滤器(Bloom Filter) 是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是 空间效率和查询时间都远远超过一般的算法 ,缺点是 有一定的误识别率和删除困难

布隆过滤器非常高效同时占空间非常少, 它判断一个元素不存在那肯定就是不存在,它判断存在的时候有一定误差,是有可能不存在的

布隆过滤器的原理:
当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。

简单的说就是:通过将一个key的hash值分布到一个大的bit数组上面,判断一个key是否存在时只需判断该的hash对应的bit位 是否都是1 ,如果 全是1则表示存在,否则不存在
优点:性能很高主要在hash算法上面,空间占用小,能够极大的缩小存储空间。
缺点:存在误判。既对应的bit位刚好被其他的key置为1了。

在这里插入图片描述

具体实现: ===》使用Guava提供的相关类库

<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>28.1-jre</version>
</dependency>

//判断一个元素是否在集合中


import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

/**
 * @Description: 判断一个元素是否在集合中
 */
public class BloomFilterTest {

        private static int size = 1000000;

        private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size);

        public static void main(String[] args) {
            for (int i = 0; i < size; i++) {
                bloomFilter.put(i);
            }
            // 获取开始时间
            long startTime = System.nanoTime();
            //判断这一百万个数中是否包含29999这个数
            if (bloomFilter.mightContain(29999)) {
                System.out.println("命中了");
            }
            // 获取结束时间
            long endTime = System.nanoTime();
            System.out.println("程序运行时间: " + (endTime - startTime) + "纳秒");
        }


}

==》 命中了 程序运行时间: 441616纳秒

//自定义错误率

public class Test2 {

    private static int size = 1000000;

    private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, 0.01);

    public static void main(String[] args) {
        for (int i = 0; i < size; i++) {
            bloomFilter.put(i);
        }
        List<Integer> list = new ArrayList<Integer>(1000);
        // 故意取10000个不在过滤器里的值,看看有多少个会被认为在过滤器里
        for (int i = size + 10000; i < size + 20000; i++) {
            if (bloomFilter.mightContain(i)) {
                list.add(i);
            }
        }
        System.out.println("误判的数量:" + list.size());
    }

}

===》误判的数量:94

  • 2、如果db查询不到数据,保存空对象到缓存层,设置较短的失效时间

缓存空对象会有两个问题:

空值做了缓存,意味着缓存层中存了更多的键, 需要更多的内存空间 (如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

缓存层和存储层的数据会有一段时间窗口的不一致, 可能会对业务有一定影响。例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

 String get(String key){
        // 从缓存中获取数据
        String cacheValue = cache.get(key);
        // 缓存为空
        if(StringUtils.isBlank(cacheValue)){
            // 从存储中获取
            String storageValue = storage.get(key);
            // 如果存储数据为空,需要设置一个过期时间
            if(storageValue == null){
                cache.expire(key,60);
            }

            return storageValue;
        }else{
            // 直接返回缓存中的值
            return cacheValue;
        }
    }

  • 3、针对业务场景对请求的参数进行有效性校验,防止非法请求击垮db

比如我们查询商品信息,我们把商品信息存储在 Mongodb 中,Mongodb 有一个 _id 是自动生成的,它有一定的生成规则,如果是直接根据 id 查询商品,在查询之前我们可以对这个 id 做认证,看是不是符合规范,当不符合的时候就直接返回默认的值, 既不用去缓存中查询,也不用操作数据库了 。这种方案可以解决一部分问题,使用场景比较少。

采用布隆过滤器BloomFilter

将所有可能存在的数据哈 希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力

缓存空值

如果一个查询返回的数据为空(不管是数据不 存在,还是系统故障)我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。 通过这个直接设置的默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库

缓存击穿

现象: 当某一key失效时,造成大量请求到db层,击垮存储层。

原因: 为了保证缓存数据的时效性,通常会设置一个失效时间,如果是热点key,高并发时会有海量请求直接越过缓存层到数据库,这样就会给数据库造成的负担增大,甚至宕机。

解决方案的核心原则 ===》规避数据库的并发操作。

解决方案:

  • 1、使用互斥锁,当缓存数据失效时,保证一个请求能够访问到数据库,并更新缓存,其他线程等待并重试;

static Lock reenLock = new ReentrantLock();
 
    public List<String> getData(String key) throws InterruptedException {
        List<String> result = new ArrayList<String>();
        // 从缓存读取数据
        result = getDataFromCache(key);
        // 缓存中数据不存在
        if (result.isEmpty(key)) {
                //获取锁获取成功,去数据库取数据
            if (reenLock.tryLock()) {
                try {
                    System.out.println("我拿到锁了,从DB获取数据库后写入缓存");
                    // 从数据库查询数据
                    result = getDataFromDB(key);
                    // 将查询到的数据写入缓存
                    setDataToCache(key,result);
                } finally {
                    // 释放锁
                    reenLock.unlock();
                }
 
            } else {//获取锁失败
                result = getDataFromCache();// 先查一下缓存
                if (result.isEmpty()) {
                    System.out.println("我没拿到锁,缓存也没数据,先小憩一下");
                    Thread.sleep(100);// 小憩一会儿
                    return getData(key);// 重试
                }
            }
        }
        return result;
    }


  • 2、缓存数据“永远不过期”,如果缓存数据不设置失效时间的话,就不会存在热点key过期造成了大量请求到数据库。 但是,缓存数据就变成“静态数据”,因此当缓存数据快要过期时, 采用异步线程的方式提前进行更新缓存数据
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分钟超时,以避免互斥锁持有者崩溃
                    redis.expire(keyMutex, 3 * 60);
                    String dbValue = db.get(key);
                    redis.set(key, dbValue);
                    redis.delete(keyMutex);
                }
            }
        });
    }
    return value;
}

缓存雪崩

现象: 多个key失效,造成大量请求到db层,导致db层负担过重甚至宕机。

如果缓存集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,造成了缓存雪崩。 由于原有缓存失效,新缓存未到期间所有原本应该访问缓存的请求都去查询数据库了,而对数据库CPU 和内存造成巨大压力,严重的会造成数据库宕机

原因: 缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到数据库,最终导致数据库瞬时压力过大而崩溃。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

解决方案核心 ===》缓存雪崩强调的多个key的集体失效,与key是否是热点数据并不是必然的因素,解决方案的核心原则则 让key之间的失效时间分布更加均匀,避免集体失效的情况

解决方案:

  • 1、使用互斥锁的方式,保证只有单个线程进行请求能够达到db;

  • 2、给每个key的失效时间在基础时间上再加上一个1~5分钟的随机值, 这样就能保证大规模key集体失效的概率,并且需要尽量让多个key的失效时间能够均匀分布;

解决方案二:

加锁排队

key: whiltList value:1000w个uid 指定setNx whiltList value nullValue mutex互斥锁解决,Redis的SETNX去set一个mutex key, 当操作返回成功时,再进行load db的操作并回设缓存; 否则,就重试整个get缓存的方法

数据预热

缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!可以通过缓存reload机制,预先去更新缓存,再即将发生大并发访问前手动触发加载缓存不同的key

双层缓存策略

C1为原始缓存,C2为拷贝缓存,C1失效时,可以访问C2,C1缓存失效时间设置为短期,C2设置为长期。

定时更新缓存策略

失效性要求不高的缓存,容器启动初始化加载,采用定时任务更新或移除缓存
设置不同的过期时间,让缓存失效的时间点尽量均匀

响应速度不给力?解锁正确缓存姿势 https://mp.weixin.qq.com/s/QidAD9OuVdEXFqxRMPx5lQ

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值