当前位置 : 主页 > 编程语言 > 其它开发 >

缓存设计问题

来源:互联网 收集:自由互联 发布时间:2022-06-22
文本摘录总结于极客时间——《Java业务开发常见错误 100 例》   在聊缓存设计概念之前,不知道大家有没有在日常的开发中遇到这些问题: 热点 Key 回原数据库的问题,如果 Key 特别

文本摘录总结于极客时间——《Java业务开发常见错误 100 例》

  在聊缓存设计概念之前,不知道大家有没有在日常的开发中遇到这些问题:

  1. 热点 Key 回原数据库的问题,如果 Key 特别热的话,实际上缓存系统也会无法承受,比较所有的访问都集中达到了一台缓存服务器中。你有没有办法把这个热点 Key 的查询压力分散到多个 Redis 节点中去?
  2. 大 Key 也是数据缓存的问题。如果一个 Key 的 Value 特别大,那么可能会对 Redis 产品巨大的性能影响,因为 Redis 时单线程模型,对于大 Key 的查询或者删除等操作,可能会引起 Redis 阻塞或者高可用切换。你知道怎么查询 Redis 中的 Key以及如何在设计上实现大 Key 的拆分吗?

  以上问题我会在最后解答,现在先让我们去了解缓存的同步、雪崩、并发、穿透等问题。

不要把 Redis 当作数据库

  通常,我们会借用 Redis 等分布式缓存数据库来缓存数据,但是千万别把 Redis 当作数据库来使用。我就见过许多案例,因为 Redis 中的数据消失导致业务逻辑错误,并且因为没有保留原始数据,业务都没法恢复。
  虽然 Redis 拥有持久化的功能,但不能因为这点就忽略它的本质——一个基于内存的KV数据库。因此,把 Redis 作为缓存使用,需要特别注意两点:

  • 第一,从客户端的角度来说,缓存数据一定是要有原始数据的,就算这个 Key 设置的过期时间是 1 分钟,但是它在第 30 秒的时候消失了,这我们也要接受。当数据消失之后,我们要能从原始数据中加载数据,并且我们一定不要认为没有 TTL 的缓存不会被删除。
  • 第二,从 Redis 服务端的角度来说,缓存系统可以保存的数据量一定是小于原始数据的。首先,我们需要先限制 Redis 对内存的使用量,随后设置合适的算法来驱逐数据,这非常重要!

  从Redis的文档可以看出,常用的淘汰策略有:

  1. allkeys-lru,针对所有 Key, 优先删除最近最少使用的 Key;
  2. volatile-lru,针对带有过期时间的 Key,优先删除最近最少使用的 Key;
  3. volatile-ttl,针对带有过期时间的 Key,优先删除即将过期的 Key(根据 TTL 的值);
  4. allkeys-lfu(Redis 4.0 以上),针对所有 Key,优先删除最少使用的 Key;
  5. volatile-lfu(Redis 4.0 以上),针对带有过期时间的 Key,优先删除最少使用的 Key。

  其实,这些算法是 Key 范围 +Key 选择算法的搭配组合,其中范围有 allkeys 和 volatile 两种,算法有 LRU、TTL 和 LFU 三种。接下来,我就从 Key 范围和算法角度,和你说说如何选择合适的驱逐算法。
  首先,从算法角度来说,Redis 4.0 以后推出的 LFU 比 LRU 更“实用”。试想一下,如果一个 Key 访问频率是 1 天一次,但正好在 1 秒前刚访问过,那么 LRU 可能不会选择优先淘汰这个 Key,反而可能会淘汰一个 5 秒访问一次但最近 2 秒没有访问过的 Key,而 LFU 算法不会有这个问题。而 TTL 会比较“头脑简单”一点,优先删除即将过期的 Key,但有可能这个 Key 正在被大量访问。
  然后,从 Key 范围角度来说,allkeys 可以确保即使 Key 没有 TTL 也能回收,如果使用的时候客户端总是“忘记”设置缓存的过期时间,那么可以考虑使用这个系列的算法。而 volatile 会更稳妥一些,万一客户端把 Redis 当做了长效缓存使用,只是启动时候初始化一次缓存,那么一旦删除了此类没有 TTL 的数据,可能就会导致客户端出错。
  所以,不管是使用者还是管理者都要考虑 Redis 的使用方式,使用者需要考虑应该以缓存的姿势来使用 Redis,管理者应该为 Redis 设置内存限制和合适的驱逐策略,避免出现 OOM。

注意缓存雪崩的问题

  缓存的 IO 读写次数比数据库高很多,因此要特别小心大量缓存失效的场景。这种情况一旦发生,可能就会有大量的数据需要回原到数据库进行查询,对数据库造成极大的压力,极限的情况下甚至会导致数据库的直接崩溃。

  导致雪崩的场景大致会有两个:

  1. 缓存系统本身不可用,导致数据回源;
  2. 大量的 key 同一时间失效,导致数据回源。

  第一种问题本身是系统可用性的问题,不涉及缓存设计的问题。今天主要是说说如何确保大量 Key 不在同一时间被动过期。
  我们看一个案例,程序初始化的时候放入 1000 条城市数据到 Redis 缓存中,过期时间是 30 秒;数据过期后从数据库获取数据然后写入缓存,每次从数据库获取数据后计数器 +1;在程序启动的同时,启动一个定时任务线程每隔一秒输出计数器的值,并把计数器归零。

@Autowired
private StringRedisTemplate stringRedisTemplate;
private AtomicInteger atomicInteger = new AtomicInteger();

@PostConstruct
public void wrongInit() {
    //初始化1000个城市数据到Redis,所有缓存数据有效期30秒
    IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30, TimeUnit.SECONDS));
    log.info("Cache init finished");
    
    //每秒一次,输出数据库访问的QPS
    
   Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        log.info("DB QPS : {}", atomicInteger.getAndSet(0));
    }, 0, 1, TimeUnit.SECONDS);
}

@GetMapping("city")
public String city() {
    //随机查询一个城市
    int id = ThreadLocalRandom.current().nextInt(1000) + 1;
    String key = "city" + id;
    String data = stringRedisTemplate.opsForValue().get(key);
    if (data == null) {
        //回源到数据库查询
        data = getCityFromDb(id);
        if (!StringUtils.isEmpty(data))
            //缓存30秒过期
            stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
    }
    return data;
}

private String getCityFromDb(int cityId) {
    //模拟查询数据库,查一次增加计数器加一
    atomicInteger.incrementAndGet();
    return "citydata" + System.currentTimeMillis();
}

  使用 wrk 工具,设置 10 线程 10 连接压测 city 接口:

wrk -c10 -t10 -d 100s http://localhost:45678/cacheinvalid/city

  启动程序 30 秒后缓存过期,回源的数据库 QPS 最高达到了 700 多:

  解决缓存 Key 同时大规模失效需要回源,导致数据库压力激增问题的方式有两种。

  方案一,差异化缓存过期时间,不要让大量的 Key 在同一时间过期的方式就是在初始化缓存的时候,设置缓存的过期时间是 30 秒 + 10 秒以内的随机延迟。这样就不会让这些 Key 集中在 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);
}

  修改后,缓存过期时的回源不会集中在同一秒,数据库的 QPS 从 700 多降到了最高 100 左右:

  方案二,让缓存不主动过期。初始化缓存数据的时候设置缓存永不过期,然后启动一个后台线程 30 秒一次定时把所有数据更新到缓存,而且通过适当的休眠,控制从数据库更新数据的频率,降低数据库压力:

@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();
}

  这样修改后,虽然缓存整体更新的耗时在 21 秒左右,但数据库的压力会比较稳定:

  使用这两种方式,我们需要特别注意以下三点:

  • 方案一和方案二是截然不同的两种缓存方式,如果无法全量缓存所有数据,那么只能使用方案一;
  • 即使使用了方案二,缓存永不过期,同样需要在查询的时候,确保有回源的逻辑。正如之前所说,我们无法确保缓存系统中的数据永不丢失。
  • 不管是方案一还是方案二,在把数据从数据库加入缓存的时候,都需要判断来自数据库的数据是否合法,比如进行最基本的判空检查。

  请记住,在把数据加入缓存之前一定要校验数据,如果发现有明显异常要及时报警,这非常重要!

注意缓存击穿问题

  在某些 Key 属于极端热点数据,且并发量很大的情况下,如果这个 Key 过期,可能会在某个瞬间出现大量的并发请求同时回源,相当于大量的并发请求直接打到了数据库。

  我们来重现下这个问题。在程序启动的时候,初始化一个热点数据到 Redis 中,过期时间设置为 5 秒,每隔 1 秒输出一下回源的 QPS:

@PostConstruct
public void init() {
    //初始化一个热点数据到Redis中,过期时间设置为5秒
    stringRedisTemplate.opsForValue().set("hotsopt", getExpensiveData(), 5, TimeUnit.SECONDS);
    //每隔1秒输出一下回源的QPS
   
   Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        log.info("DB QPS : {}", atomicInteger.getAndSet(0));
    }, 0, 1, TimeUnit.SECONDS);
}

@GetMapping("wrong")
public String wrong() {
    String data = stringRedisTemplate.opsForValue().get("hotsopt");
    if (StringUtils.isEmpty(data)) {
        data = getExpensiveData();
        //重新加入缓存,过期时间还是5秒
        stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS);
    }
    return data;
}

  可以看到,每隔 5 秒数据库都有 20 左右的 QPS:

  如果回源操作特别昂贵,那么这种并发就不能忽略不计。这时,我们可以考虑使用锁机制来限制回源的并发。比如如下代码示例,使用 Redisson 来获取一个基于 Redis 的分布式锁,在查询数据库之前先尝试获取锁:

@Autowired
private RedissonClient redissonClient;
@GetMapping("right")
public String right() {
    String data = stringRedisTemplate.opsForValue().get("hotsopt");
    if (StringUtils.isEmpty(data)) {
        RLock locker = redissonClient.getLock("locker");
        //获取分布式锁
        if (locker.tryLock()) {
            try {
                data = stringRedisTemplate.opsForValue().get("hotsopt");
                //双重检查,因为可能已经有一个B线程过了第一次判断,在等锁,然后A线程已经把数据写入了Redis中
                if (StringUtils.isEmpty(data)) {
                    //回源到数据库查询
                    data = getExpensiveData();
                    stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS);
                }
            } finally {
                //别忘记释放,另外注意写法,获取锁后整段代码try+finally,确保unlock万无一失
                locker.unlock();
            }
        }
    }
    return data;
}

  这样就可以把数据库的并发限制在 1:
  但实际上在真是的业务场景下,不一定要这么严格地使用双重检查分布式锁进行全局的并发限制,因为这样虽然可以把数据库回源并发降到最低,但也限制了缓存失效时的并发。可以考虑的方式是:

  • 方案一,使用进程内的锁进行限制,这样每一个节点都可以以一个并发回源数据库;
  • 方案二,不使用锁进行限制,而是使用类似 Semaphore 的工具限制并发数,比如限制为 10,这样既限制了回源并发数不至于太大,又能使得一定量的线程可以同时回源。
注意缓存穿透问题

  在之前的例子中,缓存回源的逻辑都是当缓存中查不到需要的数据时,回源到数据库查询。这里容易出现的一个漏洞是,缓存中没有数据不一定代表数据没有缓存,还有一种可能是原始数据压根就不存在。
  这里需要注意,缓存穿透和缓存击穿的区别:

  • 缓存穿透是指,缓存没有起到压力缓冲的作用;
  • 而缓存击穿是指,缓存失效时瞬时的并发打到数据库。

  解决缓存穿透有以下两种方案。

  方案一,对于不存在的数据,同样设置一个特殊的 Value 到缓存中,比如当数据库中查出的用户信息为空的时候,设置 NODATA 这样具有特殊含义的字符串到缓存中。但这样其实可能会被攻击然后加入大量的无效数据到缓存中去
  担心这种情况,还用布隆过滤器做前置过滤。
  你可以把所有可能的值保存在布隆过滤器中,从缓存读取数据前先过滤一次:

  • 如果布隆过滤器认为值不存在,那么值一定是不存在的,无需查询缓存也无需查询数据库;
  • 对于极小概率的误判请求,才会最终让非法 Key 的请求走到缓存或数据库。

  要用上布隆过滤器,我们可以使用 Google 的 Guava 工具包提供的 BloomFilter 类改造一下程序:启动时,初始化一个具有所有有效用户 ID 的、10000 个元素的 BloomFilter,在从缓存查询数据之前调用其 mightContain 方法,来检测用户 ID 是否可能存在;如果布隆过滤器说值不存在,那么一定是不存在的,直接返回:

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;
}

  其实,方案二可以和方案一同时使用,即将布隆过滤器前置,对于误判的情况再保存特殊值到缓存,双重保险避免无效数据查询请求打到数据库。

注意缓存数据同步策略

  前面提到的 3 个案例,其实都属于缓存数据过期后的被动删除。在实际情况下,修改了原始数据后,考虑到缓存数据更新的及时性,我们可能会采用主动更新缓存的策略。
  实际上,先更新数据库再删除缓存,访问的时候按需加载数据到缓存策略是最好的。虽然在极端情况下,这种策略也可能出现数据不一致的问题,但概率非常低,基本可以忽略。
  需要注意的是,更新数据库后删除缓存的操作可能失败,如果失败则考虑把任务加入延迟队列进行延迟重试,确保数据可以删除,缓存可以及时更新。因为删除操作是幂等的,所以即使重复删问题也不是太大,这又是删除比更新好的一个原因。
  因此,针对缓存更新更推荐的方式是,缓存中的数据不由数据更新操作主动触发,统一在需要使用的时候按需加载,数据更新后及时删除缓存中的数据即可。

开篇解答

  第一个问题:分型一个场景:假如在一个非常热点的数据,数据更新不是很频繁,但是查询非常的频繁,要保证基本保证100%的缓存命中率,该怎么处理?
  我们的做法是,空间换效率,同一个key保留2份,1个不带后缀,1个带后缀,不带的后缀的有ttl,带后缀的没有,先查询不带后缀的,查询不到,做两件事情:1、后台程序查询DB更新缓存;2查询带后缀返回给调用方。这样可以尽可能的避免缓存击穿而引起的数据库挂了。
  第二个问题:

  • 该key需要每次都整存整取
      可以尝试将对象分拆成几个key-value, 使用multiGet获取值,这样分拆的意义在于分拆单次操作的压力,将操作压力平摊到多个redis实例中,降低对单个redis的IO影响;
  • 该对象每次只需要存取部分数据
      可以像第一种做法一样,分拆成几个key-value, 也可以将这个存储在一个hash中,每个field代表一个具体的属性,使用hget,hmget来获取部分的value,使用hset,hmset来更新部分属性。
网友评论