Redis缓存三大问题 - 缓存穿透篇

586 阅读4分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。


Redis是我们日常在工作中使用非常多的缓存解决手段,使用缓存,能够提升我们应用程序的性能,同时极大程度的降低数据库的压力。但如果使用不当,同样会造成许多问题,其中三大经典问题就包括了缓存穿透、缓存击穿和缓存雪崩。是不是听上去一脸懵逼?没关系,看完这篇就明白了。

缓存穿透

缓存穿透是指用户在查找一个数据时查找了一个根本不存在的数据。按照缓存设计流程,首先查询redis缓存,发现并没有这条数据,于是直接查询数据库,发现也没有,于是本次查询结果以失败告终。

当存在大量的这种请求或恶意使用不存在的数据进行访问攻击时,大量的请求将直接访问数据库,造成数据库压力甚至可能直接瘫痪。以电商商城为例,以商品id进行商品查询,这时如果使用一个不存在的id进行攻击,每次的攻击都将访问在数据库上。

来看一下应对方案:

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操作可以实现自动数组扩容,所以不用担心在使用过程中数组位数不够的情况。

//源码参考https://www.cnblogs.com/CodeBear/p/10911177.html
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的两倍。

好了,这篇文章我们先介绍到这,下一篇再来说说剩下的两个问题~

最后

如果觉得对您有所帮助,小伙伴们可以点赞、转发一下,非常感谢

公众号码农参上,加个好友,做个点赞之交啊