首页 > 其他分享 >[10] 缓存穿透&击穿&雪崩&过期

[10] 缓存穿透&击穿&雪崩&过期

时间:2023-02-15 23:11:42浏览次数:51  
标签:10 缓存 删除 过期 list Redis key

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

1. 缓存穿透

1.1 概念

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

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

1.2 解决方案

a. 缓存空对象

当存储层不命中后,即使返回的空对象也将其缓存起来,同时会设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源。

但是这种方法会存在两个问题:

  1. 如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键;如果是黑客或者恶意攻击,拿不存在的 id 去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而宕掉;
  2. 即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响。

b. Google Guava

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

导入依赖

<!-- guava Google 开源的 Guava 中自带的布隆过滤器 -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>23.0</version>
</dependency>

HelloWorld

@Test
public void bloomFilter() {
    // 创建布隆过滤器对象
    BloomFilter<Integer> filter = BloomFilter.create(Funnels.integerFunnel(), 100);
    // 判断指定元素是否存在
    System.out.println(filter.mightContain(1));
    System.out.println(filter.mightContain(2));
    // 将元素添加进布隆过滤器
    filter.put(1);
    filter.put(2);
    System.out.println(filter.mightContain(1));
    System.out.println(filter.mightContain(2));
}

取样本 100W 数据,查查不在 100W 范围内的其它 10W 数据是否存在:

最终:

c. Redis Bloom

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

导入依赖:

<!-- redisson -->
<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>3.13.4</version>
</dependency>

白名单架构说明:

  • 误判问题,但是概率小可以接受,不能从布隆过滤器删除;
  • 全部合法的 key 都需要放入 filter+redis 里面,不然数据就是返回 null。

测试代码:

public static final int _1W = 10000;

// 布隆过滤器里预计要插入多少数据
public static int size = 100 * _1W;
// 误判率,它越小误判的个数也就越少
public static double fpp = 0.03;

static RedissonClient redissonClient = null;
static RBloomFilter rBloomFilter = null;

static {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://192.168.111.147:6379").setDatabase(0);
    // 构造 redisson
    redissonClient = Redisson.create(config);
    // 通过 redisson 构造 rBloomFilter
    rBloomFilter = redissonClient.getBloomFilter("phoneListBloomFilter", new StringCodec());

    rBloomFilter.tryInit(size, fpp);

    // a. 布隆过滤器有 + redis有
    rBloomFilter.add("10086");
    redissonClient.getBucket("10086", new StringCodec()).set("chinaMobile10086");

    // b. 布隆过滤器有 + redis无
    // rBloomFilter.add("10087");

    // c. 都没有

}

public static void main(String[] args) {
    String phoneListById = getPhoneListById("10087");
    System.out.println("------ query result: " + phoneListById);
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    redissonClient.shutdown();
}

private static String getPhoneListById(String IDNumber) {
    String result = null;

    if (IDNumber == null) {
        return null;
    }
    // 1. 先去布隆过滤器里面查询
    if (rBloomFilter.contains(IDNumber)) {
        // 2. 布隆过滤器里有,再去 redis 里面查询
        RBucket<String> rBucket = redissonClient.getBucket(IDNumber, new StringCodec());
        result = rBucket.get();
        if (result != null) {
            return "data come from redis: " + result;
        } else {
            result = getPhoneListByMySQL(IDNumber);
            if (result == null) {
                return null;
            }
            // 3. 重新将数据更新回 redis
            redissonClient.getBucket(IDNumber, new StringCodec()).set(result);
        }
        return "data come from mysql: " + result;
    }
    return result;
}

private static String getPhoneListByMySQL(String IDNumber) {
    return "chinaMobile" + IDNumber;
}

查看 bloom 在 redis 上存储的信息:

延伸:黑名单使用

2. 缓存击穿

2.1 概念

这里需要注意和缓存穿透的区别,缓存击穿,是指一个 key 非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个 key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。

当某个 key 在过期的瞬间,有大量的请求并发访问,这类数据一般是热点数据,由于缓存过期,会同时访问数据库来查询最新数据,并且回写缓存,会导使数据库瞬间压力过大。

2.2 解决方案

设置热点数据永不过期

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

加互斥锁

多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

互斥更新、随机退避、差异失效时间

【举例说明】taobao 聚划算案例落地

采用定时器将参与聚划算活动的特价商品新增进入 Redis 中:

@Slf4j
@Service
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 结构来实现存储
                this.redisTemplate.delete(Constants.JHS_KEY);
                // 先删除旧数据,再推入新数据
                this.redisTemplate.opsForList().leftPushAll(Constants.JHS_KEY, list);
                // ========== 这个替换操作不是原子性的,存在热点缓存突然失效的隐患 ==========

                // 每分钟更新一次特价商品列表
                try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) {}
                log.info("聚划算定时刷新...");
            }
        }, "t1").start();
    }

    /**
     * 模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
     */
    public List<Product> products() {
        List<Product> list = new ArrayList<>();
        for (int i = 1; i <= 20; i++) {
            Random rand = new Random();
            int id = rand.nextInt(10000);
            Product obj = new Product((long) id, "product" + i, i, "detail");
            list.add(obj);
        }
        return list;
    }
}

聚划算商品列表接口:

@RequestMapping(value = "/pruduct/find", method = RequestMethod.GET)
@ApiOperation("分页显示聚划算特价商品(7x24)")
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 = this.redisTemplate.opsForList().range(Constants.JHS_KEY, start, end);
        if (CollectionUtils.isEmpty(list)) {
            // TODO DB查询
        }
        log.info("查询结果:{}", list);
    } catch (Exception ex) {
        // 这里的异常一般是redis瘫痪/redis网络超时
        log.error("exception:", ex);
        // TODO DB查询
    }
    return list;
}

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

热点 key 失效 + QPS 过高 = DB 被打爆

互斥更新、差异化失效时间:

@PostConstruct
public void initJHS() {
    log.info("启动定时器淘宝聚划算功能模拟..." + DateUtil.now());
    new Thread(() -> {
        // 模拟定时器,定时把数据库的特价商品,刷新到redis中
        while (true) {
            // 模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
            List<Product> list = this.products();

            // 先更新 B 缓存
            this.redisTemplate.delete(Constants.JHS_KEY_B);
            this.redisTemplate.opsForList().leftPushAll(Constants.JHS_KEY_B, list);
            this.redisTemplate.expire(Constants.JHS_KEY_B, 70L, TimeUnit.SECONDS);
             // 再更新 A 缓存
            this.redisTemplate.delete(Constants.JHS_KEY_A);
            this.redisTemplate.opsForList().leftPushAll(Constants.JHS_KEY_A, list);
            this.redisTemplate.expire(Constants.JHS_KEY_A, 60L, TimeUnit.SECONDS);

            // 每分钟更新一次特价商品列表
            try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) {}
            log.info("聚划算定时刷新...");
        }
    }, "t1").start();
}

先查询 A 再查询 B:

@RequestMapping(value = "/pruduct/find", method = RequestMethod.GET)
@ApiOperation("分页显示聚划算特价商品(7x24)")
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 = this.redisTemplate.opsForList().range(Constants.JHS_KEY, start, end);
        if (CollectionUtils.isEmpty(list)) {
            log.info("========= A 缓存已经失效了,记得人工修补,B 缓存自动延续 10s");
            // 用户先查询缓存 A,如果查询不到(例如更新缓存的时候删除了)再查询缓存 B
            this.redisTemplate.opsForList().range(Constants.JHS_KEY_B, start, end);
        }
        log.info("查询结果:{}", list);
    } catch (Exception ex) {
        // 这里的异常一般是redis瘫痪/redis网络超时
        log.error("exception:", ex);
        // TODO DB查询
    }
    return list;
}

3. 缓存雪崩

3.1 概念

缓存雪崩,是指在某一个时间段,缓存集中过期失效。Redis 宕机!

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

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

3.2 解决方案

  1. Redis 高可用。主从+哨兵;集群
  2. 限流降级。ehcache 本地缓存 + Hystrix/Sentinel 限流&降级;
  3. 数据预热。数据加热的含义就是在正式部署之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的 key,设置不同的过期时间,让缓存失效的时间点尽量均匀。

4. 缓存过期

  1. 生产上你们的 Redis 内存设置多少?
  2. 如何配置、修改 Redis 的内存大小?
  3. 如果内存满了你怎么办?
  4. Redis 清理内存的方式?定期删除和惰性删除了解过吗?
  5. Redis 缓存淘汰策略?
  6. Redis 的 LRU 了解过吗?

4.1 内存配置

如果不设置最大内存大小或设置最大内存大小为 0,在 64 位操作系统下不限制内存大小,在 32 位操作系统下最多使用 3GB 内存。

生产上一般推荐 Redis 设置内存为最大物理内存的 3/4。

如何修改 Redis 内存设置?

  • 通过配置修改 maxmemory <bytes>
  • 通过命令修改 config set maxmemory <bytes>

真要打满了会怎么样?如果 Redis 内存使用超出了设置的最大值会怎样?

4.2 删除策略

a. 立即删除

Redis 不可能时时刻刻遍历所有被设置了生存时间的 key,来检测数据是否已经到达过期时间,然后对它进行删除。

立即删除能保证内存中数据的最大新鲜度,因为它保证过期键值会在过期后马上被删除,其所占用的内存也会随之释放。但是立即删除对 CPU 是最不友好的。因为删除操作会占用 CPU 的时间,如果刚好碰上了 CPU 很忙的时候,比如正在做交集或排序等计算的时候,就会给 CPU 造成额外的压力,这会产生大量的性能消耗,同时也会影响数据的读取操作。

【小结】对 CPU 不友好,用处理器性能换取存储空间 (拿时间换空间)。

b. 惰性删除

数据到达过期时间,不做处理。等下次访问该数据时,

  • 如果未过期,返回数据 ;
  • 发现已过期,删除&返回不存在。

惰性删除策略的缺点是,它对内存是最不友好的。

如果一个 key 已经过期,而这个 key 又仍然保留在 Redis 中,那么只要这个过期 key 不被删除,它所占用的内存就不会释放。

在使用惰性删除策略时,如果数据库中有非常多的过期 key,而这些过期 key 又恰好没有被访问到的话,那么它们也许永远也不会被删除(除非用户手动执行 FLUSHDB),我们甚至可以将这种情况看作是一种内存泄漏 —— 无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖于内存的 Redis 服务器来说,肯定不是一个好消息。

【小结】对 memory 不友好,用存储空间换取处理器性能(拿空间换时间)。

c. 定期删除

定期删除策略是前两种策略的折中:

  1. 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响;
  2. 周期性轮询 Redis 库中的时效性数据,采用随机抽取的策略,利用过期数据占比的方式控制删除频度。
  • 特点1:CPU 性能占用设置有峰值,检测频度可自定义设置;
  • 特点2:内存压力不是很大,长期占用内存的冷数据会被持续清理;
  • 总结:周期性抽查存储空间 (随机抽查,重点抽查)

【举例】Redis 默认每隔 100ms 检查是否有过期的 key,有过期 key 则删除。注意:Redis 不是每隔 100ms 将所有的 key 检查一次而是随机抽取进行检查!因此,如果只采用定期删除策略,会导致很多 key 到时间没有删除。

定期删除策略的难点是确定删除操作执行的时长和频率:

  • 如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成立即删除策略,以至于将CPU时间过多地消耗在删除过期键上面;
  • 如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除束略一样,出现浪费内存的情况。

因此,如果采用定期删除策略的话,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率。

4.3 过期淘汰策略

删除策略漏洞分析:

  1. 定期删除时,从来没有被抽查到
  2. 惰性删除时,也从来没有被点中使用过(大量过期的 key 堆积在内存中,导致 Redis 内存空间紧张或者很快耗尽)

必须要有一个更好的兜底方案 → 引入 2x4=8 种 key 的过期淘汰策略!

  • 2 个维度
    • 过期键中筛选
    • 所有键中筛选
  • 4 个方面
    • LRU
    • LFU
    • random
    • ttl
策略 简述
noeviction(默认) 不会驱逐任何 key
allkeys-lru 对所有 key 使用 LRU 算法进行删除
volatile-lru 对所有设置了过期时间的 key 使用 LRU 算法进行删除
allkeys-random 对所有 key 随机删除
volatile-random 对所有设置了过期时间的 key 随机删除
volatile-ttl 删除马上要过期的 key
allkeys-lfu 对所有 key 使用 LFU 算法进行删除
volatile-lfu 对所有设置了过期时间的 key 使用 LFU 算法进行删除

配置方式依旧是配置文件和命令两种:

标签:10,缓存,删除,过期,list,Redis,key
From: https://www.cnblogs.com/liujiaqi1101/p/17125122.html

相关文章

  • day10-1-中文乱码处理
    中文乱码处理1.问题抛出当表单提交的数据为中文时,会出现乱码:(1)Monster.java:packagecom.li.web.datavalid.entity;importorg.hibernate.validator.constraints.Email......
  • 刷刷刷 Day | 1005. K 次取反后最大化的数组和
    1005.K次取反后最大化的数组和LeetCode题目要求给你一个整数数组nums和一个整数k,按以下方法修改该数组:选择某个下标i 并将nums[i]替换为-nums[i]。重复这......
  • 自命为缓存之王的Caffeine(5)
    您好,我是湘王,这是我的博客园,欢迎您来,欢迎您再来~   普通的缓存和Token的区别在于时效性和持久性。如果用Redis实现Token的话,可以:1、设置rediskv键值对的过期时间(......
  • leetcode-1051-easy
    HeightCheckerAschoolistryingtotakeanannualphotoofallthestudents.Thestudentsareaskedtostandinasinglefilelineinnon-decreasingorderby......
  • leetcode-10460-easy
    LastStoneWeightYouaregivenanarrayofintegersstoneswherestones[i]istheweightoftheithstone.Weareplayingagamewiththestones.Oneachtur......
  • 在 Flask 中使用 Redis 来缓存数据
    (一)Redis简介Redis是一个高性能的key-value数据库,它是基于内存运行的数据库,因此有很高的性能,存取速度非常快,而且Redis还可以定期的将数据同步到磁盘中,实现数据的持......
  • 2023.02.10 模拟赛小结
    2023.02.10模拟赛小结目录2023.02.10模拟赛小结更好的阅读体验戳此进入赛时思路T1Code(生成器)Code(主程序)T2CodeT3正解T1CodeT2T3UPD更好的阅读体验戳此进入赛时思路T1......
  • fastai在Windows10多线程处理受限提示 “Due to IPython and Windows limitation, pyt
    fastai在Win10直接使用Jupyternotebook,会有这个限制提示dls=TextDataLoaders.from_folder(untar_data(URLs.IMDB),valid='test')“DuetoIPythonandWindowslimit......
  • 【算法训练营day43】LeetCode1049. 最后一块石头的重量II LeetCode494. 目标和 LeetCo
    LeetCode1049.最后一块石头的重量II题目链接:1049.最后一块石头的重量II独上高楼,望尽天涯路一开始还是没有想到怎么转化成01背包问题,所以直接看题解找思路慕然回首,灯......
  • 云音乐 iOS 跨端缓存库
    云音乐iOS跨端缓存库-NEMichelinCachehttps://mp.weixin.qq.com/s/jZ6QEuc0qoAn27lYzN1Yfw云音乐iOS跨端缓存库-NEMichelinCache原创 绎推 网易云音乐技术团......