缓存的常见问题

  1. 1. 缓存雪崩
  2. 2. 缓存击穿
  3. 3. 缓存穿透
  4. 4. 缓存数据同步策略

1. 缓存雪崩

缓存系统的IOPS比数据库高很多,因此需要注意短时间内的大量缓存失效的情况。这种情况一旦发生,可能就会在瞬间有大量的数据需要回源到数据库查询,对数据库造成极大的压力,极限情况下甚至导致后端数据库直接崩溃。这就是我们说的缓存雪崩。

产生雪崩的原因有两种:

  • 缓存系统本身不可用,导致大量请求直接回源到数据库
  • 应用设计层面大量的Key在同一时间过期,导致大量的数据回源
    • 解决方法
      • 差异化缓存过期时间
        • 不让大量的Key在同一时间过期
        • 初始化缓存的时候,设置缓存的过期时间为30秒 + 10秒以内的随机延迟(扰动值)。这样key就不会集中在30秒这个时刻过期,而是会分散在30 - 40秒之间
      • 让缓存不主动过期
        • 初始化缓存数据的时候设置缓存永不过期,然后启动一个后台线程30秒一次定时将所有数据更新到缓存,通过适当的休眠,控制从数据库更新数据的频率,降低数据库的压力
        • 如果无法全量缓存所有的数据,那么就无法使用该种方案
// 差异化缓存过期时间
@PostConstruct
public void rightInit1() {
    //这次缓存的过期时间是30秒+10秒内的随机延迟
    IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.SECONDS));
    log.info("Cache init finished");
    //同样1秒一次输出数据库QPS:
   Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        log.info("DB QPS : {}", atomicInteger.getAndSet(0));
    }, 0, 1, TimeUnit.SECONDS);
}

// 让缓存不主动过期
@PostConstruct
public void rightInit2() throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(1);
    //每隔30秒全量更新一次缓存 
    Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        IntStream.rangeClosed(1, 1000).forEach(i -> {
            String data = getCityFromDb(i);
            //模拟更新缓存需要一定的时间
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            } catch (InterruptedException e) { }
            if (!StringUtils.isEmpty(data)) {
                //缓存永不过期,被动更新
                stringRedisTemplate.opsForValue().set("city" + i, data);
            }
        });
        log.info("Cache update finished");
        //启动程序的时候需要等待首次更新缓存完成
        countDownLatch.countDown();
    }, 0, 30, TimeUnit.SECONDS);

    Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        log.info("DB QPS : {}", atomicInteger.getAndSet(0));
    }, 0, 1, TimeUnit.SECONDS);

    countDownLatch.await();
}

2. 缓存击穿

在某些Key属于极端热点数据并且并发量很大的情况下,如果这个Key过期,可能会在某个瞬间出现大量的并发请求同时回源,相当于大量的并发请求直接打到了数据库。这就是我们所说的缓存击穿或缓存并发问题。

如果说回源操作比较昂贵的话,那么这种并发就不能忽略不计了。可以考虑使用锁机制来限制回源的并发,或者可以使用类似Semaphore的工具来限制并发数,比如限制为10.这样子既限制了回源并发数不至于太大,又能够使得一定量的线程可以同时回源。

3. 缓存穿透

缓存穿透指的是实际上缓存里有key 和value,但是其value可能为空,如果没做正确处理,那我们的逻辑可能会认为没有对当前的key做好缓存,会对所有的请求都回源到数据库上,这就会给数据库造成压力了。

针对这种问题,可以用以下方案解决:

  • 对于不存在的数据,同样设置一个特殊的Value到缓存当中,比如NODATA,这样子就不会有缓存穿透的问题

    • 可能会将大量无效的数据加入到缓存当中
  • 使用布隆过滤器

    • 放在缓存数据读取前先进行过滤操作
    • Google Guava BloomFilter
private BloomFilter<Integer> bloomFilter;

@PostConstruct
public void init() {
    //创建布隆过滤器,元素数量10000,期望误判率1%
    bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);
    //填充布隆过滤器
    IntStream.rangeClosed(1, 10000).forEach(bloomFilter::put);
}

@GetMapping("right2")
public String right2(@RequestParam("id") int id) {
    String data = "";
    //通过布隆过滤器先判断
    if (bloomFilter.mightContain(id)) {
        String key = "user" + id;
        //走缓存查询
        data = stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isEmpty(data)) {
            //走数据库查询
            data = getCityFromDb(id);
            stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
        }
    }
    return data;
}

4. 缓存数据同步策略

  • 当原始数据被修改了以后,我们很可能会采用主动更新缓存的策略
    • 可能的策略有
      • 先更新缓存,再更新数据库
        • 不可行
        • 数据库操作失败是有可能的,会导致缓存和数据库当中的数据不一致
      • 先更新数据库,再更新缓存
        • 不可行
        • 多线程情况下数据库中更新的顺序和缓存更新的顺序会不同,可能会导致旧数据最后到,导致问题的出现
      • 先删除缓存,再更新数据库,访问的时候按需加载数据到缓存当中
        • 很可能删除缓存以后还没来得及更新数据库,就有另外一个线程先读取了旧值到缓存中
      • 先更新数据库,再删除缓存,访问的时候按需加载数据到缓存当中
        • 出现缓存不一致的概率很低

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 stone2paul@gmail.com

文章标题:缓存的常见问题

文章字数:1.3k

本文作者:Leilei Chen

发布时间:2020-10-07, 06:55:09

最后更新:2020-10-07, 06:55:54

原始链接:https://www.llchen60.com/%E7%BC%93%E5%AD%98%E7%9A%84%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录
×

喜欢就点赞,疼爱就打赏