redis缓存击穿、缓存穿透、缓存雪崩解决方案

1、概述

Redis缓存的使用,极大的提升了应用程序的性能和效率,特别是数据查询方面。但同时,它也带来了一
些问题。其中,最要害的问题,就是数据的一致性问题,从严格意义上讲,这个问题无解。如果对数据
的一致性要求很高,那么就不能使用缓存。
另外的一些典型问题就是,缓存穿透、缓存雪崩和缓存击穿。目前,业界也都有比较流行的解决方案。

2、缓存穿透(查不到)

1)概念

缓存穿透的概念很简单,用户想要查询一个数据,发现redis内存数据库没有,也就是缓存没有命中,于是向持久层数据库查询。发现也没有,于是本次查询失败。当用户很多的时候,缓存都没有命中(秒杀!),于是都去请求了持久层数据库。这会给持久层数据库造成很大的压力,这时候就相当于出现了缓存穿透。

2)解决方案

a)布隆过滤器

1、简介

布隆过滤器是一种数据结构,对所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃,从而避免了对底层存储系统的查询压力;
在这里插入图片描述



Bloom Filter是一种空间效率很高的概率型数据结构,它利用位数组很简洁地表示一个集合,并能判断一个元素是否属于这个集合。Bloom Filter的这种高效是有一定代价的:在判断一个元素是否属于某个集合时,有可能会把不属于这个集合的元素误认为属于这个集合(false positive)。因此,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter通过极少的错误换取了存储空间的极大节省。
那么它的诞生契机是什么呢?我们平常在检测集合中是否存在某元素时,都会采用比较的方法。
考虑以下情况:
如果集合用线性表存储,查找的时间复杂度为O(n)。
如果用平衡BST(如AVL树、红黑树)存储,时间复杂度为O(logn)。
如果用哈希表存储,并用链地址法与平衡BST解决哈希冲突(参考JDK8的HashMap实现方法),时间复杂度也要有O[log(n/m)],m为哈希分桶数。
总而言之,当集合中元素的数量极多(百/千万级甚至更多)时,不仅查找会变得很慢,而且占用的空间也会大到无法想象。而布隆(BF)过滤器就是解决这个矛盾的利器。

2、布隆过滤器原理

BF是由一个长度为m比特的位数组(bit array)与k个哈希函数(hash function)组成的数据结构。位数组均初始化为0,所有哈希函数都可以分别把输入数据尽量均匀地散列。
当要插入一个元素时,将其数据分别输入k个哈希函数,产生k个哈希值。以哈希值作为位数组中的下标,将所有k个对应的比特置为1。
当要查询(即判断是否存在)一个元素时,同样将其数据输入哈希函数,然后检查对应的k个比特。如果有任意一个比特为0,表明该元素一定不在集合中。如果所有比特均为1,表明该集合有(较大的)可能性在集合中。为什么不是一定在集合中呢?因为一个比特被置为1有可能会受到其他元素的影响,这就是所谓“假阳性”(false positive)。相对地,“假阴性”(false negative)在BF中是绝不会出现的。
下图示出一个m=18, k=3的BF示例。集合中的x、y、z三个元素通过3个不同的哈希函数散列到位数组中。当查询元素w时,因为有一个比特为0,因此w不在该集合中

在这里插入图片描述


BF的优点是显而易见的:
不需要存储数据本身,只用比特表示,因此空间占用相对于传统方式有巨大的优势,并且能够保密数据;
时间效率也较高,插入和查询的时间复杂度均为O(k);
哈希函数之间相互独立,可以在硬件指令层面并行计算。
但是,它的缺点也同样明显:
存在假阳性的概率,不适用于任何要求100%准确率的情境;
只能插入和查询元素,不能删除元素,这与产生假阳性的原因是相同的。我们可以简单地想到通过计数(即将一个比特扩展为计数值)来记录元素数,但仍然无法保证删除的元素一定在集合中。
布隆过滤器有这么些特点:
哈希函数个数k越多,假阳性概率越低;
位数组长度m越大,假阳性概率越低;
已插入元素的个数n越大,假阳性概率越高。

3、实现

布隆过滤器(guava):
导入依赖:

<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava</artifactId>
  <version>22.0</version>
</dependency>

解决方式:

    @Resource//不能使用@Autowired,如果非要使用,不注明泛型类型即可
    private RedisTemplate<String, Object> redisTemplate;

    public static final String REDIS_USER_KEY = "user:id:"; 	

	private static final double fpp = 0.03;
    private static BloomFilter<Integer> bf;

    @PostConstruct
    public void init1() {
        List<User> list = list();
        if (list.size() < 1) {
            return;
        }
        bf = BloomFilter.create(Funnels.integerFunnel(), list.size(), fpp);
        // 初始化所有的用户id到过滤器中
        for (int i = 0; i < list.size(); i++) {
            bf.put(Integer.valueOf(list.get(i).getId()));
        }
    }

    /**缓存穿透
     * 解决方式:布隆过滤器(guava)
     * @param id
     * @return
     */
    @GetMapping("/getById-2")
    public User getById_2(@RequestParam("id") Integer id) {
        //判断布隆过滤器是否存在
        if (!bf.mightContain(id)) {
            return null;
        }
        return getUser(REDIS_USER_KEY + id, String.valueOf(id));
    }

	/**
     * 获取用户
     * @param s
     * @param value
     * @return
     */
    private User getUser(String s, String value) {
        Object object = redisTemplate.opsForValue().get(REDIS_USER_KEY + s);
        if (object != null) {
            return (User) object;
        } else {
            log.info("查询数据库");
            User user = userService.getById(value);
            if (Objects.nonNull(user)) {
                redisTemplate.opsForValue().set(REDIS_USER_KEY + s, user);
            }
            return user;
        }
    }

布隆过滤器(guava+redis):
com.zengqingfa.springboot.mybatis.demo.core.RedisBloomFilter

package com.zengqingfa.springboot.mybatis.demo.core;

import com.google.common.base.Preconditions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

/**
 *
 * @fileName: RedisBloomFilter
 * @author: zengqf3
 * @date: 2021-3-26 15:07
 * @description:
 */
@Component
public class RedisBloomFilter {

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 根据给定的布隆过滤器添加值
     * @param bloomFilterHelper
     * @param key
     * @param value
     * @param <T>
     */
    public <T> void put(BloomFilterHelper<T> bloomFilterHelper, String key, T value) {
        Preconditions.checkArgument(bloomFilterHelper != null, "bloomFilterHelper不能为空");
        int[] offset = bloomFilterHelper.murmurHashOffset(value);
        for (int i : offset) {
            redisTemplate.opsForValue().setBit(key, i, true);
        }
    }


    /**
     * 根据给定的布隆过滤器判断值是否存在
     * @param bloomFilterHelper
     * @param key
     * @param value
     * @param <T>
     * @return
     */
    public <T> boolean contains(BloomFilterHelper<T> bloomFilterHelper, String key, T value) {
        Preconditions.checkArgument(bloomFilterHelper != null, "bloomFilterHelper不能为空");
        int[] offset = bloomFilterHelper.murmurHashOffset(value);
        for (int i : offset) {
            if (!redisTemplate.opsForValue().getBit(key, i)) {
                return false;
            }
        }
        return true;
    }
}

com.zengqingfa.springboot.mybatis.demo.core.BloomFilterHelper

package com.zengqingfa.springboot.mybatis.demo.core;

import com.google.common.base.Preconditions;
import com.google.common.hash.Funnel;
import com.google.common.hash.Hashing;

/**
 *
 * @fileName: BloomFilterHelper
 * @author: zengqf3
 * @date: 2021-3-26 15:27
 * @description:
 */
public class BloomFilterHelper<T> {
    /**
     * hash循环次数
     */
    private int numHashFunctions;

    /**
     * bitsize长度
     */
    private int bitSize;

    private Funnel<T> funnel;

    /**
     * @param funnel
     * @param expectedInsertions 期望插入长度
     * @param fpp 误差率
     */
    public BloomFilterHelper(Funnel<T> funnel, int expectedInsertions, double fpp) {
        Preconditions.checkArgument(funnel != null, "funnel不能为空");
        this.funnel = funnel;
        bitSize = optimalNumOfBits(expectedInsertions, fpp);
        numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, bitSize);
    }

    public int[] murmurHashOffset(T value) {
        int[] offset = new int[numHashFunctions];

        long hash64 = Hashing.murmur3_128().hashObject(value, funnel).asLong();
        int hash1 = (int) hash64;
        int hash2 = (int) (hash64 >>> 32);
        for (int i = 1; i <= numHashFunctions; i++) {
            int nextHash = hash1 + i * hash2;
            if (nextHash < 0) {
                nextHash = ~nextHash;
            }
            offset[i - 1] = nextHash % bitSize;
        }

        return offset;
    }

    /**
     * 计算bit数组长度
     */
    private int optimalNumOfBits(long n, double p) {
        if (p == 0) {
            p = Double.MIN_VALUE;
        }
        return (int) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
    }

    /**
     * 计算hash方法执行次数
     */
    private int optimalNumOfHashFunctions(long n, long m) {
        return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
    }
}

实现:

 	@Autowired
    private RedisBloomFilter redisBloomFilter;

    private static BloomFilterHelper<Integer> bfRedis;

    @PostConstruct
    public void init2() {
        List<User> list = list();
        if (list.size() < 1) {
            return;
        }
        bfRedis = new BloomFilterHelper<>(Funnels.integerFunnel(), list.size(), fpp);
        // 初始化所有的用户id到过滤器中
        for (int i = 0; i < list.size(); i++) {
            redisBloomFilter.put(bfRedis, list.get(i).getId(), Integer.valueOf(list.get(i).getId()));
        }
    }

    /**缓存穿透
     * 解决方式三:布隆过滤器(guava+redis)
     * @param id
     * @return
     */
    @GetMapping("/getById-3")
    public User getById_3(@RequestParam("id") String id) {
        //判断布隆过滤器是否存在
        if (!redisBloomFilter.contains(bfRedis, id, Integer.valueOf(id))) {
            return null;
        }
        return getUser(REDIS_USER_KEY + id, id);
    }

b)缓存空对象

1、简介

在这里插入图片描述

当存储层不命中后,即使返回的空对象也将其缓存起来,同时会设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源;
但是这种方法会存在两个问题:
1、如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键;
2、即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响。

2、实战

空对象:

@Data
public class NullEntity implements Serializable {


}

解决:

    @Resource
    private UserService userService;

    @Resource//不能使用@Autowired,如果非要使用,不注明泛型类型即可
    private RedisTemplate<String, Object> redisTemplate;

    public static final String REDIS_USER_KEY = "user:id:";


	/**缓存穿透
     * 解决方式:缓存空对象
     * 缓存空对象会带来比较大的问题,就是缓存中会存在很多空对象,
     * 占用内存的空间,浪费资源,一个解决的办法就是设置空对象的较短的过期时间
     * @param id
     * @return
     */
    @GetMapping("/getById-1")
    public User getById_1(@RequestParam("id") String id) {
        Object object = redisTemplate.opsForValue().get(REDIS_USER_KEY + id);
        if (object != null) {
            // 检验该对象是否为缓存空对象,是则直接返回null
            if (object instanceof NullEntity) {
                return null;
            }
            return (User) object;
        } else {
            log.info("查询数据库");
            User user = userService.getById(id);
            if (Objects.nonNull(user)) {
                redisTemplate.opsForValue().set(REDIS_USER_KEY + id, user);
            } else {
                redisTemplate.opsForValue().set(REDIS_USER_KEY + id, new NullEntity(), 60, TimeUnit.SECONDS);
            }
            return user;
        }
    }

3、缓存击穿(量太大,缓存过期!)

1)概述

这里需要注意和缓存击穿的区别,缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
当某个key在过期的瞬间,有大量的请求并发访问,这类数据一般是热点数据,由于缓存过期,会同时访问数据库来查询最新数据,并且回写缓存,会导使数据库瞬间压力过大。

2)解决方案

a)设置热点数据永不过期

从缓存层面来看,没有设置过期时间,所以不会出现热点 key 过期后产生的问题。

b)加互斥锁

分布式锁:使用分布式锁,保证对于每个key同时只有一个线程去查询后端服务,其他线程没有获得分布式锁的权限,因此只需要等待即可。这种方式将高并发的压力转移到了分布式锁,因此对分布式锁的考验很大。

在这里插入图片描述

实战:使用redission加锁实现

依赖:

<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson</artifactId>
  <version>3.8.2</version>
</dependency>

配置:

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient getClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        RedissonClient redisson = Redisson.create(config);
        return redisson;
    }
}

实现:

   @Autowired
    private RedissonClient redissonClient;

    /**
     * 缓存击穿方案:加锁
     * @param id
     * @return
     */
    @GetMapping("/getById-4")
    public User getById_4(@RequestParam("id") String id) {
        Object object = redisTemplate.opsForValue().get(REDIS_USER_KEY + id);
        if (object == null) {
            User user = null;
            RLock lock = redissonClient.getLock(REDIS_USER_LOCK_KEY + id);
            try {
                // 1. 最常见的使用方法
                //lock.lock();

                // 2. 支持过期解锁功能,10秒钟以后自动解锁, 无需调用unlock方法手动解锁
                //lock.lock(10, TimeUnit.SECONDS);

                // 3. 尝试加锁,最多等待3秒,上锁以后10秒自动解锁
                boolean flag = lock.tryLock(3, 10, TimeUnit.SECONDS);
                if (flag) {
                    log.info("查询数据库");
                    user = userService.getById(id);
                    if (Objects.nonNull(user)) {
                        redisTemplate.opsForValue().set(REDIS_USER_KEY + id, user);
                    }
                    return user;
                }
            } catch (Exception e) {
                log.error("e={}", e.getMessage());
            } finally {
                lock.unlock();
            }
            return user;
        }
        return (User) object;
    }

4、缓存雪崩

1)概念

缓存雪崩,是指在某一个时间段,缓存集中过期失效。Redis 宕机!
产生雪崩的原因之一,比如在写本文的时候,马上就要到双十二零点,很快就会迎来一波抢购,这波商品时间比较集中的放入了缓存,假设缓存一个小时。那么到了凌晨一点钟的时候,这批商品的缓存就都过期了。而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。

在这里插入图片描述


其实集中过期,倒不是非常致命,比较致命的缓存雪崩,是缓存服务器某个节点宕机或断网。因为自然形成的缓存雪崩,一定是在某个时间段集中创建缓存,这个时候,数据库也是可以顶住压力的。无非就是对数据库产生周期性的压力而已。而缓存服务节点的宕机,对数据库服务器造成的压力是不可预知的,很有可能瞬间就把数据库压垮。

2)解决方案

a)redis高可用

这个思想的含义是,既然redis有可能挂掉,那我多增设几台redis,这样一台挂掉之后其他的还可以继续工作,其实就是搭建的集群。(异地多活!)

b)限流降级

这个解决方案的思想是,在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。

c)数据预热

数据加热的含义就是在正式部署之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。

实战:设置随机的过期时间
 	private static final Long EXPIRE_TIME = 24 * 60 * 60L;

    /**
     * 缓存雪崩方案:设置随机的过期时间
     * @param id
     * @return
     */
    @GetMapping("/getById-5")
    public User getById_5(@RequestParam("id") String id) {
        Object object = redisTemplate.opsForValue().get(REDIS_USER_KEY + id);
        if (object == null) {
            log.info("查询数据库");
            User user = userService.getById(id);
            if (Objects.nonNull(user)) {
                redisTemplate.opsForValue()
                        .set(REDIS_USER_KEY + id, 
                             user, 
                             EXPIRE_TIME + RandomUtil.randomInt(60 * 5, 2 * 60 * 60),
                             TimeUnit.SECONDS);
            }
            return user;
        }
        return (User) object;
    }
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值