redis 缓存穿透 缓存击穿 缓存雪崩

 

 

 

Redis(Remote Dictionary Server ), 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。 它支持多种类型的数据结构,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询。 Redis 内置了 复制(replication)LUA脚本(Lua scripting), LRU驱动事件(LRU eviction)事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动 分区(Cluster)提供高可用性(high availability)。

 

redis作为缓存时redis将所有数据放在内存中,因为多线程会有CPU上下文的切换(导致耗时),对于内存系统来说,如果没有上下文切换那么效率就是最高的。多次读写都是在同一个CPU上的,在内存情况下效果最佳。而且redis在处理命令时是单线程。

 

 

 

redis使用缓存目的:

1、提高性能

2、保护存储层

 

一、缓存穿透

缓存穿透是指查询一个根本不存在的数据,缓存层和持久层都不会命中。在日常工作中出于容错的考虑,如果从持久层查不到数据则不写入缓存层,缓存穿透将导致不存在的数据每次请求都要到持久层去查询,失去了缓存保护后端持久的意义。

 
解决方案:
(1)缓存空对象

在存储层没有命中的情况下,将(key,null)加入缓存层。

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

<1> value为null 同样会占用内存空间,空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间,比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

<2> 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致。

 

(2)布隆过滤器

在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截,当收到一个对key请求时先用布隆过滤器验证是key否存在,如果存在在进入缓存层、存储层。

 

算法描述:

  • 初始状态时,BloomFilter是一个长度为m的位数组,每一位都置为0。
  • 添加元素x时,x使用k个hash函数得到k个hash值,对m取余,对应的bit位设置为1。
  • 判断y是否属于这个集合,对y使用k个哈希函数得到k个哈希值,对m取余,所有对应的位置都是1,则认为y属于该集合(哈希冲突,可能存在误判),否则就认为y不属于该集合。可以通过增加哈希函数和增加二进制位数组的长度来降低错报率。

 

构建bitmap:

 查询y1:

 查询x:

 查询z:

 

错报原因:

一个key映射数组上多位,一位会被多个key使用,也就是多对多的关系。如果一个key映射的所有位值为1,就判断为存在。但是可能会出现key1 和 key2 同时映射到下标为100的位,key1不存在,key2存在,这种情况下会发生错误率。

 

使用bitmap做布隆过滤器。这种方法适用于数据命中不高、数据相对固定、实时性低的应用场景,代码维护较为复杂,但是缓存空间占用少。

 

public class RedisBloomFilter {
    private static final Logger LOGGER = Logger.getLogger(RedisBloomFilter.class);
    private static final String BF_KEY_PREFIX = "bf:";


    private int numApproxElements;
    private double fpp;
    private int numHashFunctions;
    private int bitmapLength;


    private JedisResourcePool jedisResourcePool;


    /**
     * 构造布隆过滤器。注意:在同一业务场景下,三个参数务必相同
     *
     * @param numApproxElements 预估元素数量
     * @param fpp               可接受的最大误差(假阳性率)
     * @param jedisResourcePool Codis专用的Jedis连接池
     */
    public RedisBloomFilter(int numApproxElements, double fpp, JedisResourcePool jedisResourcePool) {
        this.numApproxElements = numApproxElements;
        this.fpp = fpp;
        this.jedisResourcePool = jedisResourcePool;


        bitmapLength = (int) (-numApproxElements * Math.log(fpp) / (Math.log(2) * Math.log(2)));
        numHashFunctions = Math.max(1, (int) Math.round((double) bitmapLength / numApproxElements * Math.log(2)));
    }


    /**
     * 取得自动计算的最优哈希函数个数
     */
    public int getNumHashFunctions() {
        return numHashFunctions;
    }


    /**
     * 取得自动计算的最优Bitmap长度
     */
    public int getBitmapLength() {
        return bitmapLength;
    }
}

 /**
     * 计算一个元素值哈希后映射到Bitmap的哪些bit上
     *
     * @param element 元素值
     * @return bit下标的数组
     */
    private long[] getBitIndices(String element) {
        long[] indices = new long[numHashFunctions];


        byte[] bytes = Hashing.murmur3_128()
            .hashObject(element, Funnels.stringFunnel(Charset.forName("UTF-8")))
            .asBytes();


        long hash1 = Longs.fromBytes(
            bytes[7], bytes[6], bytes[5], bytes[4], bytes[3], bytes[2], bytes[1], bytes[0]
        );
        long hash2 = Longs.fromBytes(
            bytes[15], bytes[14], bytes[13], bytes[12], bytes[11], bytes[10], bytes[9], bytes[8]
        );


        long combinedHash = hash1;
        for (int i = 0; i < numHashFunctions; i++) {
            indices[i] = (combinedHash & Long.MAX_VALUE) % bitmapLength;
            combinedHash += hash2;
        }


        return indices;
 }

/**
     * 插入元素
     *
     * @param key       原始Redis键,会自动加上'bf:'前缀
     * @param element   元素值,字符串类型
     * @param expireSec 过期时间(秒)
     */
    public void insert(String key, String element, int expireSec) {
        if (key == null || element == null) {
            throw new RuntimeException("键值均不能为空");
        }
        String actualKey = BF_KEY_PREFIX.concat(key);


        try (Jedis jedis = jedisResourcePool.getResource()) {
            try (Pipeline pipeline = jedis.pipelined()) {
                for (long index : getBitIndices(element)) {
                    pipeline.setbit(actualKey, index, true);
                }
                pipeline.syncAndReturnAll();
            } catch (IOException ex) {
                LOGGER.error("pipeline.close()发生IOException", ex);
            }
            jedis.expire(actualKey, expireSec);
        }
    }


    /**
     * 检查元素在集合中是否(可能)存在
     *
     * @param key     原始Redis键,会自动加上'bf:'前缀
     * @param element 元素值,字符串类型
     */
    public boolean mayExist(String key, String element) {
        if (key == null || element == null) {
            throw new RuntimeException("键值均不能为空");
        }
        String actualKey = BF_KEY_PREFIX.concat(key);
        boolean result = false;


        try (Jedis jedis = jedisResourcePool.getResource()) {
            try (Pipeline pipeline = jedis.pipelined()) {
                for (long index : getBitIndices(element)) {
                    pipeline.getbit(actualKey, index);
                }
                result = !pipeline.syncAndReturnAll().contains(false);
            } catch (IOException ex) {
                LOGGER.error("pipeline.close()发生IOException", ex);
            }
        }


        return result;
  }

public class RedisBloomFilterTest {
    private static final int NUM_APPROX_ELEMENTS = 3000;
    private static final double FPP = 0.03;
    private static final int DAY_SEC = 60 * 60 * 24;
    private static JedisResourcePool jedisResourcePool;
    private static RedisBloomFilter redisBloomFilter;


    @BeforeClass
    public static void beforeClass() throws Exception {
        jedisResourcePool = RoundRobinJedisPool.create()
            .curatorClient("10.10.99.130:2181,10.10.99.132:2181,10.10.99.133:2181,10.10.99.124:2181,10.10.99.125:2181,", 10000)
            .zkProxyDir("/jodis/bd-redis")
            .build();
        redisBloomFilter = new RedisBloomFilter(NUM_APPROX_ELEMENTS, FPP, jedisResourcePool);
        System.out.println("numHashFunctions: " + redisBloomFilter.getNumHashFunctions());
        System.out.println("bitmapLength: " + redisBloomFilter.getBitmapLength());
    }


    @AfterClass
    public static void afterClass() throws Exception {
        jedisResourcePool.close();
    }


    @Test
    public void testInsert() throws Exception {
        redisBloomFilter.insert("topic_read:8839540:20190609", "76930242", DAY_SEC);
        redisBloomFilter.insert("topic_read:8839540:20190609", "76930243", DAY_SEC);
        redisBloomFilter.insert("topic_read:8839540:20190609", "76930244", DAY_SEC);
        redisBloomFilter.insert("topic_read:8839540:20190609", "76930245", DAY_SEC);
        redisBloomFilter.insert("topic_read:8839540:20190609", "76930246", DAY_SEC);
    }


    @Test
    public void testMayExist() throws Exception {
        System.out.println(redisBloomFilter.mayExist("topic_read:8839540:20190609", "76930242"));
        System.out.println(redisBloomFilter.mayExist("topic_read:8839540:20190609", "76930244"));
        System.out.println(redisBloomFilter.mayExist("topic_read:8839540:20190609", "76930246"));
        System.out.println(redisBloomFilter.mayExist("topic_read:8839540:20190609", "76930248"));
   }
}

 

优点:效果好

缺点:难维护、删除数据难、添加数据时需要添加到布隆过滤器

 

二、缓存击穿

当前key是一个热点key(例如一个秒杀活动),并发量非常大。

重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等。

在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。

 

原因:缓存中没有,数据库中有,是一个并发问题

 

解决方案:
(1)永不过期
  • 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期
  • 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去更新缓存

 

(2)分布式锁

只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。set(key,value,timeout)

 

127.0.0.1:6379> setnx lock value1 #在键lock不存在的情况下,将键key的值设置为value1 
(integer) 1 
127.0.0.1:6379> setnx lock value2 #试图覆盖lock的值,返回0表示失败 
(integer) 0 
127.0.0.1:6379> get lock #获取lock的值,验证没有被覆盖
"value1" 
127.0.0.1:6379> del lock #删除lock的值,删除成功 
(integer) 1
127.0.0.1:6379> setnx lock value2 #再使用setnx命令设置,返回0表示成功 
(integer) 1 
127.0.0.1:6379> get lock #获取lock的值,验证设置成功 
"value2"

两种方案对比:

  • 分布式互斥锁:这种方案思路比较简单,但是存在一定的隐患,如果在查询数据库 + 和 重建缓存(key失效后进行了大量的计算)时间过长,也可能会存在死锁和线程池阻塞的风险,高并发情景下吞吐量会大大降低。
  • “永远不过期”:就是构建缓存其余线程(非构建缓存的线程)可能访问的是老数据

 

三、缓存雪崩

由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不可用(宕机)或者大量缓存由于超时时间相同在同一时间段失效(大批key失效/热点数据失效),大量请求直接到达存储层,存储层压力过大导致系统雪崩。


 

解决方案:

(1)高可用集群 redis cluster或者sentinel

 

(2)采用多级缓存,本地进程作为一级缓存,redis作为二级缓存,不同级别的缓存设置的超时时间不同,即使某级缓存过期了,也有其他级别缓存兜底

(3)缓存的过期时间用随机值,尽量让不同的key的过期时间不同

 

 

https://www.zhihu.com/question/300767410/answer/1749442787

https://www.cnblogs.com/williamjie/p/11132211.html

https://www.jianshu.com/p/47fd7f86c848

https://www.jianshu.com/p/c2defe549b40

https://blog.csdn.net/womenyiqilalala/article/details/105205532

 

posted @ 2021-12-16 00:10  小海哥哥de  阅读(97)  评论(0编辑  收藏  举报