004-Redis-缓存雪崩、缓存穿透、缓存预热、缓存更新、缓存降级

一、缓存雪崩

我们可以简单的理解为:由于原有缓存失效,新缓存未到期间 (例如:我们设置缓存时采用了相同的过期时间,在同一时刻出现大面积的缓存过期),所有原本应该访问缓存的请求都去查询数据库了,而对数据库CPU和内存造成巨大压力,严重的会造成数据库宕机。从而形成一系列连锁反应,造成整个系统崩溃。

解决办法:

  • 大多数系统设计者考虑用加锁( 最多的解决方案)或者队列的方式保证来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。还有一个简单方案就时讲缓存失效时间分散开。

解决方案:

  • 错开缓存的过期时间,可通过设置缓存数据的过期时间为默认值基础上加上一个随机值,防止同一时间大量数据过期现象发生。
  • 搭建高可用的redis集群,避免出现缓存服务器宕机引起的雪崩问题。
  • 参照hystrix,进行熔断降级。
二、缓存穿透

缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找不到,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。这样请求就绕过缓存直接查数据库,这也是经常提的缓存命中率问题。

缓存雪崩是指缓存中数据大批量到过期时间,引发的大部分缓存突然同时不可用,而查询数据量巨大,引起数据库压力过大甚至宕机的情况。

需要注意缓存击穿和缓存雪崩的不同之处缓存击穿指的是大量的并发请求去查询同一条数据;而缓存雪崩是大量缓存同时过期,导致很多查询请求都查不到缓存数据从而查数据库。

解决办法:

  • 最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
  • 另外也有一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。通过这个直接设置的默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库,这种办法最简单粗暴。

方法一 布隆过滤器

5TB的硬盘上放满了数据,请写一个算法将这些数据进行排重。如果这些数据是一些32bit大小的数据该如何解决?如果是64bit的呢?

对于空间的利用到达了一种极致,那就是Bitmap和布隆过滤器(Bloom Filter)。
Bitmap: 典型的就是哈希表
缺点是,Bitmap对于每个元素只能记录1bit信息,如果还想完成额外的功能,恐怕只能靠牺牲更多的空间、时间来完成了。

布隆过滤器(推荐)

就是引入了k(k>1)个相互独立的哈希函数,保证在给定的空间、误判率下,完成元素判重的过程。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。

Bloom-Filter算法的核心思想就是利用多个不同的Hash函数来解决“冲突”。

  • Hash存在一个冲突(碰撞)的问题,用同一个Hash得到的两个URL的值有可能相同。为了减少冲突,我们可以多引入几个Hash,如果通过其中的一个Hash值我们得出某元素不在集合中,那么该元素肯定不在集合中只有在所有的Hash函数告诉我们该元素在集合中时,才能确定该元素存在于集合中。这便是Bloom-Filter的基本思想。

Bloom-Filter一般用于在大数据量的集合中判定某元素是否存在。

受提醒补充:缓存穿透与缓存击穿的区别

**缓存击穿:**是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据。
解决方案:在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key。

来看一下应对方案:

1、缓存空对象

修改数据库写回缓存逻辑,对于缓存中不存在,数据库中也不存在的数据,我们仍然将其缓存起来,并且设置一个缓存过期时间。

请添加图片描述

如上图所示,查询数据库失败时,仍以查询的key值缓存一个空对象(key,null)。但是这么做仍然存在不少问题:

a、这时在缓存中查找这个key值时,会返回一个null的空对象。需要注意的是这个空对象可能并不是客户端需要的,所以需要对结果为空进行处理后,再返回给客户端
b、占用redis中大量内存。因为空对象能够被缓存,redis会使用大量的内存来存储这些值为空的key
c、如果在写缓存后数据库中存入的这个key的数据,由于缓存没有过期,取到的仍为空值,所以可能出现短暂的数据不一致问题

2、布隆过滤器

布隆过滤器是一个二进制向量,或者说二进制的数组,或者说是位(bit)数组。

请添加图片描述

因为是二进制的向量,它的每一位只能存放0或者1。当需要向布隆过滤器中添加一个数据映射时,添加的并不是原始的数据,而是使用多个不同的哈希函数生成多个哈希值,并将每个生成哈希值指向的下标位置置为1。所以,别再说从布隆过滤器中取数据啦,我们根本就没有存原始数据。

请添加图片描述
例如"Hydra"的三个哈希函数生成的下标分别为1,3,6,那么将这三位置为1,其他数据以此类推。**那么这样的数据结构能够起到什么效果呢?**我们可以根据这个位向量,来判断数据是否存在。

具体流程:

  • a、计算数据的多个哈希值;

  • b、判断这些bit是否为1,全部为1,则数据可能存在;

  • c、若其中一个或多个bit不为1,则判断数据不存在。

需要注意,布隆过滤器是存在误判的,因为随着数据存储量的增加,被置为1的bit数量也会增加,因此,有可能在查询一个并不存在的数据时,碰巧所有bit都已经被其他数据置为了1,也就是发生了哈希碰撞。因此,布隆过滤器只能做到判断数据是否可能存在,不能做到百分百的确定。

Google的guava包为我们提供了单机版的布隆过滤器实现,来看一下具体使用

首先引入maven依赖:

复制代码

<dependency>

    <groupId>com.google.guava</groupId>
    
    <artifactId>guava</artifactId>
    
    <version>27.1-jre</version>

</dependency>

向布隆过滤器中模拟传入1000000条数据,给定误判率,再使用不存在的数据进行判断:

复制代码

public class BloomTest {
    public static void test(int dataSize,double errorRate){ 
        BloomFilter<Integer> bloomFilter=    
                BloomFilter.create(Funnels.integerFunnel(), dataSize, errorRate);
        for(int i = 0; i< dataSize; i++){
            bloomFilter.put(i);
        }
        int errorCount=0;
        for(int i = dataSize; i<2* dataSize; i++){
            if(bloomFilter.mightContain(i)){
                errorCount++;
            }
        }
        System.out.println("Total error count: "+errorCount);
    }    
    public static void main(String[] args) {    
        BloomTest.test(1000000,0.01);    
        BloomTest.test(1000000,0.001);    
    }
}

测试结果:

Total error count: 10314

Total error count: 994

可以看出,在给定误判率为0.01时误判了10314次,在误判率为0.001时误判了994次,大体符合我们的期望。

但是因为guava的布隆过滤器是运行在的jvm内存中,所以仅支持单体应用,并不支持微服务分布式。那么有没有支持分布式的布隆过滤器呢,这时Redis站了出来,自己造成的问题自己来解决!

Redis的**BitMap(位图)**支持了对位的操作,通过一个bit位来表示某个元素对应的值或者状态。

//对key所存储的字符串值,设置或清除指定偏移量上的位(bit)

setbit key offset value

//对key所存储的字符串值,获取指定偏移量上的位(bit)

getbit key offset

既然布隆过滤器是对位进行赋值,我们就可以使用BitMap提供的setbit和getbit命令非常简单的对其进行实现,并且setbit操作可以实现自动数组扩容,所以不用担心在使用过程中数组位数不够的情况。

复制代码

public class RedisBloomTest {
private static int dataSize = 1000;
private static double errorRate = 0.01;

//bit数组长度
private static long numBits;
//hash函数数量
private static int numHashFunctions;
public static void main(String[] args) {
    numBits = optimalNumOfBits(dataSize, errorRate);
    numHashFunctions = optimalNumOfHashFunctions(dataSize, numBits);
    System.out.println("Bits length: "+numBits);
    System.out.println("Hash nums: "+numHashFunctions);
    Jedis jedis = new Jedis("127.0.0.1", 6379);
    for (int i = 0; i <= 1000; i++) {
        long[] indexs = getIndexs(String.valueOf(i));
        for (long index : indexs) {
            jedis.setbit("bloom", index, true);
        }
    }
    num:
    for (int i = 1000; i < 1100; i++) {
        long[] indexs = getIndexs(String.valueOf(i));
        for (long index : indexs) {
            Boolean isContain = jedis.getbit("bloom", index);
            if (!isContain) {
                System.out.println(i + "不存在");
                continue  num;
            }
        }
        System.out.println(i + "可能存在");
    }
}
//根据key获取bitmap下标
private static long[] getIndexs(String key) {
    long hash1 = hash(key);
    long hash2 = hash1 >>> 16;
    long[] result = new long[numHashFunctions];
    for (int i = 0; i < numHashFunctions; i++) {
        long combinedHash = hash1 + i * hash2;
        if (combinedHash < 0) {
            combinedHash = ~combinedHash;
        }
        result[i] = combinedHash % numBits;
    }
    return result;
}
private static long hash(String key) {
    Charset charset = Charset.forName("UTF-8");
    return Hashing.murmur3_128().hashObject(key, Funnels.stringFunnel(charset)).asLong();
}
//计算hash函数个数
private static int optimalNumOfHashFunctions(long n, long m) {
    return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}
//计算bit数组长度
  private static long optimalNumOfBits(long n, double p) {
      if (p == 0) {
          p = Double.MIN_VALUE;
      }
      return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
  }
}

基于BitMap实现分布式布隆过滤器的过程中,哈希函数的数量以及位数组的长度都是动态计算的。可以说,给定的容错率越低,哈希函数的个数则越多,数组长度越长,使用的redis内存开销越大。

guava中布隆过滤器的数组最大长度是由int值的上限决定的,大概为21亿,而redis的位数组为512MB,也就是2^32位,所以最大长度能够达到42亿,容量为guava的两倍。

方法二 加分布式锁🔒

缓存击穿是指缓存中没有但数据库中有的数据,由于出现大量的并发请求,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。

造成这种情况大致有两种情况:

  • 第一次查询数据时,没有进行缓存预热,数据并没有加入缓存当中。缓存由于到达过期时间导致失效。

解决思路:

  • 当缓存不命中时,在查询数据库前使用redis分布式锁,使用查询的key值作为锁条件;
  • 获取锁的线程在查询数据库前,再查询一次缓存。这样做是因为高并发请求获取锁的时候造成排队,但第一次进来的线程在查询完数据库后会写入缓存,之后再获得锁的线程直接查询缓存就可以获得数据;
  • 读取完数据后释放分布式锁。
public String queryData(String key) throws Exception {
    String data;
    data = queryDataFromRedis(key);// 查询缓存数据    
    if (data == null) {    
        if(redisLock.tryLock()){//获取分布式锁    
            data = queryDataFromRedis(key); // 再次查询缓存    
            if (data == null) {    
                data = queryDataFromDB(key); // 查询数据库    
                writeDataToRedis(data); // 将查询到的数据写入缓存    
            }    
            redisLock.unlock();//释放分布式锁    
        }
    }
    return data;
}

具体分布式锁的实现可以使用redis中强大的setnx命令:
复制代码

/*
* 加锁
* key-;value-* nxxx-nx(只在key不存在时才可以set)|xx(只在key存在的时候set)

* expx--ex代表秒,px代表毫秒;time-过期时间,单位是expx所代表的单位。

* */

jedis.set(key, value, nxxx, expx, time);

//解锁

jedis.del(key);

通过在加锁的同时设置过期时间,还可以防止线程挂掉仍然占用锁的情况。

三、缓存预热
  • 缓存预热这个应该是一个比较常见的概念,相信很多小伙伴都应该可以很容易的理解,缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!

解决思路:
1、直接写个缓存刷新页面,上线时手工操作下;
2、数据量不大,可以在项目启动的时候自动进行加载;
3、定时刷新缓存;

四、缓存更新

除了缓存服务器自带的缓存失效策略之外(Redis默认的有6中策略可供选择),我们还可以根据具体的业务需求进行自定义的缓存淘汰,常见的策略有两种:
(1)定时去清理过期的缓存;
(2)当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。

两者各有优劣,第一种的缺点是维护大量缓存的key是比较麻烦的,第二种的缺点就是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂!具体用哪种方案,大家可以根据自己的应用场景来权衡。

五、缓存降级

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。

降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。
以参考日志级别设置预案:
(1)一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
(2)警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
(3)错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
(4)严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值