第7章 缓存雪崩+缓存击穿+缓存穿透

7.1 缓存雪崩

7.1.1 发生

Redis主机挂了,Redis全盘崩溃;比如缓存中有大量数据同时过期。

7.1.2 解决

Redis缓存集群实现高可用

  • 主从 + 哨兵
  • Redis Cluster

ehcache本地缓存 + Sentinel限流 & 降级

开启Redis持久化机制AOF/RDB,尽快恢复缓存集群

7.2 缓存穿透

7.2.1 是什么

请求去查询一条记录,先redis后mysql,发现都查询不到该条记录,但是请求每次都会打到数据库上面去,导致后台数据库压力暴增,这种现象我们称为缓存穿透,这个redis变成了一个摆设。。。

简单说就是:本来无一物,既不在Redis缓存中,也不在数据库中。

7.2.2 危害

第一次来查询后,一般我们有回写redis机制。第二次来查的时候,redis就有了,偶尔出现穿透现象一般情况无关紧要。

7.2.3 解决

缓存穿透恶意攻击空对象缓存、BloomFilter
1)方案1:空对象缓存或者缺省值

一般情况下:一旦发生缓存穿透,我们就可以针对查询的数据,在Redis中缓存一个控制或是和业务层协商确定的缺省值(例如,库存的缺省值可以设为0)。紧接着,应于发送的后续请求再进行查询时,就可以直接从Redis中读取控制或缺省值,返回给业务应用了,避免了把大量请求发送给数据库处理,保持了数据库的正常运行。

但是,遇到黑客或者恶意攻击?

黑客会对你的系统进行攻击,哪一个不存在的id去查询数据,会产生大量的请求到数据库取查询。
可能会导致你的数据库由于压力过大而宕机。

id相同打你系统:第一次打到mysql,空对象缓存后第二次就返回null了,避免mysql被攻击,不用再到数据库中去走一圈了。

id不相同打你系统:由于存在空对象缓存和缓存回写,redis中的无关紧要的key也会越写越多(记得设置redis过期时间)。

2)方案2:Google布隆过滤器Guava解决缓存穿透【一般用于单机版】

Guava中布隆过滤器的实现算是比较权威的,所以实际项目中我们不需要手动实现一个布隆过滤器。

Guava’s BloomFilter源码剖析

代码实现:

  • 建Module:bloomfilter-demo

  • 改pom

    <dependencies>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>30.1-jre</version>
        </dependency>
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.15.3</version>
        </dependency>
    
  • 写yaml

  • 主启动

  • 业务类

    public class GuavaBloomFilterDemo {
        public static final int _1w = 10000;
    
        //布隆过滤器里预计要插入多少数据
        public static int size = 100 * _1w;
    
        //误判率,它越小误判的个数也就越少(思考:是不是可以设置为无限小,没有误判岂不更好?)
        public static double fpp = 0.03;
    
        /**
         * hello world入门
         */
        public void bloomFilter(){
            //创建布隆过滤器对象
            BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),100);
    
            //判断指定元素是否存在
            System.out.println(bloomFilter.mightContain(1));  //false
            System.out.println(bloomFilter.mightContain(2));
    
            //将元素添加进布隆过滤器
            bloomFilter.put(1);
            bloomFilter.put(2);
    
            System.out.println(bloomFilter.mightContain(1)); //true
            System.out.println(bloomFilter.mightContain(2));
        }
    
        /**
         * 误判率演示 + 源码分析
         */
        public void bloomFilter2(){
            //创建布隆过滤器对象
            BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),size);
    
            //1.先往布隆过滤器里面插入100w样本数据
            for (int i = 0; i < size; i++) {
                bloomFilter.put(i);
            }
    
            List<Integer> listSample = new ArrayList<>(size);
    
            //2.这100w的样本数据,是否都在布隆过滤器里面存在? 是的
            for (int i = 0; i < size; i++) {
                if (bloomFilter.mightContain(i)){
                    listSample.add(i);
                }
            }
    
            System.out.println("存在的数量:"+listSample.size());
    
            //3.故意取10w个不在过滤器里面的值,看看有多少个会被认为在过滤器里,误判率演示
            List<Integer> list = new ArrayList<>(10*_1w);
    
            //100000 / 3033 = 0.03(误判率)
            for (int i = size; i < size+100000; i++) {
                if (bloomFilter.mightContain(i)) {
                    System.out.println(i+"--->被误判啦……");
                    list.add(i);
                }
            }
            System.out.println("误判的数量:"+list.size());  // 误判的数量:3033
        }
    }
    
  • 源码分析:

    //创建布隆过滤器对象
    BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),size);
    
    --->
    //expectedInsertions:
    public static <T> BloomFilter<T> create(Funnel<? super T> funnel, int expectedInsertions) {
        return create(funnel, (long) expectedInsertions);
    }
    
    --->
    public static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions) {
        return create(funnel, expectedInsertions, 0.03); // FYI, for 3%, we always get 5 hash functions
    }
    
  • 默认误判率:0.03

  • 1百万的数据底层用来7298440个bit数组存储

  • 布隆过滤器说明

    //加了一个误判率:0.01 ========> 需要的bit位(numBits):9585058,numHashFunctions:7
    BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),size,0.01); //误判的数量:947
    
    //继续改小,这样写可以,但是程序执行效率急剧下降
    BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),size,0.000000000001); //误判的数量:0
    
3)方案3:Redis布隆过滤器解决缓存穿透

Guava缺点:Guava提供的布隆过滤器的实现还是很不错的,但是它有一个重大的缺陷就是只能单机使用,而现在互联网一般都是分布式的场景。为了解决这个问题,我们就需要用到Redis中的布隆过滤器了。

白名单过滤器:

误判问题,但是概率小可以接受,不能从布隆过滤器删除。

全部合法的key都需要放入过滤器 + redis 里面,不然数据就是返回null。

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.15.3</version>
</dependency>
public class RedissonBloomFilterDemo {

    public static final int _1w = 10000;

    //布隆过滤器里预计要插入多少数据
    public static int size = 100 * _1w;

    //误判率,它越小误判的个数也就越少(思考:是不是可以设置为无限小,没有误判岂不更好?)
    public static double fpp = 0.03;

    static RedissonClient redisClient = null;  // <==>jedis
    static RBloomFilter rbloomFilter = null;  // redis内置的布隆过滤器

    @Autowired
    RedisTemplate redisTemplate;

    static {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.123.133:6379").setDatabase(0);

        //构造redisson
        redisClient = Redisson.create(config);

        //通过redisson构造BloomFilter
        rbloomFilter = redisClient.getBloomFilter("phoneListBloomFilter", new StringCodec());

        rbloomFilter.tryInit(size, fpp);

        //1.测试 布隆过滤器有 + redis有
//        rbloomFilter.add("10086");
//        redisClient.getBucket("10086", new StringCodec()).set("chinamobile10086");

        //2.测试 布隆过滤器有 + redis无
//        rbloomFilter.add("10087");

        //3.测试 布隆过滤器无 + redis无
    }

    public static String getPhoneListById(String IdNumber) {

        String result = null;

        if (IdNumber == null) {
            return null;
        }

        //1.先去布隆过滤器里面查询
        if (rbloomFilter.contains(IdNumber)) {
            //2.布隆过滤器里面有,再去redis里面查询
            RBucket<String> rBucket = redisClient.getBucket(IdNumber, new StringCodec());

            result = rBucket.get();

            if (result != null) {
                return "i com from redis:" + result;
            } else {
                result = getPhoneListByMySQL(IdNumber);

                if (result == null) {
                    return null;
                }

                //重新将数据更新回redis
                redisClient.getBucket(IdNumber, new StringCodec()).set(result);
            }
            return "i come from mysql:" + result;
        }
        return result;
    }

    public static String getPhoneListByMySQL(String IdNumber) {
        return "chinamobile" + IdNumber;
    }

    public static void main(String[] args) {
        String phoneListById = getPhoneListById("10088");
        System.out.println("---查询出来的结果:" + phoneListById);

        //暂停几秒钟线程
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        redisClient.shutdown();
    }
}

重要总结:

4)作业:黑名单的使用

7.2.4 CentOS7下布隆过滤器两种安装方式

1)采用docker安装RedisBloom

Redis在4.0之后有了插件功能(Module),可以使用外部的扩展功能,可以使用RedisBloom作为Redis布隆过滤器插件。

docker run -p 6379:6379 --name redis6379bloom -d redislabs/rebloom

docker exec -it redis6379bloom /bin/bash

redis-cli

常用命令:

bf.reserve key error_rate的值 inital_size的值  #默认的error+rate是0.01,默认的initial_size是100

bf.add key 值

bf.exists key 值

bf.madd 一次添加多个元素

bf.mexists  一次查询多个元素是否存在
2)编译安装

7.3 缓存击穿

7.3.1 是什么

大量的请求同时查询一个key时,此时这个key正好失效了,就会导致大量的请求都打到数据库上面去。

简单说就是热点key突然失效了,暴打MySQL。

7.3.2 危害

会造成某一时刻数据库请求量过大,压力剧增。

7.3.3 解决

缓存击穿热点key失效互斥更新、随机退避、差异失效时间

方案1:对于访问频繁的热点key,干脆就不设置过期时间

方案2:互斥独占锁防止击穿。

  • 多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个互斥锁来锁住它

  • 其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

    public String get(String key){
        String value = redis.get(key);  //查询缓存
        
        if(value != null){
            //缓存存在直接返回
            return value;
        }else{
            //缓存不存在则对方加锁
            //假设请求量很大,缓存过期
            synchronized(TestFurure.class){
                values = redis.get(key);  //再查一遍redis
                
                if(value != null){
                    //查到数据直接返回
                    return value;
                }else{
                    //第二次查询缓存也不存在,直接查DB
                    value = dao.get(key);
                    
                    //数据缓存
                    redis.setnx(key,value,time);
                    
                    //返回
                    return value;
                }
            }
        }
    }
    

7.3.4 案例:聚划算功能实现+防止缓存击穿

1)分析过程
步骤说明
1100%高并发,绝对不可以用MySQL实现
2先把MySQL里面参加活动的数据抽取进Redis,一般采用定时器扫描来决定上线活动还是下线取消
3支持分页功能,一页20条记录
请大家思考,redis里面什么样子的数据类型支持上述功能? —— list(zset主要做排行榜)
2)代码解析
/**
 * 聚划算项目 —— 商品
 * <p>
 * 分布式定时工具,是什么? —— 'xxl-job'
 *
 * @author fy
 * @date 2022/11/8 13:57
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {
    private Long id;
    private String name;
    private Integer price;
    private String detail;
}

@Service
@Slf4j
public class JHSTaskService {

    @Autowired
    private RedisTemplate redisTemplate;

    @PostConstruct
    public void initJHS() {
        log.info("启动定时器淘宝聚划算功能模拟......" + DateUtil.now());

        new Thread(() -> {
            //模拟定时器,定时把数据库的特价商品,刷新到redis中
            while (true) {
                //模拟从数据库读取100件商品,用于加载到聚划算的页面中
                List<Product> list = this.products();

                //采用redis list数据结构的push来实现存储
                redisTemplate.delete("jhs");

                //lpush命令
                redisTemplate.opsForList().leftPushAll("jhs", list);

                //间隔一分钟 执行一遍
                try {
                    TimeUnit.MINUTES.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                log.info("run jhs定时刷新.........");
            }
        }, "t1").start();
    }

    public List<Product> products() {
        List<Product> list = new ArrayList<>();

        for (int i = 1; i <= 20; i++) {
            Random random = new Random();
            int id = random.nextInt(10000);
            Product obj = new Product((long) id, "product" + i, i, "detail");
            list.add(obj);
        }
        return list;
    }
}

@RestController
@Slf4j
public class JHSProductController {

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 分页查询:在高兴发情况下,只能走redis查询,走db的话必定会把db打垮
     *
     * @param page
     * @param size
     * @return
     */
    @GetMapping("/product/find")
    public List<Product> find(int page, int size) {

        List<Product> list = null;

        long start = (page - 1) * size;
        long end = start + size - 1;

        try {
            //采用redis list数据结构的lrange命令实现分页查询
            list = redisTemplate.opsForList().range("jhs", start, end);

            if (CollectionUtils.isEmpty(list)) {
                // TODO 走DB查询
            }
            log.info("查询结果:{}", list);
        } catch (Exception e) {
            //这里的异常,一般是redis瘫痪,或redis网络超时
            log.error("exception:",e);

            //TODO 走DB查询
        }

        return list;
    }
}
3)Bug说明

上述聚划算的功能算是完成,请思考在高并发下有什么经典生产问题?

QPS上1000后导致可怕的缓存击穿~

Snipaste_2022-11-08_14-39-38 Snipaste_2022-11-08_14-41-07
4)定时轮询,互斥更新,差异失效时间
	@PostConstruct
    public void initJHS() {

        log.info("启动AB定时器淘宝聚划算功能模拟......" + DateUtil.now());

        new Thread(() -> {
            //模拟定时器,定时把数据库的特价商品,刷新到redis中
            while (true) {
                //模拟从数据库读取100件商品,用于加载到聚划算的页面中
                List<Product> list = this.products();

                /**
                 * 互斥更新、随机退避、差异失效时间
                 */
                //先更新B缓存
                redisTemplate.delete("jhs-B");
                redisTemplate.opsForList().leftPushAll("jhs-B",list);
                redisTemplate.expire("jhs-B",20L,TimeUnit.DAYS);

                //再更新A缓存
                redisTemplate.delete("jhs-A");
                redisTemplate.opsForList().leftPushAll("jhs-A",list);
                redisTemplate.expire("jhs-A",15L,TimeUnit.DAYS);

                //间隔一分钟 执行一遍
                try {
                    TimeUnit.MINUTES.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                log.info("run jhs定时刷新.........");
            }
        }, "t1").start();
    }

	@GetMapping("/product/findPlus")
    public List<Product> findPlus(int page, int size) {

        List<Product> list = null;

        long start = (page - 1) * size;
        long end = start + size - 1;

        try {
            //采用redis list数据结构的lrange命令实现分页查询
            list = redisTemplate.opsForList().range("jhs-A", start, end);

            if (CollectionUtils.isEmpty(list)) {
                log.info("==========A缓存已经失效了,记得人工修补,B缓存自动延续5天");

                //用户先查询缓存A,如果缓存A查询不到,再查询缓存B
                list = redisTemplate.opsForList().range("jhs-B", start, end);
            }

            log.info("查询结果:{}", list);
        } catch (Exception e) {
            //这里的异常,一般是redis瘫痪,或redis网络超时
            log.error("exception:",e);
            //TODO 走DB查询
        }
        return list;
    }

用lru脚本也可以,这只是换一种解决方案。
另一种方案:服务降级,提前关闭入口,预热下一波数据。(客户买单)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值